Module 8: Project The Reservo Multi Agent System

One Blackboard for the Whole System

Description

The three pieces from lessons 03, 04, and 05 —pipeline, fan-out, handoff— each resolve one complete sub-task. This lesson wires in the system's last structural piece, Blackboard (Module 6), with no change at all, and confirms something Module 6 already established but that's worth seeing, once more, over this capstone's three new demos: not every pattern writes to the shared state equally.

You'll run, in a single run, Luis's piece (pipeline, DOES write), Marta's piece (fan-out, does NOT write), and Valentina's piece (handoff, also doesn't write) — and you'll see, with the Blackboard's WRITE_SEQ as witness, that the sequence number doesn't advance a single time during Marta's and Valentina's pieces.

Connection to the module

Blackboard, WriteLogEntry, and WRITE_SEQ are exactly the same class from Module 6 — same fields (member, room, tier, hours, price_cents, booking_id, log), same write method. This lesson adds nothing to it — it reuses the three pieces already wired in from lessons 03, 04, and 05 to confirm, with all three running in the same process, the "who writes" rule M6 established with Ana's request. Lesson 08 comes back to this exact same Blackboard for the complete Demo C.


Analogy: the kitchen whiteboard, one shift with three different tables

M6's kitchen whiteboard doesn't fill up with everything that happens in the restaurant — only with what the rest of the kitchen might need later: which table ordered what, whether it's confirmed. This lesson is one full shift with three differently shaped tables: Luis's table, which ends in a real booking (that DOES go on the whiteboard); Marta's table, which only asked to compare prices and ask about a general policy (none of that is a booking, none of it goes on the whiteboard); and Valentina's table, which only quoted without confirming (that doesn't go on the whiteboard either, not yet).


Worked example: three members, one 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 reached ({max_iterations})")


def search_docs(query):
    q = query.lower()
    if "cancel" in q:
        return "[cancellation-policy] You can cancel at no charge up until 2 hours before the reserved time."
    if "no" in q and ("present" in q or "show" in q or "arrive" in q):
        return ("[no-show-policy] If you don't show up for a confirmed booking and don't "
                 "cancel at least 2 hours in advance, Reservo charges 50% of the quoted "
                 "price as a no-show fee.")
    return "No relevant policy was found for that question."


SPECIALISTS = {
    "booking_agent": {"tools": {"get_quote": rt.get_quote, "book_room": rt.book_room}},
    "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)


# ---- M3: pipeline, unchanged (lesson 03) ----
@dataclass
class PipelineStage:
    kind: str
    name: str
    label: str


def last_tool_result(history):
    for m in reversed(history):
        if isinstance(m["content"], list):
            for b in m["content"]:
                if b["type"] == "tool_result":
                    try:
                        return ast.literal_eval(b["content"])
                    except (ValueError, SyntaxError):
                        return b["content"]
    return None


def build_stage_task(stage, payload):
    if stage.kind == "quote":
        return f"Quote {payload['room']} {payload['tier']} {payload['hours']}h."
    if stage.kind == "validate_policy":
        return (f"What's the cancellation policy for a {payload['hours']}h booking "
                 f"of {payload['room']}, before confirming it?")
    if stage.kind == "confirm":
        if payload.get("cleared_to_book"):
            return (f"Book {payload['room']} {payload['tier']} {payload['hours']}h "
                     f"for {payload['member']} -- the cancellation policy was already validated.")
        return (f"Book {payload['room']} {payload['tier']} {payload['hours']}h "
                 f"for {payload['member']}.")
    raise ValueError(f"don't know how to build the task for stage {stage.kind!r}")


def extract_payload(stage, history, payload):
    new_payload = dict(payload)
    result = last_tool_result(history)
    if stage.kind == "quote":
        new_payload["price_cents"] = result["price_cents"]
    elif stage.kind == "validate_policy":
        new_payload["cancellation_policy"] = result
        new_payload["cleared_to_book"] = True
    elif stage.kind == "confirm":
        new_payload["booking_id"] = result["booking_id"]
        new_payload["confirmed"] = result["confirmed"]
    return new_payload


def run_pipeline(stages, model_scripts, initial_payload):
    payload = dict(initial_payload)
    trace = []
    for i, stage in enumerate(stages):
        task = build_stage_task(stage, payload)
        final, history = run_specialist(stage.name, task, model_scripts[i])
        payload = extract_payload(stage, history, payload)
        trace.append({"stage": i + 1, "label": stage.label, "history": history})
    return payload, trace


# ---- M5: handoff, unchanged (lesson 05) ----
HANDOFF_TOOL_NAME = "handoff_to_specialist"


@dataclass
class HandoffPackage:
    sender: str
    receiver: str
    reason: str
    task: str
    context: dict = field(default_factory=dict)


def run_agent_with_handoff(question, model_script, tools, self_name, 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, None
        block = turn["content"][0]
        if block["name"] == HANDOFF_TOOL_NAME:
            inp = block["input"]
            package = HandoffPackage(
                sender=self_name, receiver=inp["receiver"], reason=inp["reason"],
                task=inp["task"], context=inp.get("context", {}),
            )
            return None, messages, package
        tool_result_blocks = dispatch_parallel(turn["content"], tools)
        messages.append({"role": "user", "content": tool_result_blocks})
    raise RuntimeError(f"max_iterations reached ({max_iterations})")


def run_specialist_with_handoff(name, task, model_script):
    tools = SPECIALISTS[name]["tools"]
    return run_agent_with_handoff(task, model_script, tools, self_name=name)


def run_with_handoff(name, task, model_scripts):
    final, history, package = run_specialist_with_handoff(name, task, model_scripts[name])
    trace = [{"agent": name, "history": history, "package": package}]
    if package is None:
        return final, trace
    receiver_final, receiver_history, receiver_package = run_specialist_with_handoff(
        package.receiver, package.task, model_scripts[package.receiver],
    )
    trace.append({"agent": package.receiver, "history": receiver_history, "package": receiver_package})
    return receiver_final, trace


# ---- M6: Blackboard, unchanged ----
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()

print("=== 1) Luis (pipeline): booking_agent DOES write -- there's a real booking ===")
PIPELINE_STAGES = [
    PipelineStage(kind="quote", name="booking_agent", label="quote"),
    PipelineStage(kind="validate_policy", name="policy_agent", label="validate the cancellation policy"),
    PipelineStage(kind="confirm", name="booking_agent", label="confirm the booking"),
]
INITIAL_PAYLOAD = {"room": "Studio", "tier": "pro", "hours": 3, "member": "Luis"}
script_quote = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Studio", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": "Studio pro 3h costs 9600 cents."}]},
]
script_policy = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
     "input": {"query": "cancellation policy"}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": (
        "You can cancel at no charge up until 2 hours before the reserved time. "
        "There's nothing stopping this from being confirmed.")}]},
]
script_confirm = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "book_room",
     "input": {"room": "Studio", "tier": "pro", "hours": 3, "member": "Luis"}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": "I booked Studio pro 3h for Luis (confirmation #1)."}]},
]
bb.write("supervisor", member="Luis")
payload, _ = run_pipeline(PIPELINE_STAGES, [script_quote, script_policy, script_confirm], INITIAL_PAYLOAD)
bb.write("booking_agent", room=payload["room"], tier=payload["tier"],
         hours=payload["hours"], price_cents=payload["price_cents"])
