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,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"
|
||||
Reference in New Issue
Block a user