Módulo 8: Project The Reservo Multi Agent System

Proyecto: el sistema multi-agente de Reservo

Descripción

Siete lecciones de este módulo construyeron, una por una, las piezas del sistema: el registro de especialistas (02), el pipeline conectado con la Demo A de Luis (03), el fan-out conectado con la Demo B de Marta (04), el handoff de Valentina en aislado (05), el blackboard confirmado sobre las tres mesas (06), y el conteo de costo de coordinación probado contra las Demos A y B (07). Esta lección —el capstone de toda la guía— las junta todas en un solo sistema, agrega la tercera demo que faltaba (Valentina, con sus tres tracks combinados: pipeline, fan-out y handoff), y presenta el conteo de costo comparado de las tres.

No hay ninguna pieza de bajo nivel nueva en esta lección. Es la primera vez que ves el sistema completo —supervisor, booking_agent, policy_agent, pricing_agent, blackboard, y el conteo de costo generalizado— funcionando de punta a punta, sobre tres peticiones de forma genuinamente distinta, 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 07: SPECIALISTS, run_specialist, run_pipeline, run_tracks_parallel, run_with_handoff, Blackboard, Track, coordination_cost. Lo único nuevo es la Demo C completa (Valentina, los tres patrones a la vez) y el resumen comparado de las tres demos — el cierre de este módulo, y de toda la guía.


Analogía: el negocio, entregado, con las cuentas ya hechas

Ya viste, pieza por pieza, cada rol del restaurante: el maître que decide (M2), la línea de montaje (M3), los mozos que resuelven preguntas a la vez (M4), el mozo que llama al sommelier (M5), la pizarra de la cocina (M6), y el libro de cuentas que mide cuánto cuesta cada tipo de mesa (M7 de este módulo). Esta última lección es la noche de apertura: tres mesas reales, de forma completamente distinta, atendidas por el mismo negocio, con el libro de cuentas abierto al lado de cada una. Al final de la noche, no solo el restaurante funcionó — hay un número exacto de cuánto costó atender a cada mesa, y por qué.


El sistema completo

Todo lo que sigue es exactamente el código de las lecciones 02 a 07, ensamblado en un solo script. Ninguna función cambia de firma; el único código genuinamente nuevo de esta lección son las tres demos y el resumen final.

import ast
import concurrent.futures
import itertools
from dataclasses import dataclass, field
import reservo_tools as rt


# ---- runner base (agent-fundamentals, sin cambios) ----
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})")


# ---- M2: policy_agent (stub) + SPECIALISTS + run_specialist (lección 02) ----
def search_docs(query):
    q = query.lower()
    if "cancela" in q:
        return "[cancellation-policy] Puedes cancelar sin cargo hasta 2 horas antes del horario reservado."
    if "no" in q and ("present" in q or "show" in q or "llega" in q):
        return ("[no-show-policy] 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.")
    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 (lección 03) ----
@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 (lección 05) ----
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 (lección 06) ----
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)
            )


# ---- M7: la capa de composición ----
@dataclass
class Track:
    key: str
    pattern: str  # "pipeline" | "fanout" | "handoff"
    description: str


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


# ---- lección 07: el conteo generalizado de costo de coordinación ----
def count_model_calls(history):
    return sum(1 for m in history if m["role"] == "assistant")


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


PLAN_CALLS = 1
COMPOSE_CALLS = 1
DISPATCH_HOPS = 2


def track_calls_and_hops(track, result):
    if track.pattern == "pipeline":
        _, trace = result
        calls = sum(count_model_calls(s["history"]) for s in trace)
        tool_calls = sum(count_tool_calls(s["history"]) for s in trace)
        hops = DISPATCH_HOPS + (len(trace) - 1)
    elif track.pattern == "fanout":
        _, history = result
        calls = count_model_calls(history)
        tool_calls = count_tool_calls(history)
        hops = DISPATCH_HOPS
    elif track.pattern == "handoff":
        _, trace = result
        calls = sum(count_model_calls(step["history"]) for step in trace)
        tool_calls = sum(count_tool_calls(step["history"]) for step in trace)
        hops = DISPATCH_HOPS + 1
    else:
        raise ValueError(f"patrón desconocido: {track.pattern!r}")
    return calls, tool_calls, hops


