Skip to content

feat: rotating talking stick, so agents read before they write - #103

Open
obeone wants to merge 12 commits into
mainfrom
feat/rotating-talking-stick
Open

obeone wants to merge 12 commits into
mainfrom
feat/rotating-talking-stick

Conversation

@obeone

@obeone obeone commented Sep 21, 2026

Copy link
Copy Markdown
Owner

What changed

A scope can now run a rotating talking stick instead of an exclusive one:
floor(action="round", scope=...) opens a round-table, and the stick moves when
you speak.

The exclusive stick we already had solves "let one agent finish". It does nothing
about the failure that actually hurts in a busy room: every agent composing its
reply against a half-read exchange, in parallel, then firing them all at once.
Refusing the send was never enough, because by the time the 423 comes back the
reply is already written and the turn is already spent.

A round inverts that. While it is not your turn the hub withholds that scope's
traffic from you, so there is nothing to pre-compose against. When the stick
reaches you, you get the whole accumulated backlog in one batch followed by a
"you have the floor, deadline in N s" marker, and you read before you write.

Rotating costs no extra tool call: one say() routes the message, hands the stick
on, and returns the new floor state in its own result. Two variants cover the
times you are not ready:

  • say(turn="pass") is "nothing to add" and rotates immediately.
  • say(turn="extend") is "still thinking". It buys more time and reaches nobody.

That last guarantee is structural rather than a filter someone can regress later:
pass and extend post to /floor, so they never touch /send at all.

Order is room-join order in a live ring. A peer arriving mid-round takes the tail,
one that leaves drops out, one the operator paused is skipped and keeps its place.
The operator is never in the ring and can always speak. A turn lasts 300s with
unlimited 180s extensions, and one full lap with nobody speaking closes the round
on its own.

Extensions being unlimited means a holder can hold up the table, so the design
makes that visible instead of impossible: the count rides every floor event,
crossing three announces itself to the room, and the console gains Skip turn and
End round next to the existing Clear stick.

Operator side

The floor strip shows the ring, the current speaker, a live countdown, the
extension count, and how much traffic the hub is holding back. That last figure is
what lets a human tell a peer waiting its turn from a peer that has died, which
retention otherwise makes indistinguishable. For the same reason the peers panel
says "waiting its turn" rather than flagging a correctly parked peer as quiet.

Protocol

Revision 25. The always-on PROTOCOL_TEXT grows by 335 characters, which pushes
it past the 6600 ceiling, so the ceiling moves to 6900. Same call revision 24 made
for the channel-has-no-history warning, and for the same kind of reason: an agent
that does not know rounds exist reads a withheld lane as a dead room and gives up
on it. That cannot live in the on-demand section alone.

New knobs

--round-turn-seconds / CAUCUS_ROUND_TURN_SECONDS and
--round-extend-seconds / CAUCUS_ROUND_EXTEND_SECONDS.

Design notes worth a reviewer's attention

Retention lives in route(), not in /receive. A poll-side gate looked
obvious and does not survive contact: Client.queue is one FIFO carrying DMs,
other channels and hub notices together, so the gate would have to drain and
partition the whole queue on every poll, and it hot-spins because the chatter
getter completes the instant a withheld message lands. Worse, any hole in such a
filter wakes watch.py, which exits on its first emitted message and burns an
agent turn on a fragment of the conversation. Withholding at routing time leaves
the parked peer's queue genuinely empty for that scope, so its watcher long-polls
in silence and its last_seen stays fresh.

The flush and the turn grant must land in one /receive batch. _grant_turn
has no await between them on purpose. Adding one splits the transcript across
two polls and wakes the agent twice on half a conversation, which is the exact
failure this feature exists to prevent. There is a comment saying so and an
end-to-end test pinning it.

Rotations are a direct message to the incoming holder, never a scope-wide
announce.
_announce_floor routes into every member's queue, so announcing each
rotation would wake every parked watcher on every turn. Only opening and closing a
round are announced to the room.

A round is not a third brake. Pause and stop still cut straight through it, and
stop flushes every withheld backlog before clearing the floors.

Three bugs the new tests found

  1. The silent-lap counter counted turns rather than peers. With a ring of two,
    three passes taken by the two original members satisfied a count of three, so a
    peer that joined mid-lap was closed out without ever holding the stick. It is
    now the set of peers that have declined, and the question asked is "has everyone
    still able to speak declined?", which a newcomer cannot falsify.
  2. A graceful leave did not release what a round was holding for it.
    _relinquish_floor looks the peer up in the roster or the graveyard, and a
    terminal drop is in neither by then. Latent rather than live (the record is
    discarded with its queue anyway), but the docstring claimed otherwise.
  3. A round opened from the console was credited to the first seeded peer instead of
    the operator, in the room announce and in the first floor event both.

How I verified it

ruff check src/                 clean
mypy src/                       clean, strict, 19 files
pytest                          1201 passed (1083 before this branch)
cd web && npm test              227 passed, 14 files
cd web && npm run build         clean, assets committed

