Módulo 6: Estado compartido y el patrón blackboard

Anatomía del Blackboard

Descripción

El Módulo 5 construyó el paquete de handoff: un agente arma, a mano, un mensaje dirigido a otro —quién envía, a quién, la tarea, el payload mínimo—. Cada paquete es una decisión de diseño: hay que pensar, en el momento de escribirlo, exactamente qué necesita el receptor.

Esta lección construye la pieza que reemplaza esa decisión por una más simple: Blackboard, una estructura de datos con campos fijos —member, room, tier, hours, price_cents, booking_id— que cualquier agente de la corrida puede leer o escribir, más un log: la lista completa de quién escribió qué, en qué orden. Vas a crear un Blackboard vacío, hacer el primer write de la guía —el supervisor anotando quién es el socio— y ver, con tus propios ojos, cómo el objeto y su log cambian con esa sola escritura.

Conexión con el módulo

Esta lección es la base formal sobre la que se apoyan las cinco lecciones que siguen: Blackboard y WriteLogEntry, tal como se construyen aquí, se reusan sin cambios en el resto del módulo. La lección 03 los usa por primera vez con un especialista real (booking_agent) escribiendo resultados de una tool ejecutada; la lección 05 vuelve sobre el log para auditar una corrida completa.


Analogía: la pizarra en blanco, antes de que nadie escriba nada

Retoma la sala de guardia de la lección 01. Antes de que cualquier médico escriba algo, la pizarra existe igual: tiene sus columnas fijas —cama, diagnóstico, medicación— aunque estén vacías. Esa estructura fija es lo primero que hace falta: sin columnas definidas, cada médico escribiría lo que se le ocurriera, en el lugar que se le ocurriera, y la pizarra dejaría de ser útil para nadie. La primera escritura —"cama 4, ingresó con fiebre"— es el momento en que la pizarra empieza a servir para algo. Esta lección construye exactamente eso: las columnas fijas, y la primera anotación.


Ejemplo trabajado: Blackboard, WriteLogEntry, y el primer write

from dataclasses import dataclass, field
import itertools

WRITE_SEQ = itertools.count(1)


@dataclass
class WriteLogEntry:
    """Una entrada del log: quién escribió QUÉ campo, con QUÉ valor, en
    qué SECUENCIA. `seq` viene de un contador (itertools.count) -- nunca
    de datetime.now(), la misma disciplina de IDs deterministas de toda
    esta guía."""
    seq: int
    writer: str
    field: str
    value: object


@dataclass
class Blackboard:
    """El estado compartido de UNA corrida del sistema de Reservo. Los
    seis primeros campos son los HECHOS que los agentes leen y escriben;
    `log` es el registro de cada escritura, en orden."""
    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):
        """Escribe uno o más campos a la vez, y registra cada uno en el
        log con su autor. `writer` es el NOMBRE del agente que escribe
        -- un string, no una referencia al objeto agente -- porque el
        log es para leer humanos, no para que otro código lo consuma."""
        for key, value in fields.items():
            setattr(self, key, value)
            self.log.append(
                WriteLogEntry(seq=next(WRITE_SEQ), writer=writer, field=key, value=value)
            )


print("--- un Blackboard recién creado: todos los campos vacíos, el log vacío ---")
bb = Blackboard()
print(bb)

print()
print("--- el supervisor escribe el primer dato: quién es el socio ---")
bb.write("supervisor", member="Ana")
print(bb)

print()
print("--- el log completo, con un solo write hasta ahora ---")
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<12} escribió {entry.field}={entry.value!r}")

Qué esperar:

--- un Blackboard recién creado: todos los campos vacíos, el log vacío ---
Blackboard(member=None, room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[])