def coordination_cost(plan, track_results):
    per_track = {}
    for t in plan:
        calls, tool_calls, hops = track_calls_and_hops(t, track_results[t.key])
        per_track[t.key] = {"pattern": t.pattern, "calls": calls, "tools": tool_calls, "hops": hops}

    call_counts = [v["calls"] for v in per_track.values()]
    total_specialist_calls = sum(call_counts)
    total_tool_calls = sum(v["tools"] for v in per_track.values())
    total_hops = sum(v["hops"] for v in per_track.values())
    total_model_calls = PLAN_CALLS + total_specialist_calls + COMPOSE_CALLS

    surrounding = PLAN_CALLS + COMPOSE_CALLS
    sequential_rounds = sum(call_counts) + surrounding
    parallel_rounds = (max(call_counts) if call_counts else 0) + surrounding

    return {
        "per_track": per_track, "total_model_calls": total_model_calls,
        "total_tool_calls": total_tool_calls, "total_hops": total_hops,
        "sequential_rounds": sequential_rounds, "parallel_rounds": parallel_rounds,
    }


def print_cost_report(label, plan, cost):
    print(f"--- costo de coordinación: {label} ---")
    for t in plan:
        d = cost["per_track"][t.key]
        print(f"  {t.key:<18} patrón={d['pattern']:<9} "
              f"llamadas={d['calls']:<3} tools={d['tools']:<3} hops={d['hops']}")
    rounds_saved = cost["sequential_rounds"] - cost["parallel_rounds"]
    pct = round(100 * rounds_saved / cost["sequential_rounds"]) if cost["sequential_rounds"] else 0
    print(f"  TOTAL llamadas al modelo: {cost['total_model_calls']}")
    print(f"  TOTAL tool calls:         {cost['total_tool_calls']}")
    print(f"  TOTAL hops entre agentes: {cost['total_hops']}")
    print(f"  rondas secuencial={cost['sequential_rounds']} vs. paralelo={cost['parallel_rounds']} "
          f"({rounds_saved} rondas menos, {pct}% menos, con {len(plan)} track(s) corriendo a la vez)")
    print()

Hasta acá, ni una sola línea distinta de lo que ya ejecutaste en las lecciones 02 a 07. Lo que sigue son las tres demos.


Demo A y Demo B: recapitulando lo ya construido

print("========== DEMO A: Luis (pipeline) ==========")
bb_a = Blackboard()
PLAN_A = [Track(key="book_studio", pattern="pipeline",
                 description="cotizar Studio pro 3h, validar política de cancelación, confirmar")]
