Módulo 6: Estado compartido y el patrón blackboard
Leyendo del Blackboard
Descripción
La lección 03 dejó un Blackboard con seis campos escritos: member='Ana', la cotización completa
de Focus pro 3h, y booking_id=1. Esta lección hace la mitad que faltaba: policy_agent responde
la pregunta de Ana —"¿puedo cancelar mi reserva sin cargo?"— sin que nadie le diga el número de
reserva. Lo lee directamente de bb.booking_id.
Compara esto con cómo resolvería la misma situación un handoff del Módulo 5: ahí, booking_agent
tendría que saber, en el momento de terminar su turno, que el socio va a preguntar por política
después, y armar un paquete con booking_id incluido a propósito para policy_agent. Acá,
booking_agent no necesita anticipar nada — simplemente escribió lo que produjo (lección 03), y
policy_agent lee lo que necesita cuando le toca, sin que exista ninguna coordinación directa entre
los dos.
Conexión con el módulo
Esta lección lee el Blackboard que la lección 03 dejó escrito, con SPECIALISTS extendido para
incluir a policy_agent. La lección 06 vuelve sobre esta misma lectura para medir, con números,
cuánto del Blackboard completo policy_agent realmente necesitó — solo booking_id, de seis
campos disponibles.
Analogía: el médico del turno de la tarde, que nunca habló con el de la mañana
Vuelve a la pizarra de la sala de guardia. El médico del turno de la tarde no llamó por teléfono al
de la mañana para preguntarle "¿en qué cama está el paciente que ingresó hoy?" — leyó la pizarra, y
ahí estaba. Los dos médicos ni siquiera se cruzaron: uno escribió, horas antes, sin saber quién iba
a leer después; el otro leyó, horas después, sin haber coordinado nada con quien escribió. Eso es
exactamente lo que hace policy_agent en esta lección con booking_agent.
Ejemplo trabajado: policy_agent lee booking_id, sin que nadie se lo diga
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}},
}
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)
)
# --- lo que la lección 03 ya dejó listo: supervisor + booking_agent escribieron ---
bb = Blackboard()
bb.write("supervisor", member="Ana")
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)."}]},
]
_, history_booking = run_specialist("booking_agent", "Cotiza y reserva Focus pro 3h para Ana.", model_script_booking)
quote_result, booking_result = all_tool_results(history_booking)
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=quote_result["price_cents"])
bb.write("booking_agent", booking_id=booking_result["booking_id"])
print("--- estado del Blackboard al llegar a esta lección ---")
print(f"member={bb.member!r} 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}")
# --- lo nuevo de esta lección: Ana pregunta por su reserva, SIN decir el número ---
member_question = "Ana pregunta: ¿Puedo cancelar mi reserva sin cargo?"
print()
print(f"--- pregunta del socio: {member_question!r} ---")
print("policy_agent no recibió el booking_id en la pregunta -- lo LEE del Blackboard.")
task_policy = f"¿Puedo cancelar la reserva {bb.booking_id} 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 {bb.booking_id} se puede cancelar sin cargo hasta 2 "
"horas antes del horario reservado."
)}]},
]
final_policy, history_policy = run_specialist("policy_agent", task_policy, model_script_policy)
print("respuesta de policy_agent:", final_policy["content"][0]["text"])
print()
print("--- policy_agent NO escribió nada al Blackboard: solo leyó ---")
print("writes totales de policy_agent en el log:",
sum(1 for e in bb.log if e.writer == "policy_agent"))
print("campos que policy_agent leyó para resolver la tarea: booking_id (1 campo)")
Qué esperar:
--- estado del Blackboard al llegar a esta lección ---
member='Ana' room='Focus' tier='pro' hours=3 price_cents=6000 booking_id=1
--- pregunta del socio: 'Ana pregunta: ¿Puedo cancelar mi reserva sin cargo?' ---
policy_agent no recibió el booking_id en la pregunta -- lo LEE del Blackboard.
tarea armada leyendo bb.booking_id: '¿Puedo cancelar la reserva 1 sin cargo?'
respuesta de policy_agent: 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ó ---
writes totales de policy_agent en el log: 0
campos que policy_agent leyó para resolver la tarea: booking_id (1 campo)
Fíjate en la línea task_policy = f"¿Puedo cancelar la reserva {bb.booking_id} sin cargo?". Ana
nunca mencionó el número 1 — dijo "mi reserva", sin más—. El sistema completo (el paso que en una
corrida real haría el supervisor, o el propio policy_agent antes de construir su tarea) resolvió
"mi reserva" leyendo bb.booking_id, el mismo valor que booking_agent había escrito minutos antes
sin saber que alguien lo iba a necesitar así.
Leer no dejó ningún rastro — y eso es una decisión de diseño, no un descuido
Nota algo importante: policy_agent leyó bb.booking_id con una simple expresión
(bb.booking_id), y esa lectura no generó ninguna entrada en el log. WriteLogEntry registra
escrituras, nunca lecturas — el Blackboard de esta guía no lleva un "read log" simétrico al
"write log". Esto es intencional: DISEÑO de este módulo pide auditar quién escribió qué, no
todas las veces que alguien miró un valor —una lectura no cambia el estado compartido, así que no
hay ninguna corrección ni conflicto que rastrear en ella—.
La consecuencia práctica: si quisieras responder "¿qué agentes leyeron booking_id durante esta
corrida?", el Blackboard tal como está construido no te lo puede decir por sí solo — tendrías
que instrumentarlo aparte (algo que la lección 06 hace, con una lista separada, solo para poder
medir el trade-off, nunca como parte del Blackboard en sí).
Errores comunes
-
Pedirle al socio el
booking_idcuando ya está en el Blackboard. Si Ana ya reservó en esta misma corrida, volver a preguntarle "¿cuál es tu número de reserva?" ignora exactamente la ventaja que ofrece un estado compartido — el dato ya está disponible, sin que nadie tenga que repetirlo. -
Asumir que
bb.booking_idsiempre tiene un valor. No — sibooking_agenttodavía no reservó nada en esta corrida (por ejemplo, el socio solo pidió cotizar),bb.booking_idesNone, y construir una tarea con él produciría un texto sin sentido ("la reserva None"). El mini-proyecto de la lección 08 construye la guarda que evita esto. -
Confundir "policy_agent leyó booking_id" con "policy_agent sabe cómo se calculó ese valor". No —
policy_agentrecibe el número1, sin ningún contexto sobre cómo se llegó a él (que fuebook_roomquien lo generó, conitertools.count). ElBlackboardcomparte el resultado, no el proceso que lo produjo — para eso está ellog, no el campo en sí. -
Pensar que la ausencia de log de lecturas es un defecto del diseño. No lo es — es la diferencia entre auditar "quién cambió el estado compartido" (lo que sí importa para depurar una corrida) y "quién miró un valor sin cambiarlo" (que, sin más contexto, no aporta nada a esa auditoría). La lección 06 sí instrumenta lecturas, pero como una medición externa puntual, no como una característica permanente de
Blackboard. -
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. La solución de siempre: un proceso nuevo.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado tú mismo y confirma que la tarea armada para policy_agent incluye el
número 1, exactamente el booking_id que booking_agent había escrito en la lección 03.
Ver solución
No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida
coincide con el "Qué esperar" del ejemplo trabajado, la lectura de bb.booking_id funcionó
correctamente.
Ejercicio 2: pricing_agent lee tier y hours, no booking_id (Medio)
Extiende SPECIALISTS con pricing_agent y haz que compare Studio y Boardroom, reusando el
mismo tier y hours que ya están en el Blackboard (no le pidas al socio que los repita).
Confirma que la tarea armada usa esos valores leídos, no un texto escrito a mano con los números.
Ver solución
SPECIALISTS["pricing_agent"] = {"tools": {"get_quote": rt.get_quote}}
task_pricing = f"Compara Studio y Boardroom, ambas {bb.tier}, {bb.hours}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_pricing, history_pricing = run_specialist("pricing_agent", task_pricing, model_script_pricing)
print("respuesta:", final_pricing["content"][0]["text"])
Salida esperada:
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.
Explicación: pricing_agent reusa tier='pro' y hours=3, que booking_agent ya había
escrito en la lección 03 — Ana nunca tuvo que repetir "pro, 3 horas" para comparar salas.
9600 = 4000 * 3 * 80 // 100 y 19200 = 8000 * 3 * 80 // 100 confirman los precios.
Ejercicio 3: ¿Qué pasa si booking_id todavía es None? (Difícil)
Sobre un Blackboard nuevo, donde el supervisor escribió member pero booking_agent todavía
no corrió, intenta armar la tarea de policy_agent de la misma forma que el ejemplo trabajado
(f"¿Puedo cancelar la reserva {bb.booking_id} sin cargo?"). ¿Qué texto se produce? ¿Por qué ese
texto es un problema, aunque Python no lance ningún error?
Ver solución
bb_vacio = Blackboard()
bb_vacio.write("supervisor", member="Marta")
task_rota = f"¿Puedo cancelar la reserva {bb_vacio.booking_id} sin cargo?"
print(repr(task_rota))
Salida real:
'¿Puedo cancelar la reserva None sin cargo?'
Explicación: Python no lanza ningún error — un f-string interpola None como el texto literal
"None", sin quejarse. El problema no es técnico, es semántico: "la reserva None" no identifica
ninguna reserva real, y si esta tarea llegara a policy_agent, el especialista no tendría forma de
responder algo útil (no existe ninguna reserva con ese "número"). Este es exactamente el tipo de
falla silenciosa que la lección 02 ya advirtió con el typo de campo: el código corre sin
detenerse, pero produce un resultado sin sentido. El mini-proyecto de la lección 08 construye una
guarda explícita (require) que convierte este caso en un error ruidoso, capturable, en vez de un
texto roto que sigue de largo.
Resumen y siguiente paso
policy_agentrespondió una pregunta sobre una reserva específica sin que nadie mencionara el número — lo leyó directamente debb.booking_id, escrito minutos antes porbooking_agentsin saber que alguien lo iba a necesitar así.- Leer del
Blackboardes una simple expresión (bb.campo) — no genera ninguna entrada en el log, a diferencia de escribir. El log de esta guía registra escrituras, no lecturas, a propósito. pricing_agentpuede reusartieryhoursya escritos porbooking_agent, sin que el socio tenga que repetirlos — otra forma de la misma ventaja.- Un campo que nadie escribió todavía es
None, y Python lo interpola sin error en un f-string — produciendo texto sin sentido en vez de detenerse. El mini-proyecto construye la guarda que lo corrige.
Siguiente lección: 05 — El log de escritura. Profundizamos en el log del Blackboard:
cómo auditar una corrida completa, incluso cuando un valor se corrige a mitad de camino.
Recursos adicionales
- Anthropic — Multi-agent research system — Un sistema real donde un sub-agente consume un dato que otro produjo, sin que exista ninguna comunicación directa entre los dos.
- Python — f-strings — El mecanismo que interpola
bb.booking_iden el texto de la tarea, y por qué interpolaNonesin lanzar ningún error. - Anthropic — Tool use (function calling) overview — El protocolo que
policy_agentsigue usando, sin cambios, con su única toolsearch_docs. - Python — Atributos de instancia — La forma en que
bb.booking_idse lee directamente, sin ningún método getter de por medio.