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

  1. Escribir el texto final del modelo al Blackboard, en vez del tool_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 devuelto 6000), escribir el texto propagaría el error; anclar en all_tool_results no.

  2. Intentar escribir booking_id antes de que book_room haya corrido. En el ejemplo trabajado, el segundo write depende de que results tenga dos elementos — si booking_agent solo hubiera cotizado (sin reservar), results tendría un solo elemento y quote_result, booking_result = results fallaría con un ValueError de desempaquetado. La lección 04 muestra cómo leer un campo que legítimamente puede no estar escrito todavía.

  3. Pensar que dos write del mismo agente son "una sola escritura" en el log. No — cada write deja tantas entradas como campos nombrados recibió, y dos llamadas a write en momentos distintos quedan como grupos de entradas separados en la secuencia, exactamente en el orden en que ocurrieron.

  4. Olvidar que bb.write no valida que el writer sea 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 un writer que nunca va a coincidir con ningún agente real de SPECIALISTS. 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.

  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. La solución de siempre: un proceso nuevo, un reservo_tools.BOOKINGS vací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_agent escribe al Blackboard dos veces a medida que produce datos: una tras cotizar, otra tras reservar — no todo de una vez al final.
  • all_tool_results recupera todos los tool_result del historial, en orden, con ast.literal_eval (nunca eval()) — la extensión de last_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 Blackboard puede quedar parcialmente lleno de forma legítima (booking_id=None si 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

  1. Python — ast.literal_eval — La función que ancla cada escritura del Blackboard en el dato real de la tool, nunca en texto libre — la misma que ya usó last_tool_result en el Módulo 3.
  2. Anthropic — Tool use (function calling) overview — El protocolo tool_use/tool_result del que all_tool_results extrae cada dato estructurado.
  3. 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.
  4. 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.