bb_a.write("supervisor", member="Luis")

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_A = {"room": "Studio", "tier": "pro", "hours": 3, "member": "Luis"}
script_quote_a = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Studio", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": "Studio pro 3h cuesta 9600 centavos."}]},
]
script_policy_a = [
    {"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_a = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "book_room",
     "input": {"room": "Studio", "tier": "pro", "hours": 3, "member": "Luis"}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": "Reservé Studio pro 3h para Luis (confirmación #1)."}]},
]


def job_a_book_studio():
    return run_pipeline(PIPELINE_STAGES, [script_quote_a, script_policy_a, script_confirm_a], INITIAL_PAYLOAD_A)


results_a = run_tracks_parallel({"book_studio": job_a_book_studio})
payload_a, trace_a = results_a["book_studio"]
bb_a.write("booking_agent", room=payload_a["room"], tier=payload_a["tier"],
           hours=payload_a["hours"], price_cents=payload_a["price_cents"])
bb_a.write("booking_agent", booking_id=payload_a["booking_id"])
print("Blackboard final:", bb_a)
cost_a = coordination_cost(PLAN_A, results_a)
print_cost_report("Demo A (Luis, 1 track, pipeline)", PLAN_A, cost_a)


print("========== DEMO B: Marta (fan-out) ==========")
bb_b = Blackboard()
PLAN_B = [
    Track(key="compare_rooms", pattern="fanout", description="comparar Studio y Boardroom pro 2h"),
    Track(key="no_show_policy", pattern="fanout", description="pregunta general de no-presentación"),
]
bb_b.write("supervisor", member="Marta")

script_pricing_b = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote", "input": {"room": "Studio", "tier": "pro", "hours": 2}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote", "input": {"room": "Boardroom", "tier": "pro", "hours": 2}},
    ]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": (
        "Studio pro 2h: 6400 centavos. Boardroom pro 2h: 12800 centavos. Studio es la opción más barata de las dos.")}]},
]
script_no_show_b = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
     "input": {"query": "qué pasa si un socio no se presenta a una 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 job_b_compare_rooms():
    return run_specialist("pricing_agent", "Compara Studio y Boardroom pro 2h.", script_pricing_b)


def job_b_no_show_policy():
    return run_specialist("policy_agent", "¿Qué pasa si un socio no se presenta a una reserva confirmada?", script_no_show_b)


results_b = run_tracks_parallel({"compare_rooms": job_b_compare_rooms, "no_show_policy": job_b_no_show_policy})
print("Blackboard final:", bb_b, "(ningún track escribió -- ninguno reservó nada)")
cost_b = coordination_cost(PLAN_B, results_b)
print_cost_report("Demo B (Marta, 2 tracks, fan-out)", PLAN_B, cost_b)

Qué esperar (idéntico a lo ya visto en las lecciones 03, 04, 06 y 07 de este módulo -- acá corren, por primera vez, en el mismo proceso, una después de la otra):

========== DEMO A: Luis (pipeline) ==========
Blackboard final: Blackboard(member='Luis', room='Studio', tier='pro', hours=3, price_cents=9600, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Luis'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Studio'), 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=9600), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])
--- costo de coordinación: Demo A (Luis, 1 track, pipeline) ---
  book_studio        patrón=pipeline  llamadas=6   tools=3   hops=4
  TOTAL llamadas al modelo: 8
  TOTAL tool calls:         3
  TOTAL hops entre agentes: 4
  rondas secuencial=8 vs. paralelo=8 (0 rondas menos, 0% menos, con 1 track(s) corriendo a la vez)

========== DEMO B: Marta (fan-out) ==========
Blackboard final: Blackboard(member='Marta', room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=7, writer='supervisor', field='member', value='Marta')]) (ningún track escribió -- ninguno reservó nada)
--- costo de coordinación: Demo B (Marta, 2 tracks, fan-out) ---
  compare_rooms      patrón=fanout    llamadas=2   tools=2   hops=2
  no_show_policy     patrón=fanout    llamadas=2   tools=1   hops=2
  TOTAL llamadas al modelo: 6
  TOTAL tool calls:         3
  TOTAL hops entre agentes: 4
  rondas secuencial=6 vs. paralelo=4 (2 rondas menos, 33% menos, con 2 track(s) corriendo a la vez)

Fíjate en el seq del WriteLogEntry de Marta: seq=7, no seq=1 — el WRITE_SEQ es un contador de proceso, y ya llevaba 6 escrituras acumuladas de la Demo A cuando empezó la Demo B, exactamente la misma continuidad que ya confirmó M7 L07 Ejercicio 2.


Demo C: Valentina — pipeline, fan-out y handoff, los tres a la vez

La pieza nueva de esta lección. Valentina hace una petición compuesta que, aplicando el criterio de las tres preguntas de M7 L02, se descompone en tres tracks: una reserva con orden fijo (pipeline), una comparación independiente (fan-out), y una cotización que se topa con una pregunta de política a mitad de camino (handoff).

print()
print("========== DEMO C: Valentina (pipeline + fan-out + handoff) ==========")
REQUEST_C = (
    "Valentina (equipo de producto): cotiza y reserva Focus pro 2h para la "
    "revisión de diseño de esta tarde, validando la política de cancelación "
    "antes de confirmar. Aparte, compara Studio y Boardroom pro 2h por si "
    "necesitamos más espacio, y cotiza el Studio pro 4h para el taller del "
    "viernes -- dime qué pasa si alguien no llega a ese taller."
)
print(REQUEST_C)

