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

El log de escritura

Descripción

Las lecciones 03 y 04 usaron bb.log de pasada, para mostrar el orden en que las escrituras ocurrieron. Esta lección se detiene en el log en sí: por qué existe, qué garantiza, y qué revela que el estado actual del Blackboard —los seis campos con su último valor— no puede revelar por sí solo. El caso central: Ana cambia de sala a mitad de la corrida. booking_agent cotiza Focus primero, y después vuelve a cotizar Studio, corrigiendo lo que ya había escrito. El estado final del Blackboard solo muestra Studio — pero el log conserva la corrección completa, con las dos cotizaciones, quién las escribió, y en qué orden.

Sin el log, esa corrección desaparecería sin dejar rastro: cualquiera que mirara el Blackboard después vería room='Studio' como si esa hubiera sido siempre la elección, sin ninguna forma de saber que Focus estuvo ahí primero. Con el log, la corrección es un hecho auditable, no un detalle perdido.

Conexión con el módulo

Esta lección extiende Blackboard de las lecciones 02-04 con history_of, un método nuevo que lee el log filtrado por campo. No cambia write ni ninguna estructura anterior. La lección 07 vuelve a citar el log completo de una corrida de cuatro roles; la lección 08 lo usa para diagnosticar el escenario "mal hecho" del mini-proyecto.


Analogía: el historial clínico, no solo el diagnóstico de hoy

Una ficha médica bien llevada no solo dice "el paciente tiene X" — dice cuándo se diagnosticó X, qué decía la ficha antes de ese diagnóstico, y quién lo escribió. Si un médico corrige un diagnóstico anterior, esa corrección queda agregada a la ficha, no borra lo que había antes: un tratamiento que ya se administró basado en el diagnóstico viejo sigue siendo parte de la historia real del paciente, aunque hoy se sepa que ese diagnóstico estaba equivocado. El log del Blackboard funciona igual: nunca borra una entrada anterior, solo agrega la corrección encima.


Ejemplo trabajado: Ana cambia de sala a mitad de la corrida

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):
    import ast
    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)
            )

    def history_of(self, field_name):
        """Todas las entradas del log para UN campo, en orden -- deja ver
        cómo cambió ese valor a lo largo de la corrida, no solo su
        estado actual."""
        return [e for e in self.log if e.field == field_name]


bb = Blackboard()
bb.write("supervisor", member="Ana")

print("--- booking_agent cotiza Focus pro 3h y escribe ---")
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=6000)
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r}")

print()
print("--- Ana cambia de sala a mitad de la corrida: 'mejor Studio, misma duración' ---")
print("booking_agent vuelve a cotizar y vuelve a escribir -- MISMOS campos, valores nuevos")
bb.write("booking_agent", room="Studio", tier="pro", hours=3, price_cents=9600)
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r}")

print()
print("--- booking_agent confirma la reserva de Studio ---")
model_script_book_studio = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Studio", "tier": "pro", "hours": 3, "member": "Ana"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Studio pro 3h para Ana (confirmación #1), 9600 centavos."}]},
]
_, history_book = run_specialist("booking_agent", "Reserva Studio pro 3h para Ana.", model_script_book_studio)
(booking_result,) = all_tool_results(history_book)
bb.write("booking_agent", booking_id=booking_result["booking_id"])

print()
print("--- estado FINAL del Blackboard: solo el último valor de cada campo ---")
print(f"room={bb.room!r} tier={bb.tier!r} hours={bb.hours!r} price_cents={bb.price_cents!r} booking_id={bb.booking_id!r}")

print()
print("--- el log completo: la CORRECCIÓN queda visible, aunque el estado actual ya la tapó ---")
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")

print()
print("--- auditando UN campo con history_of: cómo cambió price_cents durante la corrida ---")
for entry in bb.history_of("price_cents"):
    print(f"  #{entry.seq} {entry.writer} puso price_cents={entry.value}")

Qué esperar:

--- booking_agent cotiza Focus pro 3h y escribe ---
room='Focus' tier='pro' hours=3 price_cents=6000

--- Ana cambia de sala a mitad de la corrida: 'mejor Studio, misma duración' ---
booking_agent vuelve a cotizar y vuelve a escribir -- MISMOS campos, valores nuevos
room='Studio' tier='pro' hours=3 price_cents=9600

--- booking_agent confirma la reserva de Studio ---