bb.write("booking_agent", booking_id=payload["booking_id"])
print("Blackboard after Luis:", bb)

print()
print("=== 2) Marta (fan-out): neither pricing_agent nor policy_agent write -- they're comparisons, not a booking ===")
seq_before_marta = bb.log[-1].seq
script_pricing = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote", "input": {"room": "Studio", "tier": "pro", "hours": 2}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote", "input": {"room": "Boardroom", "tier": "pro", "hours": 2}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Studio pro 2h: 6400 cents. Boardroom pro 2h: 12800 cents. Studio is the cheaper option of the two."}]},
]
run_specialist("pricing_agent", "Compare Studio and Boardroom pro 2h.", script_pricing)
print(f"seq of the last write BEFORE Marta: {seq_before_marta} -- after her fan-out: {bb.log[-1].seq} (no change)")

print()
print("=== 3) Valentina (handoff): also doesn't write -- the context stays local to the answer, not the Blackboard ===")
script_workshop_booking = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Studio", "tier": "pro", "hours": 4}}]},
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
     "input": {"receiver": "policy_agent", "reason": "no-show question, outside my expertise",
               "task": "what happens if a member doesn't show up for a confirmed booking?",
               "context": {"room": "Studio", "tier": "pro", "hours": 4, "price_cents": 12800}}}]},
]
script_workshop_policy = [
    {"stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
     "input": {"query": "what happens if i do not show up for my reservation"}}]},
    {"stop_reason": "end_turn", "content": [{"type": "text", "text": (
        "If you don't show up for a confirmed booking and don't cancel at least 2 hours in advance, "
        "Reservo charges 50% of the quoted price as a no-show fee.")}]},
]
run_with_handoff("booking_agent", "Quote Studio pro 4h. What happens if I don't show up?",
                  {"booking_agent": script_workshop_booking, "policy_agent": script_workshop_policy})
print(f"seq of the last write after Valentina: {bb.log[-1].seq} (no change again)")