bb_c = Blackboard()
PLAN_C = [
    Track(key="book_focus", pattern="pipeline",
          description="cotizar Focus pro 2h, validar política de cancelación, confirmar"),
    Track(key="compare_rooms", pattern="fanout",
          description="comparar Studio y Boardroom pro 2h -- sub-pregunta independiente"),
    Track(key="workshop_no_show", pattern="handoff",
          description="cotizar Studio pro 4h; ceder el turno si aparece la pregunta de no-presentación"),
]
print("plan del supervisor (concepto):")
for t in PLAN_C:
    print(f"  [{t.key}] patrón={t.pattern} -- {t.description}")
bb_c.write("supervisor", member="Valentina")

INITIAL_PAYLOAD_C = {"room": "Focus", "tier": "pro", "hours": 2, "member": "Valentina"}
script_quote_c = [
    {"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_c = [
    {"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_c = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "book_room",
     "input": {"room": "Focus", "tier": "pro", "hours": 2, "member": "Valentina"}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": "Reservé Focus pro 2h para Valentina (confirmación #2)."}]},
]


def job_c_book_focus():
    return run_pipeline(PIPELINE_STAGES, [script_quote_c, script_policy_c, script_confirm_c], INITIAL_PAYLOAD_C)


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


def job_c_compare_rooms():
    return run_specialist("pricing_agent", "Compara Studio y Boardroom pro 2h.", script_pricing_c)


script_workshop_booking = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Studio", "tier": "pro", "hours": 4}}]},
    {"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": "Studio", "tier": "pro", "hours": 4, "price_cents": 12800}}}]},
]
script_workshop_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_workshop = {"booking_agent": script_workshop_booking, "policy_agent": script_workshop_policy}


def job_c_workshop_no_show():
    return run_with_handoff("booking_agent", "Cotiza Studio pro 4h. ¿Qué pasa si no llego?", model_scripts_workshop)


jobs_c = {"book_focus": job_c_book_focus, "compare_rooms": job_c_compare_rooms, "workshop_no_show": job_c_workshop_no_show}
results_c = run_tracks_parallel(jobs_c)

for key in sorted(results_c):
    print(f"--- track {key!r} ({[t.pattern for t in PLAN_C if t.key == key][0]}) ---")
    if key == "book_focus":
        payload, trace = results_c[key]
        for step in trace:
            print(f"  etapa {step['stage']} ({step['label']}) -> {step['agent']}: {step['output']}")
    elif key == "compare_rooms":
        final, _ = results_c[key]
        print(f"  respuesta: {final['content'][0]['text']}")
    elif key == "workshop_no_show":
        final, trace = results_c[key]
        print(f"  handoff: {trace[0]['agent']} -> {trace[0]['package'].receiver}")
        print(f"  respuesta final: {final['content'][0]['text']}")

payload_focus_c, _ = results_c["book_focus"]
bb_c.write("booking_agent", room=payload_focus_c["room"], tier=payload_focus_c["tier"],
           hours=payload_focus_c["hours"], price_cents=payload_focus_c["price_cents"])
bb_c.write("booking_agent", booking_id=payload_focus_c["booking_id"])
print()
print("Blackboard final:", bb_c)

compare_final_c, _ = results_c["compare_rooms"]
no_show_final_c, no_show_trace_c = results_c["workshop_no_show"]
no_show_ctx_c = no_show_trace_c[0]["package"].context
no_show_charge_c = no_show_ctx_c["price_cents"] // 2
final_response_c = (
    f"Reservé Focus pro {bb_c.hours}h para {bb_c.member} ({bb_c.price_cents} "
    f"centavos, confirmación #{bb_c.booking_id}) -- ya validé la política de "
    f"cancelación. {compare_final_c['content'][0]['text']} Para el taller del "
    f"viernes, el Studio pro 4h sale {no_show_ctx_c['price_cents']} centavos; "
    f"si alguien no llega, el cargo de no-presentación sería de {no_show_charge_c} centavos."
)
print("respuesta final para Valentina:", final_response_c)
print()

cost_c = coordination_cost(PLAN_C, results_c)
print_cost_report("Demo C (Valentina, 3 tracks, pipeline + fan-out + handoff)", PLAN_C, cost_c)

