Three features sharing one idea: a model here started from nothing every
conversation and had no notion that anything else existed.
THE ROSTER. `chat.roster_block` builds one line per model this *person* can
reach -- through `permissions.models_visible_to`, never the table -- and
`{{model_roster}}` carries it, gated on the `friend` family for the reason the
memories block is gated on `memory`: a list of peers a model cannot talk to is
context spent on nothing, and one checkbox is then the whole switch. New
`Model.notes` column, a column and not a `capabilities_json` key for the reason
`context_length` and `reasoning_efforts` both carry.
ASKING A FRIEND. A second entry point in `services/subagent.py` rather than a
second module, so one place still owns the bounds and the lifecycle. `_create_
child` takes the friend's (model_id, connection_id) *pair*, because Model is
unique on both and an id alone does not say which endpoint. Three things differ
from a helper: the effort is the friend's own default and never the parent's (the
1.3.0 bug by another door -- the vocabularies differ and a level a model does not
take raises inside its chat template), the chat is ordinary even when the asker's
is an agent chat, and `scope_json["role"]` marks it so `core.friend` speaks
instead of `core.subagent`. `friend` joins the unattended withdrawal set: a
friend that could ask a friend is the same unbounded fan-out in politer clothes.
Budget, concurrency and quota are shared with helpers, so one reply cannot spend
the allowance twice.
PERSONALITY. One table, two roles, `owner_id IS NULL` the discriminator: the
model's own persona, and its read of one person. Keyed on the model's *text* id
with no foreign key, because "Test & refresh" deletes a model the endpoint has
stopped listing and a personality must not be collateral. `PersonaRevision`
copies SkillRevision, and so does the argument: the safety story for a model
rewriting itself is a record and a way back, not a gate. The reflection is shown
to the person it is about, in their own settings, which is the whole of why
keeping one is acceptable. `persona` is withdrawn from any unattended chat --
a helper's task, a friend's question and a schedule's instruction are all words
nobody watched being written.
Two bugs found while reading for this, both silent:
`review_model_id` stored a `Model` primary key, so a refresh taken while an
endpoint was not listing that model unset the administrator's choice -- and
`_reviewer` then fell back to the chat's own model, so pictures were judged by
a model nobody chose. Now the text id, with the primary key still accepted.
`_messages_after` used a bare `>` on `created_at`, so a row sharing the edited
turn's microsecond survived a rewind -- and `_send` writes a user turn and its
placeholder back to back, which is exactly that tie. Deliberately NOT
`thread_tail`'s `(created_at, id)` tiebreak: ids are random UUIDs, so that
settles a tie by coin toss. A tie now reads as "later", which is the safe
direction for an operation whose purpose is to discard what follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.4 KiB
Python
176 lines
6.4 KiB
Python
"""The list of other models a model is given, and what decides it is there.
|
|
|
|
The roster is one `{{variable}}` and one fragment, so the interesting assertions
|
|
are about *absence*: it is missing on a single-model instance, missing for a
|
|
model that may not ask anyone anything, and missing a model this account cannot
|
|
reach. A list that is merely wrong would be bad; a list naming something the
|
|
reader has no access to is a leak and a dead end at once, because asking it
|
|
anything is refused by the same check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import ROLE_USER, Chat, Connection, Group, Model, User
|
|
from lembas.services import chat as chat_service
|
|
from lembas.services import harness as harness_service
|
|
from lembas.services import settings_store
|
|
from lembas.services.crypto import encrypt
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def three_models(db, registered):
|
|
settings_store.update(db, {"enabled": True}, key=settings_store.SUBAGENTS)
|
|
settings_store.update(db, {"default_permissions": {"tools.friend": True}})
|
|
connection = Connection(
|
|
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
|
)
|
|
db.add(connection)
|
|
db.commit()
|
|
rows = [
|
|
("test-model", "The asker", "", ""),
|
|
("big-model", "Big", "Long reasoning problems", "70B, Q4, MMLU 82"),
|
|
("small-model", "Small", "Quick summaries", ""),
|
|
]
|
|
for index, (name, label, description, notes) in enumerate(rows):
|
|
db.add(
|
|
Model(
|
|
connection_id=connection.id,
|
|
model_id=name,
|
|
display_name=label,
|
|
description=description,
|
|
notes=notes,
|
|
position=index,
|
|
capabilities_json={"tools": True},
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _user(db) -> User:
|
|
return db.scalars(select(User).order_by(User.created_at)).first()
|
|
|
|
|
|
def _chat(db, model_id: str = "test-model") -> Chat:
|
|
chat = Chat(user_id=_user(db).id, title="t", model_id=model_id)
|
|
db.add(chat)
|
|
db.commit()
|
|
return chat
|
|
|
|
|
|
def _offered(db, families: list[str]) -> list[dict]:
|
|
"""Tool schemas for the families named, built from the real definitions so a
|
|
family that stops existing takes these tests with it rather than passing on
|
|
a hand-written string."""
|
|
from lembas.services import tools as tools_service
|
|
|
|
return [
|
|
tool.schema
|
|
for tool in tools_service.registry(db).values()
|
|
if tools_service.gate_of(tool.family) in families
|
|
]
|
|
|
|
|
|
def _values(db, chat: Chat, *, families: list[str]) -> dict[str, str]:
|
|
return harness_service.context_variables(db, _user(db), _offered(db, families), chat)
|
|
|
|
|
|
def _preamble(db, chat: Chat, *, families: list[str]) -> str:
|
|
"""The whole harness, through the path a request actually takes."""
|
|
return harness_service.compose(db, _user(db), _offered(db, families), chat)
|
|
|
|
|
|
def test_every_other_model_is_listed_with_its_id(db):
|
|
block = chat_service.roster_block(db, _user(db), exclude="test-model")
|
|
assert "big-model" in block
|
|
assert "small-model" in block
|
|
assert "Big" in block
|
|
|
|
|
|
def test_the_asking_model_is_not_in_its_own_roster(db):
|
|
block = chat_service.roster_block(db, _user(db), exclude="test-model")
|
|
assert "test-model" not in block
|
|
|
|
|
|
def test_the_description_and_the_notes_both_reach_it(db):
|
|
"""Two fields on purpose: the description says what a model is for and is
|
|
also shown to people, the notes say what it *is* and are for this alone. A
|
|
model choosing whom to ask wants both."""
|
|
block = chat_service.roster_block(db, _user(db), exclude="test-model")
|
|
assert "Long reasoning problems" in block
|
|
assert "70B, Q4, MMLU 82" in block
|
|
|
|
|
|
def test_a_model_this_account_cannot_reach_is_absent(db):
|
|
group = Group(name="Wheel")
|
|
db.add(group)
|
|
restricted = db.scalar(select(Model).where(Model.model_id == "big-model"))
|
|
restricted.public = False
|
|
restricted.groups = [group]
|
|
user = _user(db)
|
|
user.role = ROLE_USER
|
|
db.commit()
|
|
|
|
block = chat_service.roster_block(db, user, exclude="test-model")
|
|
assert "big-model" not in block
|
|
assert "small-model" in block
|
|
|
|
|
|
def test_a_disabled_model_is_absent(db):
|
|
off = db.scalar(select(Model).where(Model.model_id == "small-model"))
|
|
off.enabled = False
|
|
db.commit()
|
|
assert "small-model" not in chat_service.roster_block(db, _user(db), exclude="test-model")
|
|
|
|
|
|
def test_the_block_is_bounded(db):
|
|
"""Every model an instance has multiplies this, and the harness has a budget
|
|
the whole of it shares."""
|
|
connection = db.scalars(select(Connection)).first()
|
|
for index in range(60):
|
|
db.add(
|
|
Model(
|
|
connection_id=connection.id,
|
|
model_id=f"filler-{index}",
|
|
display_name=f"Filler {index}",
|
|
notes="x" * 400,
|
|
position=10 + index,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
block = chat_service.roster_block(db, _user(db), exclude="test-model")
|
|
assert len(block) <= chat_service.MAX_ROSTER_CHARS + chat_service.MAX_ROSTER_ENTRY
|
|
assert len(block.splitlines()) <= chat_service.MAX_ROSTER_MODELS
|
|
|
|
|
|
# --- Whether it is sent at all ------------------------------------------------
|
|
def test_the_variable_is_empty_for_a_model_that_cannot_ask_anyone(db):
|
|
"""Gated on the family, exactly as the memories block is gated on memory. A
|
|
list of peers a model cannot reach is context spent on nothing, and it is why
|
|
the roster and the tool are one switch rather than two."""
|
|
chat = _chat(db)
|
|
assert _values(db, chat, families=["memory"])["model_roster"] == ""
|
|
assert _values(db, chat, families=["friend"])["model_roster"] != ""
|
|
|
|
|
|
def test_the_fragment_vanishes_on_a_single_model_instance(db):
|
|
"""`requires` rather than a conditional in the text: a heading above an empty
|
|
list reads as "there is nobody", which is a different and wrong claim."""
|
|
for extra in db.scalars(select(Model).where(Model.model_id != "test-model")):
|
|
db.delete(extra)
|
|
db.commit()
|
|
|
|
chat = _chat(db)
|
|
assert _values(db, chat, families=["friend"])["model_roster"] == ""
|
|
assert "The other models here" not in _preamble(db, chat, families=["friend"])
|
|
|
|
|
|
def test_the_fragment_carries_the_list_when_there_is_one(db):
|
|
chat = _chat(db)
|
|
assembled = _preamble(db, chat, families=["friend"])
|
|
assert "The other models here" in assembled
|
|
assert "big-model" in assembled
|