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

Leyendo del Blackboard

Descripción

La lección 03 dejó un Blackboard con seis campos escritos: member='Ana', la cotización completa de Focus pro 3h, y booking_id=1. Esta lección hace la mitad que faltaba: policy_agent responde la pregunta de Ana —"¿puedo cancelar mi reserva sin cargo?"— sin que nadie le diga el número de reserva. Lo lee directamente de bb.booking_id.

Compara esto con cómo resolvería la misma situación un handoff del Módulo 5: ahí, booking_agent tendría que saber, en el momento de terminar su turno, que el socio va a preguntar por política después, y armar un paquete con booking_id incluido a propósito para policy_agent. Acá, booking_agent no necesita anticipar nada — simplemente escribió lo que produjo (lección 03), y policy_agent lee lo que necesita cuando le toca, sin que exista ninguna coordinación directa entre los dos.

Conexión con el módulo

Esta lección lee el Blackboard que la lección 03 dejó escrito, con SPECIALISTS extendido para incluir a policy_agent. La lección 06 vuelve sobre esta misma lectura para medir, con números, cuánto del Blackboard completo policy_agent realmente necesitó — solo booking_id, de seis campos disponibles.


Analogía: el médico del turno de la tarde, que nunca habló con el de la mañana

Vuelve a la pizarra de la sala de guardia. El médico del turno de la tarde no llamó por teléfono al de la mañana para preguntarle "¿en qué cama está el paciente que ingresó hoy?" — leyó la pizarra, y ahí estaba. Los dos médicos ni siquiera se cruzaron: uno escribió, horas antes, sin saber quién iba a leer después; el otro leyó, horas después, sin haber coordinado nada con quien escribió. Eso es exactamente lo que hace policy_agent en esta lección con booking_agent.


Ejemplo trabajado: policy_agent lee booking_id, sin que nadie se lo diga

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}},
}


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


# --- lo que la lección 03 ya dejó listo: supervisor + booking_agent escribieron ---
bb = Blackboard()
bb.write("supervisor", member="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)."}]},
]
_, history_booking = run_specialist("booking_agent", "Cotiza y reserva Focus pro 3h para Ana.", model_script_booking)
quote_result, booking_result = all_tool_results(history_booking)
bb.write("booking_agent", room="Focus", tier="pro", hours=3, price_cents=quote_result["price_cents"])
bb.write("booking_agent", booking_id=booking_result["booking_id"])

print("--- estado del Blackboard al llegar a esta lección ---")
print(f"member={bb.member!r} 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}")

# --- lo nuevo de esta lección: Ana pregunta por su reserva, SIN decir el número ---
member_question = "Ana pregunta: ¿Puedo cancelar mi reserva sin cargo?"
print()
print(f"--- pregunta del socio: {member_question!r} ---")
print("policy_agent no recibió el booking_id en la pregunta -- lo LEE del Blackboard.")

task_policy = f"¿Puedo cancelar la reserva {bb.booking_id} 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 {bb.booking_id} se puede cancelar sin cargo hasta 2 "
            "horas antes del horario reservado."
        )}]},
]
final_policy, history_policy = run_specialist("policy_agent", task_policy, model_script_policy)
print("respuesta de policy_agent:", final_policy["content"][0]["text"])

print()
print("--- policy_agent NO escribió nada al Blackboard: solo leyó ---")
print("writes totales de policy_agent en el log:",
      sum(1 for e in bb.log if e.writer == "policy_agent"))
print("campos que policy_agent leyó para resolver la tarea: booking_id (1 campo)")

Qué esperar:

--- estado del Blackboard al llegar a esta lección ---
member='Ana' room='Focus' tier='pro' hours=3 price_cents=6000 booking_id=1

--- pregunta del socio: 'Ana pregunta: ¿Puedo cancelar mi reserva sin cargo?' ---
policy_agent no recibió el booking_id en la pregunta -- lo LEE del Blackboard.
tarea armada leyendo bb.booking_id: '¿Puedo cancelar la reserva 1 sin cargo?'
respuesta de policy_agent: 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ó ---
writes totales de policy_agent en el log: 0
campos que policy_agent leyó para resolver la tarea: booking_id (1 campo)

Fíjate en la línea task_policy = f"¿Puedo cancelar la reserva {bb.booking_id} sin cargo?". Ana nunca mencionó el número 1 — dijo "mi reserva", sin más—. El sistema completo (el paso que en una corrida real haría el supervisor, o el propio policy_agent antes de construir su tarea) resolvió "mi reserva" leyendo bb.booking_id, el mismo valor que booking_agent había escrito minutos antes sin saber que alguien lo iba a necesitar así.


Leer no dejó ningún rastro — y eso es una decisión de diseño, no un descuido

Nota algo importante: policy_agent leyó bb.booking_id con una simple expresión (bb.booking_id), y esa lectura no generó ninguna entrada en el log. WriteLogEntry registra escrituras, nunca lecturas — el Blackboard de esta guía no lleva un "read log" simétrico al "write log". Esto es intencional: DISEÑO de este módulo pide auditar quién escribió qué, no todas las veces que alguien miró un valor —una lectura no cambia el estado compartido, así que no hay ninguna corrección ni conflicto que rastrear en ella—.

La consecuencia práctica: si quisieras responder "¿qué agentes leyeron booking_id durante esta corrida?", el Blackboard tal como está construido no te lo puede decir por sí solo — tendrías que instrumentarlo aparte (algo que la lección 06 hace, con una lista separada, solo para poder medir el trade-off, nunca como parte del Blackboard en sí).