print()
print("--- the Blackboard's full log (one run, 3 different members) ---")
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<14} wrote {entry.field}={entry.value!r}")

What to expect:

=== 1) Luis (pipeline): booking_agent DOES write -- there's a real booking ===
Blackboard after Luis: Blackboard(member='Luis', room='Studio', tier='pro', hours=3, price_cents=9600, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Luis'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Studio'), 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=9600), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])

=== 2) Marta (fan-out): neither pricing_agent nor policy_agent write -- they're comparisons, not a booking ===
seq of the last write BEFORE Marta: 6 -- after her fan-out: 6 (no change)

=== 3) Valentina (handoff): also doesn't write -- the context stays local to the answer, not the Blackboard ===
seq of the last write after Valentina: 6 (no change again)

--- the Blackboard's full log (one run, 3 different members) ---
  #1 supervisor     wrote member='Luis'
  #2 booking_agent  wrote room='Studio'
  #3 booking_agent  wrote tier='pro'
  #4 booking_agent  wrote hours=3
  #5 booking_agent  wrote price_cents=9600
  #6 booking_agent  wrote booking_id=1

Six entries in the log, all from Luis's table — neither Marta nor Valentina add a single line, even though both ran real specialists, with real tool calls, within the same process. The Blackboard's seq is an honest witness to this: if Marta or Valentina had written anything, bb.log[-1].seq would have advanced past 6 — and it doesn't, confirming in code what this lesson's prose claims.


The criterion's question 3, applied three times

Does any data this sub-task produces get needed LATER in the same run, by
another part of the system we don't yet know who's going to read it?

  Luis (pipeline)   -> YES. The confirmed booking (room, tier, hours, price_cents,
                        booking_id) is a FACT of the system: if later another
                        part of Reservo needs to know whether Luis has an
                        active booking, that data has to be available without
                        anyone having to ask booking_agent again.

  Marta (fan-out)    -> NO. A price comparison and a general policy question
                        are information Marta consumes ONCE, in her answer --
                        there's no "system fact" another part needs later.
                        Nobody's going to ask "what did pricing_agent quote
                        Marta?" in another run.

  Valentina (handoff) -> NOT YET. Quoting without confirming is information for
                        ONE answer, not a persistent fact -- if Valentina
                        later DOES confirm that booking (as she does in
                        lesson 08's full Demo C), THAT part of her request
                        does write to the Blackboard, with a different
                        pipeline Track.

The rule isn't "pipeline always writes, fan-out and handoff never do" — it's more precise than that: what gets written is whatever produces a confirmed fact the rest of the system might need later, regardless of which pattern produced it. In this lesson, the only sub-task that produces such a fact is Luis's confirmed booking — that's a coincidence of these three particular demos, not a fixed rule of the pattern. Lesson 08's Demo C confirms it: its pipeline track (Valentina's Focus booking) DOES write, while its fan-out track and its handoff track, again, don't.


Common mistakes

  1. Concluding "fan-out and handoff never write to the Blackboard." It's not a rule of the pattern — it's a consequence of what each sub-task produces. A fan-out that DID confirm a booking (say, two independent bookings resolved at once) SHOULD write each one; this lesson doesn't show that because neither Marta's nor Valentina's piece confirms anything, not because the mechanism prevents it.

  2. Writing to the Blackboard INSIDE a fan-out's parallel section. Although this lesson has no case that needs it, M6 L07's rule still stands for when it does: any write happens AFTER the ThreadPoolExecutor closes, never during, to avoid race conditions over the same Blackboard object.

  3. Thinking WRITE_SEQ resets with each new Blackboard(). As M7 L07 Exercise 2 already confirmed, WRITE_SEQ is a process-level counter, not an instance-level one — if this lesson created a second Blackboard() after the first, its first entry would continue from 7, not from 1.


Exercises

Exercise 1: Add a fourth table that DOES write, with fan-out (Easy)

Design a fourth piece —a new member, "Pedro"— whose request is a fan-out of TWO independent bookings (for example, "Book Focus basic 1h for me, and separately book Boardroom basic 1h for my colleague"), and confirm the Blackboard DOES receive writes from both, even though they run at the same time.

See solution
# run_tracks_parallel: the same function from M4/M7, reused unchanged
# (you already wired it in in this module's lesson 04).
def run_tracks_parallel(jobs):
    results = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as pool:
        future_to_key = {pool.submit(fn): key for key, fn in jobs.items()}
        for future in concurrent.futures.as_completed(future_to_key):
            key = future_to_key[future]
            results[key] = future.result()
    return results


