The Blackboard / Shared-State Pattern, Implemented via the Reactive Reducer


Back to: Intro

A little background

The last two articles were about reasoning - debate, where the agents argue about the same problem. This one is about coordination, and it’s the pattern where the reactive reducer’s design shows up most directly: blackboard / shared-state. In the classic blackboard architecture, there are three parts: the blackboard (a shared data structure), the knowledge sources (specialized agents that read and write it), and the controller (the thing that decides which source fires next, based on what’s on the board).

Sound familiar? The context store is the blackboard. The agents are the knowledge sources. And the microstep reducer loop - the thing that evaluates guards over context and routes to the next state - is the controller. This isn’t a stretch or an analogy for the article. It’s the same architecture, with the controller made structural.


The pattern

Blackboard / Shared-State. Agents coordinate through a shared workspace rather than through tightly controlled point-to-point handoffs. Each agent can inspect relevant portions of the shared state, contribute new facts or artifacts, update hypotheses, claim work, or react to changes made by others. The blackboard becomes the durable coordination surface, allowing agents to operate asynchronously and with relatively loose coupling.

Good for:

  • Long-running tasks
  • Asynchronous agents
  • Complex problem solving
  • Shared memory
  • Multi-agent planning
  • Systems where agents may join or leave dynamically

Common failure points:

  • A bad write can contaminate every agent
  • Concurrent updates can conflict
  • Stale reads cause incorrect decisions
  • Provenance becomes essential
  • Shared state can become an uncontrolled context dump

The machine: the store is the board, the reducer is the controller

A long-running incident investigation. The board holds the incident, the facts each analyst contributes, and their hypotheses. Facts persist across rounds - that’s the blackboard property. Hypotheses get consumed and re-formed. The lead assesses the board after each round and decides: solved, continue, or escalate.

fsm:
  name: incident-blackboard
  version: 1
  initialState: open

  context:
    incident:   { type: string }
    facts:      { type: list, of: string }   # the board - accumulates, never cleared
    hypotheses: { type: map,  of: string }   # working positions - keyed by author, cleared each round
    status:     { type: enum, values: [solved, continue, escalate] }
    resolution: { type: string }

  agents:
    lead:        { canTransition: [open, assess, resolve, escalate] }
    log_analyst: { canTransition: [investigate] }
    db_expert:   { canTransition: [investigate] }
    net_expert:  { canTransition: [investigate] }

  states:
    open:
      description: "Stage the incident: updateContext(field=incident, value=<what's broken, when, scope>)."
      requires:
        - { name: incident, from: agent }
      transitions:
        - { toState: investigate }

    investigate:
      description: >-
        The board - incident: {{ context.incident }}. Established facts: {{ context.facts }}.
        Append any NEW fact you established this round, one updateContext(field=facts, value=<fact>)
        call per fact; don't restate what's already up there. Then state your current hypothesis
        given the whole board: updateContext(field=hypotheses, value=<hypothesis>).
      requires:
        # `facts` is a list, so a write appends one element - three analysts contributing to one
        # cumulative board, not three private fields. Optional: a round where an analyst learns
        # nothing new is a legitimate round.
        - { name: facts,      from: { agent: log_analyst }, required: false, prompt: "Append a new fact from the logs." }
        - { name: hypotheses, from: { agent: log_analyst },                  prompt: "Your hypothesis, given the whole board." }
        - { name: facts,      from: { agent: db_expert },   required: false, prompt: "Append a new fact from the database." }
        - { name: hypotheses, from: { agent: db_expert },                    prompt: "Your hypothesis, given the whole board." }
        - { name: facts,      from: { agent: net_expert },  required: false, prompt: "Append a new fact from the network." }
        - { name: hypotheses, from: { agent: net_expert },                   prompt: "Your hypothesis, given the whole board." }
      transitions:
        # No join guard. `hypotheses` is a map and its entries are keyed by their actual writer, so a
        # requirement is satisfied only when ITS named source's key is there. The three required
        # entries ARE the join.
        - { toState: assess }

    assess:
      description: >-
        The whole board - facts: {{ context.facts }}; this round's hypotheses: {{ context.hypotheses }}.
        Decide: solved (a hypothesis is supported by the facts), continue (more investigation needed),
        or escalate (the board is stuck).
        Stage: updateContext(field=status, value=<solved|continue|escalate>).
      requires:
        - { name: status, from: { agent: lead } }
      transitions:
        - { toState: resolve,    guards: [ { backend: cel, expression: "context.status == 'solved'" } ] }
        - toState: investigate
          guards: [ { backend: cel, expression: "context.status == 'continue'" } ]
          onTransition:
            # Consume this round's working positions and the routing trigger. `facts` is untouched -
            # that is the whole pattern.
            - { remove: { hypotheses: "*" } }
            - { assign: { status: "" } }
        - { toState: escalate,   guards: [ { backend: cel, expression: "context.status == 'escalate'" } ] }

    resolve:
      description: "Compose the resolution from the board's facts and the winning hypothesis. Stage: updateContext(field=resolution, value=<resolution>)."
      requires:
        - { name: resolution, from: { agent: lead } }
      transitions:
        - { toState: done }

    escalate:
      description: "The board is stuck. Stage a summary of what's on it and what's missing, then end: updateContext(field=resolution, value=<escalation summary>)."
      requires:
        - { name: resolution, from: { agent: lead } }
      transitions:
        - { toState: done }

    done:
      description: "Resolved or escalated."
      terminal: true

  policy:
    maxIterations: 10            # bounds the investigate/assess rounds (headless)
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

