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, a set of specialists that each declare when they are worth waking, and 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 specialist is worth waking. The load-bearing part.
Control
Which eligible specialist 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 declares an activation condition: a predicate over the blackboard's contents that says when this specialist has something to contribute. When a condition matches, that KS becomes eligible; a control component picks which eligible KS runs; it posts its contribution, which may make others eligible.

the loopno arrows between specialists
       ┌──────────────── blackboard ────────────────┐
       │  raw   evidence   hypotheses   solution   │
       └───┬───────┬────────────┬───────────┬──────┘
           │       │            │           │
      condition?  condition?   condition?  condition?
           │       │            │           │
        extract  propose      score      accept        ← knowledge sources
           └───────┴──────┬─────┴───────────┘
                      control: run the highest-priority eligible one
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 KS; 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 are genuinely fixed — intake, validate, transform, emit — a pipeline is clearer, cheaper, and easier to debug, and you should build that instead. Reach for a blackboard when the set of applicable steps varies per input, which is exactly the case a fixed graph handles worst.

1 On iii

The activation condition is already a config field

The part you would expect to write as code — the predicate deciding whether a specialist wakes — turns out to be a declarative slot on a trigger binding.

Levels are state scopes; nothing needs creating, they exist on first write. Knowledge sources are functions bound 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 precisely because there is no call stack to read — "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 that 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 simply never wakes. No exception, no warning, no failed binding — just a system that quietly does nothing. This 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 entirely, so a single-node blackboard has an unordered agenda. Every eligible KS still runs; you just lose control over which one runs first, which — see the next rung — is the difference between an optimization and a bill.

3 From scratch

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

Building it with nothing underneath is the only way to see which parts iii was quietly handling.

The store, the knowledge sources and the control loop are small. What takes the space is everything around them — 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". That is the honest shape of opportunistic control, and writing it out is what makes the earlier gap concrete: nothing calls anything, so nothing naturally ends. Termination is a thing you build. It looks free in the diagram.

stalled is the one I did not anticipate. A knowledge source whose condition stays true but which contributes nothing will be selected forever — 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 — and because a wrong predicate fails silently (nothing fires), it is the failure you should build your tests around.
  • 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", not "nothing is eligible anymore" — a different question, and the one that actually ends a run.
  • Agent knowledge sources make the priority function load-bearing. With deterministic specialists, running them in a poor order costs microseconds. With model-backed ones it costs a model call, in dollars and seconds. The agenda stops being an optimization and becomes the cost control — which is what makes iii's priority gap matter 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 is known, no specialist can contribute further. For agent systems — which routinely hit the edge of what they can determine — that is a far more useful thing to return than an exception.
The reframe worth keeping

Agents are usually wired as pipelines because that is what the tooling suggests, not because the work is pipeline-shaped. If you have ever added a conditional branch to skip a stage that had nothing to do, you have started hand-rolling activation conditions. The blackboard is what that becomes if you take it seriously.

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 — a pipeline handles that fine — but when you genuinely cannot say in advance which specialists apply. That is the case a fixed graph handles worst and the case agent systems hit constantly.

On iii, most of it 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 is the part that looks free in the diagram, is absent from both substrates, and determines whether your system stops or spins. Everything else is genuinely as easy as it looks.

The companion repo has both implementations side by side, plus a demo that walks the activation conditions flipping between allow and skip against a live engine. Six tests, offline and deterministic; the one worth reading first is test_quiescence_when_nothing_eligible, because that outcome is the whole architecture in one assertion.