The chat's own model answers, then each other member in order, then the order runs
backwards asking each whether it disagrees, ending at the main model, which either
closes or sends them round again. Design and reasoning: LLeMbas.wiki/Crowd-chats.
THE SPEAKER SEAM, WHICH IS ALSO A BUG FIX
`chat_service.speaker_for` makes the *message* name the answering model and the
chat only the default. That closes a live half-wired feature -- `wake_chat` takes a
model override and `schedule/runner` passes one, and it reached the row and never
the request, so a schedule naming another model got the chat's model wearing the
other one's name.
The seam is wider than `build_request`: `{{model_name}}`, the authored prompt's
model layer, `vision` (where a wrong answer makes the endpoint reject the whole
request), the effort vocabulary (which raises inside the model's own chat template,
and whose refusal narrows every Model row sharing the id), `resolve_tools`,
`context_length` -> `_too_big`, and `ToolContext.model_id`. `resolve_endpoint` may
now only write back `chat.connection_id` when the speaker *is* the chat's model.
WHY N CHAINED REPLIES
`Generation` is one reply's state and `_follow` streams per message, so one
generation cannot stream into nine bubbles and `ensure` would not know which of the
nine it was after a restart. A subagent per speaker cannot work either: its answer
comes back as a tool result and tool results are never replayed, so speaker 3 could
not see speaker 2 -- which is the whole point. Chained, exactly one incomplete row
exists at a time, and `tests/test_crowd_chain.py` asserts that at every
observation.
The round lives on `Message.crowd_json`, not on the chat: the row is the authority,
and chat-level state would describe turns a rewind or a restart had removed.
`crowd.next_turn` is pure, so all eight refusals are tested with no endpoint.
THREE RULES, EACH A BUG WRITTEN THE OTHER WAY ROUND
- `if not _advance_crowd(g): _drain(g)` -- advancing must *suppress* draining, or a
queued human turn puts a second incomplete row beside the next speaker's.
- `_advance_crowd` refuses unless the finishing row is the newest, or regenerating
member 2 creates a second member 3 and two chains race down one turn.
- an error skips one speaker and two in a row end the round: the usual failure is a
small member's window overflowing, and `_drain`'s stop-on-error would kill every
crowd at whichever member is smallest.
Each other speaker's turn is relabelled as attributed user content, which is both
how a model can disagree with words it did not write and how the history keeps
alternating. The per-speaker instruction is payload-only -- as a row it could be
dropped from the request by a `created_at` tie, and every later speaker would answer
it. Compaction, titling and the notification are gated to once per turn; `_inject`
is off during a round; the way back gets no tools and a member is treated as
unattended.
Membership stores the model as text with no foreign key: "Test & refresh" deletes
and recreates Model rows, and a cascade would empty the crowd out of every chat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
8.4 KiB
Python
232 lines
8.4 KiB
Python
"""The crowd's order of speaking, as arithmetic.
|
|
|
|
`crowd.next_turn` is a pure function so that the interesting half of this feature
|
|
— every way a round refuses to continue — can be tested without an endpoint, a
|
|
session or a clock. The order the owner asked for is one sequence, and getting it
|
|
wrong in either direction is a feature that looks like it works: a backward pass
|
|
that starts on the speaker who has just spoken asks it whether it disagrees with
|
|
itself, and one that runs to the main model twice gives it two closing turns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from lembas.services import crowd
|
|
|
|
|
|
def _first(speakers: int) -> crowd.Turn:
|
|
turn = crowd.next_turn(speakers=speakers, state=None, turn_id="u1")
|
|
assert turn is not None
|
|
return turn
|
|
|
|
|
|
def _walk(speakers: int, *, again_at: set[int] = frozenset(), max_rounds: int = 2) -> list[str]:
|
|
"""The whole sequence as `phase/index` strings, for one readable assertion."""
|
|
state = None
|
|
seen: list[str] = []
|
|
for _ in range(60):
|
|
again = state is not None and state.round in again_at and state.phase == crowd.PHASE_CLOSE
|
|
turn = crowd.next_turn(
|
|
speakers=speakers,
|
|
state=state,
|
|
turn_id="u1",
|
|
again=again,
|
|
max_rounds=max_rounds,
|
|
)
|
|
if turn is None or turn.stopped:
|
|
if turn is not None and turn.stopped:
|
|
seen.append(f"stopped:{turn.stopped}")
|
|
break
|
|
seen.append(f"{turn.phase}/{turn.index}")
|
|
state = turn
|
|
return seen
|
|
|
|
|
|
# --- The order ----------------------------------------------------------------
|
|
def test_one_model_is_not_a_crowd():
|
|
"""The chat's own model with nobody else answers exactly as it always did."""
|
|
assert crowd.next_turn(speakers=1, state=None, turn_id="u1") is None
|
|
|
|
|
|
def test_two_speakers_go_out_and_straight_back_to_the_main_model():
|
|
"""With one member there is nobody to ask on the way back, so the round is
|
|
main, member, main — and the backward pass is empty rather than asking the
|
|
member about its own answer."""
|
|
assert _walk(2) == ["out/1", "close/0"]
|
|
|
|
|
|
def test_three_speakers_come_back_through_the_middle():
|
|
assert _walk(3) == ["out/1", "out/2", "back/1", "close/0"]
|
|
|
|
|
|
def test_five_speakers_walk_out_and_back_in_order():
|
|
assert _walk(5) == [
|
|
"out/1", "out/2", "out/3", "out/4",
|
|
"back/3", "back/2", "back/1",
|
|
"close/0",
|
|
]
|
|
|
|
|
|
def test_the_way_back_never_asks_the_last_speaker_about_itself():
|
|
"""It starts one short of the speaker that has just finished."""
|
|
for speakers in range(2, 7):
|
|
sequence = _walk(speakers)
|
|
out = [s for s in sequence if s.startswith("out/")]
|
|
back = [s for s in sequence if s.startswith("back/")]
|
|
if back:
|
|
assert back[0] != out[-1].replace("out/", "back/")
|
|
|
|
|
|
def test_the_main_model_gets_exactly_one_closing_turn():
|
|
for speakers in range(2, 7):
|
|
assert _walk(speakers).count("close/0") == 1
|
|
|
|
|
|
def test_the_first_reply_is_not_scheduled_by_this():
|
|
"""The composer starts it, as it always has. A round *begins* at the second
|
|
speaker, which is why `state=None` returns index 1."""
|
|
assert _first(4).index == 1
|
|
assert _first(4).phase == crowd.PHASE_OUT
|
|
assert _first(4).round == 1
|
|
|
|
|
|
def test_the_size_of_the_round_is_recorded_on_every_turn():
|
|
"""`of` is what the chip in the transcript counts against."""
|
|
turn = _first(4)
|
|
assert turn.of == 4
|
|
|
|
|
|
# --- Going round again ---------------------------------------------------------
|
|
def test_without_being_asked_the_round_ends_at_the_main_model():
|
|
assert _walk(3, again_at=set()) == ["out/1", "out/2", "back/1", "close/0"]
|
|
|
|
|
|
def test_asked_for_another_round_it_starts_again_at_the_second_speaker():
|
|
"""The main model has just spoken as the closer, so round two begins with the
|
|
others rather than with it."""
|
|
sequence = _walk(3, again_at={1}, max_rounds=2)
|
|
assert sequence == [
|
|
"out/1", "out/2", "back/1", "close/0",
|
|
"out/1", "out/2", "back/1", "close/0",
|
|
]
|
|
|
|
|
|
def test_the_round_cap_stops_it_and_says_why():
|
|
"""Reached rather than never: the cap is a ceiling on ordinary work here,
|
|
unlike a runaway backstop, so somebody has to be able to see it was hit."""
|
|
sequence = _walk(3, again_at={1, 2, 3}, max_rounds=2)
|
|
assert sequence[-1] == f"stopped:{crowd.STOPPED_ROUNDS}"
|
|
assert sequence.count("close/0") == 2
|
|
|
|
|
|
def test_one_round_means_one_round():
|
|
sequence = _walk(3, again_at={1, 2}, max_rounds=1)
|
|
assert sequence.count("close/0") == 1
|
|
assert sequence[-1] == f"stopped:{crowd.STOPPED_ROUNDS}"
|
|
|
|
|
|
# --- Running out of time -------------------------------------------------------
|
|
def _stale(seconds: int) -> crowd.Turn:
|
|
began = datetime.now(UTC) - timedelta(seconds=seconds)
|
|
return crowd.Turn(
|
|
turn="u1", round=1, phase=crowd.PHASE_OUT, index=1, of=4,
|
|
started_at=began.isoformat(),
|
|
)
|
|
|
|
|
|
def test_a_round_that_has_run_long_enough_is_stopped():
|
|
stopped = crowd.next_turn(speakers=4, state=_stale(1000), turn_id="u1", wall_seconds=900)
|
|
assert stopped is not None
|
|
assert stopped.stopped == crowd.STOPPED_TIME
|
|
|
|
|
|
def test_a_round_inside_its_time_carries_on():
|
|
turn = crowd.next_turn(speakers=4, state=_stale(10), turn_id="u1", wall_seconds=900)
|
|
assert turn is not None
|
|
assert not turn.stopped
|
|
assert turn.index == 2
|
|
|
|
|
|
def test_the_clock_covers_the_whole_turn_not_one_speaker():
|
|
"""`started_at` is carried from the round's first turn, never refreshed, so a
|
|
crowd of slow members cannot outrun the limit one speaker at a time."""
|
|
first = _first(4)
|
|
second = crowd.next_turn(speakers=4, state=first, turn_id="u1")
|
|
assert second is not None
|
|
assert second.started_at == first.started_at
|
|
|
|
|
|
def test_an_unreadable_stamp_reads_as_no_time_passed():
|
|
"""A round abandoned because of a bad timestamp would be a feature failing
|
|
for a reason nobody could see."""
|
|
broken = crowd.Turn(
|
|
turn="u1", round=1, phase=crowd.PHASE_OUT, index=1, of=4, started_at="not a date"
|
|
)
|
|
turn = crowd.next_turn(speakers=4, state=broken, turn_id="u1", wall_seconds=1)
|
|
assert turn is not None
|
|
assert not turn.stopped
|
|
|
|
|
|
# --- Errors -------------------------------------------------------------------
|
|
def test_one_speaker_failing_is_skipped_rather_than_ending_the_round():
|
|
"""The commonest failure is a small member's window overflowing on a
|
|
transcript several models have written into. Ending the round there would kill
|
|
every crowd at whichever member is smallest."""
|
|
turn = crowd.next_turn(speakers=5, state=_first(5), turn_id="u1", errored=True)
|
|
assert turn is not None
|
|
assert not turn.stopped
|
|
assert turn.index == 2
|
|
assert turn.errors == 1
|
|
|
|
|
|
def test_two_failures_in_a_row_end_the_round():
|
|
"""Which is `_drain`'s protection kept: the endpoint has actually gone, and
|
|
feeding it the next prompt produces a second failure and spends the words to
|
|
do it."""
|
|
first = crowd.next_turn(speakers=5, state=_first(5), turn_id="u1", errored=True)
|
|
second = crowd.next_turn(speakers=5, state=first, turn_id="u1", errored=True)
|
|
assert second is not None
|
|
assert second.stopped == crowd.STOPPED_ERRORS
|
|
|
|
|
|
def test_the_count_is_of_consecutive_failures():
|
|
"""One failure, then a success, then a failure is not a dead endpoint."""
|
|
state = crowd.next_turn(speakers=6, state=_first(6), turn_id="u1", errored=True)
|
|
assert state.errors == 1
|
|
state = crowd.next_turn(speakers=6, state=state, turn_id="u1", errored=False)
|
|
assert state.errors == 0
|
|
state = crowd.next_turn(speakers=6, state=state, turn_id="u1", errored=True)
|
|
assert state is not None
|
|
assert not state.stopped
|
|
|
|
|
|
# --- What is stored -----------------------------------------------------------
|
|
def test_the_state_survives_a_round_trip_through_the_row():
|
|
"""It is read back off a message after a restart, so the two halves have to
|
|
agree exactly."""
|
|
|
|
class Row:
|
|
crowd_json = None
|
|
|
|
turn = _first(4)
|
|
Row.crowd_json = turn.as_json()
|
|
assert crowd.state_of(Row) == turn
|
|
|
|
|
|
def test_a_message_with_no_state_is_not_part_of_a_round():
|
|
class Row:
|
|
crowd_json = None
|
|
|
|
assert crowd.state_of(Row) is None
|
|
assert crowd.state_of(None) is None
|
|
|
|
|
|
def test_nonsense_on_the_row_reads_as_no_round():
|
|
"""A hand-edited database must not raise inside the generation loop."""
|
|
|
|
class Row:
|
|
crowd_json = {"round": "third", "index": None}
|
|
|
|
assert crowd.state_of(Row) is None
|