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:
2026-09-26 13:38:52 +00:00
co-authored by Claude Opus 5
parent ac51dd46cc
commit da0797ccad
27 changed files with 3018 additions and 59 deletions
+292
View File
@@ -0,0 +1,292 @@
"""Which model answers one reply, and where that is decided.
Until 1.6.0 it was `chat.model_id` and nothing else, while `Message.model_id` was
written on every assistant placeholder and read only for display. The two could
disagree, and did: `wake_chat` accepts a `model_id` override, `schedule/runner`
passes `schedule.model_id or chat.model_id`, and that reached the row and never
reached the request — so a schedule naming another model got the chat's model
wearing the other one's name on the bubble. Half a feature, wired and unread.
The row is the authority now. That is also what makes a reply survive a restart:
`_follow` calls `ensure`, which starts a **new** generation against the same row,
so anything the request depends on has to be durable — and the in-process registry
is not.
Everything that differs per model is asserted here, because each of them fails
differently and three of them fail silently:
* the model id sent, which is the visible one;
* `vision`, where a wrong answer makes the endpoint reject the **whole request**;
* the reasoning-effort vocabulary, which raises inside the model's chat template;
* the tools capability, `context_length`, `{{model_name}}`, the personality, and
the authored prompt's model layer.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Connection, Model, User
from lembas.services import chat as chat_service
from lembas.services import harness as harness_service
from lembas.services import personas as personas_service
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def two_models(db, registered):
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(
Model(
connection_id=connection.id,
model_id="the-chats-model",
display_name="Chat model",
position=0,
context_length=8192,
reasoning_efforts=["low", "medium", "high"],
system_prompt="You are the chat's model.",
capabilities_json={"tools": True, "vision": True},
)
)
db.add(
Model(
connection_id=connection.id,
model_id="the-other-model",
display_name="Other model",
position=1,
context_length=128000,
reasoning_efforts=["low", "medium", "xhigh"],
system_prompt="You are the other model.",
capabilities_json={"tools": False, "vision": False},
)
)
db.commit()
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _chat(db) -> Chat:
connection = db.scalars(select(Connection)).first()
chat = Chat(
user_id=_user(db).id,
title="t",
model_id="the-chats-model",
connection_id=connection.id,
)
db.add(chat)
db.commit()
return chat
def _turn(db, chat, *, model_id: str = ""):
"""A user turn and the assistant placeholder that answers it."""
chat_service.create_message(db, chat, ROLE_USER, "Say something")
return chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=model_id or chat.model_id
)
# --- Where it is decided ------------------------------------------------------
def test_the_row_names_the_model_and_the_chat_is_the_default(db):
chat = _chat(db)
assert chat_service.speaker_for(db, chat).model_id == "the-chats-model"
placeholder = _turn(db, chat, model_id="the-other-model")
assert chat_service.speaker_for(db, chat, placeholder).model_id == "the-other-model"
def test_a_row_naming_no_model_falls_back_to_the_chat(db):
"""Every existing row names one, but a row written by an older release or by
some future caller that forgot must not send an empty model id."""
chat = _chat(db)
placeholder = _turn(db, chat)
placeholder.model_id = ""
db.commit()
assert chat_service.speaker_for(db, chat, placeholder).model_id == "the-chats-model"
# --- What the request carries -------------------------------------------------
def test_the_request_is_sent_to_the_model_the_row_names(db):
"""The bug, in one assertion. This failed before the speaker existed."""
chat = _chat(db)
placeholder = _turn(db, chat, model_id="the-other-model")
body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db))
assert body["model"] == "the-other-model"
def test_the_endpoint_is_resolved_for_the_row_s_model(db):
chat = _chat(db)
placeholder = _turn(db, chat, model_id="the-other-model")
speaker = chat_service.speaker_for(db, chat, placeholder)
_endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker)
assert model_id == "the-other-model"
def test_resolving_another_model_s_connection_does_not_repoint_the_chat(db):
"""`resolve_endpoint` writes `chat.connection_id` when the original has gone.
For a speaker that is not the chat's own model that would quietly move the
whole conversation to another endpoint."""
chat = _chat(db)
original = chat.connection_id
second = Connection(name="Second", base_url="http://127.0.0.2:1", api_key_encrypted=encrypt(""))
db.add(second)
db.commit()
db.add(Model(connection_id=second.id, model_id="only-here", position=9))
db.commit()
chat_service.resolve_endpoint(db, chat, chat_service.Speaker("only-here", None))
db.expire_all()
assert db.get(Chat, chat.id).connection_id == original
def test_the_effort_vocabulary_is_the_answering_model_s(db):
"""Not cosmetic: an effort a model does not take is rendered into its chat
template and raises there, failing the whole reply. gpt-oss takes
low/medium/high; a Bonsai takes low/medium/xhigh and refuses high."""
chat = _chat(db)
chat.params_json = {"reasoning_effort": "high"}
db.commit()
placeholder = _turn(db, chat, model_id="the-other-model")
body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db))
# `high` is not in the other model's list, so it is not sent at all rather
# than being sent to a template that raises on it.
assert body.get("reasoning_effort") != "high"
kwargs = body.get("chat_template_kwargs") or {}
assert kwargs.get("reasoning_effort") != "high"
def test_an_effort_the_answering_model_does_take_is_sent(db):
chat = _chat(db)
chat.params_json = {"reasoning_effort": "medium"}
db.commit()
placeholder = _turn(db, chat, model_id="the-other-model")
body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db))
assert body["reasoning_effort"] == "medium"
def test_vision_follows_the_answering_model(db):
"""An image sent to a model without vision is not degraded gracefully: most
endpoints reject the entire request."""
chat = _chat(db)
assert chat_service.model_supports(db, chat, "vision") is True
assert (
chat_service.model_supports(
db, chat, "vision", speaker=chat_service.Speaker("the-other-model")
)
is False
)
def test_the_authored_prompt_uses_the_answering_model_s_layer(db):
chat = _chat(db)
speaker = chat_service.Speaker("the-other-model")
assert "chat's model" in chat_service.effective_system_prompt(db, chat)
assert "other model" in chat_service.effective_system_prompt(db, chat, speaker)
def test_the_tools_capability_is_the_answering_model_s(db):
"""`tools` off is the first gate and returns nothing at all, so a model that
cannot take a tools array must not be handed one -- its replies fail rather
than degrade."""
chat = _chat(db)
user = _user(db)
settings_store.update(db, {"default_permissions": {"tools.web_search": True}})
assert tools_service.resolve_tools(db, chat, user).defs
assert not tools_service.resolve_tools(
db, chat, user, chat_service.Speaker("the-other-model")
).defs
def test_the_context_limit_is_the_answering_model_s(db):
chat = _chat(db)
assert chat_service.model_for(db, chat).context_length == 8192
other = chat_service.model_row(db, chat_service.Speaker("the-other-model"))
assert other.context_length == 128000
def test_the_model_name_variable_is_the_answering_model_s(db):
"""Telling a speaker it is the main model is a lie it then reasons from."""
chat = _chat(db)
values = harness_service.context_variables(
db, _user(db), [], chat, chat_service.Speaker("the-other-model")
)
assert values["model_name"] == "Other model"
def test_the_personality_is_the_answering_model_s(db):
chat = _chat(db)
user = _user(db)
settings_store.update(db, {"default_permissions": {"tools.persona": True}})
personas_service.write(db, model_key="the-chats-model", owner=user, content="I am the chat's.")
personas_service.write(db, model_key="the-other-model", owner=user, content="I am the other.")
offered = [
tool.schema
for tool in tools_service.registry(db).values()
if tools_service.gate_of(tool.family) == "persona"
]
mine = harness_service.context_variables(db, user, offered, chat)
theirs = harness_service.context_variables(
db, user, offered, chat, chat_service.Speaker("the-other-model")
)
assert mine["persona"] == "I am the chat's."
assert theirs["persona"] == "I am the other."
def test_a_tool_acts_as_the_answering_model(db):
"""`ToolContext.model_id` is which model a tool acts *as* -- whose personality
`persona_write` rewrites, and whose endpoint the image reviewer reaches for."""
chat = _chat(db)
context = tools_service.context_for(
db, _user(db), chat, speaker=chat_service.Speaker("the-other-model", "abc")
)
assert context.model_id == "the-other-model"
assert context.connection_id == "abc"
# --- The half-wired feature this closes ---------------------------------------
async def test_a_schedule_naming_another_model_now_sends_it(db, monkeypatch):
"""`wake_chat(model_id=…)` wrote the override onto the row and `_run` ignored
it. End to end: the turn goes in through the documented path, and the request
built for the placeholder it created names the model the caller asked for."""
from lembas.services import generation as generation_service
from lembas.services import wake as wake_service
chat = _chat(db)
monkeypatch.setattr(generation_service, "running_for", lambda chat_id: None)
monkeypatch.setattr(generation_service, "ensure", lambda chat_id, message_id: None)
message_id = await wake_service.wake_chat(
chat.id, "Run the nightly summary", model_id="the-other-model"
)
db.expire_all()
from lembas.db.models import Message
placeholder = db.get(Message, message_id)
assert placeholder.model_id == "the-other-model"
body = chat_service.build_request(
db, db.get(Chat, chat.id), upto=placeholder, user=_user(db)
)
assert body["model"] == "the-other-model"