Harness Engineering / One workflow, three architectures / 2 of 3 · Blackboard

The oldest multi-agent architecture is the least used

Hearsay-II solved speech understanding in 1975 with a shape almost nobody reaches for now: a shared knowledge space, plus knowledge sources that each declare when they are worth waking. The shape has no control flow at all: nothing calls anything. That last property is why it fits agents better than the pipeline you probably drew instead. A pipeline commits to the order of operations at design time, and an agent's whole value is deciding at runtime.

companion repo · github.com/tacoda/iii-blackboard
built twice · once as iii configuration, once from scratch in stdlib Python
the workload · the same incident analysis all three repos run : extract claims, propose an explanation, score it, accept
verification · every iii command shown was executed against a live engine (0.22.0) before it was written down
tests · 6 passed, offline and deterministic
Blackboard
The shared knowledge space, partitioned into levels. Every contribution lands here and nowhere else.
Activation condition
A predicate over the blackboard deciding whether a knowledge source is worth waking. The load-bearing part.
Control
Which eligible knowledge source runs next. An optimization with plain code; cost control with a model in the loop.
0 The shape

Nothing calls anything

A pipeline is a graph you draw. A blackboard is a space you post into. The difference shows up the moment the work stops matching the graph.

The blackboard is a shared, structured store, partitioned into levels: raw input, derived evidence, candidate hypotheses, the solution. Independent knowledge sources watch it. Each one declares an activation condition, which is a predicate over the blackboard's contents. The predicate says when that knowledge source has something to contribute. When it matches, the knowledge source becomes eligible, and a control component picks which eligible one runs. It posts its contribution, and that contribution can make others eligible.

the loopno arrows between knowledge sources
flowchart TD
  subgraph bb["blackboard"]
    raw["raw"]
    evidence["evidence"]
    hypotheses["hypotheses"]
    solution["solution"]
  end
  subgraph ks["knowledge sources"]
    extract["extract"]
    propose["propose"]
    score["score"]
    accept["accept"]
  end
  control["control: run the highest-priority eligible one"]
  raw -.- c1["condition?"] -.- extract
  evidence -.- c2["condition?"] -.- propose
  hypotheses -.- c3["condition?"] -.- score
  solution -.- c4["condition?"] -.- accept
  extract --- control
  propose --- control
  score --- control
  accept --- control
  class raw,evidence,hypotheses,solution dim
  class c1,c2,c3,c4 block
  class control pass
where each shape puts the decision
 Pipeline / DAGBlackboard
Order of operationsDecided when you draw itDecided per step, from current state
Adding a capabilityRewire the graphAdd a knowledge source; nothing else changes
A stage with nothing to doRuns anyway, or you branchIts condition doesn't match. It doesn't run
Partial resultsUsually a failed runQuiescence: an honest outcome
PredictabilityYou know what will happenYou know what can happen, not what will
The honest case against

That last row is the price, and it is a real one. A blackboard run is harder to reason about before it happens, and harder to test exhaustively. If your stages never change (intake, validate, transform, emit), a pipeline is clearer, cheaper, and easier to debug. So build the pipeline instead. When the set of applicable steps varies per input, reach for a blackboard. A fixed graph handles that variation worst.

1 On iii

The activation condition is already a config field

You would expect to write the predicate that wakes a knowledge source as code. On iii it is a declarative slot on a trigger binding.

Levels are state scopes, and nothing needs creating: they exist on first write. A knowledge source is any function you bind to a state trigger. The interesting piece is conditions: an array on the binding, where fp::condition expresses the precondition as data:

a knowledge sourcethe precondition, as config
iii trigger engine::register_trigger \
  trigger_type=state function_id=promote-hypothesis \
  config='{"scope":"bb:hypotheses"}' \
  conditions='[{
    "function_id": "fp::condition",
    "config": { "path": "/new_value/confidence",
                "op": ">", "to": 0.8 }
  }]'

Operators are == != > >= < <= exists not_empty, plus negate. And the predicate returns more than a boolean:

evaluateda decision and a reason
$ iii trigger fp::condition \
    event='{"new_value":{"confidence":0.9}}' \
    condition_config='{"path":"/new_value/confidence","op":">","to":0.8}'
{"decision": "allow", "reason": "`/new_value/confidence` is 0.9, expected > 0.8"}

$ # same condition, weaker hypothesis
{"decision": "skip",  "reason": "`/new_value/confidence` is 0.5, expected > 0.8"}

That reason string is the detail worth stealing. Opportunistic control is the hardest kind to debug, because there is no call stack to read. So "why didn't anything happen?" is the characteristic failure. Here every scheduling decision explains itself, and you did not have to instrument anything to get it. Classic blackboard implementations built those explanations by hand.

One typo builds a blackboard where nothing ever fires

Paths are JSON pointer: /new_value/confidence, not new_value.confidence. A dotted path is not an error. It resolves to "nothing", the condition evaluates to skip, and the knowledge source never wakes. You get no exception, no warning, and no failed binding, just a system that quietly does nothing. This typo cost me a confused ten minutes, and it will cost you the same.