--- estado FINAL del Blackboard: solo el último valor de cada campo ---
room='Studio' tier='pro' hours=3 price_cents=9600 booking_id=1

--- el log completo: la CORRECCIÓN queda visible, aunque el estado actual ya la tapó ---
  #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ó room='Studio'
  #7 booking_agent  escribió tier='pro'
  #8 booking_agent  escribió hours=3
  #9 booking_agent  escribió price_cents=9600
  #10 booking_agent  escribió booking_id=1

--- auditando UN campo con history_of: cómo cambió price_cents durante la corrida ---
  #5 booking_agent puso price_cents=6000
  #9 booking_agent puso price_cents=9600

Lee las dos secciones finales con cuidado. El estado (bb.room, bb.price_cents, etc.) solo te dice dónde terminó todo: Studio, 9600 centavos. Si alguien te preguntara "¿por qué el precio final es 9600 y no 6000?", el estado solo no te lo puede responder — necesitas el log. Con history_of("price_cents"), la respuesta queda completa: booking_agent escribió 6000 primero (#5), y el mismo booking_agent lo corrigió a 9600 después (#9). Ninguna otra escritura tocó ese campo en el medio — la corrección fue del mismo agente, no una interferencia de otro.


Por qué el log nunca borra, solo agrega

Blackboard.write no tiene ningún mecanismo para "reemplazar" una entrada del log — cada llamada agrega entradas nuevas, sin tocar las anteriores. Esta es una decisión de diseño deliberada: si write sobrescribiera la entrada anterior de price_cents en vez de agregar una nueva, la corrección de Ana desaparecería del historial exactamente igual que desaparece del estado — perdiendo la única fuente que permite reconstruir, después, que hubo una corrección en absoluto.

El costo de esta decisión es que el log crece sin límite durante una corrida — cada write suma entradas, nunca las reduce. Para una corrida de una sola petición de Reservo, ese crecimiento es trivial (decenas de entradas, no miles). Un sistema que corriera muchísimas peticiones sobre el mismo proceso, sin nunca reiniciar el Blackboard, sí necesitaría pensar en esto — pero esta guía nunca reusa un Blackboard entre corridas (la lección 08 lo confirma con un caso donde reusarlo mal es exactamente el error).


Errores comunes

  1. Pensar que bb.price_cents después de la corrección todavía vale 6000. No — el estado siempre refleja la escritura más reciente; solo el log, no el estado, conserva el valor anterior. Confundir estos dos es el error más común al trabajar con un Blackboard.

  2. Usar history_of para tomar decisiones en tiempo de ejecución. El propósito del log es auditar después de que la corrida terminó (o a mitad de ella, para depurar) — ningún especialista de este módulo consulta history_of para decidir qué hacer; todos leen el campo directamente (bb.price_cents), que siempre da el valor vigente.

  3. Olvidar que dos write con el mismo campo, del mismo agente, no son un error. A diferencia de escribir a un campo con un writer distinto sin ninguna razón clara (una señal de posible conflicto, que la lección 06 toca), que el mismo agente corrija su propio dato en la misma corrida es exactamente el caso de esta lección — legítimo, y el log lo documenta sin ambigüedad.

  4. Pensar que hace falta un campo corrected=True o algo parecido para marcar una corrección. No — el log ya lo deja implícito: si history_of(campo) tiene más de una entrada, hubo una corrección; el número de entradas y su orden son toda la información que se necesita.

  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 el ejemplo trabajado tú mismo y confirma que history_of("room") muestra las dos entradas (Focus, después Studio), en ese orden, con los números de secuencia correctos.

Ver solución
for entry in bb.history_of("room"):
    print(f"  #{entry.seq} {entry.writer} puso room={entry.value!r}")

Salida esperada:

  #2 booking_agent puso room='Focus'
  #6 booking_agent puso room='Studio'

Si tu salida coincide, confirmaste que el log conserva ambas escrituras de room en el orden correcto — la misma verificación que ya hiciste con price_cents en el ejemplo trabajado.

Ejercicio 2: ¿Cuántos campos NO tuvieron ninguna corrección? (Medio)

Sobre el Blackboard final del ejemplo trabajado, cuenta cuántos de los seis campos tienen exactamente una entrada en su history_of (sin ninguna corrección), contra cuántos tienen más de una.

Ver solución
ALL_FIELDS_L05 = ["member", "room", "tier", "hours", "price_cents", "booking_id"]
for f in ALL_FIELDS_L05:
    n = len(bb.history_of(f))
    print(f"{f:12} -> {n} escritura(s)")

Salida esperada:

member       -> 1 escritura(s)
room         -> 2 escritura(s)
tier         -> 2 escritura(s)
hours        -> 2 escritura(s)
price_cents  -> 2 escritura(s)
booking_id   -> 1 escritura(s)

Explicación: member y booking_id se escribieron una sola vez cada uno (el socio no cambió, y la reserva se confirmó una sola vez, al final, ya sobre Studio). room, tier, hours y price_cents tienen dos entradas cada uno porque los cuatro se volvieron a escribir juntos, en el mismo write, cuando Ana cambió de sala — confirma lo que ya viste en el Ejercicio 2 de la lección 02: un write con varios campos deja una entrada de log por cada campo, no una sola.

Ejercicio 3: Un campo que "cambia" al mismo valor (Difícil)

Ejecuta bb.write("booking_agent", tier="pro") una vez más, sobre el bb final del ejemplo trabajado —con el mismo valor "pro" que ya tenía—. ¿El log agrega una entrada nueva? ¿Debería Blackboard.write, tal como está construido en esta lección, distinguir entre "escribir un valor distinto" y "volver a escribir el mismo valor"?

Ver solución
n_antes = len(bb.history_of("tier"))
bb.write("booking_agent", tier="pro")
n_despues = len(bb.history_of("tier"))
print(f"entradas de 'tier' antes: {n_antes}, después: {n_despues}")
for entry in bb.history_of("tier"):
    print(f"  #{entry.seq} {entry.writer} puso tier={entry.value!r}")

Salida esperada:

entradas de 'tier' antes: 2, después: 3
  #3 booking_agent puso tier='pro'
  #7 booking_agent puso tier='pro'
  #11 booking_agent puso tier='pro'

Explicación: sí, el log agrega una tercera entrada, aunque el valor no haya cambiado — Blackboard.write, tal como está construido en esta lección, registra cada llamada a write con ese campo, sin comparar el valor nuevo contra el anterior. Esto es una decisión de diseño válida, no un bug: para los fines de esta guía (auditar quién tocó cada campo, y cuándo), que alguien "reafirme" un valor sin cambiarlo también es información útil — por ejemplo, confirma que booking_agent volvió a pasar por ese campo en un segundo write, aunque el resultado haya sido el mismo. Una versión más estricta de write podría comparar getattr(self, key) == value antes de agregar una entrada, pero eso cambiaría el propósito del log: de "cada vez que alguien escribió" a "cada vez que algo realmente cambió" — una decisión distinta, que esta lección no toma.


Resumen y siguiente paso

  • El estado del Blackboard (bb.room, bb.price_cents, etc.) siempre refleja la escritura más reciente; el log conserva la historia completa, incluidas las correcciones que el estado ya tapó.
  • history_of(field_name) filtra el log por un solo campo, dejando ver cómo cambió ese valor a lo largo de la corrida — sin ese método, reconstruir la historia de un campo requeriría filtrar bb.log a mano cada vez.
  • Un write nunca borra ni reemplaza entradas anteriores del log — solo agrega. Esa es la propiedad que hace posible auditar correcciones después de que ocurrieron.
  • El log registra cada llamada a write, incluso si el valor nuevo es igual al anterior — una decisión de diseño explícita, no un descuido.

Siguiente lección: 06 — El trade-off medido: visibilidad contra aislamiento. Contamos, con números reales, cuánto del Blackboard completo ve cada agente, comparado con lo mínimo que un paquete de handoff a medida necesitaría.


Recursos adicionales

  1. Python — dataclasses — El módulo detrás de WriteLogEntry, con history_of filtrando por el campo field de cada entrada.
  2. Anthropic — Multi-agent research system — Un sistema real donde poder reconstruir qué sub-agente produjo cada dato, y cuándo, es parte de cómo se depuran resultados inesperados.
  3. Python — List comprehensions — El mecanismo detrás de history_of, filtrando self.log por un predicado simple.
  4. Python — Igualdad de valores (==) — La comparación que una versión más estricta de write podría usar para distinguir una reafirmación de un cambio real, discutida en el Ejercicio 3.