Módulo 6: Estado compartido y el patrón blackboard
Escribiendo en el Blackboard
Descripción
La lección 02 hizo un solo write a mano, con un valor que ya conocíamos de antemano
(member="Ana"). Esta lección hace el write real: booking_agent corre de verdad —cotiza,
reserva— y sus resultados se escriben al Blackboard anclados en el tool_result real, no en
el texto que el modelo redactó al final. Es el mismo principio que ya viste en el Módulo 3, lección
03, con extract_payload: nunca confiar en la frase "Focus pro 3h cuesta 6000 centavos" como fuente
del dato — confiar en el diccionario {'price_cents': 6000} que get_quote realmente devolvió.
La diferencia con el Módulo 3 es el destino de ese dato. Ahí, extract_payload armaba un
payload que viajaba, explícitamente, a la siguiente etapa del pipeline —quien lo escribía sabía
exactamente quién lo iba a recibir—. Acá, booking_agent escribe al Blackboard sin saber quién va
a leer después: podría ser policy_agent, podría ser pricing_agent, podría ser nadie en una
corrida donde el socio solo pidió cotizar.
Conexión con el módulo
Esta lección usa Blackboard y WriteLogEntry de la lección 02 sin ningún cambio, y agrega
all_tool_results, la función que ancla cada escritura en el dato real de la tool. La lección 04
lee lo que esta lección escribe; la lección 07 corre esta misma secuencia dentro de una corrida
completa con los cuatro roles.
Analogía: la ficha de la cama, llenada con los resultados del análisis, no con lo que el médico recuerda
Vuelve a la pizarra de la sala de guardia. Un médico no anota en la ficha de la cama 4 "creo que el
paciente tenía algo de fiebre" — anota el número exacto que arrojó el termómetro. Si anotara de
memoria, cualquier otro médico que lea la ficha después heredaría un dato potencialmente equivocado,
sin ninguna forma de verificarlo. Esta lección aplica esa misma disciplina al Blackboard:
booking_agent no escribe lo que dijo en su texto final — escribe el número exacto que
get_quote y book_room devolvieron.
Ejemplo trabajado: booking_agent corre, y escribe dos veces a medida que produce datos
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})")
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
def all_tool_results(history):
"""Todos los tool_result del historial, EN ORDEN, parseados con
ast.literal_eval -- nunca con eval(). El mismo principio de anclar en
el dato real de la tool que ya viste en el Módulo 3, lección 03
(`last_tool_result`) -- acá se necesitan TODOS los resultados de la
corrida, no solo el último, porque booking_agent produce DOS: la
cotización y la reserva."""
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)
)
bb = Blackboard()
bb.write("supervisor", member="Ana")
print("--- después del write del supervisor ---")
print(f"member={bb.member!r}")
TASK = "Cotiza y reserva Focus pro 3h para 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)."
)}]},
]
print()
print("--- booking_agent corre, y escribe DOS veces a medida que produce datos ---")
final, history = run_specialist("booking_agent", TASK, model_script_booking)
results = all_tool_results(history)
print("tool_results en orden:", results)
quote_result, booking_result = results
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=quote_result["price_cents"])
print("write 1 (booking_agent, tras get_quote):",
f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r}")
bb.write("booking_agent", booking_id=booking_result["booking_id"])
print("write 2 (booking_agent, tras book_room):", f"booking_id={bb.booking_id!r}")
print()
print("--- estado final del Blackboard ---")
print(bb)
print()
print("--- el log completo, quién escribió qué y en qué orden ---")
for entry in bb.log:
print(f" #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
Qué esperar:
--- después del write del supervisor ---
member='Ana'
--- booking_agent corre, y escribe DOS veces a medida que produce datos ---
tool_results en orden: [{'price_cents': 6000}, {'booking_id': 1, 'confirmed': True}]
write 1 (booking_agent, tras get_quote): room='Focus' tier='pro' hours=3 price_cents=6000
write 2 (booking_agent, tras book_room): booking_id=1
--- 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, quién escribió qué y en qué orden ---
#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
Fíjate en algo importante: booking_agent escribe dos veces, no una — una vez apenas tiene la
cotización (room, tier, hours, price_cents), y otra vez, después, cuando la reserva ya está
confirmada (booking_id). Esto refleja algo real: si un especialista falla o se corta a mitad de su
tarea —una tool que lanza una excepción, un max_iterations alcanzado—, el Blackboard conserva lo
que sí llegó a escribirse. Con un pipeline (Módulo 3), un fallo a mitad de camino detiene todo el
payload que viajaba junto; con un Blackboard, cada escritura queda registrada apenas ocurre, sin
depender de que el resto de la tarea del agente también termine bien.
all_tool_results, no last_tool_result: por qué la diferencia importa
El Módulo 3 construyó last_tool_result(history) — devuelve el último tool_result del
historial, suficiente ahí porque cada etapa del pipeline corría una sola tool antes de terminar.
booking_agent, en esta lección, corre dos: get_quote y después book_room. Si usaras
last_tool_result acá, solo verías el resultado de book_room ({'booking_id': 1, 'confirmed': True}) y perderías price_cents por completo — nunca llegaría a escribirse en el Blackboard.
all_tool_results resuelve esto recorriendo el historial completo, en orden, y devolviendo todos
los tool_result, no solo el último. El principio de fondo —anclar en el dato real de la tool,
ast.literal_eval, nunca eval()— es exactamente el mismo del Módulo 3; lo que cambia es cuántos
resultados hace falta recuperar.
Errores comunes
-
Escribir el texto final del modelo al
Blackboard, en vez deltool_result. El texto final —"Focus pro 3h cuesta 6000 centavos"— es una frase para el socio, no un dato confiable para que otro agente lo lea después. Si el modelo se equivocara al redactar ese texto (dijera "6500" por error de redacción, aunque la tool haya devuelto6000), escribir el texto propagaría el error; anclar enall_tool_resultsno. -
Intentar escribir
booking_idantes de quebook_roomhaya corrido. En el ejemplo trabajado, el segundowritedepende de queresultstenga dos elementos — sibooking_agentsolo hubiera cotizado (sin reservar),resultstendría un solo elemento yquote_result, booking_result = resultsfallaría con unValueErrorde desempaquetado. La lección 04 muestra cómo leer un campo que legítimamente puede no estar escrito todavía. -
Pensar que dos
writedel mismo agente son "una sola escritura" en el log. No — cadawritedeja tantas entradas como campos nombrados recibió, y dos llamadas awriteen momentos distintos quedan como grupos de entradas separados en la secuencia, exactamente en el orden en que ocurrieron. -
Olvidar que
bb.writeno valida que elwritersea un nombre de agente real.bb.write("booking_gaent", ...)—con un typo en el nombre del escritor, no del campo— se acepta sin ningún error; el log queda con unwriterque nunca va a coincidir con ningún agente real deSPECIALISTS. Es un error del mismo tipo que el del Ejercicio 3 de la lección 02, esta vez en el nombre del autor, no en el nombre del campo. -
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, unreservo_tools.BOOKINGSvacío.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado tú mismo y confirma que tu salida coincide, línea por línea, con el
"Qué esperar" — en particular, que results tiene exactamente dos elementos, en el orden
[cotización, reserva].
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 booking_agent escribió en el orden correcto.
Ejercicio 2: Otra sala, otras horas, el mismo mecanismo (Medio)
Repite el ejemplo trabajado, pero con la tarea "Cotiza y reserva Boardroom pro 4h para Sofía."
Escribe el guion correspondiente y confirma, con all_tool_results, que price_cents y
booking_id quedan escritos correctamente en el Blackboard.
Ver solución
bb_ex2 = Blackboard()
bb_ex2.write("supervisor", member="Sofía")
model_script_ex2 = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 4}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 4, "member": "Sofía"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Boardroom pro 4h cuesta 25600 centavos. Reservé la sala para Sofía (confirmación #1)."}]},
]
_, history_ex2 = run_specialist("booking_agent", "Cotiza y reserva Boardroom pro 4h para Sofía.", model_script_ex2)
quote_ex2, booking_ex2 = all_tool_results(history_ex2)
bb_ex2.write("booking_agent", room="Boardroom", tier="pro", hours=4, price_cents=quote_ex2["price_cents"])
bb_ex2.write("booking_agent", booking_id=booking_ex2["booking_id"])
print(bb_ex2)
print("cálculo a mano Boardroom pro 4h:", 8000 * 4 * 80 // 100)
Salida esperada:
Blackboard(member='Sofía', room='Boardroom', tier='pro', hours=4, price_cents=25600, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Sofía'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Boardroom'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=4, writer='booking_agent', field='hours', value=4), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=25600), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])
cálculo a mano Boardroom pro 4h: 25600
Explicación: el mecanismo no cambió con otra sala u otras horas — 25600 = 8000 * 4 * 80 // 100
confirma el precio, y la estructura del log (seis entradas, mismo orden de campos) es idéntica a la
del ejemplo trabajado, solo con valores distintos.
Ejercicio 3: booking_agent que solo cotiza, sin reservar (Difícil)
En un proceso nuevo, corre booking_agent con una tarea que solo cotiza —sin book_room—
y confirma qué pasa si intentas desempaquetar results esperando dos elementos. Después, escribe la
versión correcta: solo el write de la cotización, dejando booking_id en None.
Ver solución
model_script_solo_quote = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Studio pro 2h cuesta 6400 centavos."}]},
]
_, history_solo = run_specialist("booking_agent", "Cotiza Studio pro 2h.", model_script_solo_quote)
results_solo = all_tool_results(history_solo)
print("results_solo:", results_solo)
try:
q, b = results_solo
except ValueError as e:
print(f"ValueError capturado al desempaquetar: {e}")
bb_ex3 = Blackboard()
bb_ex3.write("booking_agent", room="Studio", tier="pro", hours=2, price_cents=results_solo[0]["price_cents"])
print(bb_ex3)
Salida esperada:
results_solo: [{'price_cents': 6400}]
ValueError capturado al desempaquetar: not enough values to unpack (expected 2, got 1)
Blackboard(member=None, room='Studio', tier='pro', hours=2, price_cents=6400, booking_id=None, log=[WriteLogEntry(seq=1, writer='booking_agent', field='room', value='Studio'), WriteLogEntry(seq=2, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=3, writer='booking_agent', field='hours', value=2), WriteLogEntry(seq=4, writer='booking_agent', field='price_cents', value=6400)])
Explicación: con una sola tool ejecutada, results_solo tiene un solo elemento — desempaquetar
q, b = results_solo esperando dos falla con un ValueError claro, la misma disciplina de "falla
ruidoso" que ya viste con KeyError en el Módulo 2. La versión correcta escribe solo los cuatro
campos que sí hay datos para respaldar (room, tier, hours, price_cents), dejando
booking_id en None — un Blackboard parcialmente lleno es un estado válido, no un error, porque
todavía no llegó ningún tool_result de book_room.
Resumen y siguiente paso
booking_agentescribe alBlackboarddos veces a medida que produce datos: una tras cotizar, otra tras reservar — no todo de una vez al final.all_tool_resultsrecupera todos lostool_resultdel historial, en orden, conast.literal_eval(nuncaeval()) — la extensión delast_tool_result(Módulo 3) para agentes que corren más de una tool antes de terminar.- Cada escritura queda anclada en el dato real que la tool devolvió, nunca en el texto libre que el modelo redactó para el socio — la misma disciplina de anclaje del Módulo 3, aplicada ahora a un estado compartido en vez de a un payload dirigido.
- Un
Blackboardpuede quedar parcialmente lleno de forma legítima (booking_id=Nonesi el especialista todavía no reservó) — la lección 04 muestra cómo leer un campo con esa posibilidad en mente.
Siguiente lección: 04 — Leyendo del Blackboard. policy_agent responde una pregunta sobre
una reserva específica sin que nadie se la mencione explícitamente — leyendo booking_id
directamente de lo que booking_agent ya escribió.
Recursos adicionales
- Python —
ast.literal_eval— La función que ancla cada escritura delBlackboarden el dato real de la tool, nunca en texto libre — la misma que ya usólast_tool_resulten el Módulo 3. - Anthropic — Tool use (function calling) overview — El protocolo
tool_use/tool_resultdel queall_tool_resultsextrae cada dato estructurado. - Anthropic — Multi-agent research system — Un caso real donde el resultado verificado de una herramienta, no la narración de un agente sobre ese resultado, es lo que otros componentes del sistema consumen después.
- Python — Desempaquetado de secuencias — El mecanismo detrás de
quote_result, booking_result = results, y por qué falla ruidoso cuando la cantidad de elementos no coincide.