Blackboard / Shared-State work flow:

incident staged

every named analyst's hypothesis keyed in

status == solved

status == continue (hypotheses cleared, facts kept)

status == escalate

resolution staged

escalation staged

open

investigate

assess

resolve

escalate

done

Three things to hold onto:

  • The investigate state is the blackboard, and its requires are the knowledge sources. Three analysts, fanned out concurrently, each reading the board and writing to it. They don’t know about each other - the log analyst doesn’t summon the DB expert, doesn’t message the net expert. They all write to the same two fields, and neither field is anyone’s private slot. That’s the “loose coupling” the pattern asks for, and it’s structural: the agents’ only interface to the world is the context store.
  • The continue edge consumes the hypotheses but keeps the facts. That’s the load-bearing detail, and it’s the opposite of what the pipeline and debate articles do. In a review loop you consume the trigger and the artifact (clear the draft, re-draft it). Here the facts are the board - a list that only ever grows. Round 2’s analysts read round 1’s facts and build on them; they don’t start over. The hypotheses are the working positions - remove: { hypotheses: "*" } clears the map, and the next round re-forms it. Facts persist, hypotheses churn. That’s exactly what a blackboard is for.
  • The join is the requirement set, not a guard. Three requirements name the same hypotheses field with three different sources. Because it’s a map, every entry is keyed by whoever actually wrote it, and a requirement is satisfied only when its declared source’s key is present - so a contribution from anyone else lands under its own key and satisfies nothing. Provenance stops being metadata you hope somebody logged; it’s the join key.

There’s a subtlety in why the round trigger has to be the map and not the list. A list field is present from its first append onward and never goes back to absent, so it can’t distinguish “contributed this round” from “contributed at some point.” That’s why facts is optional and hypotheses is what gates: the board is the thing that accumulates, and the map is the thing that re-arms. Every accumulating loop ends up making this split somewhere.


Walking two rounds

The lead stages the incident. investigate parks and summons all three analysts at once. Each reads the board (the incident, plus any prior-round facts) and appends what it found, then writes its hypothesis. Each hypothesis write lands under its author’s key; when all three keys exist, the state’s requirements are satisfied and the machine moves to assess. No counting, and nothing to keep in sync with the number of analysts - add a fourth and you add a requirement, not a guard.

The lead reads the full board. The DB expert’s hypothesis (“connection pool exhaustion”) is supported by the log analyst’s fact (“thread waits spike at 14:02”) and the net expert’s fact (“no network latency anomaly”). But the lead wants one more round to confirm the pool config, and writes status = continue. The continue edge fires: the hypotheses map is cleared, status is consumed so the lead is genuinely asked again next round, and facts is untouched. The machine re-parks at investigate and re-summons the analysts; their objective is templated on the board, which now holds round 1’s facts. They contribute round 2 - the DB expert confirms the pool config against the prior fact, appending it rather than overwriting anything. All three hypotheses land again, the lead assesses, and this time writes status = solved. resolve composes the resolution from a board that still has every fact either round established. done.

net_expertdb_expertlog_analystleadMachine (reducer)net_expertdb_expertlog_analystleadMachine (reducer)all three keyspresent → assesshypotheses cleared + status consumed,FACTS KEPT → investigate (round 2)all three keyspresent → assessincidentsummon (investigate)summonsummonfacts += fact, hypotheses['agent:log_analyst']facts += fact, hypotheses['agent:db_expert']facts += fact, hypotheses['agent:net_expert']status=continuesummon (board now has round-1 facts)summonsummonfacts += new fact, hypotheses['agent:log_analyst']facts += new fact, hypotheses['agent:db_expert']facts += new fact, hypotheses['agent:net_expert']status=solvedresolution (resolve)done (terminal)

