Skip to content

Write an agent

An agent's work starts when the library notifies it and finishes when the agent reports that it has stopped.

  1. It receives a notification.
  2. It reads whatever it wants from the board.
  3. It decides what to add, or to add nothing.
  4. It writes each thing it wants to add.
  5. It acknowledges.

The library asks nothing of an agent outside these steps. Steps 2 and 3 are invisible to the library: reads go straight to the board, and nothing reports what the agent concluded. The library learns what the agent wrote, because it sequenced those writes itself. Deciding to add nothing is an ordinary outcome and skips step 4.

Step 3 is where the agent's expertise sits, and the examples on this page implement it as an algorithm in the agent's own code. An agent that asks a language model instead takes the same five steps and offers the board to the model as tools it may call, which Give an agent a language model covers.

The smallest one

from blackboard import Agent


def investigate(notification):
    window = model.reader.read_premise("window").value
    findings = look_for_trouble(window)  # your own work
    if findings:
        model.control.write("platform", {"findings": findings}, writer="ocp")
    model.control.ack(notification.notification_id, agent="ocp")


model.control.register_agent(Agent(name="ocp", notify=investigate))

Declaring what wakes it

Agent(
    name="ocp",
    notify=investigate,
    subscribes_to=["window", "namespace"],  # only these wake it
    writes_to=["platform"],  # it may write only here
)

Omit subscribes_to and the agent is woken by every premise; no level wakes it. Name a level, and a contribution to that level wakes the agent, which is how one agent's finding starts another's work.

Omit writes_to and every level is permitted. Name a level, and a write to any other level comes back Rejected with the cause NOT_PERMITTED. Naming none, as writes_to=[] does, permits none.

The permission goes to the run with the rest of the declaration, so every replica reads the same answer and a write refused on one is refused on all. It is held against the writer's name and constrains writes made as ocp and nothing else: a write under a name the run does not carry reaches any declared level.

Naming a level that was never declared is a different failure, and it is caught earlier: register_agent raises UndeclaredRegionError rather than letting the agent register with a permission it can never use. Naming a premise in writes_to raises RegionKindError, because only a level takes a contribution.

An agent is never woken by its own write.

What acknowledging means

Acknowledging a notification also acknowledges every notification you were sent whose range ends at or before that notification's range. The cursor is cumulative, so answering the widest range answers the narrower ones inside it, and an agent that reads to the board's end has to acknowledge only the last identifier it holds.

The cumulative cursor also covers the notification you never received. You cannot acknowledge by name a notification you never received. A delivery that raised an exception is suppressed, so one agent's failure does not reach an unrelated writer, and the notification that it carried never reaches you. Your next acknowledgment covers it.

Acknowledging means the agent has stopped working on that notification. It does not mean the agent found anything, and it does not mean the agent will not be woken again.

A run that closes on silence or on the wall clock names every agent still holding a notification in its outcome's unfinished. A run that a caller aborted leaves unfinished empty, because abort closes the run without collecting the agents still holding one.

Joining a run

Name the agent when the run is created, which is how agents normally arrive.

model = create_model(..., agents=[Agent(name="ocp", notify=investigate)])

An agent that joins a run already under way registers itself instead, and is woken the same way.

model.control.register_agent(Agent(name="netops", notify=investigate))

In both cases the agent is woken immediately, covering every subscribed region that already holds something, because an agent that has just joined is out of date with the whole board. A subscribed premise carrying a batch window is the exception: registering schedules the notification a window away, so an agent with nothing else pending is woken when that window passes.

Both doors leave the same row on the run: the name, what wakes it, what it may write to, and where it is reached. They differ in when and not in what, so any process serving that board knows the agent is there.

Saying where it is reached

notify is a callable, and a callable is held by one process. An agent deployed as its own service gives an address instead, which is written to the run:

Agent(
    name="ocp",
    subscribes_to=["window"],
    address="https://ocp.internal/notify",
)

A declaration carries notify, address, or both, and one naming neither raises ValueError, because nothing could reach it. Delivering to an address needs a transport, which the process serving the board is given as reach:

model = create_model(..., reach=notifier.reach)

Notify agents over HTTP covers what to pass and what it costs. What the address buys is that the process which took the registration is no longer the only one that can wake the agent: any process holding a transport reaches it, so losing that process loses nothing.

Coming back after a restart

An agent that restarts registers again under the same name. Registering again replaces the row it left on the run, its address and its subscriptions with it, so a redeployed agent that moved is reached where it moved to.

Its cursor survives, because the control component has not forgotten what the agent acknowledged. Whatever notification the old process was still holding is discarded, and one fresh notification covers everything since that cursor, because a notification carries no values.

Naming the same agent twice in one roster at creation raises DuplicateAgentError, since that is one list written at one moment and a repeat there is a mistake rather than an agent coming back after a restart.

Registering wakes the agent, so the callback runs before create_model or register_agent returns. A callback that needs the model must be given it another way, because the call has not returned yet.

holder = []


def investigate(notification):
    model = holder[0]
    ...


model = create_model(...)
holder.append(model)  # before registering
model.control.register_agent(Agent(name="ocp", notify=investigate))

Doing the work elsewhere

The callback runs on the thread that dispatched the notification. Nothing requires the agent to work there.

def hand_off(notification):
    executor.submit(do_the_work, notification)  # return at once

Acknowledgment is everything the control component learns about how an agent ran. The control component records what the agent wrote, because it sequenced those writes itself, and it learns nothing about how long the work took, its success or failure, or where it happened. It never kills an agent.

An agent in its own service

Everything above is an agent in the same process as the blackboard, reading through model.reader and writing through model.control. An agent deployed on its own does the same five steps against the same names, over HTTP.

pip install 'blackboardx[agent]'
from blackboard.agent import BoardClient
from blackboard.wire import NotificationBody


@app.post("/notify")
def notify(body: dict):
    notification = NotificationBody.from_json(body)
    with BoardClient(
        base_url="https://blackboard.internal/v1",
        board_id=notification.board_id,
        agent="triage",
    ) as board:
        window = board.read_premise("window").value
        signals = board.read_level("signals", notification.from_sequence)
        for signal in signals:
            board.write("findings", investigate(signal, window))
        board.ack(notification.notification_id)
    return "", 204

The URL that route is served at is the address on the agent's declaration, which is what any process serving the board posts to.

A client is bound to one board and one agent name, so no method takes the board or the agent name, and no method can be given the wrong board or the wrong name.

NotificationBody.from_json raises wire.WireError on a body that is not an object, or that leaves out board_id, notification_id, agent, from_sequence or to_sequence. WireError's base is Exception rather than BlackboardError, so an except BlackboardError around the route does not catch it.

Writing the body once

BoardClient and Control.as_agent both satisfy AgentBoard, the protocol that one board looks like to one agent. Write the body against AgentBoard, and the body runs in both deployments with no edit:

from blackboard import AgentBoard


def investigate(board: AgentBoard, from_sequence: int) -> None:
    severity = board.read_premise("severity")
    for signal in board.read_level("signals", from_sequence):
        board.write("findings", conclude(signal, severity.value))

In process, ask the control component for the board as that agent sees it:

investigate(model.control.as_agent("triage"), notification.from_sequence)

Deployed on its own, hand it the client:

with BoardClient(base_url=..., board_id=..., agent="triage") as board:
    investigate(board, notification.from_sequence)

AgentBoard carries the four reads that BoardReader has and the three writes that Control has, each without the agent's own name, because the object already holds it. It also carries a board_id property for a body that has to name its own board. BoardClient also satisfies BoardReader on its own, so an admission rule or a termination predicate written against that protocol reads a remote board too.

as_agent registers nothing. Registering decides what wakes an agent; as_agent decides the name that agent's writes carry, so it serves a writer that never registered as well as one that did.

Answer the notification before you acknowledge. Acknowledging is what tells the run this agent has stopped, and what acknowledging means covers the rest.

Async

AsyncBoardClient has the same methods with await in front of them.

async with AsyncBoardClient(
    base_url="https://blackboard.internal/v1",
    board_id=notification.board_id,
    agent="triage",
) as board:
    signals = await board.read_level("signals", notification.from_sequence)

The two clients are written separately, not one in terms of the other. A synchronous method that starts an event loop per call throws away the connection pool, so each is implemented on its own, and the request they build is one piece of shared code.

What comes back, and what is raised

A write answers the way Control.write answers, with Written or Rejected, and setting a premise adds Conflict. Each of those is a decision that the run made about a write that it understood, so the caller handles the decision where it made the call. A Conflict is answered by reading the premise and deciding again.

outcome = board.set_premise("severity", "high", expected_version=3)
if isinstance(outcome, Conflict):
    current = board.read_premise("severity")
    outcome = board.set_premise("severity", decide(current.value), current.version)

Everything else is raised. A region name and an idempotency key are the caller's to get right, so a wrong name or a wrong key raises an exception rather than answering, and it raises the same exception whichever deployment the body runs in. Where the blackboard has an exception for a condition, the client raises that one.

Raised When Where
UndeclaredRegionError No region by that name Either deployment
RegionKindError That name is a premise, and the call takes a level Either deployment
IdempotencyKeyError A key that already named one region, sent naming another Either deployment
UnsetPremiseError A premise declared without an opening value Either deployment
UnknownNotificationError Acknowledging one this agent was never sent Either deployment
UnknownBoardError The blackboard holds no run for that board, and no record to answer a read from Over HTTP
Unreachable It could not be reached, or kept answering 5xx Over HTTP
ProtocolError The two halves are out of step Over HTTP

RejectionCause has three members, ADMISSION, NOT_PERMITTED and RUN_CLOSED, and a Rejected never carries anything else.

What is attempted again

A read and an acknowledgment are attempted again when the blackboard cannot be reached, or answers 408, 425, 429, or any 5xx. Reading twice returns the same thing, and acknowledging a notification that is no longer outstanding changes nothing. The wait between attempts doubles, and each wait is drawn from the range between half of that wait and all of it, and a blackboard that sent Retry-After gets the delay it asked for, up to thirty seconds.

A write is attempted again only when it carries a key. A request that timed out may still have been received, and a contribution appended twice does not leave the same board, so a write with nothing to identify it raises Unreachable and what to do next is the agent's to decide.

Give the write a key and the blackboard writes it once, however many times it arrives:

board.write("findings", conclusion, idempotency_key=f"{notification.notification_id}-1")

The client then retries that write like a read. If the first attempt landed and its answer was lost, the second attempt comes back as the first attempt's result with repeated set, so the agent sees one write and the board holds one row.

Name the key after work that the agent can name again after a restart, rather than a fresh random string each time. The notification's id and a counter is usually enough.

A key names one write on one board. Sending it for a different region is a mistake rather than a retry, and raises IdempotencyKeyError. Over HTTP the blackboard answers 409 naming the key and the client raises from that, so the in-process body and the deployed one meet the same exception. Sending the key again for the region it does name returns the first write, whatever content goes with it.

Retrying a keyed write needs a blackboard at 0.8 or later. An older blackboard takes the key and ignores it, and a retry against it writes twice.

Reading a whole level

read_level(level) reads to the end, following the blackboard's pages. Give it a limit to ask for one page instead, and continue from one past the last sequence you received.

page = board.read_level("signals", from_sequence, limit=100)
next_from = page[-1].sequence + 1 if page else from_sequence

One request to a blackboard answers with at most 100 contributions when it names no limit, and at most 1000, whatever limit it names. The cap is silent: a limit of 5000 comes back with 1000 and the list alone does not say it was cut. In process there is no cap, and read_level with no limit returns the whole level in one call.

A sequence number is the cursor because an offset would shift when a concurrent write lands.

Authentication and connections

The client opens a plain httpx client unless you give it one. Give it a client configured with whatever your deployment needs, and keep it for the life of the service rather than building one per notification:

session = httpx.Client(headers={"authorization": token}, timeout=10.0)
board = BoardClient(base_url=..., board_id=..., agent="triage", http_client=session)

A client you supply is yours to close. A client that the library opened is closed with the BoardClient.