Módulo 8: Project The Reservo Multi Agent System
Un blackboard para todo el sistema
Descripción
Las tres piezas de las lecciones 03, 04 y 05 —pipeline, fan-out, handoff— resuelven, cada una, una
sub-tarea completa. Esta lección conecta la última pieza estructural del sistema, Blackboard
(Módulo 6), sin ningún cambio, y confirma algo que ya estableció el Módulo 6 pero que vale la pena
ver, una vez más, sobre las tres demos nuevas de este capstone: no todos los patrones escriben al
estado compartido por igual.
Vas a correr, en una sola corrida, la pieza de Luis (pipeline, SÍ escribe), la pieza de Marta
(fan-out, NO escribe), y la pieza de Valentina (handoff, tampoco escribe) — y vas a ver, con el
WRITE_SEQ del Blackboard como testigo, que el número de secuencia no avanza ni una sola vez
durante las piezas de Marta y Valentina.
Conexión con el módulo
Blackboard, WriteLogEntry y WRITE_SEQ son exactamente la misma clase del Módulo 6 — mismos
campos (member, room, tier, hours, price_cents, booking_id, log), mismo método
write. Esta lección no le agrega nada — reusa las tres piezas ya conectadas de las lecciones 03,
04 y 05 para confirmar, con las tres corriendo en el mismo proceso, la regla de "quién escribe" que
M6 estableció con la petición de Ana. La lección 08 retoma exactamente este mismo Blackboard para
la Demo C completa.
Analogía: la pizarra de la cocina, un turno con tres mesas distintas
La pizarra de la cocina de M6 no se llena con todo lo que pasa en el restaurante — solo con lo que el resto de la cocina podría necesitar después: qué mesa pidió qué, si ya se confirmó. Esta lección es un turno completo con tres mesas de forma distinta: la mesa de Luis, que termina en una reserva real (eso SÍ va a la pizarra); la mesa de Marta, que solo pidió comparar precios y preguntar por una política general (nada de eso es una reserva, nada va a la pizarra); y la mesa de Valentina, que solo cotizó sin confirmar (tampoco va a la pizarra, todavía).
Ejemplo trabajado: tres socios, un mismo Blackboard
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})")
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": {"get_quote": rt.get_quote, "book_room": rt.book_room}},
"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 (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, "label": stage.label, "history": history})
return payload, trace
# ---- M5: handoff, sin cambios (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, 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)
)
bb = Blackboard()
print("=== 1) Luis (pipeline): booking_agent SÍ escribe -- hay una reserva real ===")
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": "Studio", "tier": "pro", "hours": 3, "member": "Luis"}
script_quote = [
{"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 = [
{"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 = [
{"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)."}]},
]
bb.write("supervisor", member="Luis")
payload, _ = run_pipeline(PIPELINE_STAGES, [script_quote, script_policy, script_confirm], INITIAL_PAYLOAD)
bb.write("booking_agent", room=payload["room"], tier=payload["tier"],
hours=payload["hours"], price_cents=payload["price_cents"])
bb.write("booking_agent", booking_id=payload["booking_id"])
print("Blackboard tras Luis:", bb)
print()
print("=== 2) Marta (fan-out): ni pricing_agent ni policy_agent escriben -- son comparaciones, no una reserva ===")
seq_before_marta = bb.log[-1].seq
script_pricing = [
{"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."}]},
]
run_specialist("pricing_agent", "Compara Studio y Boardroom pro 2h.", script_pricing)
print(f"seq del último write ANTES de Marta: {seq_before_marta} -- después de su fan-out: {bb.log[-1].seq} (sin cambio)")
print()
print("=== 3) Valentina (handoff): tampoco escribe -- el context queda local a la respuesta, no al Blackboard ===")
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": "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": 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.")}]},
]
run_with_handoff("booking_agent", "Cotiza Studio pro 4h. ¿Qué pasa si no llego?",
{"booking_agent": script_workshop_booking, "policy_agent": script_workshop_policy})
print(f"seq del último write tras Valentina: {bb.log[-1].seq} (sin cambio otra vez)")
print()
print("--- log completo del Blackboard (una sola corrida, 3 socios distintos) ---")
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
Qué esperar:
=== 1) Luis (pipeline): booking_agent SÍ escribe -- hay una reserva real ===
Blackboard tras Luis: 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)])
=== 2) Marta (fan-out): ni pricing_agent ni policy_agent escriben -- son comparaciones, no una reserva ===
seq del último write ANTES de Marta: 6 -- después de su fan-out: 6 (sin cambio)
=== 3) Valentina (handoff): tampoco escribe -- el context queda local a la respuesta, no al Blackboard ===
seq del último write tras Valentina: 6 (sin cambio otra vez)
--- log completo del Blackboard (una sola corrida, 3 socios distintos) ---
#1 supervisor escribió member='Luis'
#2 booking_agent escribió room='Studio'
#3 booking_agent escribió tier='pro'
#4 booking_agent escribió hours=3
#5 booking_agent escribió price_cents=9600
#6 booking_agent escribió booking_id=1
Seis entradas en el log, todas de la mesa de Luis — ni Marta ni Valentina agregan una sola línea,
aunque las dos corrieron especialistas reales, con tool calls reales, dentro del mismo proceso. El
seq del Blackboard es un testigo honesto de esto: si Marta o Valentina hubieran escrito algo,
bb.log[-1].seq habría avanzado más allá de 6 — y no avanza, confirmando en código lo que la
prosa de esta lección afirma.
La pregunta 3 del criterio, aplicada tres veces
¿Algún dato que esta sub-tarea produce hace falta MÁS ADELANTE en la misma
corrida, para otra parte del sistema que todavía no sabemos quién va a leerlo?
Luis (pipeline) -> SÍ. La reserva confirmada (room, tier, hours, price_cents,
booking_id) es un HECHO del sistema: si más adelante otra
parte de Reservo necesita saber si Luis tiene una reserva
activa, ese dato tiene que estar disponible sin que nadie
se lo tenga que volver a preguntar a booking_agent.
Marta (fan-out) -> NO. Una comparación de precios y una pregunta general de
política son información que Marta consume UNA VEZ, en su
respuesta -- no hay ningún "hecho del sistema" que otra
parte necesite después. Nadie va a preguntar "¿qué le
cotizó pricing_agent a Marta?" en otra corrida.
Valentina (handoff) -> NO, todavía. Cotizar sin confirmar es información de
UNA respuesta, no un hecho persistente -- si Valentina
más adelante SÍ confirma esa reserva (como hace en la
Demo C completa de la lección 08), ESA parte de su
petición sí escribe al Blackboard, con un Track de
pipeline distinto.
La regla no es "pipeline siempre escribe, fan-out y handoff nunca" — es más precisa que eso: se escribe lo que produce un hecho confirmado que el resto del sistema podría necesitar después, sin importar qué patrón lo produjo. En esta lección, la única sub-tarea que produce un hecho así es la reserva confirmada de Luis — es una coincidencia de estas tres demos en particular, no una regla fija del patrón. La Demo C de la lección 08 lo confirma: su track de pipeline (la reserva de Focus de Valentina) SÍ escribe, mientras que su track de fan-out y su track de handoff, otra vez, no.
Errores comunes
-
Concluir "fan-out y handoff nunca escriben al Blackboard". No es una regla del patrón — es consecuencia de qué produce cada sub-tarea. Un fan-out que SÍ confirmara una reserva (por ejemplo, dos reservas independientes resueltas a la vez) SÍ debería escribir cada una; esta lección no lo muestra porque ni la pieza de Marta ni la de Valentina confirman nada, no porque el mecanismo se lo impida.
-
Escribir al Blackboard DENTRO de la sección paralela de un fan-out. Aunque esta lección no tiene ningún caso que lo necesite, la regla de M6 L07 sigue firme para cuando sí haga falta: cualquier escritura ocurre DESPUÉS de que el
ThreadPoolExecutorcierra, nunca durante, para evitar condiciones de carrera sobre el mismo objetoBlackboard. -
Pensar que
WRITE_SEQreinicia con cadaBlackboard()nuevo. Como ya confirmó M7 L07 Ejercicio 2,WRITE_SEQes un contador a nivel de proceso, no de instancia — si esta lección creara un segundoBlackboard()después del primero, su primera entrada seguiría desde7, no desde1.
Ejercicios
Ejercicio 1: Agrega una cuarta mesa que SÍ escribe, con fan-out (Fácil)
Diseña una cuarta pieza —un socio nuevo, "Pedro"— cuya petición sea un fan-out de DOS reservas
independientes (por ejemplo, "Reserva el Focus basic 1h para mí, y aparte reserva el Boardroom
basic 1h para mi colega"), y confirma que el Blackboard SÍ recibe escrituras de las dos, aunque
corran a la vez.
Ver solución
# run_tracks_parallel: la misma función de M4/M7, reusada sin cambios
# (ya la conectaste en la lección 04 de este módulo).
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
def job_book_focus_pedro():
script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "basic", "hours": 1, "member": "Pedro"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus basic 1h para Pedro."}]},
]
return run_specialist("booking_agent", "Reserva Focus basic 1h para Pedro.", script)
def job_book_boardroom_colega():
script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Boardroom", "tier": "basic", "hours": 1, "member": "Colega de Pedro"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Boardroom basic 1h para el colega de Pedro."}]},
]
return run_specialist("booking_agent", "Reserva Boardroom basic 1h para el colega.", script)
results_pedro = run_tracks_parallel({"focus": job_book_focus_pedro, "boardroom": job_book_boardroom_colega})
bb.write("supervisor", member="Pedro")
for key in sorted(results_pedro):
final, history = results_pedro[key]
tool_result = next(
b["content"] for m in history if isinstance(m["content"], list)
for b in m["content"] if b["type"] == "tool_result"
)
print(f" [{key}] tool_result: {tool_result}")
Explicación (sin necesidad de imprimir el Blackboard completo): cada job_* de esta solución
SÍ llama a book_room — a diferencia de pricing_agent en la Demo B, estas dos ramas del fan-out
producen reservas reales, así que —fuera de la sección paralela, después de que run_tracks_parallel
retorna— cada una debería escribirse al Blackboard con su propio bb.write("booking_agent", ...),
confirmando que "fan-out no escribe" nunca fue una regla del mecanismo, sino de lo que las Demos A,
B y C en particular producen.
Ejercicio 2: ¿Por qué supervisor escribe member ANTES de correr ningún especialista? (Medio)
En las tres piezas de esta lección, bb.write("supervisor", member=...) ocurre antes de correr
booking_agent, policy_agent o pricing_agent. Sin ejecutar código, explica por qué este orden
importa, usando el razonamiento de M6 L07 sobre la petición de Ana.
Ver solución
Porque member es un dato que el sistema conoce desde el momento en que llega la petición —no
depende de ningún resultado de ningún especialista— así que escribirlo primero deja el
Blackboard en un estado útil incluso si, por alguna razón, ninguno de los tracks terminara de
correr (una tool que falla, un max_iterations alcanzado). Si el orden se invirtiera —escribir
member DESPUÉS de correr los especialistas—, cualquier fallo a mitad de camino dejaría un
Blackboard sin siquiera saber quién hizo la petición, un estado peor para depurar que uno
incompleto pero con el dato más básico ya presente. Es la misma razón por la que M7 L07 escribió
member como el primer write de toda la corrida de Ana, antes de abrir los tracks paralelos.
Ejercicio 3: Diseña una petición donde el track de HANDOFF sí debería escribir (Difícil)
Esta lección mostró que el handoff de Valentina no escribe porque termina en una cotización, no en
una confirmación. Diseña, en una frase, una petición de Reservo donde un handoff SÍ terminara
produciendo un hecho digno de escribirse al Blackboard — y explica exactamente qué campo(s)
escribirías y por qué.
Ver solución
Un ejemplo válido: "Reserva el Focus pro 2h para mí -- y si hay algún problema con la
disponibilidad, pregúntale a alguien más qué opciones tengo." Si booking_agent, a mitad de
intentar reservar, se topara con una situación fuera de su expertise habitual (por ejemplo, una
política especial de sobre-reserva que vive en policy_agent) y cediera el turno, pero el
resultado final de esa cadena SÍ terminara en una reserva confirmada (policy_agent autoriza
la excepción y alguien —el propio flujo, no necesariamente policy_agent— confirma la reserva),
entonces ese resultado tendría exactamente los mismos campos dignos de escribirse que el pipeline
de Luis: room, tier, hours, price_cents, booking_id. La regla de la pregunta 3 del
criterio no distingue por PATRÓN —distingue por si el resultado final es un hecho confirmado que el
resto del sistema podría necesitar después—, y un handoff que termina en una confirmación real
cumple esa condición exactamente igual que un pipeline.
Resumen y siguiente paso
Blackboard, sin ningún cambio desde el Módulo 6, sigue registrando solo lo que otra parte del sistema podría necesitar después — confirmado sobre tres socios nuevos en una sola corrida.- La regla no es "el patrón decide si se escribe" — es "el resultado decide": la reserva confirmada de Luis escribe seis entradas; la comparación de Marta y la cotización sin confirmar de Valentina, ninguna.
WRITE_SEQse mantuvo en6durante toda la pieza de Marta y de Valentina — el testigo en código de que ninguna de las dos escribió nada.
Siguiente lección: 07 — Midiendo el costo de coordinación del sistema completo. La única pieza genuinamente nueva de este módulo: una función que cuenta llamadas, hops y rondas para cualquier combinación de tracks — probada contra las Demos A y B.
Recursos adicionales
- Anthropic — Multi-agent research system — Un sistema real donde solo los hechos que otro componente necesita después se persisten en un estado compartido, no cada paso intermedio.
- Python — Dataclasses — La estructura detrás de
BlackboardyWriteLogEntry, sin cambios desde el Módulo 6. - Python —
itertools.count— El contador determinista detrás deWRITE_SEQy de_booking_ids, la razón por la que este módulo nunca necesitarandomniuuid4. - Anthropic — Building effective agents — El principio de mantener el estado compartido mínimo y explícito, la misma disciplina que esta lección confirma sobre tres casos nuevos.