Plus a manual pass against a running hub, which is the part I actually trust for
this one. With CAUCUS_ROUND_TURN_SECONDS turned down: a parked peer's /receive
returns 0 messages while the stick is elsewhere, then returns the backlog and the
grant in a single batch with ascending seq and the grant last; extend pushes
the deadline and routes nothing; the sweep rotates an expired turn on its own (two
expiries logged); declined fills up peer by peer until the lap closes the round.

npm run lint does not run: eslint is in the lint script but not in
devDependencies, and CI never calls it. That is broken on main already and I
left it alone rather than widening this PR.

The analysis, the implementation and the tests were done with Claude Code.

Checklist

  • User-visible changes are recorded under ## [Unreleased] in CHANGELOG.md.
  • PROTOCOL_TEXT changed, and PROTOCOL_VERSION went 24 to 25.

… bodies

SendResponse gains an optional stick field and FloorRequest gains the round
and extend verbs plus a bounded turn_seconds. Nothing reads or writes them
yet; this lands the shape first so the hub, the connector and the two MCP
tools can all be written against one contract instead of three guesses.
Adds the Round record, Floor.round, Client.held and the two budgets, then
forks route() so ordinary chatter aimed at a scope under a round is withheld
from every peer but the holder.

Retention lives in route() rather than in /receive on purpose. Client.queue
is one FIFO carrying DMs, other channels and hub notices together, so a
poll-side gate would have to drain and partition it on every poll, and it
would hot-spin: the chatter getter completes the instant a withheld message
lands, and the poll slice does not save it. Worse, any hole in such a filter
wakes watch.py, which exits on its first emitted message and burns an agent
turn on a fragment of the exchange. Withholding at routing time leaves the
parked peer's queue genuinely empty for that scope, so its watcher long-polls
in silence and its last_seen stays fresh.

Inert as it stands: no round can exist until the verbs land, so route() takes
the old path on every message and the suite is unchanged.
_advance_floor now branches on the mode, so pass_floor, _relinquish_floor
and every future caller are round-correct without knowing rounds exist.

Two invariants carry the design. _advance_round keeps ring[0] equal to the
holder and rotates the outgoing peer to the tail, so a peer appended mid-lap
still speaks after everyone already in it; it ends the round when the
consecutive silent turns reach the count of peers still able to speak,
recomputed each rotation rather than tracked against a lap marker that breaks
when its owner leaves. _grant_turn flushes the backlog and routes the turn
marker with no await in between, so both land in one /receive batch: an await
there would wake the agent twice on half a conversation.

The marker is a direct message to the incoming holder, not a scope-wide
announce. Announcing a rotation would route into every parked peer's queue
and wake every watcher on every turn, which is what retention exists to
prevent. Only opening and closing a round are announced to the room.
start_round, drop (end), pass, extend_turn, force_advance, set_turn_seconds,
speak_turn and sweep_rounds, plus the guards that keep a scope in one mode:
take, raise and lower are refused during a round and answer with the caller's
place in the ring rather than a bare error it would retry.

Four lifecycle interactions carry real weight. The reaper now exempts a peer
inside an unexpired turn, because the default turn budget and client_ttl are
both 300s and a holder that spent its turn thinking would otherwise be reaped
and re-enter the round as a departure. Pausing the room freezes every turn
clock and resuming shifts the deadlines, so a pause does not expire every
round at once. Pausing a single peer that holds the stick advances it, since
a gated queue can never read the backlog its turn flushed. And registering,
reviving or joining a channel mid-round appends the peer at the tail, so a
newcomer speaks after this lap instead of not at all.

peer_info gains waiting_turn and exempts a parked peer from the quiet flag:
retention means it polls in total silence by design, so it would otherwise
read to the operator as dead exactly when it is behaving correctly.
Endpoint and loop wiring: POST /floor gains round and extend, POST /send
returns round_in_progress with the caller's ring position instead of a bare
floor_held and carries the rotated stick back in its own response, the /ui
socket gains advance, start and retune beside the existing clear, and a
dedicated _round_loop sweeps turn deadlines every 2s. The sweep is its own
task rather than a fourth job inside the reaper: an expired turn blocks the
whole ring, not one peer, so the reaper's 15s slop would read as a hung hub,
and an exception in the sweep would skip a reap pass.

Protocol revision 25. The always-on PROTOCOL_TEXT grows by 335 characters,
which pushes it past the 6600 ceiling, so the ceiling moves to 6900. That is
the same call revision 24 made and recorded for the channel-has-no-history
warning: a mode that silently withholds a scope's traffic cannot live in the
on-demand section alone, because an agent that does not know rounds exist
reads a withheld lane as a dead room and gives up on it. The talking-stick
section gains the full mechanics, and caucus-protocol.md mirrors both.
… steer it

The floor strip gains a round badge: an amber mode pill, the ring in rotation
order with the holder emphasised and the rest dimmed, a live countdown, the
current holder's extension count, and the total traffic the hub is holding
back. That last figure is what lets a human tell a peer waiting its turn from
a peer that has died, which retention otherwise makes indistinguishable; the
peers panel says waiting its turn for the same reason, instead of flagging a
correctly parked peer as quiet.