def job_book_focus_pedro():
    script = [
        {"stop_reason": "tool_use", "content": [
            {"type": "tool_use", "id": "toolu_01", "name": "book_room",
             "input": {"room": "Focus", "tier": "basic", "hours": 1, "member": "Pedro"}}]},
        {"stop_reason": "end_turn", "content": [
            {"type": "text", "text": "I booked Focus basic 1h for Pedro."}]},
    ]
    return run_specialist("booking_agent", "Book Focus basic 1h for Pedro.", script)


def job_book_boardroom_colleague():
    script = [
        {"stop_reason": "tool_use", "content": [
            {"type": "tool_use", "id": "toolu_01", "name": "book_room",
             "input": {"room": "Boardroom", "tier": "basic", "hours": 1, "member": "Pedro's colleague"}}]},
        {"stop_reason": "end_turn", "content": [
            {"type": "text", "text": "I booked Boardroom basic 1h for Pedro's colleague."}]},
    ]
    return run_specialist("booking_agent", "Book Boardroom basic 1h for the colleague.", script)


results_pedro = run_tracks_parallel({"focus": job_book_focus_pedro, "boardroom": job_book_boardroom_colleague})
bb.write("supervisor", member="Pedro")
for key in sorted(results_pedro):
    final, history = results_pedro[key]
    tool_result = next(
        b["content"] for m in history if isinstance(m["content"], list)
        for b in m["content"] if b["type"] == "tool_result"
    )
    print(f"  [{key}] tool_result: {tool_result}")

Explanation (no need to print the full Blackboard): every job_* in this solution DOES call book_room — unlike pricing_agent in Demo B, these two fan-out branches produce real bookings, so —outside the parallel section, after run_tracks_parallel returns— each one should get written to the Blackboard with its own bb.write("booking_agent", ...), confirming that "fan-out doesn't write" was never a rule of the mechanism, only of what Demos A, B, and C in particular produce.

Exercise 2: Why does supervisor write member BEFORE running any specialist? (Medium)

In this lesson's three pieces, bb.write("supervisor", member=...) happens before running booking_agent, policy_agent, or pricing_agent. Without running code, explain why this order matters, using M6 L07's reasoning about Ana's request.

See solution

Because member is data the system knows from the moment the request arrives —it doesn't depend on any specialist's result— so writing it first leaves the Blackboard in a useful state even if, for some reason, none of the tracks finished running (a tool that fails, max_iterations reached). If the order were reversed —writing member AFTER running the specialists—, any failure mid-way would leave a Blackboard that doesn't even know who made the request, a worse state to debug than an incomplete one that at least has the most basic piece of data already present. It's the same reason M7 L07 wrote member as the whole Ana run's very first write, before opening the parallel tracks.

Exercise 3: Design a request where the HANDOFF track should write (Hard)

This lesson showed Valentina's handoff doesn't write because it ends in a quote, not a confirmation. Design, in one sentence, a Reservo request where a handoff WOULD end up producing a fact worth writing to the Blackboard — and explain exactly which field(s) you'd write and why.

See solution

One valid example: "Book Focus pro 2h for me -- and if there's any availability problem, ask someone else what options I have." If booking_agent, mid-way through trying to book, hit a situation outside its usual expertise (say, a special overbooking policy that lives in policy_agent) and handed off the turn, but the chain's final result DID end in a confirmed booking (policy_agent authorizes the exception and someone —the flow itself, not necessarily policy_agent— confirms the booking), then that result would have exactly the same fields worth writing as Luis's pipeline: room, tier, hours, price_cents, booking_id. The criterion's question 3 rule doesn't distinguish by PATTERN —it distinguishes by whether the final result is a confirmed fact the rest of the system might need later—, and a handoff that ends in a real confirmation meets that condition exactly the same way a pipeline does.


Summary and next step

  • Blackboard, with no change since Module 6, still only logs what another part of the system might need later — confirmed over three new members in a single run.
  • The rule isn't "the pattern decides whether it writes" — it's "the result decides": Luis's confirmed booking writes six entries; Marta's comparison and Valentina's unconfirmed quote, none.
  • WRITE_SEQ stayed at 6 throughout Marta's and Valentina's entire pieces — the code witness that neither of them wrote anything.

Next lesson: 07 — Measuring the Coordination Cost of the Complete System. This module's only genuinely new piece: a function that counts calls, hops, and rounds for any combination of tracks — tested against Demos A and B.


Additional resources

  1. Anthropic — Multi-agent research system — A real system where only the facts another component needs later get persisted to shared state, not every intermediate step.
  2. Python — Dataclasses — The structure behind Blackboard and WriteLogEntry, unchanged since Module 6.
  3. Python — itertools.count — The deterministic counter behind WRITE_SEQ and _booking_ids, the reason this module never needs random or uuid4.
  4. Anthropic — Building effective agents — The principle of keeping shared state minimal and explicit, the same discipline this lesson confirms over three new cases.