CompletionBus
NAME
LLM::Agent::CompletionBus - the outstanding background work a run will not end without, and the turns it comes back as
SYNOPSIS
use LLM::Agent::CompletionBus;
# One bus per conversation, not per run: a child that settles after its
# parent run ended is delivered to the NEXT run of the same conversation.
my $bus = LLM::Agent::CompletionBus.new(max-outstanding => 16);
my $loop = LLM::Agent::Loop.new(
:@backends, provider => $subagents, completion-bus => $bus,
);
# ...from a composer, when a background operation starts:
my %admitted = $bus.open('reviewer-1', kind => 'subagent',
label => 'review billing/');
# %admitted<ok> is False when the bus is already at max-outstanding, and
# %admitted<outstanding> / %admitted<max> are what the refusal says.
# ...and when it finishes, from whatever thread noticed:
$bus.settle('reviewer-1', deliverable => %(
kind => 'subagent-settled',
head => '[background event] ... The reviewer agent has finished:',
body => $child-answer, # excerpt-seam routed by the loop
tail => 'Incorporate this result and continue your work.',
extras => %( 'completion-of' => 'reviewer-1', 'call-id' => 'c1' ),
));
# The loop, and NOTHING else, drains:
for $bus.drain -> %item { ... }
DESCRIPTION
A background tool call answers twice: once immediately, with an acknowledgement that says the work has started, and once later, with what the work actually produced. This class is the thing in between ā the register of work that has been acknowledged and not yet reported, and the queue of the reports themselves.
Two questions live here, and they are the only two:
Is the run allowed to end? A model that has stopped asking for tools normally means the run is over. With background work outstanding it means nothing of the sort: the answer it is waiting for has not arrived yet.
quietis the whole of that decision.What has arrived since the last round?
drainanswers with every deliverable in the order it landed, and takes them off the bus.
Everything else ā what a deliverable says, how a turn is framed, when a run parks and for how long ā belongs to LLM::Agent::Loop and to the composer that opened the operation. This class holds no opinion about any of it.
Tracked operations, and untracked deliverables
There are two ways something reaches a run through this bus, and the difference is whether the run waits for it.
| Kind | How it arrives | Does the run park for it? |
|---|---|---|
| tracked | open ... settle | YES, until it settles |
| untracked | push | no, ever |
A tracked operation is one somebody promised an answer for. A task
call that acknowledged immediately told the model "its answer will arrive
later"; open is that promise written down, and until the matching
settle the run will not end. That is the whole of Park, don't end.
An untracked deliverable is news. A detached shell job that exited, a
watcher that noticed something ā nothing acknowledged them, nothing is
waiting for them, and a run that parked on one would park for ever
because nothing has undertaken to produce it. push puts it in the
queue and touches the outstanding count not at all: it will be delivered
at the next round boundary if the run is still going, and it will not by
itself keep the run alive.
quiet, and the race it closes
quiet is the finish decision, and the reason it is a method here
rather than two reads at the call site is that it must be one
snapshot:
quiet = nothing outstanding AND nothing queued
Both halves, under one lock, at one instant. Read separately ā "outstanding is 0" and then, a microsecond later, "the queue is empty" ā they describe two different moments, and the operation that settled between them is a result that reaches nobody: the run ends, and the answer the model was told to expect is dropped on the floor.
state is the same snapshot with the numbers in it
(< { outstanding, queued, quiet } >), and it is what the park polls:
one lock acquisition per pass, and a decision that cannot be made from a
world that has moved on.
Single consumer: drain is the driver's
drain is destructive: what it hands back is gone from the bus,
because the bus has no way of taking it back if the caller drops it. That
makes it exactly as safe as one consumer and no safer, and the contract
is therefore explicit:
Only the run's driving thread may call drain, at a round boundary,
and it must record everything it is handed. Everything else here ā
open, settle, push, quiet, state, outstanding-ops,
close-all ā is safe from any thread and may be called concurrently
from as many as you like.
A second consumer does not corrupt anything: the lock is real. What it does is take half the completions and put them somewhere the model will never see them, which is a bug that looks like a flaky model rather than like a race.
The bus lock is a leaf
Nothing this class does calls anything it does not own: no callback, no emit, no I/O, no other lock. Every method is a short critical section over a Hash and an Array.
That is a promise callers may rely on, and it is what makes it safe to
call open or push from inside somebody else's critical section ā
a composer's question table, a notification sink on a flusher thread.
Taking the bus lock can never wait on anything except another bus
operation, and no bus operation waits on anything at all.
The other side of the same promise: do not put callbacks on it. If
this class ever grows an on-settle hook, the leaf property is gone and
so is the freedom above.
A deliverable
A deliverable is plain data, and its shape is the contract between whoever produced the work and the loop that renders it:
| Key | What it is |
|---|---|
| kind | REQUIRED. What sort of event this is: 'subagent-settled', 'job-exited' |
| head | The framing and the identity ā who, what, and that this is not the user |
| body | The content, which the loop routes through the observation excerpt seam |
| tail | What the model should do about it |
| extras | Envelope extras for the injected turn: completion-of, call-id, ... |
The three-way split is not decoration. The loop excerpts body and
only body: a child that answered with a megabyte cannot be allowed
into an unelidable user turn whole, and framing that got excerpted along
with it would leave a turn that no longer says it is an automated event.
So the words that must survive at any size go in head and tail, and
the words that may be cut go in body.
A deliverable with no kind, or with nothing at all in any of the three
text fields, is refused ā settle closes the operation anyway and
push answers False. An empty user turn is worse than a missing one.
seq (this bus's own arrival order) and op-id (the tracked
operation's key, absent for an untracked deliverable) are stamped on the
way in and are not the caller's to supply.
Scope: one bus per conversation
A bus belongs to a conversation, not to a run. A child that settles thirty seconds after its parent run was cancelled has still done the work and still has something to say, and the next run of the same conversation is exactly who should hear it ā which is the same argument that makes a steer queue outlive one run.
clear is the other end of that: a host swapping to a different
conversation calls it, and everything the old one had outstanding goes,
because a completion delivered into a conversation that never asked for
it is a turn out of nowhere.
SEE ALSO
LLM::Agent::Loop (the park, the round-top drain, and the only caller
of drain), LLM::Agent::Subagents (the composer that opens and
settles a delegation), LLM::Agent::Event (BackgroundOpStarted,
BackgroundOpSettled, BackgroundOpDelivered, RunParked,
RunResumed).
the bus is at
max-outstanding(< reason => 'at-capacity'>);
$keyis already open (< reason => 'duplicate'>) ā which is a bug in the caller rather than a condition, but not one worth taking a tool call down over.