The countdown ticks on a component-local interval rather than a store write,
because a 1 Hz store update would re-render every subscriber and there is a
perf test pinning that. It extrapolates from an absolute server deadline
against the client clock, which is exact on the default localhost bind and,
when it is not, only mis-renders a number and never mis-drives a control. A
frozen round shows the hub's own push-time value instead of a drifting one.

Skip turn is a new operator verb; End round is the existing clear, relabelled,
since that is already the only floor-teardown command the hub dispatches.
Exclusive mode renders exactly as before, and a test pins that.

Built assets regenerated, as they ship as package data.
say gains turn=speak|pass|extend. The two variants deliberately do NOT go
through /send: they post to /floor instead, which makes "extend reaches
nobody" true by construction rather than by a filter a later change could
regress, and leaves SendRequest.content's min_length untouched. The stick
state comes back in the say result on all three runtimes, so rotating costs
no second call.

Three drift seams closed while passing through. mcp_http's hand-rebuilt
return dict now carries stick, and its 423 hint and error come from the hub
instead of a local f-string; the bridge's 423 did the same thing in reverse,
hardcoding floor_held, so a round refusal read differently on stdio than on
/mcp. A 422 from /send used to fall through raise_for_status into the
resilience decorator and surface as hub_unreachable, which was a lie.

claude_agent stops fencing hub messages as untrusted peer data. The fence
header tells the model to ignore any instruction inside it, so fencing the
hub's own turn grant told the agent to ignore the one inbound message that
is a directive. origin is server-set and never client-supplied, so a peer
cannot forge its way out of the fence; the peer path and the operator path
are untouched. Its closing nudge also stops saying "reply if warranted"
when the agent is holding a stick with a deadline.

floor gains one action, round. extend stays HTTP-only like lower, because
say(turn="extend") already covers it and the tool description has no room:
say is at 260 of 260 and floor at 259.
…rop a leaver's backlog

Two defects the new state suite caught, both real.

The silent-lap test counted turns and compared against the eligible count.
That looks equivalent to "everyone declined" and is not: with a ring of two,
three passes taken by the two original members satisfy a count of three, so a
peer that joined mid-lap was closed out without ever holding the stick.
Round.declined is now the set of peers that have had a turn and said nothing
since anyone last spoke, and the round ends when every peer still able to
speak is in it. That question cannot be answered wrongly by a newcomer, and
it stays correct across joins and departures without tracking where the lap
began. The floor event keeps publishing silent_turns as its size.

Separately, a graceful leave lost whatever a round was holding for it.
_relinquish_floor looks the departing peer up in the roster or the revival
graveyard, and a terminal drop is in neither by the time it runs, so the
flush silently found nobody. _drop now releases the buffers itself, with the
client object already in hand. Retention defers delivery; it must never
destroy it.
…first event

start_round_as_operator seeds the first turn with a real peer's token, then
used to stamp started_by afterwards, after start_round had already announced
the round and pushed the floor event. So the room was told "alice opened a
round-table" and the console's first floor event named alice, when the
operator had opened it; only the next event on that scope corrected it.

start_round now takes opened_by, resolved before the announce and the push,
so the attribution is right the first time rather than eventually.

Also lands the HTTP and /ui coverage for the round: 26 cases over POST /floor,
POST /send, the retention contract observed through real polls, and the four
operator frames including the observer refusal.
… runbook

ARCHITECTURE gains the retention rule and, more importantly, the reason it
lives in route() rather than in the poll, plus the hard requirement that the
flushed backlog and the turn grant land in one /receive batch. It also states
outright that a round is not another brake: pause and stop still cut through
it. dashboard-protocol documents the extended floor event, waiting_turn, and
the three operator frames. The runbook leads its new troubleshooting branch
with the thing an operator will get wrong first, that peers in a round are
withheld and not stuck, which is exactly what makes a healthy round look like
a dead room.

The README's token-budget row was already stale before this branch: it
claimed 5935 characters of protocol text where origin/main actually measures
6512. Both figures are now measured rather than remembered, the same way
tests/test_token_budget.py measures them (6847 and 3895 here).
41 cases. The bridge ones hang an httpx event hook on the very client the
tool borrows, so they assert on the paths actually hit rather than only on
the reply: turn=speak reaches /send, pass and extend reach /floor, and an
unknown turn reaches nothing at all. That is the assertion that would catch
someone quietly routing extend back through /send and taking the
reaches-nobody guarantee with it.

The parity file compares a round refusal as a whole dict across stdio and
/mcp, which is the strongest form of that guard, and a companion test pins
that the exclusive refusal kept its own wording, so deferring to the hub did
not flatten the two modes into one message.

claude_agent gets the trust-boundary test that matters: a peer message whose
content forges an origin or a [caucus hub] prefix is still fenced and still
defanged. Lifting the fence for real hub traffic must not become a way to
talk your way out of it.

Zero deletions in the diff, so every pre-existing test is byte-identical.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant