API reference¶
The public surface is seven import paths: the package and six submodules. Everything else in the package is internal, and a name outside these pages may change without a deprecation.
| Import path | Holds | Imported by |
|---|---|---|
blackboard |
The board, the control component, create_model, the AgentBoard an agent body is written against, and the scheduled work: close_expired, relay_unsent and Sweep |
The blackboard and the agent |
blackboard.wire |
The request and response bodies that cross between the two halves, and the seven operations that carry them | Both |
blackboard.server |
Answering an agent's request, without a web framework | The blackboard |
blackboard.delivery |
Sending notifications to agents over HTTP | The blackboard |
blackboard.agent |
Reading and writing a board from an agent | The agent |
blackboard.tools |
Each method of AgentBoard as a tool a language model can call, for the application to choose among |
The agent |
blackboard.conformance |
The suite every store implementation is held to | Anyone writing a store |
blackboard¶
blackboard ¶
A skeletal blackboard system.
The library supplies the board, the shared structure through which
independent agents contribute to one result, and the control component,
which determines which agents are notified of a change, which proposed writes are
admitted, when the run's limits still hold, and when the run has finished. An
application creates a model by supplying its regions,
their opening premise values, the agents the run starts with, an admission
rule, a termination predicate, and limits. The public surface is the set of
names in __all__; every other name is internal.
AdmissionRule
module-attribute
¶
AdmissionRule: TypeAlias = Callable[
[ProposedWrite, "BoardReader"], Accept | Reject
]
The rule the control component calls on every proposed write.
NotificationId
module-attribute
¶
NotificationId = NewType('NotificationId', int)
The identifier an acknowledgment names.
ProposedWrite
module-attribute
¶
ProposedWrite: TypeAlias = (
ProposedContribution | ProposedPremiseWrite
)
A proposed write, level or premise, as the admission rule receives it.
RunOutcome
module-attribute
¶
RunOutcome: TypeAlias = Settled | WallClockExpired | Aborted
The three states a run closes in.
Each names the agents that did not finish, meaning those holding an unacknowledged notification. Why a run ended and which agents failed to finish are separate facts, and a run settles normally while one agent never returns.
TerminationPredicate
module-attribute
¶
TerminationPredicate: TypeAlias = Callable[
["BoardReader"], TerminationDecision
]
The predicate the control component calls when no work is outstanding.
AgentProgress
dataclass
¶
How far one agent has been told, and how far it has answered.
Both are sequence numbers on this board, so they are comparable with a
contribution's sequence and with each other. An agent is unfinished
when it has been told further than it has answered, which is what any
process reads to name the agents a closing run did not hear back from.
Both only ever rise. Two processes notifying one agent leave the higher of what they wrote, so neither can undo the other and no lock is needed to keep them ordered.
BlackboardError ¶
Bases: Exception
The base of every error this library raises.
BoardChange
dataclass
¶
One write to any region, at its position in the total order.
writer and written_at follow the same rules as on
:class:Contribution.
Conflict
dataclass
¶
A premise write that failed because the version it named is not current.
Contribution
dataclass
¶
One unit stored in a level, at its position in the total order.
writer is the name the write carried and written_at is the
store's clock when it landed. Both are None on a record written
before the library recorded them, and writer is None for an
opening premise value, which the application supplied rather than any
agent.
Deleted
dataclass
¶
What removing one board took with it.
A board the store never held names nothing rather than failing, so
deleting one twice is safe. Both fields are counts, and are named as
counts because regions elsewhere on this surface holds names.
DuplicateRegionError ¶
Bases: BlackboardError
A declaration named a region that already exists.
IdempotencyKeyError ¶
Bases: BlackboardError
An idempotency key was used for a region it did not name before.
A key names one write. Sending it again for a different region is a caller's mistake rather than a retry, and the store refuses it instead of silently answering with the write it does name.
InMemoryStore ¶
A store held in process memory. A test double, not a way to run a deployment.
Nothing it holds outlives the process, and two processes running the same code share nothing. An application keeps its record in a database through an adapter; a test that wants no file uses this.
Content is carried as JSON, as it is in every store that crosses a process
boundary. Holding a Python object as it stands would make this store accept
what a deployment then refuses, and preserve types, such as a tuple, that no
other store returns. Content that JSON cannot carry raises TypeError
before anything is stored.
Sequence assignment is the only point where two writes to one board wait on each other, so writes from concurrent threads all succeed with distinct sequence numbers.
declare ¶
Creates a region on one board. A name already declared is refused.
append ¶
append(
board_id: str,
level: str,
content: object,
idempotency_key: str | None = None,
writer: str | None = None,
notify: frozenset[str] = frozenset(),
) -> Written
Adds one contribution to a level and returns where it landed.
set ¶
set(
board_id: str,
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
writer: str | None = None,
notify: frozenset[str] = frozenset(),
) -> Written | Conflict
Replaces a premise's value under the version the caller expects.
A write naming any version other than the premise's current one fails: it returns a conflict carrying the current version, takes no sequence number, and leaves the premise unchanged. A premise never written has version 0. A conflict writes nothing, so it uses up no key.
read_level ¶
read_level(
board_id: str,
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
read_premise ¶
read_premise(board_id: str, premise: str) -> PremiseState
Returns a premise's current value and version.
read_board ¶
read_board(
board_id: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
delete ¶
delete(board_id: str) -> Deleted
Removes one board's regions, record, premise values, and counter.
The application decides when a board is deleted; nothing in the
library calls this. Close the run first: a Control still serving
this board goes on writing to a record that is no longer there.
Level
dataclass
¶
A declaration of a region that accumulates contributions in arrival order.
The control component reads the batch window when a change to this level lands, to schedule its notification. The board ignores it. A window collapses a burst of writes into one notification, which costs a subscriber one wake instead of one per write.
Premise
dataclass
¶
A declaration of a region that holds one current value under a version.
The control component reads the batch window when a change to this premise lands, to schedule its notification. The board ignores it.
PremiseState
dataclass
¶
The current value of a premise and the version its write produced.
writer and written_at describe the write that set the current
value, and are None where the record predates them or the value is
the opening one.
RegionKindError ¶
Bases: BlackboardError
An operation that takes a level named a premise, or the reverse.
RunRecord
dataclass
¶
What the store holds about one board's run.
now is the store's clock at the moment of the read, so a caller
decides that a deadline has passed by comparing two instants that came
from the same clock. Pods disagree about the time and the store does not.
closed_as is None while the run is open, and otherwise names the
outcome: settled, wall_clock_expired or aborted.
UndeclaredRegionError ¶
Bases: BlackboardError
An operation named a region that no declaration created.
Unsent
dataclass
¶
One notification a write recorded and nothing has sent yet.
The row exists because the intent to notify is written in the same transaction as the contribution, so a process that commits a write and stops before delivering has not lost it. Whoever holds the agent sends it and marks it sent.
through is the sequence the notification's range ends at, which is
also the identifier it carries.
UnsetPremiseError ¶
Bases: BlackboardError
A premise was read before any write gave it a value.
Written
dataclass
¶
A write the board sequenced, at the sequence number it received.
version is the premise's new revision count. A level write leaves
it None, because a level holds no version to replace.
repeated says the store had already written this idempotency key and
answered with what that write produced. Nothing was added.
Clock ¶
Bases: Protocol
Reads the current instant and arms calls to fire at chosen instants.
call_at ¶
call_at(
when: datetime, call: Callable[[], None]
) -> ScheduledCall
Arms a call to fire at an instant and returns its cancellation handle.
ManualClock ¶
A deterministic clock for tests; time moves only through advance.
advance ¶
advance(delta: timedelta) -> None
Moves time forward and fires every due call on the calling thread.
Calls fire in due order; ties fire in arming order. A call armed during the advance fires inside it when its instant falls at or before the target. A call armed at or before the current instant fires on the next advance, a zero advance included. A fired call may itself advance the clock; the outer advance never moves time back below where an inner one left it.
ScheduledCall ¶
Bases: Protocol
A call armed on a clock, cancellable until it fires.
SystemClock ¶
The default clock, backed by the operating system.
Aborted
dataclass
¶
A caller closed the run.
Accept
dataclass
¶
The admission rule's verdict admitting a proposed write.
Agent
dataclass
¶
An agent declaration: identity, delivery, and what it wants.
subscribes_to names the regions, level or premise, whose changes wake
this agent, and naming any region excludes every region not named. Omitting it
subscribes the agent to every premise and to no level, which is the
common case: a premise holds something the work was given, which bears
on any agent's work, while another agent's conclusion does not.
writes_to names the levels the agent may write to, and omitting it
permits every level.
address is where the agent is reached, and is written to the run so
that any process serving the board knows it. notify is how it is
reached from this process, and is written nowhere, because a function is
not data. A declaration carries at least one of the two: notify alone
is an agent living in this process, address alone is one reached over
the wire, and both is an address on the record with a faster path where
the callable is present.
An agent declared by address alone is delivered to by the transport
the process was given as reach, on the same paths a callable is
delivered to on: when it joins, when a write it subscribes to lands, and
when a batch window closes. A process given no transport reaches it not
at all, and leaves the row a write recorded for one that was.
The control component invokes notify to deliver a notification,
holding no lock, on the thread that closed the batch window or, when
deliveries chain, on a thread already draining them; two notifications
to one agent can therefore arrive on two threads at once. The callback
may run the whole agent cycle inline or hand the notification to the
application's own execution. A callback that blocks on wait_closed
holds the run open, because its own unacknowledged notification counts
as outstanding work.
Both subscribes_to and writes_to are read on every write and
every notification, so each is kept as a frozenset of what was given.
A declaration built from a generator therefore behaves like one built
from a set, rather than emptying itself on first use.
AgentBoard ¶
Bases: Protocol
One board, as one agent sees it: what an agent body is written against.
BoardClient satisfies this over HTTP and Control.as_agent returns one in
process, so a body written once serves both deployments. Every
method omits the agent's own name, because the object already carries it.
Reads are the four operations that BoardReader has. Writes are the three that
Control has, without the identity argument.
read_regions ¶
Returns the regions declared on this board, with their kinds.
read_level ¶
read_level(
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
read_premise ¶
read_premise(premise: str) -> PremiseState
Returns a premise's current value and version.
read_board ¶
read_board(
from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
write ¶
Proposes a contribution to a level, as this object's agent.
set_premise ¶
set_premise(
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
) -> Written | Conflict | Rejected
Sets a premise, provided it is still at expected_version.
ack ¶
ack(notification_id: NotificationId | int) -> None
Records that this agent finished responding to a notification.
BoardReader ¶
Bases: Protocol
The four read operations, the handle the admission rule receives.
read_level ¶
read_level(
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
read_premise ¶
read_premise(premise: str) -> PremiseState
Returns a premise's current value and version.
read_board ¶
read_board(
from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
BoardStore ¶
Bases: Protocol
The operations the control component performs on a store.
A store holds many boards. Every call that acts on one names it first,
so one connection to a database serves every board an application runs.
Two sweep for work instead and name none: runs_past_deadline answers
what needs closing and unsent what needs sending, over every board
the store holds.
InMemoryStore and SqliteStore satisfy this protocol, as does any adapter an
application writes against its own database. The control component names
no concrete type, and holds every implementation to one conformance suite.
Content crosses this protocol as JSON. An implementation returns what JSON carries,
so a tuple that is written comes back as a list, and content that JSON cannot carry
raises TypeError before anything is stored.
append ¶
append(
board_id: str,
level: str,
content: object,
idempotency_key: str | None = None,
writer: str | None = None,
notify: frozenset[str] = frozenset(),
) -> Written
Adds one contribution to a level and returns where it landed.
notify names the agents that should hear of this write. One row
per name is recorded in the same transaction as the contribution, so
a process that commits and stops before delivering has not lost the
intent. A write that is a repeat records none, because nothing
happened.
writer is the name the write carried, recorded on the row so the
record answers who wrote it. The store stamps the instant itself.
idempotency_key names one write on one board. A key that the store has
already written answers with what that write produced, marked
repeated, and adds nothing. A key that named a different region
raises IdempotencyKeyError, because that is a mistake rather than
a retry. Without a key nothing is deduplicated.
set ¶
set(
board_id: str,
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
writer: str | None = None,
notify: frozenset[str] = frozenset(),
) -> Written | Conflict
Replaces a premise's value under the version the caller expects.
writer is recorded as it is on append.
idempotency_key works as it does for append. A conflict
writes nothing, so it uses up no key.
read_level ¶
read_level(
board_id: str,
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
limit caps how many come back. A caller continues from one past
the last sequence it received, which an offset could not do, because an offset
shifts when a concurrent write lands, and a sequence number does not.
read_premise ¶
read_premise(board_id: str, premise: str) -> PremiseState
Returns a premise's current value and version.
read_board ¶
read_board(
board_id: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
delete ¶
delete(board_id: str) -> Deleted
Removes one board's regions, record, premise values, and counter.
Delete removes everything or nothing: a caller that gets an answer got a board that is gone. A board that the store never held names nothing rather than failing, so a delete that runs twice is safe.
Nothing in the library calls this. Deleting is a retention decision and the control component makes none.
open_run ¶
open_run(
board_id: str, *, wall_clock: float, idle: float
) -> None
Records that a run is open over this board, with its two limits.
Both limits are seconds. The store computes the two deadlines from its own clock, so no instant crosses the call and no caller's clock decides when a run ends.
read_run ¶
read_run(board_id: str) -> RunRecord | None
Returns the run over this board, or nothing where none was opened.
The record carries the store's clock alongside the deadlines, so a caller compares two instants that came from one clock.
touch_run ¶
touch_run(board_id: str, *, idle: float) -> None
Pushes the idle deadline out by idle seconds from now.
The wall clock is left where it was, because it bounds the whole run rather than the quiet in it. A run already closed is unchanged.
close_run ¶
close_run(
board_id: str,
*,
closed_as: str,
reason: str | None = None,
unfinished: frozenset[str] = frozenset(),
) -> bool
Records how the run ended, and says which caller recorded it.
Answers True to the one caller that closed the run and False
to every other, so a run closes once however many callers reach the
deadline together. That is what makes closing safe without a lock.
runs_past_deadline ¶
runs_past_deadline(limit: int = 100) -> list[str]
Returns boards whose run is open and past one of its deadlines.
A run closes because nothing happened, so no request is in flight to notice. This is what a caller polls to close those runs.
unsent ¶
unsent(limit: int = 100) -> list[Unsent]
Returns notifications recorded by a write and not yet sent.
Across every board, oldest first by the sequence the notification ends at, so the process that reads them sends the earliest work first. A caller sends what it can reach: an agent it holds a callable for, or one the run records an address for, which is any of them.
mark_sent ¶
mark_sent(
board_id: str, agent: str, *, through: int
) -> None
Records that this agent was sent everything through through.
Removes every row for this agent on this board at or below that sequence, because a notification covers a range and sending it answers every row inside it. That is the same cumulative rule acknowledgment follows.
A row that never existed changes nothing. Marking after sending rather than before is what makes delivery at least once: a process that sends and stops before marking sends again, and a repeat costs nothing.
read_agents ¶
read_agents(board_id: str) -> list[AgentProgress]
Returns how far each agent on this board has been told and answered.
One entry per agent the board has notified, in any order. A board that has notified nobody returns nothing rather than refusing.
Each entry is internally consistent, so acknowledged_through never
exceeds notified_through on a row that comes back. The entries are
not read as one snapshot across agents, and nothing needs them to be.
declare_agent ¶
declare_agent(
board_id: str,
agent: str,
*,
subscribes_to: frozenset[str] | None = None,
writes_to: frozenset[str] | None = None,
address: str | None = None,
) -> None
Records that this agent is part of this run, and what it wants.
Called for every agent a board starts with and for every one that joins a run already under way, because those are two doors to one fact. Declaring the same agent again replaces what it declared, which is what an agent that restarted and changed its mind needs.
address is where the agent is reached, and is None for one
that lives in the process that declared it. No adapter holds the
callable that reaches it; that is not data.
Creates the entry where none exists, leaving how far the agent has got where it was.
mark_notified ¶
mark_notified(
board_id: str, agent: str, *, through: int
) -> None
Records that the agent has been told everything through through.
Creates the entry where none exists. A through below what is
already recorded changes nothing, so two processes notifying one
agent leave the higher of the two whichever order they arrive in.
That is what makes notifying from more than one process safe without
a lock.
acknowledge ¶
acknowledge(
board_id: str, agent: str, *, through: int
) -> AgentProgress | None
Records the agent's answer, and returns the entry as it stood before.
Answers None where the agent has no entry, and where through
is beyond what the agent was told. Both mean the acknowledgment names
a range the store never handed out.
acknowledged_through only rises, so of several callers naming one
through exactly one is answered with an entry below it. That is
how a first acknowledgment is told from a repeat without a second
read.
read_regions ¶
Returns the regions declared on one board, with their kinds.
A store records a region's name and its kind, and nothing else. A premise comes back with the default batch window, whatever window it was declared with, because the window tells the control component when to notify and is no part of the record.
A board that nobody declared a region on returns nothing rather than refusing, because a board comes into being by being written to.
Control ¶
The control component's write path, over the board it is given.
The board holds the record and outlives this object. The run is in the store beside it, being its two deadlines, its outcome, its agents, how far each has been told and has answered, and what a write recorded and nothing has sent. So this object holds nothing but the callables it was given and the batch windows it has armed.
reader
property
¶
reader: BoardReader
The board's read side. Reads bypass the control component entirely.
as_agent ¶
as_agent(name: str) -> AgentBoard
Returns this board as one agent sees it.
The object carries the agent's name, so its methods are the ones
BoardClient has and an agent body written against AgentBoard runs in
this process or against a blackboard over HTTP.
The agent need not be registered. Registering decides what wakes it; this call decides what it writes as.
declare ¶
Creates a region on the board and records its kind.
register_agent ¶
register_agent(agent: Agent) -> None
Registers an agent and wakes it.
The agent is out of date with everything already on the board, so registering issues one notification covering the regions it subscribes to that already hold something. Its cursor starts at zero, since it has read nothing.
Registering a name that is already registered replaces that agent's declaration, including its callback and its subscriptions, which is what an agent that restarted or moved needs. Its cursor survives, because it has not forgotten what it acknowledged. Whatever notification it was still holding is discarded, and the one issued here covers everything since that cursor, because a notification carries no values.
write ¶
write(
level: str,
content: object,
idempotency_key: str | None = None,
*,
writer: str,
) -> Written | Rejected
Runs one level write through admission and, if admitted, the board.
idempotency_key names one write. A key already written answers
with what that write produced, marked repeated, and changes
nothing: no intent to notify, no notification, and no push of the
idle deadline, because nothing happened. A key that named a different
region raises IdempotencyKeyError, because the caller chose it.
set_premise ¶
set_premise(
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
*,
writer: str,
) -> Written | Conflict | Rejected
Runs one premise write through admission and, if admitted, the board.
idempotency_key works as it does for write. A conflict stores
nothing, so it uses up no key.
abort ¶
abort(reason: str) -> None
Closes the run as aborted. A run already closed keeps its outcome.
outcome ¶
outcome() -> RunOutcome | None
Returns the closed run's outcome, or nothing while the run is open.
wait_closed ¶
wait_closed(
timeout: timedelta | None = None,
) -> RunOutcome | None
Blocks until the run closes and returns its outcome.
With a timeout, returns nothing when the run is still open after it.
ack ¶
ack(
notification_id: NotificationId | int, *, agent: str
) -> None
Records that the agent finished responding to a notification.
The cursor advances to the end of the range the notification covered, and every notification to this agent whose range ends at or before that one is acknowledged with it, because the cursor is cumulative and answering the wider range answered the narrower ones.
An acknowledgment of a notification no longer outstanding changes nothing; an acknowledgment that names a notification never issued to that agent raises an error.
relay ¶
relay() -> list[str]
Sends what a write recorded and nothing has sent, and names them.
A process that took a write and stopped before delivering left the intent on the record. This sends it.
An agent this process declared is reached the way it was declared, through its callable or through the address the run records. One this process never saw declared is reached through that address alone, if this process was given a transport; without one, such a row is left for a process that has either.
A send that raises leaves the row for the next pass, so nothing is
marked that was not sent. Call it on whatever schedule suits the
deployment, beside :func:close_expired.
An agent whose callable queues rather than sends, which is what a lane does, is named once the notification is handed over. That is the most this can know: the row it leaves is cleared by the lane, after a send this call has already returned from.
notify_due ¶
notify_due() -> list[str]
Delivers what this process's agents are owed, and names them.
A write taken by one process reaches the agents declared with another here. That is what an agent declared by callback needs: the process holding the callback is the only one that can call it, so it is the one that reads the record and decides. An agent the run records an address for is reached by whichever process took the write, and does not wait for this.
A run inside one process never needs this. That process notifies on
the write path and closes its own windows on a timer, so this
finds nothing to do. Call it on whatever schedule suits the
deployment, beside close_expired.
DuplicateAgentError ¶
Bases: BlackboardError
One roster named the same agent twice.
A roster is one list written at one moment, so a repeat in it is a mistake. Registering a name again later names a returning agent rather than a duplicate, and replaces that agent's declaration.
Notification
dataclass
¶
One notification: the range it covers and the regions that changed.
It carries no values. The agent reads the board itself, over any range and any region, not only the regions this notification names.
PremiseError ¶
Bases: BlackboardError
The opening premises are not exactly the premises that were declared.
ProposedContribution
dataclass
¶
A level write as the admission rule sees it, before sequencing.
ProposedPremiseWrite
dataclass
¶
A premise write as the admission rule sees it, before sequencing.
Reject
dataclass
¶
The admission rule's verdict refusing a proposed write, with its reason.
Rejected
dataclass
¶
A write the control component refused, with the cause and its reason.
RejectionCause ¶
Bases: Enum
Why the control component refused a write.
Every cause is a decision the run made about a write it understood. What the application's own declarations settle, such as a region nobody declared, raises an error instead.
ADMISSION: the admission rule rejected it. NOT_PERMITTED: the
writing agent did not declare that level. RUN_CLOSED: the run has
closed.
RunClosedError ¶
Bases: BlackboardError
A declaration or registration reached a run that has closed.
RunLimits
dataclass
¶
The two limits on a run, both durations, both required.
Time is the only bound. A count of writes would limit the cause of a notification and a count of notifications would limit the effect, and limiting the effect can leave a change that no agent hears while the run is still open, which ends the shared record without closing the run.
Settled
dataclass
¶
Nothing happened for the idle limit, so the run closed.
Sweep ¶
Calls :func:close_expired on an interval, on a thread of its own.
A convenience, the way HttpNotifier is a convenience over the wire
protocol. The library ships the mechanism and the application owns the
loop: a scheduled job, a serverless invocation, or a thread beside the
service are all equally supported, and this is only the last of those
written out so an application does not have to.
control_for is what makes a pass relay as well as reap. It answers
with the Control for a board, and is the same callable
BoardService takes. A sweep given none reaps and nothing more, which
is what a sweep did before this argument existed.
interval is the seconds between passes. jitter spreads the first
pass over a fraction of that interval, chosen once when the sweep starts,
so replicas that started together do not query together for ever after.
Pass jitter=0.0 for a test that wants the first pass promptly.
A pass that raises is logged and the loop continues. A loop that died on one failure would leave every later run open, which is the failure the sweep exists to prevent.
wait_for_pass ¶
wait_for_pass(timeout: float, passes: int = 1) -> bool
Blocks until this many passes have run. True if they did.
For a test that needs a pass to have happened before it asserts.
TerminationDecision ¶
Bases: Enum
The termination predicate's answer when no work is outstanding.
UnknownNotificationError ¶
Bases: BlackboardError
The named notification was never issued to the acknowledging agent.
WallClockExpired
dataclass
¶
The wall clock limit passed while the run was open.
Model
dataclass
¶
A handle to a board that lives in the store: its reader and its control.
It holds nothing. Every call reads what it needs from the store, so
holding one keeps no run open, caches no registry, and reserves nothing.
Build one wherever it is convenient, on any process, and discard it; the
board is unaffected either way. Dropping the last one closes nothing,
because a run ends on its deadlines or on control.abort and on nothing
else.
The name invites the opposite reading, so it is said plainly here. The
same shape appears elsewhere: a boto3 Bucket is a handle to something
living in S3 rather than a copy of it, and a LangGraph graph is stateless
with the thread identifier travelling in the config on every call.
Board reads go to reader directly, consume no control capacity, and
cannot be refused. Writes, acknowledgments, and lifecycle calls go
through control.
reader is control.reader, and is a field of its own so a component
that only reads takes the reader alone. board_id names the board, so a
registry keyed by it does not have to repeat the string the caller passed.
SchemaVersionError ¶
Bases: BlackboardError
A record was written by a schema this version of the library cannot read.
Upgrade blackboardx to a version that reads it. The library never
rewrites a record backwards, because an older version would then read
fields a newer one wrote and take them at face value.
SqliteStore ¶
Keeps the board in SQLite. Satisfies BoardStore.
path names the database file and has no default, because where the
record is kept is stated rather than defaulted. A path on disk suits
local development, where the record outlives the run that made it.
":memory:" keeps it in the process, which suits a test and nothing
else: a second store over ":memory:" in the same process shares
nothing with the first, so it reads an empty board.
One file holds many boards. Every call that acts on one names it, and two boards under different identifiers share the file and see none of each other's writes, sequence numbers included. The two that sweep for work read across boards by design, and answer what is owed rather than what is on any board.
The schema is created on construction, because SQLite has no server to migrate separately and the file is the application's own.
schema_sql
classmethod
¶
schema_sql() -> str
Returns the statements this store runs when it opens a file.
For an application that would rather create the tables itself. Every statement is conditional, so applying them twice changes nothing.
MongoStore ¶
Keeps the board in MongoDB. Satisfies BoardStore.
database is the application's own pymongo.database.Database, and this
adapter does not open or close the client behind it.
One database holds many boards. Every call that acts on one names it,
and two boards under different identifiers share the collections and see
none of each other's writes. The two that sweep for work read across
boards by design, and answer what is owed rather than what is on any
board.
Requires the mongodb extra::
pip install 'blackboardx[mongodb]'
Requires a replica set or a sharded cluster, because every write spans two documents and is therefore a transaction. A standalone server raises on the first write, naming the reason.
Content is stored as a document rather than as encoded text, so the record is queryable in the database that was chosen for querying it. It goes through JSON on the way in, so what the board holds is what every other implementation holds.
Call :meth:create_indexes once, or create the equivalent indexes from
whatever migration the application already runs.
from_uri
classmethod
¶
from_uri(
uri: str, database: str, **client_kwargs: Any
) -> Iterator[MongoStore]
Opens a client for the duration of a with block, for a script or test.
An application that already runs a client passes its database to the constructor instead. This is for the callers that have no client to pass.
create_indexes ¶
create_indexes() -> None
Creates the indexes this adapter reads by, if they are not there.
Creating an index that exists changes nothing, so calling it against a database that already has them is not an error.
PostgresStore ¶
Keeps the board in Postgres. Satisfies BoardStore.
pool is the application's own connection pool, and this adapter does not open it
or close it. Every call names the board within the
database; two boards under different identifiers share the tables and
see none of each other's writes.
Requires the postgres extra::
pip install 'blackboardx[postgres]'
Call :meth:create_schema once against a database that has none, or
run the equivalent DDL from whatever migration tool the application
already uses.
from_dsn
classmethod
¶
from_dsn(
dsn: str, **pool_kwargs: Any
) -> Iterator[PostgresStore]
Opens a pool for the duration of a with block, for a script or test.
An application that already runs a pool passes it to the constructor instead. This is for the callers that have no pool to pass.
schema_sql
classmethod
¶
schema_sql() -> str
Returns the statements :meth:create_schema runs, without running them.
For an application whose database is owned by a migration tool, or
whose role may not create tables, or that reviews DDL before it runs.
Take these, put them wherever your migrations live, and never call
:meth:create_schema.
Every statement is conditional, so applying them to a database that already holds the tables changes nothing.
create_schema ¶
create_schema() -> None
Creates the tables this adapter reads, if they are not there.
Every statement is IF NOT EXISTS, so calling it against a
database that already has them changes nothing.
Safe to call from every replica at once. IF NOT EXISTS is not
atomic in Postgres: two sessions can both find a table missing and
one then fails creating it. A lock around the whole thing serialises
them, and it is released when the transaction ends however it ends.
:meth:schema_sql hands over the same statements for an application
that would rather run them itself.
close_expired ¶
close_expired(
store: BoardStore, limit: int = 100
) -> list[str]
Closes the runs past a deadline that no process is watching, and names them.
A run closes because nothing happened, so no request is in flight to notice. Any process holding the store may call this: closing is a write only one caller wins, so several callers running it together still close each run once.
Call it on whatever schedule suits the deployment, being a thread beside
the service, a scheduled job, or a test advancing time by hand. The
library takes no view. limit bounds one pass.
reader_for ¶
reader_for(store: BoardStore, board_id: str) -> BoardReader
Returns a read handle over one board of a store, with no run behind it.
A read needs the record and not the run, so a process holding the store can serve a
read for any board in it. Writing needs a Control.
relay_unsent ¶
relay_unsent(
store: BoardStore,
control_for: Callable[[str], Control | None],
limit: int = 100,
) -> list[str]
Sends what a write recorded and nothing has sent, and names the agents.
The other job that needs a schedule. :func:close_expired takes a store,
because closing needs nothing else; sending needs the callables, which no
store holds, so this takes a callable of yours answering with the
Control for a board. It is the same callable BoardService takes.
A board yours answers nothing for is left, because another replica holds what this one does not. One board failing is no reason to leave the rest of the pass undone.
limit bounds how many owed notifications one pass reads.
attach_model ¶
attach_model(
*,
board_id: str,
store: BoardStore,
regions: Iterable[Level | Premise],
agents: Iterable[Agent] | None = None,
admission_rule: AdmissionRule | None = None,
termination_predicate: TerminationPredicate
| None = None,
limits: RunLimits,
clock: Clock | None = None,
on_open: Callable[[Model], None] | None = None,
on_closed: Callable[[RunOutcome], None] | None = None,
reach: Callable[[str, Notification], None]
| None = None,
) -> Model
Opens a run over a board that already holds a record.
create_model declares the regions, so it opens a board once. This
opens a run over one that exists, which is what a replica replacing the
one that died does, and what a service resuming a run it evicted does.
There is no premises argument. The record already holds their values,
and the versions they are at.
regions still names what the run expects, and is checked against what
the record holds. A board holding no regions is refused rather than
quietly created, because attaching to nothing would build a run whose
every write is rejected. A name or a kind that disagrees with the record
is refused naming the region.
The run carries over with it: its agents, what wakes each and where it
is reached, and how far each has been told and has answered. What does
not carry over is the callables, which no store holds. An agent this
process is to reach in line is declared here again with its callback;
one the run records an address for is reached by the transport given as
reach.
So an agent declared against the attached run resumes from what it answered rather than being told the whole board again, and an agent that had answered everything is declared without being woken.
create_model ¶
create_model(
*,
board_id: str,
store: BoardStore,
regions: Iterable[Level | Premise],
premises: Mapping[str, object],
agents: Iterable[Agent] | None = None,
admission_rule: AdmissionRule | None = None,
termination_predicate: TerminationPredicate
| None = None,
limits: RunLimits,
clock: Clock | None = None,
on_open: Callable[[Model], None] | None = None,
on_closed: Callable[[RunOutcome], None] | None = None,
reach: Callable[[str, Notification], None]
| None = None,
) -> Model
Opens a run and returns the model.
limits carries the run's wall clock and idle durations.
board_id names this board inside the store. The caller supplies it,
because only the caller knows what identifies a run in its own system.
The library never reads it.
store says where the record is kept and has no default, because a run
whose record no second process can read is not a shared solution model.
One store serves every board an application runs. SqliteStore serves
one machine; an adapter against your own database serves a deployment.
agents names the agents the run starts with, and naming one twice
is refused, because a roster is one list written at one moment. An agent
that registers again later is a returning agent rather than a mistake,
and replaces its own declaration. Each is registered once the premises
hold their opening values, so each receives one notification covering
everything already on the board. An agent that joins a run already under
way registers itself through Control.register_agent instead.
Everything the arguments alone can settle is settled before the store is touched: the declarations naming each region once, the opening premises being exactly the declared premises, each opening value being JSON, the roster naming each agent once, and every region an agent subscribes to or writes to being declared. A call that raises here has written nothing, so the corrected call opens the board it was going to open.
premises gives every declared premise its opening value, naming
each one exactly once. Those writes bypass admission, because they are
the application's own input rather than a proposal from a writer.
No agent is registered yet, so opening the premises wakes nobody; an agent registering afterwards is woken then. With no admission rule a write is accepted subject to the region existing, the limits holding, and the run being open; with no termination predicate the run closes when nothing has happened for the idle limit; with no clock the operating system clock serves.
blackboard.wire¶
blackboard.wire ¶
What crosses between a blackboard and an agent.
The bodies, and the operations that carry them. Both halves import this module, so one half cannot spell a field differently from the other or send to a path the other does not answer on.
Nothing here depends on a web framework or on an HTTP library. A body is a
frozen dataclass with to_json and from_json, so a route hands the result of
to_json to its framework and passes what it received to from_json.
blackboard.server answers these operations and blackboard.agent calls
them; a service that would rather write its own routes still has the bodies.
Decoding is tolerant, because the two halves are deployed separately and are therefore versioned separately. A field that a decoder does not recognise is ignored, a field that is absent takes its default, and a name is never reused for a different meaning. An older agent can therefore read a body from a newer blackboard, and a newer agent can read a body from an older one.
WireError ¶
Bases: Exception
A body could not be decoded because a field it needs is missing.
NotificationBody
dataclass
¶
Bases: _Body
One notification, as it reaches an agent.
It carries no values. The agent reads the board to find out what changed, which is why a repeat costs nothing and why order does not matter.
Both bounds are required. A body that lost from_sequence would decode
as zero, which is a range the control component never issues and which
tells an agent to read the whole level. regions stays optional,
because an agent may read past what a notification names.
WriteRequest
dataclass
¶
Bases: _Body
A contribution proposed to a level.
content is required, because a body that carries none would otherwise
store null. level has a default, because the path already names
the region and the service takes it from there; a service that mounts one
route of its own can still send it.
SetPremiseRequest
dataclass
¶
Bases: _Body
A premise value proposed under the version it expects to replace.
AckRequest
dataclass
¶
Bases: _Body
An agent reporting that it has stopped working on one notification.
WrittenBody
dataclass
¶
Bases: _Body
A write that reached the board, at the sequence it received.
version is absent on a level write, because a level holds none.
ConflictBody
dataclass
¶
Bases: _Body
A premise write that named a version other than the current one.
RejectedBody
dataclass
¶
Bases: _Body
A write the control component refused, with the cause.
ContributionBody
dataclass
¶
Bases: _Body
One contribution read back from a level.
writer and written_at are optional, so a body from an older
blackboard that carries no such field decodes as None for both and a
newer agent reads it without error. written_at crosses as an ISO-8601
string.
BoardChangeBody
dataclass
¶
Bases: _Body
One write to any region, read back from the whole board.
writer and written_at follow the rule on :class:ContributionBody.
LevelPage
dataclass
¶
Bases: _Body
Part of a level. has_more is set when the level holds more beyond it.
A reader continues from one past the last sequence it received. That is the cursor: an offset would shift when a concurrent write lands, and a sequence number does not.
BoardPage
dataclass
¶
Bases: _Body
Part of the whole board. has_more is set when the board holds more beyond
it.
PremiseBody
dataclass
¶
Bases: _Body
A premise's current value and the version that produced it.
writer and written_at follow the rule on :class:ContributionBody.
RegionBody
dataclass
¶
Bases: _Body
One declared region: its name and its kind.
A store records a name and a kind and nothing else, so a premise comes back without the batch window it was declared with. The window tells the control component when to notify and is no part of the record.
RegionList
dataclass
¶
Bases: _Body
Every region declared on one board.
ErrorBody
dataclass
¶
Bases: _Body
What a blackboard answers with when it cannot do what was asked.
error is a stable name a client can branch on. detail is written
for a person reading a log and may change between versions.
Operation
dataclass
¶
One thing an agent can ask a blackboard to do.
template names its variables in braces. Both halves build a path from
the same object, one to send and one to match, so a rename reaches the
two of them together.
blackboard.server¶
blackboard.server ¶
Answering an agent's request from the blackboard's side.
An agent that runs as its own service reads and writes over HTTP. The
operations it needs are the ones :class:~blackboard.Control already has, so the only
questions left are which path carries which operation and which status code carries
which answer. Both halves of this library take those answers from
:mod:blackboard.wire, so a service and its agents cannot disagree about
them.
:class:BoardService is the blackboard's side. It takes a method, a path,
and a decoded body, and returns a status, headers, and a body. It imports no web
framework and opens no socket: the application keeps its own server, its routing prefix,
and its authentication, and hands each request through.
from blackboard.server import BoardService, Request
runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")
@app.route("/v1/<path:rest>", methods=["GET", "POST", "PUT"])
def blackboard(rest: str):
answer = service.handle(
Request(
method=flask.request.method,
path=flask.request.path,
body=flask.request.get_json(silent=True),
query=flask.request.args,
)
)
return answer.body or "", answer.status, answer.headers
One :class:~blackboard.Control serves one board, and control_for finds
the one a request names. A dictionary's get is the usual answer; a
callable that builds a run on first sight is another.
path is matched a segment at a time and each variable is decoded once, so
a board identifier that holds a slash arrives whole as long as the framework
hands over the path it received. Some frameworks decode the path before you see it, so
keep a board identifier opaque: a UUID travels through every one of those frameworks
unchanged.
What the status codes mean¶
====== ========================================================== Status Meaning ====== ========================================================== 200 A read, or a write an idempotency key had already made 201 A write reached the board for the first time 204 An acknowledgment was recorded 400 The body or a query parameter could not be read 404 No such board, region, notification, or path 405 That path takes a different method 409 A premise moved on, or a key named another region 410 The run has closed and takes no more writes 422 The write was refused; the answer names the cause ====== ==========================================================
409 and 422 are answers rather than faults, so a client that sends the same request again gets the same one. Only 5xx and a failure to connect are worth another attempt.
Request
dataclass
¶
One request, as the service's own framework already holds it.
body is JSON that has already been parsed, or None where the
request carried none. query is the query string parsed into single
values; a parameter given twice keeps whichever value the framework kept.
Response
dataclass
¶
What to answer with: a status, headers, and JSON or nothing.
BoardService ¶
Answers the operations in :mod:blackboard.wire from a set of runs.
control_for returns the control component for a board identifier, or
None when this service holds no run for it. prefix is where the application
mounted these paths, and is stripped before matching.
blackboard.delivery¶
blackboard.delivery ¶
Sending notifications to agents over HTTP.
The control component reaches an agent by calling Agent.notify. Left to
itself that call runs on the thread of whichever agent just wrote, one agent
after another, so a write that took microseconds pays for every agent's
round trip and one agent whose endpoint hangs stalls the writer for the
whole timeout.
:class:HttpNotifier supplies that callable. It puts the notification on a
queue and returns, and a worker sends it. Each call to
:meth:HttpNotifier.to opens its own lane, so agents are reached at the same time and
an agent that is slow, retrying, or down delays only itself.
from blackboard import Agent, create_model
from blackboard.delivery import HttpNotifier
with HttpNotifier(store=store) as notifier:
model = create_model(
board_id=board_id,
store=store,
regions=[...],
premises={...},
agents=[
Agent(
name="ocp",
subscribes_to={"signals"},
notify=notifier.to("https://ocp.internal/notify"),
)
],
limits=limits,
)
The queue is held in memory, so a process that stops loses whatever had not
been sent. Give the notifier the store and it is not lost: a lane returns
before the send, so the lane clears the outbox row itself once it has sent,
and what the queue was still holding is still owed and reaches the agent
through :meth:blackboard.Control.relay. Without a store, the control
component marks the row when the lane accepts the notification, having no way
to see what became of it.
DeliveryFailed ¶
Bases: BlackboardError
A delivery did not land and is worth attempting again.
Raised for a refused connection, a timeout, a 5xx, or a 429. retry_after carries
what the server asked for, where the server said so.
DeliveryRefused ¶
Bases: BlackboardError
A delivery the agent will refuse again.
Raised for a 400, a 404, or a 422. Sending the same body a second time produces the
same answer, so the notifier reports it rather than retrying. It is not
a DeliveryFailed, because that one says another attempt might land.
Undelivered
dataclass
¶
What the notifier reports when it stops trying.
attempts is how many times the transport was called, so it is zero
for a notification handed to a notifier that had already closed.
Transport ¶
Bases: Protocol
How one notification leaves the process.
:class:HttpxTransport is the implementation the library ships. Supply
another to send over something else, or to send nothing in a test.
send ¶
send(url: str, body: dict[str, Any]) -> None
Delivers body to url as JSON.
Returns when the agent accepted it. Raises :class:DeliveryRefused
when the agent will refuse it again, and :class:DeliveryFailed, or
any other exception, when another attempt might land.
HttpxTransport ¶
Sends over HTTP with httpx.
Install it with pip install blackboardx[notifier]. One client is held
open for the life of the transport, so agents reached repeatedly reuse a
connection.
Lane ¶
One agent's delivery lane: its queue, and the worker that drains it.
What :meth:HttpNotifier.to returns. Call it to deliver a notification,
which is why an Agent takes it directly as notify. Close it when
the run that used it ends, which stops its worker and releases its queue
without closing the notifier or any other lane.
Calling it queues and returns, so the notification is not on the wire when
the call comes back. marks_sent says whether this lane records its own
sends, which the control component reads to decide whether the outbox row
is its to mark. It is true where the notifier was given a store, and a
notification only queued is then still owed until the lane has sent it.
marks_sent
property
¶
marks_sent: bool
True when this lane marks the outbox row itself, after the send.
close ¶
close() -> None
Stops this lane and reports whatever it still held.
Returns once it has reported, so nothing is left queued behind a closed lane. Closing twice does nothing the second time, and closing after the notifier has closed is safe.
HttpNotifier ¶
Sends notifications to agents without the writer waiting.
transport defaults to :class:HttpxTransport, built when the notifier is built.
attempts counts every call to the transport, so 4 means
one send and three retries. backoff decides the wait between them.
on_failure is called on the lane's own thread, once for every notification the
notifier gives up on; a failure is logged at ERROR
whatever it does.
Closing drains what is queued, waiting up to close_timeout seconds in
total rather than per lane. Whatever is still queued at that point is reported
through on_failure before close returns. A transport the notifier
built is closed with it; one you supplied is yours to close.
records_sends
property
¶
records_sends: bool
True when this notifier was given a store to mark rows in.
reach ¶
reach(address: str, notification: Notification) -> None
Sends one notification to an address, on the caller's thread.
The transport a Control takes as reach, for an agent the run
records an address for and this process holds no callable for. It
opens no lane and starts no thread, so a notifier used only this way
holds nothing between calls.
Unlike a lane, this sends once and does not return before the send.
The relay is the retry: it marks a row sent only when this returns, so
a raise here leaves the row for the next pass rather than being
reported through on_failure.
to ¶
to(url: str) -> Lane
Opens a lane to one agent and returns it.
The lane is the notify callable an Agent is created with, and
it can be closed on its own. Each call opens a lane of its own, so
give every agent its own even when two agents share an address: two agents
behind one lane share a queue and take turns.
Close a lane when the run that used it ends. A notifier serving many runs over its life would otherwise hold a queue and a thread for every agent of every run it has ever served.
close ¶
close() -> None
Sends what is queued, stops the lanes, and returns.
Waits up to close_timeout seconds in total, not per lane. After that, a lane
still parked in a retry abandons it, and everything it
still holds is reported through on_failure before this returns.
A transport this notifier built is closed here. One that was supplied is left open, because the caller that opened it owns it.
Closing twice does nothing the second time.
default_backoff ¶
default_backoff(
attempt: int, retry_after: float | None
) -> float
Returns how long to wait before attempt + 1, in seconds.
A server that named a delay gets that delay, capped at
:data:MAX_BACKOFF so one bad header cannot park a caller for an hour.
Otherwise the wait doubles with each attempt and is drawn from the range
between half of that and all of it, so callers that failed together do
not return together.
blackboard.agent¶
blackboard.agent ¶
Calling a blackboard from an agent.
An agent that runs as its own service reads and writes over HTTP. This module is the
agent's side of the protocol that :mod:blackboard.wire states, so an agent
names no URL, no status code, and no header.
A client is bound to one board and one agent name, the way
:class:~blackboard.BoardReader is bound to one board.
:class:BoardClient satisfies :class:~blackboard.AgentBoard, the protocol that one
board looks like to one agent, and what :meth:~blackboard.Control.as_agent returns
satisfies it too. An agent body written against
AgentBoard therefore runs in the blackboard's own process or against one
over HTTP, with nothing changed between them. It also satisfies
BoardReader on its own, so an admission rule or a termination predicate
written against that protocol reads a remote board as well as a local one.
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:
for change in board.read_level("signals", notification.from_sequence):
...
board.write("findings", {"cause": "a bad deploy"})
board.ack(notification.notification_id)
:class:AsyncBoardClient is the same surface with await in front of every
method. One is not written in terms of the other: a synchronous method that
starts an event loop per call throws away the connection pool, so both are implemented
separately and the request they build is one piece of shared code.
Install the agent extra with pip install blackboardx[agent].
What comes back, and what is raised¶
A write answers the way :meth:~blackboard.Control.write answers, with
:class:~blackboard.Written or :class:~blackboard.Rejected, and setting a
premise adds :class:~blackboard.Conflict. Those are answers rather than
faults, and sending the same request again gets the same one.
Everything else is raised, and where the blackboard has an exception for it, the client
raises that exception: :class:~blackboard.UndeclaredRegionError,
:class:~blackboard.RegionKindError, :class:~blackboard.UnsetPremiseError,
:class:~blackboard.UnknownNotificationError. A board that this blackboard does not
hold raises :class:UnknownBoardError, a blackboard that could not be
reached raises :class:Unreachable, and an answer that the two halves disagree about
raises :class:ProtocolError.
What is attempted again¶
A read and an acknowledgment are attempted again when the blackboard cannot be reached or answers 5xx. Reading twice returns the same thing, and an acknowledgment of a notification no longer outstanding changes nothing.
A write is attempted again only when it carries an idempotency_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 :class:Unreachable and the agent decides what to do. Given a key, the
blackboard writes it once however many times it arrives, so the client sends
it again like a read, and an attempt whose answer was lost comes back as the
first one's result with repeated set.
Name the key after work the agent can name again after a restart, rather than a fresh random string each time, so that a write repeated after a restart is deduplicated too.
Retrying a keyed write needs a blackboard at 0.8 or later. An older one takes the key and ignores it.
UnknownBoardError ¶
Bases: BlackboardError
The blackboard answered that it holds no board by that identifier.
Unreachable ¶
Bases: BlackboardError
The blackboard could not be reached, or kept answering 5xx.
ProtocolError ¶
Bases: BlackboardError
The blackboard answered something this client cannot make sense of.
A body that will not decode, a status no operation produces, or a request this client built that the blackboard refused as malformed. It means the two halves are out of step rather than that the call was wrong.
BoardClient ¶
Reads and writes one board, as one agent, over HTTP.
base_url is where the blackboard mounted the operations, including
any prefix. http_client takes an httpx.Client you configured
yourself, with the authentication, the certificates, or the proxy your
deployment needs; without one, BoardClient builds a plain httpx.Client and
closes that client with itself.
read_regions ¶
Returns the regions declared on this board, with their kinds.
read_level ¶
read_level(
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
Without a limit this reads the level to its end, following the
blackboard's pages. With a limit it returns at most that many.
read_premise ¶
read_premise(premise: str) -> PremiseState
Returns a premise's current value and version.
read_board ¶
read_board(
from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
write ¶
Proposes a contribution to a level, as this client's agent.
set_premise ¶
set_premise(
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
) -> Written | Conflict | Rejected
Sets a premise, provided it is still at expected_version.
ack ¶
ack(notification_id: NotificationId | int) -> None
Records that this agent finished responding to a notification.
AsyncBoardClient ¶
:class:BoardClient with await in front of every method.
It builds the same requests and reads the same answers; the sending differs.
http_client takes an httpx.AsyncClient.
read_regions
async
¶
Returns the regions declared on this board, with their kinds.
read_level
async
¶
read_level(
level: str,
from_sequence: int = 0,
limit: int | None = None,
) -> list[Contribution]
Returns a level's contributions from the sequence bound, inclusive.
read_premise
async
¶
read_premise(premise: str) -> PremiseState
Returns a premise's current value and version.
read_board
async
¶
read_board(
from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]
Returns every write to every region, in sequence order, from the bound.
write
async
¶
Proposes a contribution to a level, as this client's agent.
set_premise
async
¶
set_premise(
premise: str,
value: object,
expected_version: int,
idempotency_key: str | None = None,
) -> Written | Conflict | Rejected
Sets a premise, provided it is still at expected_version.
ack
async
¶
ack(notification_id: NotificationId | int) -> None
Records that this agent finished responding to a notification.
default_backoff ¶
default_backoff(
attempt: int, retry_after: float | None
) -> float
Returns how long to wait before attempt + 1, in seconds.
A server that named a delay gets that delay, capped at
:data:MAX_BACKOFF so one bad header cannot park a caller for an hour.
Otherwise the wait doubles with each attempt and is drawn from the range
between half of that and all of it, so callers that failed together do
not return together.
blackboard.tools¶
blackboard.tools ¶
Every method of AgentBoard, as a tool a model can call.
An agent decides what to write. Where that decision is an algorithm, the
application calls the methods of AgentBoard itself and needs nothing here.
Where the decision is a language model, the application cannot hand those
methods to a model API, because such an API takes a schema for each thing it
may call and answers with a request to call one by name. This module renders
each of those methods into that form and runs what comes back.
The list is the application's to build. Each tool is a name this module
exports, ALL is every one of them, and ToolSet holds the ones an
application chose. Two agents on one board can therefore be offered different
tools, because what a model is allowed to reach for is a decision about that
agent rather than about this library.
The rendering is not a copy of the protocol, and the four differences are why it belongs here rather than in each application.
The agent's name is absent from every schema. AgentBoard carries it, so a
model cannot write under a name other than the one the caller bound.
idempotency_key is absent from every schema and is filled from the
identifier the model API gives the call. A loop that sends a call twice
therefore writes once.
An outcome the model can act on comes back as text it can read, because a model reads results and catches no exceptions. A rejected write and a premise whose version moved are values on the protocol already. Five conditions the protocol raises are answered here instead: a region the board does not hold, a name of the other kind, a premise with no value yet, a key that already wrote to another region, and a notification this agent never had. The first two come back naming the regions the board holds.
A read that answers with a list is bounded. A list too large for a model to hold is cut, and the result says how many entries it left out. A premise's value is answered whole, because one value cut in half is a value the model cannot use.
ACK needs care in a way the others do not. Acknowledging says the agent
has stopped working on a notification, so a model that sends it partway
through its own reasoning tells the run that this agent has finished while it
is still working. An application that keeps acknowledgment in the loop around
the model leaves ACK out of the set it offers.
UnknownToolError ¶
Bases: BlackboardError
Raised when a call names a tool this toolset does not hold.
A caller offers its own tools beside these, and routes on ToolSet.owns
so that its own names reach its own code rather than this one.
ToolAnnotations
dataclass
¶
What a tool does to the board, for a caller choosing which to offer.
read_only is false for a tool that writes. idempotent says that
running the same call twice leaves the board as running it once did, which
holds when the caller passes the model call's identifier, because a write
takes that identifier as its idempotency key.
ToolDescriptor
dataclass
¶
One method of AgentBoard, as a model API accepts it.
method names the method this calls, and is what ties the schema to the
protocol. The parameters the schema offers, taken together with the ones
withholds lists, are exactly that method's parameters, and a test holds
them to it.
withholds lists the parameters deliberately absent from the schema.
from_call_id lists the ones filled from the caller's identifier for the
model call, and each of those is withheld.
definition ¶
definition() -> dict[str, Any]
Returns this tool in the shape the Model Context Protocol defines.
for_anthropic ¶
for_anthropic() -> dict[str, Any]
Returns this tool as the Anthropic Messages API takes it.
for_openai ¶
for_openai() -> dict[str, Any]
Returns this tool as the OpenAI chat completions API takes it.
ToolResult
dataclass
¶
What one tool call produced.
content is what goes back to the model. is_error marks a call the
model got wrong, which it can correct and send again; a write the run
refused is not one of those, because the model asked correctly and the run
said no. value is what the board returned, for a caller that wants that
value rather than the text rendered from it, and is None where the
call did not reach the board.
ToolSet ¶
Bases: Sequence[ToolDescriptor]
The descriptors a caller offers to a model, each under its own name.
A caller offers a set to a model and runs what the model asks for against a board. Selecting a subset returns another set, so a caller that offers a model only the reads runs those calls through the object it offered.
owns ¶
owns(name: str) -> bool
Says if this set holds the tool that a call names.
A model answers with the name of a tool, and the caller's own tools sit
in the same offer, so a caller asks this before handing a call to
run.
descriptor ¶
descriptor(name: str) -> ToolDescriptor
Returns the descriptor under this name.
Raises UnknownToolError where this set holds no such name.
select ¶
select(*names: str) -> ToolSet
Returns the descriptors under these names, in this set's order.
definitions ¶
definitions() -> list[dict[str, Any]]
Returns each tool in the shape the Model Context Protocol defines.
A server answering tools/list returns this as it stands. The two
provider shapes carry the same name, description and schema under the
key names each of those APIs uses.
for_anthropic ¶
for_anthropic() -> list[dict[str, Any]]
Returns the tools as the Anthropic Messages API takes them.
for_openai ¶
for_openai() -> list[dict[str, Any]]
Returns the tools as the OpenAI chat completions API takes them.
run ¶
run(
board: AgentBoard,
name: str,
arguments: Mapping[str, Any],
*,
call_id: str | None = None,
) -> ToolResult
Runs one call the model asked for, against this board.
name and arguments are what the model API answered with, and
call_id is that API's identifier for the call, which becomes the
write's idempotency key. A name this set does not hold raises
UnknownToolError, because the caller's own tools are the caller's
to route.
Arguments are checked before the board is touched, so a malformed call changes nothing and comes back saying what was wrong with it.
blackboard.conformance¶
blackboard.conformance ¶
The suite every store implementation is held to.
BoardStore is a protocol, so an application may keep its record in a database that
this library ships no adapter for. What that store has to guarantee
is not obvious from the signatures: one counter across every region rather
than one per region, a conflicting premise write taking no sequence number so
a conflict leaves no gap, a key writing once, a bounded read continuing from
a sequence rather than an offset. Each of those has a case here because an
implementation gets each of them wrong.
Subclass BoardConformance and SharedStoreConformance, supply a store
fixture returning a fresh store, and run pytest:
from blackboard.conformance import BoardConformance, SharedStoreConformance
from myapp.storage import CassandraStore
class TestCassandraStore(BoardConformance):
@pytest.fixture
def store(self):
return CassandraStore(session)
class TestCassandraHoldsManyBoards(SharedStoreConformance):
@pytest.fixture
def store(self):
return CassandraStore(session)
The four stores the library ships are held to this module rather than to a copy of it.
Running it needs pytest, which the library itself does not need:
pip install blackboardx[conformance].