--- el supervisor escribe el primer dato: quién es el socio ---
Blackboard(member='Ana', room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana')])

--- el log completo, con un solo write hasta ahora ---
  #1 supervisor   escribió member='Ana'

Fíjate en dos cosas del repr de bb después del write: (1) member cambió de None a 'Ana' directamente en el objeto —el Blackboard es mutable, a diferencia de casi todo lo que construiste hasta ahora en esta guía, donde cada estructura (RoutingDecision, PipelineStage, SubTask) se creaba una vez y no volvía a cambiar—. (2) log ahora tiene una entrada — el write no solo cambió el valor, también dejó un rastro de que ese cambio ocurrió, quién lo hizo, y en qué secuencia.


Por qué el write no recibe un destinatario

Compara la firma de bb.write("supervisor", member="Ana") con la del paquete de handoff del Módulo 5, que necesitaba un to explícito —a quién iba dirigido el mensaje—. Blackboard.write no tiene ningún parámetro parecido. Esa ausencia es exactamente el punto central del patrón: el supervisor no sabe, en el momento de escribir member, qué agente lo va a leer después —podría ser policy_agent, podría ser pricing_agent, podría ser los dos, podría ser ninguno en una corrida donde nadie necesita ese dato—. Escribe el hecho, y punto. Quien lo necesite, lo lee cuando le toca.

Esta ausencia de destinatario es también la raíz del trade-off completo del módulo: sin un to, no hay forma de limitar de antemano quién puede leer member — cualquier agente con una referencia al mismo objeto bb puede hacer bb.member en cualquier momento de la corrida. La lección 06 mide exactamente cuánto "ver todo" cuesta, comparado con un paquete diseñado a medida.


Los seis campos: por qué estos y no otros

Blackboard no intenta modelar cualquier estado posible de Reservo — tiene exactamente los seis campos que las lecciones 03, 04 y 07 necesitan para una corrida completa:

member       -- lo escribe el supervisor, al recibir la petición
room         -- lo escribe booking_agent, tras cotizar o reservar
tier         -- lo escribe booking_agent, tras cotizar o reservar
hours        -- lo escribe booking_agent, tras cotizar o reservar
price_cents  -- lo escribe booking_agent, tras cotizar
booking_id   -- lo escribe booking_agent, tras reservar (puede quedar en None si aún no reservó)

Ningún campo tiene un dueño exclusivo en el sentido de "solo este agente puede escribirlo" — nada en Blackboard.write lo impide. Pero en la práctica, en este módulo, booking_agent es el único que produce cotizaciones y reservas reales, así que es el único escritor natural de los últimos cinco campos. Esa distinción —quién puede escribir contra quién de hecho escribe— importa para la lección 06, cuando se nombra la superficie de un agente que escribe algo que no le correspondería.


Errores comunes

  1. Pensar que Blackboard() sin argumentos falla porque los campos no tienen valor. Todos los campos tienen un valor por defecto (None, o [] para log, vía default_factory) — un Blackboard recién creado es válido, simplemente vacío. No hace falta pasarle nada para construirlo.

  2. Usar field (el parámetro de write) y field (la función de dataclasses) como si fueran lo mismo. No lo son — dentro de write, **fields captura los argumentos nombrados que llegaron (member="Ana", por ejemplo) en un diccionario local llamado fields; la función field() importada de dataclasses solo se usa una vez, para declarar el default_factory de log. Son dos cosas con nombres parecidos, en espacios distintos.

  3. Olvidar que setattr(self, key, value) no valida que key sea uno de los seis campos declarados. Si write recibe un nombre de campo con un typo —room escrito como rooom— Python lo acepta sin quejarse: crea un atributo nuevo en la instancia, que ni siquiera aparece en el repr del dataclass (porque no es un campo declarado). El Ejercicio 3 de esta lección lo confirma, ejecutado.

  4. Pensar que bb.log es la única forma de saber el estado actual. No — bb.member, bb.room, etc. siempre reflejan el último valor escrito, sin tener que recorrer el log. El log sirve para reconstruir la historia, no para leer el presente — la lección 05 profundiza en esta distinción.

  5. Confundir Blackboard con SPECIALISTS. Son estructuras completamente distintas para preguntas distintas: SPECIALISTS (Módulo 2) responde "¿qué tools tiene este agente?" y no cambia durante una corrida; Blackboard responde "¿qué hechos sabemos hasta ahora?" y cambia con cada write. Un sistema real usa las dos a la vez, sin que se pisen.


Ejercicios

Ejercicio 1: Confirma tu propia ejecución (Fácil)

Ejecuta el ejemplo trabajado de esta lección tú mismo y confirma que tu salida coincide, línea por línea, con el bloque "Qué esperar". Presta atención especial al seq=1 de la primera entrada del log — si corriste algo más antes en el mismo intérprete, WRITE_SEQ puede no arrancar en 1.

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 WRITE_SEQ arrancó limpio y el write corrió sin desvíos.

Ejercicio 2: Un segundo write, de un escritor distinto (Medio)

Sobre el mismo bb del ejemplo trabajado, ejecuta un segundo write — esta vez de "booking_agent", escribiendo room="Focus" y tier="pro" a la vez (dos campos en el mismo write). Imprime el Blackboard completo y el log con las dos entradas nuevas.

Ver solución
bb.write("booking_agent", room="Focus", tier="pro")
print(bb)
print()
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")

Salida esperada:

Blackboard(member='Ana', room='Focus', tier='pro', hours=None, price_cents=None, booking_id=None, 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')])

  #1 supervisor     escribió member='Ana'
  #2 booking_agent  escribió room='Focus'
  #3 booking_agent  escribió tier='pro'

Explicación: un solo write(writer, **fields) con dos campos nombrados produce dos entradas en el log, no una — porque el for key, value in fields.items() de la lección itera cada campo por separado, aunque hayan llegado en la misma llamada. seq sigue siendo consecutivo (2, 3) sin importar que ambos hayan sido, en los hechos, "la misma escritura" desde el punto de vista de quien la hizo.

Ejercicio 3: El typo silencioso (Difícil)

En un proceso nuevo (para que WRITE_SEQ arranque en 1), ejecuta bb.write("booking_agent", rooom="Studio") —con el typo rooom, a propósito— sobre un Blackboard recién creado. (a) ¿Qué le pasa a bb.room? (b) ¿Aparece rooom en el repr de bb? (c) ¿Por qué esto es más peligroso que el KeyError que viste en el Módulo 2 cuando un target no existía en SPECIALISTS?

Ver solución
bb_typo = Blackboard()
bb_typo.write("booking_agent", rooom="Studio")
print("bb_typo.room:", bb_typo.room)
print("bb_typo.rooom:", bb_typo.rooom)
print(bb_typo)

Salida real:

bb_typo.room: None
bb_typo.rooom: Studio
Blackboard(member=None, room=None, tier=None, hours=None, price_cents=None, booking_id=None, log=[WriteLogEntry(seq=1, writer='booking_agent', field='rooom', value='Studio')])

(a) bb_typo.room sigue siendo None — el typo nunca tocó el campo real.

(b) No — rooom no aparece en el repr() del Blackboard, porque el repr que genera @dataclass solo imprime los campos declarados en la clase; rooom es un atributo que setattr creó en la instancia por fuera de esa declaración, así que queda invisible para cualquiera que solo mire print(bb_typo). Solo aparece si revisas el log directamente, o si haces bb_typo.rooom a mano sabiendo que existe.

(c) Es más peligroso porque es un error doblemente silencioso: el KeyError del Módulo 2 detiene el programa de inmediato, con un mensaje claro (KeyError('shipping_agent')), en la línea exacta donde ocurrió. El typo de write no detiene nada — el programa sigue corriendo, room queda en None como si nadie lo hubiera escrito nunca, y el dato equivocado (rooom='Studio') queda enterrado en el log, sin aparecer siquiera en el repr del objeto. Un especialista que después lea bb.room para armar su tarea recibiría None, sin ningún indicio de que la causa fue un typo en otro agente, minutos o pasos antes.


Resumen y siguiente paso

  • Blackboard tiene seis campos fijos —member, room, tier, hours, price_cents, booking_id— más un log de WriteLogEntry, con seq (un contador determinista, nunca datetime.now()), writer, field y value.
  • write(writer, **fields) no recibe ningún destinatario — a diferencia del paquete de handoff del Módulo 5. Esa ausencia es la raíz de todo el trade-off del módulo: coordinar es más simple, pero cualquier agente con acceso al objeto ve todos los campos, sin restricción.
  • Un write con varios campos a la vez produce una entrada de log por campo, no una sola — el log registra a nivel de campo individual, no a nivel de llamada.
  • setattr sin validación acepta un nombre de campo con typo silenciosamente — un error que ni siquiera aparece en el repr del objeto, más peligroso que un KeyError ruidoso.

Siguiente lección: 03 — Escribiendo en el Blackboard. Corremos booking_agent de verdad, y anclamos sus escrituras en el tool_result real de get_quote y book_room — no en el texto libre del modelo.


Recursos adicionales

  1. Python — dataclasses — El módulo detrás de Blackboard y WriteLogEntry, incluyendo default_factory para el campo mutable log.
  2. Python — itertools.count — La fuente del seq determinista de cada entrada del log.
  3. Anthropic — Multi-agent research system — Un sistema real donde el estado que un sub-agente produce queda disponible para otros sin que el primero sepa, de antemano, quién lo va a consumir.
  4. Python — setattr — La función detrás de Blackboard.write, y por qué no valida nombres de atributo por sí sola.