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 dossupervisor 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 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

  1. Pensar que el supervisor "no hizo nada" en esta corrida porque solo escribió un campo. El Paso 1 (decidir el orden: booking_agentpolicy_agentpricing_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. El Blackboard cambia cómo los especialistas comparten datos entre sí, no quién coordina la corrida.

  2. 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.

  3. Reordenar los pasos 3 y 4 sin pensar en las dependencias. Aunque el Blackboard no fuerza un orden como lo haría un pipeline (Módulo 3), en este caso concreto el Paso 3 depende de booking_id (escrito en el Paso 2) y el Paso 4 depende de tier/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.

  4. Olvidar que READS es una instrumentación de esta lección, no parte de Blackboard. Como ya viste en la lección 04, el Blackboard en sí no registra lecturas — la lista READS de este ejemplo es un mecanismo aparte, construido solo para poder reportar, al final, quién leyó qué.

  5. Ejecutar este ejemplo en un proceso que ya tenía reservas. Si book_room no devuelve booking_id: 1, el intérprete ya corrió book_room antes 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 Blackboard durante una petición compuesta, de punta a punta.
  • Los números reales: de los cuatro roles, solo supervisor y booking_agent escribieron (6 entradas de log en total); policy_agent y pricing_agent resolvieron su parte leyendo —booking_id, tier, hours— sin agregar ningún hecho nuevo.
  • El orden de ejecución sí importa, aunque el Blackboard no 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 Blackboard desaparece con el proceso (agent-memory-and-state-guide resolvería eso), y policy_agent confió en booking_id sin ninguna verificación adicional (agent-security-and-sandboxing-guide endurecerí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

  1. 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.
  2. Anthropic — Building effective agents — El principio de mantener cada pieza del sistema simple y componible — el Blackboard de esta lección no reemplaza ningún runner ni protocolo anterior, solo agrega un estado común encima.
  3. Python — collections.Counter — La estructura usada en el Ejercicio 3 para contar lecturas por campo.
  4. Anthropic — Messages API reference — La forma exacta de tool_use/tool_result/stop_reason que cada especialista de esta corrida respeta, sin cambios, dentro de run_agent_parallel.