A crowd in one chat
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>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""Several models answering one turn, in order, then again in reverse.
|
||||
|
||||
The shape the owner asked for: the chat's own model answers, then each other
|
||||
member in order; then the order runs **backwards**, each member asked whether it
|
||||
disagrees with anything; and it ends at the main model, which decides whether to
|
||||
go round again or stop.
|
||||
|
||||
## Why N chained replies and not one clever one
|
||||
|
||||
One `Generation` per speaker, one `Message` per speaker, chained where `_drain`
|
||||
already chains a queued turn. That is not the cheapest shape, it is the only one
|
||||
in which every existing invariant keeps holding for the reason it already holds:
|
||||
|
||||
* `Generation` is **one reply's** state and `_follow` streams **per message**,
|
||||
keyed on `generation.message_id`. One generation cannot stream into nine
|
||||
bubbles without a second streaming protocol, and `ensure(chat_id, message_id)`
|
||||
would have no answer to "which of the nine am I" after a restart.
|
||||
* Exactly one incomplete assistant row exists at any moment, so
|
||||
`_reply_in_flight` needs no teaching and the composer queues for the whole
|
||||
round.
|
||||
* Each speaker gets its own `steps_json`, `usage_json` and `model_id`, so the
|
||||
avatar, the metrics chip and the regenerate button are per speaker with no new
|
||||
rendering.
|
||||
|
||||
A subagent per speaker was rejected outright: a helper is handed a *serialisation*
|
||||
of the conversation, 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 entire point
|
||||
of a crowd. That feature already exists and is called `ask_friend`.
|
||||
|
||||
## Where the round lives
|
||||
|
||||
On the **message row**, in `Message.crowd_json`, and not on the chat. "The row is
|
||||
the authority, not the registry" is the rule the reload story was won with, and
|
||||
round state on the chat reintroduces exactly the split it was won against: a
|
||||
restart between speakers, or a rewind that deletes the rows, would leave
|
||||
chat-level state describing turns that no longer exist -- which is the problem
|
||||
`Chat.compacted_through_id` already documents.
|
||||
|
||||
`Message.parent_id` is **not** used for grouping. It is reserved for conversation
|
||||
branching and says so in its own comment.
|
||||
|
||||
## The scheduler is a pure function
|
||||
|
||||
`next_turn` takes numbers and returns numbers. Every refusal -- out of rounds, out
|
||||
of time, nobody to ask, not the newest message -- is therefore testable without an
|
||||
endpoint, which matters because the refusals are the interesting half.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Chat, Message
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The forward pass: everybody answers in order.
|
||||
PHASE_OUT = "out"
|
||||
# The way back: each member is asked whether it disagrees, in reverse order,
|
||||
# stopping one short of the main model.
|
||||
PHASE_BACK = "back"
|
||||
# The main model's last word, where it decides whether to go round again.
|
||||
PHASE_CLOSE = "close"
|
||||
|
||||
PHASES = (PHASE_OUT, PHASE_BACK, PHASE_CLOSE)
|
||||
|
||||
# Why a round ended, when it ended for a reason rather than by finishing.
|
||||
STOPPED_ROUNDS = "rounds"
|
||||
STOPPED_TIME = "time"
|
||||
STOPPED_ERRORS = "errors"
|
||||
|
||||
# How many speaker errors in a row end the round. One is skipped: the commonest
|
||||
# failure in a crowd is not a dead endpoint but a small member's context window
|
||||
# overflowing on a transcript several models have been writing into, and killing
|
||||
# the round at whichever member is smallest is the wrong answer. Two in a row is
|
||||
# an endpoint that has actually gone, which is what `_drain`'s refusal protects
|
||||
# against and is worth keeping.
|
||||
MAX_CONSECUTIVE_ERRORS = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Turn:
|
||||
"""Where one crowd round has got to, as it is stored on a message."""
|
||||
|
||||
turn: str
|
||||
round: int
|
||||
phase: str
|
||||
index: int
|
||||
of: int
|
||||
started_at: str
|
||||
errors: int = 0
|
||||
stopped: str = ""
|
||||
|
||||
def as_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"round": self.round,
|
||||
"phase": self.phase,
|
||||
"index": self.index,
|
||||
"of": self.of,
|
||||
"started_at": self.started_at,
|
||||
"errors": self.errors,
|
||||
"stopped": self.stopped,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_main(self) -> bool:
|
||||
return self.index == 0
|
||||
|
||||
|
||||
def state_of(message: Message | None) -> Turn | None:
|
||||
"""The round state on a message, or None if it is not part of one."""
|
||||
raw = getattr(message, "crowd_json", None) or None
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
return Turn(
|
||||
turn=str(raw.get("turn") or ""),
|
||||
round=int(raw.get("round") or 1),
|
||||
phase=str(raw.get("phase") or PHASE_OUT),
|
||||
index=int(raw.get("index") or 0),
|
||||
of=int(raw.get("of") or 1),
|
||||
started_at=str(raw.get("started_at") or ""),
|
||||
errors=int(raw.get("errors") or 0),
|
||||
stopped=str(raw.get("stopped") or ""),
|
||||
)
|
||||
except (TypeError, ValueError): # pragma: no cover - a hand-edited row
|
||||
return None
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def elapsed(started_at: str) -> float:
|
||||
"""Seconds since a round began, or 0.0 if the stamp is unreadable.
|
||||
|
||||
Unreadable reads as "no time has passed" rather than as "out of time": a
|
||||
round abandoned because of a bad timestamp would be a feature failing for a
|
||||
reason nobody could see.
|
||||
"""
|
||||
try:
|
||||
began = datetime.fromisoformat(started_at)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if began.tzinfo is None:
|
||||
began = began.replace(tzinfo=UTC)
|
||||
return max(0.0, (datetime.now(UTC) - began).total_seconds())
|
||||
|
||||
|
||||
def next_turn(
|
||||
*,
|
||||
speakers: int,
|
||||
state: Turn | None,
|
||||
turn_id: str,
|
||||
again: bool = False,
|
||||
errored: bool = False,
|
||||
max_rounds: int = 2,
|
||||
wall_seconds: int = 900,
|
||||
) -> Turn | None:
|
||||
"""Who speaks next, or None when the round is over.
|
||||
|
||||
Pure: numbers in, numbers out, no session and no clock beyond the stamp it is
|
||||
handed. `speakers` counts the main model as one of them.
|
||||
|
||||
`state=None` means the reply that has just finished was the ordinary first
|
||||
one, started by the composer as it always is -- so this is where a round
|
||||
begins rather than continues.
|
||||
"""
|
||||
if speakers < 2:
|
||||
return None
|
||||
|
||||
if state is None:
|
||||
return Turn(
|
||||
turn=turn_id,
|
||||
round=1,
|
||||
phase=PHASE_OUT,
|
||||
index=1,
|
||||
of=speakers,
|
||||
started_at=now_stamp(),
|
||||
)
|
||||
|
||||
# Errors are counted consecutively, so one member timing out is skipped and
|
||||
# an endpoint that has gone ends the round.
|
||||
errors = state.errors + 1 if errored else 0
|
||||
if errors >= MAX_CONSECUTIVE_ERRORS:
|
||||
return replace(state, stopped=STOPPED_ERRORS)
|
||||
|
||||
if wall_seconds and elapsed(state.started_at) >= wall_seconds:
|
||||
return replace(state, errors=errors, stopped=STOPPED_TIME)
|
||||
|
||||
carry = {
|
||||
"turn": state.turn,
|
||||
"of": speakers,
|
||||
"started_at": state.started_at,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
if state.phase == PHASE_OUT:
|
||||
if state.index + 1 <= speakers - 1:
|
||||
return Turn(round=state.round, phase=PHASE_OUT, index=state.index + 1, **carry)
|
||||
# The forward pass is done. The way back starts one short of the speaker
|
||||
# that has just finished -- asking it whether it disagrees with itself is
|
||||
# a round spent on nothing.
|
||||
if speakers - 2 >= 1:
|
||||
return Turn(round=state.round, phase=PHASE_BACK, index=speakers - 2, **carry)
|
||||
return Turn(round=state.round, phase=PHASE_CLOSE, index=0, **carry)
|
||||
|
||||
if state.phase == PHASE_BACK:
|
||||
if state.index - 1 >= 1:
|
||||
return Turn(round=state.round, phase=PHASE_BACK, index=state.index - 1, **carry)
|
||||
return Turn(round=state.round, phase=PHASE_CLOSE, index=0, **carry)
|
||||
|
||||
# The main model has had its last word. Another round only if it asked for
|
||||
# one *and* there is one left.
|
||||
if not again:
|
||||
return None
|
||||
if state.round + 1 > max_rounds:
|
||||
return replace(state, errors=errors, stopped=STOPPED_ROUNDS)
|
||||
return Turn(round=state.round + 1, phase=PHASE_OUT, index=1, **carry)
|
||||
|
||||
|
||||
# --- Resolving the membership --------------------------------------------------
|
||||
def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
|
||||
"""Every member that can actually be reached, in order, main model first.
|
||||
|
||||
Filtered through `permissions.models_visible_to` by way of
|
||||
`chat_service.roster_models`, so a member whose access has been revoked, whose
|
||||
model has been disabled, or whose row has gone is skipped rather than
|
||||
attempted -- and the skip is visible in the transcript rather than silent.
|
||||
|
||||
Deduplicated against the main model: adding the chat's own model to the crowd
|
||||
would have it answer twice in a row, which is not what anybody meant by it.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
reachable = {
|
||||
model.model_id: model for model in chat_service.roster_models(db, user, exclude="")
|
||||
}
|
||||
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
|
||||
seen = {chat.model_id}
|
||||
for member in sorted(chat.crowd, key=lambda row: (row.position, row.model_id)):
|
||||
if member.model_id in seen or member.model_id not in reachable:
|
||||
continue
|
||||
seen.add(member.model_id)
|
||||
speakers.append(chat_service.Speaker(member.model_id, member.connection_id))
|
||||
return speakers
|
||||
|
||||
|
||||
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
|
||||
"""Members that will be skipped, so a screen can say so rather than lie."""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
reachable = {
|
||||
model.model_id for model in chat_service.roster_models(db, user, exclude="")
|
||||
}
|
||||
return [
|
||||
member.model_id
|
||||
for member in chat.crowd
|
||||
if member.model_id not in reachable or member.model_id == chat.model_id
|
||||
]
|
||||
|
||||
|
||||
def is_newest(db: DBSession, message: Message) -> bool:
|
||||
"""Whether this is the last message in its chat.
|
||||
|
||||
The guard that stops a regenerate from forking the round. `restart` re-runs
|
||||
`_run`, whose `finally` advances the crowd again -- and speakers further down
|
||||
already exist, so without this, regenerating member 2 creates a second member
|
||||
3 and two chains race down one turn. `_drain` never needed it, because a
|
||||
queued row only ever exists *forward* of the reply.
|
||||
"""
|
||||
latest = db.scalars(
|
||||
select(Message)
|
||||
.where(Message.chat_id == message.chat_id)
|
||||
.order_by(Message.created_at.desc(), Message.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return latest is not None and latest.id == message.id
|
||||
|
||||
|
||||
# --- Asking for another round ---------------------------------------------------
|
||||
async def _run_crowd_again(context, args: dict[str, Any]):
|
||||
"""Record that the main model wants the crowd to go round again.
|
||||
|
||||
Written onto the running `Generation` rather than onto the row, because it is
|
||||
a fact about *this* reply and dies with it -- and onto a field rather than
|
||||
parsed back out of the prose, for the reason `plan_json` exists: a sentinel
|
||||
phrase in an answer is a decision nobody can see and a wording nobody can
|
||||
change.
|
||||
|
||||
Offered only on the main model's closing turn and only while a round is left,
|
||||
so a call arriving anywhere else is a call that was never on the table.
|
||||
"""
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services.tools import ToolOutcome
|
||||
|
||||
reason = str(args.get("focus") or "").strip()
|
||||
running = generation_service.running_for(context.chat_id) if context.chat_id else None
|
||||
if running is None:
|
||||
return ToolOutcome(
|
||||
"There is no round to continue.",
|
||||
{"name": "crowd_again", "status": "error", "error": "no round"},
|
||||
)
|
||||
|
||||
running.crowd_again = True
|
||||
return ToolOutcome(
|
||||
"The others will answer again."
|
||||
+ (f" You have asked them to focus on: {reason}" if reason else "")
|
||||
+ " Finish your answer now: what you write is what the person reads for "
|
||||
"this round.",
|
||||
{
|
||||
"name": "crowd_again",
|
||||
"status": "ok",
|
||||
"query": reason[:160],
|
||||
"detail": "another round",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def tool_defs() -> list:
|
||||
"""The one tool, offered only to the closing speaker of a crowd round."""
|
||||
from lembas.services.tools import FAMILY_CROWD, RISK_READ, ToolDef
|
||||
|
||||
return [
|
||||
ToolDef(
|
||||
name="crowd_again",
|
||||
family=FAMILY_CROWD,
|
||||
description=(
|
||||
"Send the other models round again, because the disagreement is "
|
||||
"real and another pass would settle it. Say what they should focus "
|
||||
"on. Use it sparingly: every round costs the person another wait, "
|
||||
"and a crowd asked to go round because the discussion was "
|
||||
"interesting will keep finding things to discuss. If the answers "
|
||||
"have converged, or the disagreement is a matter of taste, or "
|
||||
"nobody has said anything new on the way back, do not call this -- "
|
||||
"write the answer instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"focus": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"What the next round should settle, in one sentence."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
run=_run_crowd_again,
|
||||
# It changes nothing in the world; what it costs is more replies, and
|
||||
# that is bounded by `crowd.max_rounds` rather than by an approval.
|
||||
risk=RISK_READ,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CONSECUTIVE_ERRORS",
|
||||
"PHASES",
|
||||
"PHASE_BACK",
|
||||
"PHASE_CLOSE",
|
||||
"PHASE_OUT",
|
||||
"STOPPED_ERRORS",
|
||||
"STOPPED_ROUNDS",
|
||||
"STOPPED_TIME",
|
||||
"Turn",
|
||||
"elapsed",
|
||||
"is_newest",
|
||||
"member_speakers",
|
||||
"next_turn",
|
||||
"now_stamp",
|
||||
"state_of",
|
||||
"tool_defs",
|
||||
"unreachable_members",
|
||||
]
|
||||
Reference in New Issue
Block a user