Qué esperar:

========== DEMO C: Valentina (pipeline + fan-out + handoff) ==========
Valentina (equipo de producto): cotiza y reserva Focus pro 2h para la revisión de diseño de esta tarde, validando la política de cancelación antes de confirmar. Aparte, compara Studio y Boardroom pro 2h por si necesitamos más espacio, y cotiza el Studio pro 4h para el taller del viernes -- dime qué pasa si alguien no llega a ese taller.
plan del supervisor (concepto):
  [book_focus] patrón=pipeline -- cotizar Focus pro 2h, validar política de cancelación, confirmar
  [compare_rooms] patrón=fanout -- comparar Studio y Boardroom pro 2h -- sub-pregunta independiente
  [workshop_no_show] patrón=handoff -- cotizar Studio pro 4h; ceder el turno si aparece la pregunta de no-presentación
--- track 'book_focus' (pipeline) ---
  etapa 1 (cotizar) -> booking_agent: Focus pro 2h cuesta 4000 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 2h para Valentina (confirmación #2).
--- track 'compare_rooms' (fanout) ---
  respuesta: Studio pro 2h: 6400 centavos. Boardroom pro 2h: 12800 centavos. Studio es la opción más barata de las dos.
--- track 'workshop_no_show' (handoff) ---
  handoff: booking_agent -> 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: Blackboard(member='Valentina', room='Focus', tier='pro', hours=2, price_cents=4000, booking_id=2, log=[WriteLogEntry(seq=8, writer='supervisor', field='member', value='Valentina'), WriteLogEntry(seq=9, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=10, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=11, writer='booking_agent', field='hours', value=2), WriteLogEntry(seq=12, writer='booking_agent', field='price_cents', value=4000), WriteLogEntry(seq=13, writer='booking_agent', field='booking_id', value=2)])
respuesta final para Valentina: Reservé Focus pro 2h para Valentina (4000 centavos, confirmación #2) -- ya validé la política de cancelación. Studio pro 2h: 6400 centavos. Boardroom pro 2h: 12800 centavos. Studio es la opción más barata de las dos. Para el taller del viernes, el Studio pro 4h sale 12800 centavos; si alguien no llega, el cargo de no-presentación sería de 6400 centavos.

--- costo de coordinación: Demo C (Valentina, 3 tracks, pipeline + fan-out + handoff) ---
  book_focus         patrón=pipeline  llamadas=6   tools=3   hops=4
  compare_rooms      patrón=fanout    llamadas=2   tools=2   hops=2
  workshop_no_show   patrón=handoff   llamadas=4   tools=3   hops=3
  TOTAL llamadas al modelo: 14
  TOTAL tool calls:         8
  TOTAL hops entre agentes: 9
  rondas secuencial=14 vs. paralelo=8 (6 rondas menos, 43% menos, con 3 track(s) corriendo a la vez)

Verifica los tres números de la respuesta final a mano: Focus pro 2h = 2500 * 2 * 80 // 100 = 4000, Studio pro 4h = 4000 * 4 * 80 // 100 = 12800, cargo de no-presentación = 12800 // 2 = 6400. booking_id: 2 porque este proceso ya había reservado el Studio de Luis (Demo A, booking_id: 1) antes de llegar a la Demo C — Marta (Demo B) nunca reservó nada, así que no consumió ningún booking_id.


El resumen final: las tres demos, lado a lado

print("========== RESUMEN: costo de coordinación de las tres demos ==========")
print(f"{'demo':8}{'tracks':8}{'llamadas':10}{'tools':8}{'hops':6}{'rondas seq':12}{'rondas par':12}")
for label, plan, cost in [("A", PLAN_A, cost_a), ("B", PLAN_B, cost_b), ("C", PLAN_C, cost_c)]:
    print(f"{label:8}{len(plan):<8}{cost['total_model_calls']:<10}"
          f"{cost['total_tool_calls']:<8}{cost['total_hops']:<6}"
          f"{cost['sequential_rounds']:<12}{cost['parallel_rounds']:<12}")

print()
print("reservas totales en BOOKINGS al cerrar el proceso:", len(rt.BOOKINGS))

Qué esperar:

========== RESUMEN: costo de coordinación de las tres demos ==========
demo    tracks  llamadas  tools   hops  rondas seq  rondas par  
A       1       8         3       4     8           8           
B       2       6         3       4     6           4           
C       3       14        8       9     14          8           

reservas totales en BOOKINGS al cerrar el proceso: 2

Tres peticiones, tres formas distintas, tres costos de coordinación distintos — medidos, no estimados. La Demo A (un solo track de pipeline) no ahorra ninguna ronda porque no tiene con quién compartir el tiempo de coordinación. La Demo B (dos tracks de fan-out) ahorra un 33%, el mismo número que M4 L07 ya había medido con la misma estructura. La Demo C (tres tracks, los tres patrones combinados) ahorra un 43% — el ahorro crece con más tracks corriendo a la vez, siempre que uno de ellos domine el max() sin que los demás sumen tiempo extra. 2 reservas reales en todo el proceso (Luis y Valentina) — Marta nunca reservó nada, exactamente como confirmó la lección 06.


Errores comunes

  1. Pensar que la Demo C necesita algún mecanismo nuevo. No lo necesita — es exactamente run_pipeline, run_specialist y run_with_handoff, cada uno resolviendo su track, despachados juntos por run_tracks_parallel sin ningún cambio de firma.

  2. Ejecutar las tres demos en un orden distinto y esperar los mismos booking_id. Como en cada mini-proyecto de esta guía, el orden de ejecución determina la numeración — si la Demo C corriera antes que la Demo A, Valentina se quedaría con booking_id: 1.

  3. Construir la respuesta final de Valentina antes de que Paso de escritura al Blackboard termine. El booking_id y el price_cents de la respuesta final vienen de bb_c, DESPUÉS de sus dos write() — intentar componerla antes usaría datos parciales o un AttributeError.

  4. Olvidar que el ahorro de rondas no es gratis en llamadas. Las tres demos tienen el MISMO número de llamadas al modelo con o sin concurrencia — el fan-out ahorra tiempo de coordinación (rondas), nunca trabajo real (llamadas, tool calls). Confundir estos dos números fue exactamente el error que M4 L07 advirtió desde su primera lección.


Ejercicios

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

Ejecuta el sistema completo de esta lección, las tres demos en el mismo proceso, y confirma línea por línea que tu salida coincide con la de esta lección — presta especial atención a los booking_id (1 para Luis, 2 para Valentina) y al seq de la primera escritura de cada Blackboard (1, 7, 8).

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" de cada sección (salvo cualquier línea informativa de orden de llegada, que puede variar), tu Reservo desechable arrancó limpio y el sistema completo funcionó de punta a punta, con las tres demos compartiendo el mismo proceso.

Ejercicio 2: Agrega una cuarta demo, con un solo track de handoff puro (Medio)

Diseña una cuarta demo —un socio nuevo— cuya petición completa se resuelva con un solo track de patrón handoff, sin ningún pipeline ni fan-out adicional (como el caso de Camila en M7 L08). Ejecútala como parte del mismo proceso y mide su costo de coordinación con coordination_cost.

Ver solución
print()
print("========== DEMO D: Rodrigo (SOLO handoff) ==========")
bb_d = Blackboard()
PLAN_D = [Track(key="quote_no_show", pattern="handoff",
                 description="cotizar Boardroom pro 1h; ceder el turno si aparece la pregunta de no-presentación")]
bb_d.write("supervisor", member="Rodrigo")

script_rodrigo_booking = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
    {"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": "Boardroom", "tier": "pro", "hours": 1, "price_cents": 6400}}}]},
]
model_scripts_rodrigo = {"booking_agent": script_rodrigo_booking, "policy_agent": script_workshop_policy}