Errores comunes

  1. Pedirle al socio el booking_id cuando ya está en el Blackboard. Si Ana ya reservó en esta misma corrida, volver a preguntarle "¿cuál es tu número de reserva?" ignora exactamente la ventaja que ofrece un estado compartido — el dato ya está disponible, sin que nadie tenga que repetirlo.

  2. Asumir que bb.booking_id siempre tiene un valor. No — si booking_agent todavía no reservó nada en esta corrida (por ejemplo, el socio solo pidió cotizar), bb.booking_id es None, y construir una tarea con él produciría un texto sin sentido ("la reserva None"). El mini-proyecto de la lección 08 construye la guarda que evita esto.

  3. Confundir "policy_agent leyó booking_id" con "policy_agent sabe cómo se calculó ese valor". No — policy_agent recibe el número 1, sin ningún contexto sobre cómo se llegó a él (que fue book_room quien lo generó, con itertools.count). El Blackboard comparte el resultado, no el proceso que lo produjo — para eso está el log, no el campo en sí.

  4. Pensar que la ausencia de log de lecturas es un defecto del diseño. No lo es — es la diferencia entre auditar "quién cambió el estado compartido" (lo que sí importa para depurar una corrida) y "quién miró un valor sin cambiarlo" (que, sin más contexto, no aporta nada a esa auditoría). La lección 06 sí instrumenta lecturas, pero como una medición externa puntual, no como una característica permanente de Blackboard.

  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.


Ejercicios

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

Ejecuta el ejemplo trabajado tú mismo y confirma que la tarea armada para policy_agent incluye el número 1, exactamente el booking_id que booking_agent había escrito en la lección 03.

Ver solución

No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida coincide con el "Qué esperar" del ejemplo trabajado, la lectura de bb.booking_id funcionó correctamente.

Ejercicio 2: pricing_agent lee tier y hours, no booking_id (Medio)

Extiende SPECIALISTS con pricing_agent y haz que compare Studio y Boardroom, reusando el mismo tier y hours que ya están en el Blackboard (no le pidas al socio que los repita). Confirma que la tarea armada usa esos valores leídos, no un texto escrito a mano con los números.

Ver solución
SPECIALISTS["pricing_agent"] = {"tools": {"get_quote": rt.get_quote}}

task_pricing = f"Compara Studio y Boardroom, ambas {bb.tier}, {bb.hours}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_pricing, history_pricing = run_specialist("pricing_agent", task_pricing, model_script_pricing)
print("respuesta:", final_pricing["content"][0]["text"])

Salida esperada:

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.

Explicación: pricing_agent reusa tier='pro' y hours=3, que booking_agent ya había escrito en la lección 03 — Ana nunca tuvo que repetir "pro, 3 horas" para comparar salas. 9600 = 4000 * 3 * 80 // 100 y 19200 = 8000 * 3 * 80 // 100 confirman los precios.

Ejercicio 3: ¿Qué pasa si booking_id todavía es None? (Difícil)

Sobre un Blackboard nuevo, donde el supervisor escribió member pero booking_agent todavía no corrió, intenta armar la tarea de policy_agent de la misma forma que el ejemplo trabajado (f"¿Puedo cancelar la reserva {bb.booking_id} sin cargo?"). ¿Qué texto se produce? ¿Por qué ese texto es un problema, aunque Python no lance ningún error?

Ver solución
bb_vacio = Blackboard()
bb_vacio.write("supervisor", member="Marta")
task_rota = f"¿Puedo cancelar la reserva {bb_vacio.booking_id} sin cargo?"
print(repr(task_rota))

Salida real:

'¿Puedo cancelar la reserva None sin cargo?'

Explicación: Python no lanza ningún error — un f-string interpola None como el texto literal "None", sin quejarse. El problema no es técnico, es semántico: "la reserva None" no identifica ninguna reserva real, y si esta tarea llegara a policy_agent, el especialista no tendría forma de responder algo útil (no existe ninguna reserva con ese "número"). Este es exactamente el tipo de falla silenciosa que la lección 02 ya advirtió con el typo de campo: el código corre sin detenerse, pero produce un resultado sin sentido. El mini-proyecto de la lección 08 construye una guarda explícita (require) que convierte este caso en un error ruidoso, capturable, en vez de un texto roto que sigue de largo.


Resumen y siguiente paso

  • policy_agent respondió una pregunta sobre una reserva específica sin que nadie mencionara el número — lo leyó directamente de bb.booking_id, escrito minutos antes por booking_agent sin saber que alguien lo iba a necesitar así.
  • Leer del Blackboard es una simple expresión (bb.campo) — no genera ninguna entrada en el log, a diferencia de escribir. El log de esta guía registra escrituras, no lecturas, a propósito.
  • pricing_agent puede reusar tier y hours ya escritos por booking_agent, sin que el socio tenga que repetirlos — otra forma de la misma ventaja.
  • Un campo que nadie escribió todavía es None, y Python lo interpola sin error en un f-string — produciendo texto sin sentido en vez de detenerse. El mini-proyecto construye la guarda que lo corrige.

Siguiente lección: 05 — El log de escritura. Profundizamos en el log del Blackboard: cómo auditar una corrida completa, incluso cuando un valor se corrige a mitad de camino.


Recursos adicionales

  1. Anthropic — Multi-agent research system — Un sistema real donde un sub-agente consume un dato que otro produjo, sin que exista ninguna comunicación directa entre los dos.
  2. Python — f-strings — El mecanismo que interpola bb.booking_id en el texto de la tarea, y por qué interpola None sin lanzar ningún error.
  3. Anthropic — Tool use (function calling) overview — El protocolo que policy_agent sigue usando, sin cambios, con su única tool search_docs.
  4. Python — Atributos de instancia — La forma en que bb.booking_id se lee directamente, sin ningún método getter de por medio.