What the reducer does to the failure points

A bad write can contaminate every agent. The board is typed, so contamination is bounded. Each field has a declared type; an enum field rejects out-of-vocabulary values at coercion. A fact field can carry a validate predicate (e.g. a minimum length, a format check) so a garbage fact doesn’t silently land on the board. And big artifacts get declared blob and claim-checked - the board holds a reference, not a megabyte of unvetted text. A bad write is still a bad write, but it’s a typed, validated, provenanced bad write, and it’s visible in the store where you can find and correct it - not smeared through a chat transcript.

Concurrent updates can conflict. The reducer serializes them. Each agent’s contribution is a discrete updateContext write, and the machine processes writes one at a time: a write merges, the reducer re-runs to a stable configuration, and the next write is applied on top of the result. There’s no lost-update problem, no read-modify-write race, because the store has a single writer at a time - the reducer. The analysts run concurrently, but their writes are ordered by the machine. “Concurrent updates” is a concurrency problem; here it’s a queue.

Stale reads cause incorrect decisions. An agent never reads a stale board. Every turn, the runtime injects the current CONTEXT block - every set field and its value - into the agent’s guidance. The board the agent acts on is the board now, not the board when the agent was summoned. And because a write re-runs the reducer before the next agent is re-engaged, the ordering is: write → reducer re-runs → next agent sees the updated store. There’s no window where an agent acts on a board that’s already changed.

Provenance becomes essential. Provenance is structural, and - this is the part that matters - it’s recorded from the actual writer, not from what the author declared. A map entry’s key is the principal who made the write; the runtime forces it, and rejects an explicit key on a field that a named requirement is gathering, precisely so a contributor can’t file a hypothesis under someone else’s name and satisfy their requirement. The board doesn’t just hold values; it holds who put them there, as data you can index. hypotheses['agent:db_expert'] is a lookup, not a log grep.

That’s also what makes the join trustworthy rather than merely convenient. “Wait for three hypotheses” and “wait for these three agents’ hypotheses” are different claims, and only the second one survives an agent answering twice.

Shared state can become an uncontrolled context dump. The board is schema’d. The context: block declares every field the board can hold, with its type - and for a collection, of: types the elements too, so facts is a list of strings and hypotheses is a map of strings, not a bag of anything. An agent can’t write a field that isn’t declared; it’s rejected with the field list. The CONTEXT block is capped (300 chars per value on the first pass, 80 on the budget pass), so a run doesn’t drown in a giant board; big values get claim-checked and render as a staged marker. And _-prefixed fields are internal - hidden from the agents’ view - so the machine can keep its own bookkeeping off the board the agents see. The board is a data model, not a scratchpad.

Collections are exactly one level deep, deliberately: of: names a scalar type. You can have a list of strings; you can’t have a list of records. That keeps guards, the derived elicitation forms, coercion and the claim-check walk all total and one level deep, and it’s the constraint you’ll feel first if you try to put a structured finding on the board. The workaround is the one the schema already offers - a map keyed by author, or a blob field holding the structure and a claim reference on the board.

One honest trade-off remains: a classic blackboard is open-ended - any source can write anything, and the controller is a heuristic. The Aegis version is a structured blackboard: the schema is fixed at authoring time, and the controller is a declared machine. You give up the “anything goes” flexibility in exchange for the guarantees - typed writes, ordered updates, structural provenance, bounded rounds. The other thing you give up is opportunistic scheduling: a real blackboard controller picks the next knowledge source from what’s currently on the board, where investigate fans out to the same three analysts every round. Content-driven activation is expressible - split investigate into per-specialist states and guard the edges on what the facts contain - but it’s a routing decision you author, not one the controller derives.


The whole thing, one idea

MomentWhoWhat happened in context
openleadincident set
investigate (round 1)3 analysts (concurrent)facts grew by 3; hypotheses keyed by all three authors
assessleadstatus = continue → hypotheses cleared, status consumed, facts kept
investigate (round 2)3 analystsfacts grew again (round 1’s are still there); hypotheses re-formed
assessleadstatus = solved
resolveleadresolution set, composed from the whole board
done-terminal

No point-to-point handoffs. No chat. The analysts wrote to the board, and didn’t know each other existed. The facts accumulated across rounds because a list has nowhere to lose them; the hypotheses churned because a map can be cleared and re-formed, and their keys said who held which position. The reducer was the controller, deciding - from the board’s state - which state to fire next. The blackboard wasn’t a metaphor for the context store. It was the context store, with a schema, a controller, and provenance built in.

Up next: 8. Planner / Executor