Módulo 6: Estado compartido y el patrón blackboard
La corrida completa: supervisor y tres especialistas comparten un Blackboard
Descripción
Las seis lecciones anteriores construyeron cada pieza por separado: la estructura (02), la escritura
(03), la lectura (04), el log (05), y el trade-off medido (06). Esta lección las junta todas sobre
una petición real, compuesta, con los cuatro roles de Reservo —el supervisor, booking_agent,
policy_agent, pricing_agent— compartiendo un solo objeto Blackboard durante una corrida de
punta a punta.
No hay ninguna pieza nueva en esta lección — es la primera vez que ves el patrón blackboard completo
funcionando de principio a fin, exactamente como lo prometió el Módulo 1: "member, cotización y
booking_id compartidos entre los tres especialistas". Al final, vas a poder leer el log completo y
responder, sin volver a ejecutar nada, quién escribió cada dato y quién solo lo leyó.
Conexión con el módulo
Esta lección es la síntesis ejecutada de las lecciones 02 a 06: usa Blackboard, WriteLogEntry y
all_tool_results sin ningún cambio, y aplica el criterio de "leer lo que necesitas, sin que nadie
te lo diga" (lección 04) a tres lecturas distintas, no una. El Módulo 7 retoma esta misma
corrida para combinarla con supervisor, pipeline, fan-out y handoff sobre una petición todavía más
compleja.
Analogía: el turno completo de la sala de guardia, con los cuatro roles trabajando a la vez
Las lecciones anteriores mostraron piezas sueltas de la sala de guardia: una anotación en la pizarra, una lectura aislada, una corrección documentada. Esta lección es el turno completo: el médico que admite al paciente escribe en la pizarra, otro médico la lee para decidir un tratamiento, un tercero la consulta para una segunda opinión — todos trabajando sobre la misma pizarra, en la misma guardia, sin que nadie tuviera que llamar por teléfono a nadie.
Ejemplo trabajado: una petición, cuatro roles, un 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})")
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)
def all_tool_results(history):
results = []
for m in history:
if isinstance(m["content"], list):
for b in m["content"]:
if b["type"] == "tool_result":
try:
results.append(ast.literal_eval(b["content"]))
except (ValueError, SyntaxError):
results.append(b["content"])
return results
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)
)
READS = [] # (lector, campo) -- solo para reportar al final de esta lección, no vive en Blackboard
def read(reader, bb, field_name):
READS.append((reader, field_name))
return getattr(bb, field_name)
REQUEST = (
"Ana: cotiza y reserva Focus pro 3h, después dime si la puedo cancelar sin "
"cargo, y compara con Studio y Boardroom, también pro 3h."
)
print("--- petición compuesta ---")
print(REQUEST)
bb = Blackboard()
print()
print("=== Paso 1 (concepto, supervisor): lee la petición, decide el orden ===")
print("orden decidido: booking_agent -> policy_agent -> pricing_agent")
bb.write("supervisor", member="Ana")
print(f"supervisor escribe member={bb.member!r}")
print()
print("=== Paso 2 (ejecutado): booking_agent cotiza y reserva ===")
model_script_booking = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1)."}]},
]
final_b, history_b = run_specialist("booking_agent", "Cotiza y reserva Focus pro 3h para Ana.", model_script_booking)
quote_r, booking_r = all_tool_results(history_b)
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=quote_r["price_cents"])
bb.write("booking_agent", booking_id=booking_r["booking_id"])
print("respuesta:", final_b["content"][0]["text"])
print(f"booking_agent escribió room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} "
f"price_cents={bb.price_cents!r} booking_id={bb.booking_id!r}")
print()
print("=== Paso 3 (ejecutado): policy_agent responde LEYENDO booking_id del Blackboard ===")
booking_id_read = read("policy_agent", bb, "booking_id")
task_policy = f"¿Puedo cancelar la reserva {booking_id_read} sin cargo?"
print(f"tarea armada leyendo bb.booking_id: {task_policy!r}")
model_script_policy = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "política de cancelación"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
f"Sí -- la reserva {booking_id_read} se puede cancelar sin cargo hasta 2 "
"horas antes del horario reservado."
)}]},
]
final_p, history_p = run_specialist("policy_agent", task_policy, model_script_policy)
print("respuesta:", final_p["content"][0]["text"])
print("policy_agent no escribió nada al Blackboard -- solo leyó.")
print()
print("=== Paso 4 (ejecutado): pricing_agent compara, LEYENDO tier y hours del Blackboard ===")
tier_read = read("pricing_agent", bb, "tier")
hours_read = read("pricing_agent", bb, "hours")
task_pricing = f"Compara Studio y Boardroom, ambas {tier_read}, {hours_read}h."
print(f"tarea armada leyendo bb.tier y bb.hours: {task_pricing!r}")
model_script_pricing = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 3}},
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. "
"Studio es la opción más barata de las dos."
)}]},
]
final_pr, history_pr = run_specialist("pricing_agent", task_pricing, model_script_pricing)
print("respuesta:", final_pr["content"][0]["text"])
print("pricing_agent no escribió nada al Blackboard -- solo leyó.")
print()
print("--- estado final del Blackboard ---")
print(bb)
print()
print("--- el log completo de writes ---")
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
print()
print("--- reads registrados (informativo, fuera del Blackboard) ---")
for reader, field_name in READS:
print(f" {reader:<14} leyó {field_name}")
print()
print("--- resumen: quién escribe, quién solo lee ---")
writers = {e.writer for e in bb.log}
readers = {r for r, _ in READS}
print("agentes que escribieron al menos un campo:", sorted(writers))
print("agentes que solo leyeron (nunca escribieron):", sorted(readers - writers))
Qué esperar:
--- petición compuesta ---
Ana: cotiza y reserva Focus pro 3h, después dime si la puedo cancelar sin cargo, y compara con Studio y Boardroom, también pro 3h.
=== Paso 1 (concepto, supervisor): lee la petición, decide el orden ===
orden decidido: booking_agent -> policy_agent -> pricing_agent
supervisor escribe member='Ana'
=== Paso 2 (ejecutado): booking_agent cotiza y reserva ===
respuesta: Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1).
booking_agent escribió room='Focus' tier='pro' hours=3 price_cents=6000 booking_id=1
=== Paso 3 (ejecutado): policy_agent responde LEYENDO booking_id del Blackboard ===
tarea armada leyendo bb.booking_id: '¿Puedo cancelar la reserva 1 sin cargo?'
respuesta: Sí -- la reserva 1 se puede cancelar sin cargo hasta 2 horas antes del horario reservado.
policy_agent no escribió nada al Blackboard -- solo leyó.
=== Paso 4 (ejecutado): pricing_agent compara, LEYENDO tier y hours del Blackboard ===
tarea armada leyendo bb.tier y bb.hours: 'Compara Studio y Boardroom, ambas pro, 3h.'
respuesta: Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es la opción más barata de las dos.
pricing_agent no escribió nada al Blackboard -- solo leyó.
--- estado final del Blackboard ---
Blackboard(member='Ana', room='Focus', tier='pro', hours=3, price_cents=6000, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=4, writer='booking_agent', field='hours', value=3), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=6000), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])
--- el log completo de writes ---
#1 supervisor escribió member='Ana'
#2 booking_agent escribió room='Focus'
#3 booking_agent escribió tier='pro'
#4 booking_agent escribió hours=3
#5 booking_agent escribió price_cents=6000
#6 booking_agent escribió booking_id=1
--- reads registrados (informativo, fuera del Blackboard) ---
policy_agent leyó booking_id
pricing_agent leyó tier
pricing_agent leyó hours
--- resumen: quién escribe, quién solo lee ---
agentes que escribieron al menos un campo: ['booking_agent', 'supervisor']
agentes que solo leyeron (nunca escribieron): ['policy_agent', 'pricing_agent']
Lee el resumen final con cuidado: de los cuatro roles de esta corrida, solo dos —supervisor y
booking_agent— escribieron algo al Blackboard. Los otros dos —policy_agent y
pricing_agent— resolvieron su parte de la petición leyendo lo que ya estaba escrito, sin agregar
ningún hecho nuevo al estado compartido. Esta asimetría no es un accidente de este ejemplo: refleja
que, en Reservo, solo booking_agent produce hechos nuevos verificables (una cotización, una
reserva) — los otros dos especialistas consumen esos hechos para responder, sin generar hechos
propios que valga la pena compartir.
Por qué el orden de ejecución sí importa aquí (a diferencia del fan-out del Módulo 4)
Compara esta corrida con el fan-out del Módulo 4: ahí, dos sub-tareas independientes podían correr
en cualquier orden —o en paralelo— porque ninguna necesitaba el resultado de la otra. Acá, el orden
sí importa: policy_agent no podría construir su tarea (f"¿Puedo cancelar la reserva {booking_id_read} sin cargo?") si corriera antes de que booking_agent escribiera
booking_id. Este módulo no reintroduce el fan-out —los tres pasos de esta corrida son
secuenciales, uno después del otro, decididos por el supervisor (Paso 1)— pero vale la pena notar
que el Blackboard no elimina la necesidad de pensar en dependencias; solo cambia cómo se
resuelven: en vez de que booking_agent le pase el dato directamente a policy_agent (un handoff),
policy_agent lo lee del estado compartido, en el momento en que le toca correr.
Las dos superficies nombradas, sobre esta misma corrida
Con la corrida completa enfrente, las dos superficies que la lección 01 adelantó dejan de ser abstractas:
Persistencia. Cuando el proceso de este ejemplo termina, bb desaparece — junto con member,
la cotización, el booking_id, y el log completo de quién escribió qué. Si Ana volviera mañana y
preguntara "¿cuál era el precio de mi reserva de ayer?", este Blackboard, tal como está construido,
no tendría ninguna respuesta: nunca se guardó en ningún lado fuera de la memoria de este proceso.
Resolver eso —un store en disco, con lectura y escritura persistentes— es terreno de
agent-memory-and-state-guide.
Confianza ciega. En el Paso 3, policy_agent confió en que bb.booking_id era el número
correcto, sin ninguna verificación adicional — leyó el campo y lo usó directamente en su tarea. Si
booking_agent (por un bug, o por datos maliciosos que hubiera procesado en otra parte de una
corrida más compleja) hubiera escrito un booking_id incorrecto, policy_agent lo habría
propagado sin darse cuenta. Endurecer un sistema contra esto —validar antes de confiar, limitar qué
puede escribir cada agente— es terreno de agent-security-and-sandboxing-guide.
Errores comunes
-
Pensar que el supervisor "no hizo nada" en esta corrida porque solo escribió un campo. El Paso 1 (decidir el orden:
booking_agent→policy_agent→pricing_agent) es una decisión real, aunque concepto — el supervisor sigue siendo quien decide quién trabaja y en qué orden, exactamente su rol del Módulo 2. ElBlackboardcambia cómo los especialistas comparten datos entre sí, no quién coordina la corrida. -
Confundir "policy_agent y pricing_agent no escribieron nada" con "no hicieron nada útil". Los dos respondieron preguntas reales del socio — simplemente no produjeron ningún hecho nuevo que otro especialista, en esta corrida particular, necesitara leer después.
-
Reordenar los pasos 3 y 4 sin pensar en las dependencias. Aunque el
Blackboardno fuerza un orden como lo haría un pipeline (Módulo 3), en este caso concreto el Paso 3 depende debooking_id(escrito en el Paso 2) y el Paso 4 depende detier/hours(también del Paso 2) — ambos dependen del Paso 2, pero no entre sí. Podrían intercambiarse 3 y 4 sin romper nada; ninguno podría correr antes del Paso 2. -
Olvidar que
READSes una instrumentación de esta lección, no parte deBlackboard. Como ya viste en la lección 04, elBlackboarden sí no registra lecturas — la listaREADSde este ejemplo es un mecanismo aparte, construido solo para poder reportar, al final, quién leyó qué. -
Ejecutar este ejemplo en un proceso que ya tenía reservas. Si
book_roomno devuelvebooking_id: 1, el intérprete ya corrióbook_roomantes en la misma sesión.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta la corrida completa de esta lección tú mismo y confirma, línea por línea, que tu salida coincide con el "Qué esperar" — en particular, el resumen final de quién escribió y quién solo leyó.
Ver solución
No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida coincide exactamente con el "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y la corrida completa funcionó de punta a punta.
Ejercicio 2: Una petición sin la parte de pricing (Medio)
Repite la corrida, pero con una petición más corta: "Marta: cotiza y reserva Boardroom pro 2h,
después dime si hay cargo por no presentarme." (sin la parte de comparar precios). Ejecuta solo los
Pasos 1 a 3, y confirma que el Blackboard final tiene los mismos seis campos, con valores nuevos.
Ver solución
bb_ex2 = Blackboard()
bb_ex2.write("supervisor", member="Marta")
model_script_booking_ex2 = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 2}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 2, "member": "Marta"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Boardroom pro 2h cuesta 12800 centavos. Reservé la sala para Marta (confirmación #1)."}]},
]
final_ex2, history_ex2 = run_specialist("booking_agent", "Cotiza y reserva Boardroom pro 2h para Marta.", model_script_booking_ex2)
quote_ex2, booking_ex2 = all_tool_results(history_ex2)
bb_ex2.write("booking_agent", room="Boardroom", tier="pro", hours=2, price_cents=quote_ex2["price_cents"])
bb_ex2.write("booking_agent", booking_id=booking_ex2["booking_id"])
print("respuesta booking_agent:", final_ex2["content"][0]["text"])
task_policy_ex2 = f"¿Hay cargo por no presentarme a la reserva {bb_ex2.booking_id}?"
model_script_policy_ex2 = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "no me presento a mi reserva"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
f"Sí -- si no te presentas a la reserva {bb_ex2.booking_id} y no cancelas con al "
"menos 2 horas de anticipación, se cobra el 50% del precio cotizado."
)}]},
]
final_p_ex2, _ = run_specialist("policy_agent", task_policy_ex2, model_script_policy_ex2)
print("respuesta policy_agent:", final_p_ex2["content"][0]["text"])
print(bb_ex2)
print("cálculo a mano Boardroom pro 2h:", 8000 * 2 * 80 // 100)
Salida esperada:
respuesta booking_agent: Boardroom pro 2h cuesta 12800 centavos. Reservé la sala para Marta (confirmación #1).
respuesta policy_agent: Sí -- si no te presentas a la reserva 1 y no cancelas con al menos 2 horas de anticipación, se cobra el 50% del precio cotizado.
Blackboard(member='Marta', room='Boardroom', tier='pro', hours=2, price_cents=12800, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Marta'), 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=2), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=12800), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])
cálculo a mano Boardroom pro 2h: 12800
Explicación: con dos pasos en vez de tres, el Blackboard sigue teniendo exactamente los mismos
seis campos declarados —solo que ninguna lectura de pricing_agent ocurrió en esta corrida más
corta—. 12800 = 8000 * 2 * 80 // 100 confirma el precio.
Ejercicio 3: Cuenta las lecturas por campo, no solo por agente (Difícil)
Sobre READS del ejemplo trabajado, construye un conteo de cuántas veces se leyó cada campo
(no cada agente), y determina cuál de los seis campos del Blackboard nunca fue leído por ningún
agente en esta corrida.
Ver solución
from collections import Counter
field_reads = Counter(field_name for _, field_name in READS)
print("lecturas por campo:", dict(field_reads))
ALL_FIELDS_L07 = {"member", "room", "tier", "hours", "price_cents", "booking_id"}
never_read = ALL_FIELDS_L07 - set(field_reads)
print("campos nunca leídos en esta corrida:", sorted(never_read))
Salida esperada:
lecturas por campo: {'booking_id': 1, 'tier': 1, 'hours': 1}
campos nunca leídos en esta corrida: ['member', 'price_cents', 'room']
Explicación: solo tres de los seis campos se leyeron explícitamente en esta corrida
(booking_id, tier, hours) — member, price_cents y room se escribieron, pero ningún
especialista los necesitó para su parte de la tarea. Esto no significa que esos tres campos fueran
inútiles de escribir —otra corrida, con una petición distinta, sí podría necesitarlos (por ejemplo,
si el socio preguntara "¿cuánto pagué en total?", alguien leería price_cents)—, pero confirma un
punto de la lección 06: el Blackboard expone TODO lo que se escribió, sin importar si una corrida
particular termina usando esa información o no.
Resumen y siguiente paso
- Corrimos el patrón blackboard completo: el supervisor y los tres especialistas de Reservo
compartiendo un solo objeto
Blackboarddurante una petición compuesta, de punta a punta. - Los números reales: de los cuatro roles, solo
supervisorybooking_agentescribieron (6 entradas de log en total);policy_agentypricing_agentresolvieron su parte leyendo —booking_id,tier,hours— sin agregar ningún hecho nuevo. - El orden de ejecución sí importa, aunque el
Blackboardno lo fuerce como un pipeline: los Pasos 3 y 4 dependen del Paso 2, aunque no dependan entre sí. - Sobre esta misma corrida, las dos superficies nombradas en la lección 01 dejaron de ser
abstractas: este
Blackboarddesaparece con el proceso (agent-memory-and-state-guideresolvería eso), ypolicy_agentconfió enbooking_idsin ninguna verificación adicional (agent-security-and-sandboxing-guideendurecería eso).
Siguiente lección: 08 — Mini-proyecto: el Blackboard de Reservo. Aplicas el patrón completo
a escenarios nuevos, incluido el error más común: reusar un Blackboard entre corridas distintas.
Recursos adicionales
- Anthropic — Multi-agent research system — Un orquestador real coordinando varios sub-agentes que leen y escriben resultados compartidos durante una misma tarea, la forma completa que persigue esta lección.
- Anthropic — Building effective agents — El principio de mantener cada pieza del sistema simple y componible — el
Blackboardde esta lección no reemplaza ningún runner ni protocolo anterior, solo agrega un estado común encima. - Python —
collections.Counter— La estructura usada en el Ejercicio 3 para contar lecturas por campo. - Anthropic — Messages API reference — La forma exacta de
tool_use/tool_result/stop_reasonque cada especialista de esta corrida respeta, sin cambios, dentro derun_agent_parallel.