def job_d_quote_no_show():
    return run_with_handoff("booking_agent", "Cotiza Boardroom pro 1h. ¿Qué pasa si no llego?", model_scripts_rodrigo)


results_d = run_tracks_parallel({"quote_no_show": job_d_quote_no_show})
cost_d = coordination_cost(PLAN_D, results_d)
print_cost_report("Demo D (Rodrigo, 1 track, handoff puro)", PLAN_D, cost_d)

Salida esperada (continuando el mismo proceso, después de las Demos A, B y C):

========== DEMO D: Rodrigo (SOLO handoff) ==========
--- costo de coordinación: Demo D (Rodrigo, 1 track, handoff puro) ---
  quote_no_show      patrón=handoff   llamadas=4   tools=3   hops=3
  TOTAL llamadas al modelo: 6
  TOTAL tool calls:         3
  TOTAL hops entre agentes: 3
  rondas secuencial=6 vs. paralelo=6 (0 rondas menos, 0% menos, con 1 track(s) corriendo a la vez)

Explicación: Boardroom pro 1h = 8000 * 1 * 80 // 100 = 6400 centavos. Con un solo track, otra vez sin ningún ahorro de rondas (la misma conclusión de la Demo A) — pero con hops=3 en vez de hops=4, porque un handoff (DISPATCH_HOPS + 1) es un hop más barato que un pipeline de tres etapas (DISPATCH_HOPS + 2), la diferencia exacta que predijo la tabla de la lección 07.