2 The mapping

Hearsay-II to iii, verified

Most of it is a rename. Two rows are not, and they are the ones to plan around.

the blackboard architecture, mapped
Hearsay-IIiiiStatus
Blackboard, in levelsstate scopesconfig
Knowledge sourceany registered functionconfig
Activation conditionfp::condition in conditions[]config
Opportunistic triggeringstate trigger, scope/key filtersconfig
Competing writes to a slotstate::compare-and-setconfig
Change notificationstate:created / updated / deletedconfig
Explaining a decisionthe condition's reason stringfree
Agenda / priority schedulingpriority_field , RabbitMQ adapter onlygap
Quiescence detectiongap

The agenda gap is a deployment detail with real consequences. The builtin and redis queue adapters ignore message priority, so a single-node blackboard has an unordered agenda. Every eligible knowledge source still runs, so you only lose control over which one runs first. The next rung shows why that ordering is the difference between an optimization and a bill.

3 From scratch

Two hundred lines, and most of it isn't the blackboard

Building a blackboard with nothing underneath shows which parts iii was handling for you.

The store, the knowledge sources, and the control loop are small. Everything around them takes the space: content-hash compare-and-set, a change log so the loop can tell progress from stalling, and the termination logic.

scratch/blackboard.pythe control loop
def run(self) -> str:
    for step in range(self.max_steps):
        if not self.bb.empty("solution"):
            return "solved"

        eligible = [ks for ks in self.sources if ks.eligible(self.bb)]
        if not eligible:
            return "quiescent"        # nothing applies. a real answer.

        ks = max(eligible, key=lambda k: k.priority)
        before = len(self.bb.log)
        ks.action(self.bb)
        if len(self.bb.log) == before:
            return "stalled"          # eligible but contributed nothing

    return "exhausted"

Four outcomes, and only one of them is "solved". Those four are the honest shape of opportunistic control. Writing them out makes the earlier gap concrete: nothing calls anything, so nothing ends on its own. Termination is a thing you build, and it looks free in the diagram.

stalled is the one I did not anticipate. If a knowledge source keeps its condition true but contributes nothing, control selects it forever. That repeat selection is the single way this loop livelocks. The check is three lines, and it is not optional.

the same run, both implementationsstub model, offline
[0] extract-evidence   (priority 10)
[1] propose-hypothesis (priority 20)
[2] score-hypothesis   (priority 30)
[3] accept-hypothesis  (priority 40)
[4] solution present — done

outcome: solved
answer:  The backlog is a throughput limit, not a failure: one consumer, flat errors.
conf:    0.86
4 What it taught

Four things that only showed up because we built it twice

Each of these was invisible from one side or the other.

  • The activation condition is the whole architecture. Everything else is bookkeeping. Get the predicates wrong and a blackboard is a hash map with extra steps. A wrong predicate fails silently, because nothing fires, so build your tests around that failure.
  • Opportunism needs a termination story, and it is yours. Both implementations needed an explicit quiescence check. iii's state::barrier answers "wait for N contributions". It does not answer "nothing is eligible anymore", and that second question is the one that ends a run.
  • Agent knowledge sources make the priority function load-bearing. With deterministic knowledge sources, running them in a poor order costs microseconds. With model-backed ones it costs a model call, in dollars and seconds. So the agenda stops being an optimization and becomes the cost control. That shift is why iii's priority gap matters more than it first appears.
  • Partial results are a first-class outcome. A pipeline that can't finish has failed. A blackboard that reaches quiescence has told you something true: given what the blackboard holds, no knowledge source can contribute further. Agent systems often hit the edge of what they can determine, so that partial answer is more useful than an exception.
The reframe worth keeping

Most teams wire agents as pipelines because that is what the tooling suggests, not because the work is pipeline-shaped. If you have ever added a branch to skip a stage with nothing to do, you have started hand-rolling activation conditions. The blackboard is what those branches become when you commit to them.

5 Takeaway

Fifty-year-old architecture, unusually good fit

Not because it is clever, but because it declines to decide the thing agents need to decide at runtime.

Use a blackboard when the set of applicable steps varies per input. Not when the content varies, because a pipeline handles varying content fine. Use one when you cannot name in advance which knowledge sources apply, because a fixed graph handles that case worst. Agent systems hit it often.

On iii, most of the architecture is configuration. Levels are state scopes, knowledge sources are bound functions, activation conditions are fp::condition entries, and contention is compare-and-set. You get self-explaining scheduling decisions for free. You do not get an ordered agenda without RabbitMQ, and you do not get quiescence detection at all.

Build the termination logic first. It looks free in the diagram, and it is absent from both substrates. It decides whether your system stops or spins. Everything else is as easy as it looks.

The companion repo has both implementations side by side. It also has a demo that walks the activation conditions flipping between allow and skip against a live engine. The six tests are offline and deterministic. Read test_quiescence_when_nothing_eligible first, because that outcome is the whole architecture in one assertion.