Ejercicio 3: ¿Por qué la Demo C ahorra más porcentaje de rondas que la Demo B? (Difícil)

La Demo B ahorra 33% de rondas; la Demo C ahorra 43%. Sin ejecutar código nuevo, explica —usando los números ya calculados de call_counts de cada demo ([6] para A, [2, 2] para B, [6, 2, 4] para C)— por qué agregar un tercer track (con 4 llamadas internas) a la Demo C aumenta el porcentaje de ahorro en vez de diluirlo.

Ver solución

El porcentaje de ahorro depende de qué tan lejos está el sum() del max(), relativo al sum() —no del número de tracks en sí—. Para la Demo B: sum([2,2])=4, max([2,2])=2 — el paralelo "absorbe" la mitad del trabajo secuencial de los tracks (sin contar surrounding). Para la Demo C: sum([6,2,4])=12, max([6,2,4])=6 — el paralelo también absorbe la mitad del trabajo de los tracks. La diferencia real está en el surrounding (PLAN_CALLS + COMPOSE_CALLS = 2, fijo, siempre): para la Demo B, ese 2 fijo es una porción más grande del total secuencial (6 de 8 iniciales, antes de sumar surroundingsequential=6+2=8... pero cuidado, hay que recalcular con los números reales impresos: sequential_B=6, parallel_B=4, ahorro 2/6=33%; sequential_C=14, parallel_C=8, ahorro 6/14=43%). Cuantos más tracks corren a la vez, y cuanto más grande es el track dominante relativo a los demás, más rondas "se esconden" detrás del max() sin sumar al secuencial — la Demo C tiene un track de 6 llamadas que domina sobre dos tracks más chicos (2 y 4), y esos dos tracks más chicos son pura ganancia de paralelismo, sin costar nada extra en rondas. Esto confirma el principio general de M4 L07: el ahorro de fan-out crece con más sub-tareas independientes, siempre que ninguna sea más grande que la que ya domina el max().


Cierre de la guía

Ocho módulos, un solo sistema construido de punta a punta, sin ningún framework. Vale la pena mirar hacia atrás antes de mirar hacia adelante.

Lo que construiste

M1 -- El framework de decisión: cuándo multi-agente vale la pena, medido
      con números reales (3 vs. 5 llamadas para una tarea que no lo necesitaba).

M2 -- El supervisor/router: un coordinador que decide a qué especialista
      delegar -- ruteo determinista y ruteo por decisión del modelo.

M3 -- El pipeline: orden fijo, sin decisión de ruteo en cada paso, cuando
      la tarea SIEMPRE necesita los mismos pasos en el mismo orden.

M4 -- El fan-out: sub-tareas independientes, corriendo a la vez, con
      agregación determinista de resultados -- sin importar el orden de
      finalización de los hilos.

M5 -- El handoff: un agente que cede el turno directamente a mitad de
      tarea, con un paquete mínimo de transferencia -- sin volver a un
      supervisor externo.

M6 -- El blackboard: estado compartido de proceso, con un log de quién
      escribió qué -- la alternativa a pasar mensajes punto a punto.

M7 -- La composición: los cinco patrones anteriores, combinados sobre una
      petición real, con el criterio de las tres preguntas para decidir
      cuándo combinar cuáles.

M8 -- El capstone: el sistema completo, entregado, con el conteo de costo
      de coordinación que M1 prometió medir -- generalizado a cualquier
      combinación de patrones.

Cada patrón se construyó desde cero, en Python 3.14 puro, sin LangChain, sin LangGraph, sin ningún framework de orquestación — la orquestación real (el router, el paso de mensajes, el blackboard, el fan-out y su agregación, el conteo de costo) se ejecutó de verdad en cada lección, y su salida se citó. Las decisiones de cada agente fueron siempre concepto, con guiones realistas de claude-sonnet-5 — nunca una llamada real a una API. Entiendes ahora, desde adentro, qué hace un framework de orquestación por debajo antes de decidir si adoptar uno.

A dónde seguir

Este sistema, tal como quedó construido, tiene límites deliberados — cada uno apunta a la guía correcta del ecosistema agentic-engineering-ecosystem:

  • El Blackboard vive en memoria y se pierde cuando el proceso termina. Si Reservo necesitara que un miembro nuevo del equipo, mañana, en una sesión completamente distinta, pudiera consultar qué se decidió hoy, hace falta un mecanismo de persistencia real —un store en disco, escribir, leer, expirar—. Ese mecanismo es el terreno de agent-memory-and-state-guide.

  • Cada agente de este sistema corrió con un guion corto, sin preocuparse por el tamaño de su ventana de contexto. Un sistema de producción, con conversaciones más largas y más tools por agente, necesita presupuestar y comprimir qué entra en el system/messages de cada llamada — compaction, few-shot, context rot. Eso es context-engineering-guide.

  • Ningún agente de esta guía desconfió nunca de otro. El Blackboard que cualquier especialista puede escribir, el context de un handoff que el receptor acepta sin verificar — toda esa superficie asume buena fe. Un sistema real necesita defenderse de un agente que miente, de un blackboard envenenado, de una instrucción maliciosa incrustada en un handoff. Eso es agent-security-and-sandboxing-guide.

  • El conteo de costo de esta guía es simple, a propósito —llamadas, hops, rondas, sin trazas, sin rollbacks, sin costos reales de tokens—. Medir un sistema multi-agente en producción, con datos reales, evaluaciones automatizadas y observabilidad, es el terreno de evaluation-frameworks-guide y agents-in-production-guide.

  • Y si este sistema tuviera que escalar a producción real, con más agentes, más estado, y equipos distintos manteniendo cada especialista, hay frameworks que resuelven buena parte de esta orquestación por ti —LangGraph y CrewAI son los más conocidos— y un protocolo emergente para que agentes de organizaciones distintas se comuniquen entre sí, A2A (Agent2Agent protocol). Ninguno de los dos se construyó en esta guía —ninguno se necesita para entender los patrones que ya construiste a mano—, pero ahora que sabes qué hacen por debajo, adoptarlos es una decisión informada, no un salto a ciegas.

No hace falta memorizar cuál guía resuelve cuál límite — alcanza con saber que existen, y que cada límite de este sistema tiene un nombre y un lugar dentro del ecosistema. Construiste, con tus propias manos, cinco patrones de coordinación multi-agente, un sistema completo que los combina, y la disciplina de medir su costo antes de asumir que valía la pena. Eso es lo que se lleva de acá.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio que sostuvo toda la guía: empezar con el mecanismo más simple que resuelva la tarea, y solo agregar coordinación cuando el trabajo genuinamente la necesita.
  2. Anthropic — Multi-agent research system — Un sistema de producción real de Anthropic donde los cinco patrones de esta guía —o su equivalente— conviven sobre tareas complejas, la referencia que acompañó cada módulo.
  3. Anthropic — Agent SDK overview — Cómo se ve, en código de producción con llamadas reales, un sistema que aplica los mismos patrones que construiste a mano en esta guía.
  4. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada agente de este sistema completo siguió respetando, sin cambios, desde la primera lección de agent-fundamentals-and-tool-calling-guide hasta la última de esta guía.