Files
LLeMbas/tests/test_friend.py
T
HomerandClaude Opus 5 df52ec9d96 Models that know about each other, and have a self
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>
2026-09-26 02:04:55 +00:00

383 lines
15 KiB
Python

"""Putting a question to one of the other models, and getting its answer back.
Shares its machinery with `subagent_run` on purpose, so most of what is asserted
here is the *differences* — which model answers, with whose reasoning effort, in
what kind of chat, and what it may not do in turn. The generation loop is stubbed
exactly as `test_subagent.py` stubs it; what matters is the chat the friend is
given.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import select
from lembas.db.models import (
KIND_AGENT,
KIND_CHAT,
ROLE_USER,
Chat,
Connection,
Group,
Model,
User,
)
from lembas.services import chat as chat_service
from lembas.services import settings_store
from lembas.services import subagent as subagent_service
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def asking_allowed(db, registered):
"""The instance switch on, the permission granted, and three models to ask.
The gates get their own test below, which asserts both directions.
"""
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()
for index, (name, label, note) in enumerate(
[
("test-model", "The asker", ""),
("big-model", "Big", "70B, good at maths"),
("small-model", "Small", ""),
]
):
db.add(
Model(
connection_id=connection.id,
model_id=name,
display_name=label,
notes=note,
position=index,
capabilities_json={"tools": True},
)
)
db.commit()
subagent_service.clear()
yield
subagent_service.clear()
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _chat(db, **kwargs) -> Chat:
chat = Chat(user_id=_user(db).id, title="t", model_id="test-model", **kwargs)
db.add(chat)
db.commit()
return chat
class _Fake:
def __init__(self, spawned: int = 0):
self.subagents = spawned
def _spawn(monkeypatch, *, answer: str = "I disagree, and here is why.", finish: bool = True):
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER
from lembas.db.session import session_scope
seen: dict[str, str] = {}
async def fake_wake(chat_id: str, content: str, *, model_id: str = "") -> str:
seen["chat_id"] = chat_id
seen["turn"] = content
with session_scope() as db:
child = db.get(Chat, chat_id)
chat_service.create_message(db, child, ROLE_USER, content)
reply = chat_service.create_message(db, child, ROLE_ASSISTANT, answer)
seen["message_id"] = reply.id
return seen["message_id"]
monkeypatch.setattr("lembas.services.wake.wake_chat", fake_wake)
monkeypatch.setattr("lembas.services.generation.running_for", lambda chat_id: None)
return seen
async def _ask(db, chat: Chat, args: dict, *, generation=None):
"""Through `resolve_tools`, never by hand — what may be run is what was
offered, and a hand-built context falls back to the import-time registry,
which has never held this tool."""
from lembas.services import generation as generation_service
user = _user(db)
resolved = tools_service.resolve_tools(db, chat, user)
context = tools_service.context_for(db, user, chat, tools=resolved)
fake = generation if generation is not None else _Fake()
original = generation_service.running_for
def running_for(chat_id):
return fake if chat_id == chat.id else original(chat_id)
generation_service.running_for = running_for
try:
return await tools_service.run_tool(context, "ask_friend", json.dumps(args))
finally:
generation_service.running_for = original
# --- Whose chat it is ---------------------------------------------------------
async def test_the_friend_answers_as_itself_not_as_the_asking_model(db, monkeypatch):
"""The whole feature. `generation` resolves the endpoint from the child chat
row, so the model on that row is the one that answers."""
parent = _chat(db)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "Is this right?"})
child = db.get(Chat, seen["chat_id"])
assert child.model_id == "big-model"
assert child.parent_chat_id == parent.id
assert child.unattended is True
assert child.temporary is True
async def test_the_friend_can_be_named_by_its_label_as_well_as_its_id(db, monkeypatch):
"""The roster prints both, so a model will sometimes type back the pretty
one. Refusing that is a round spent on a spelling."""
parent = _chat(db)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "Big", "question": "Is this right?"})
assert db.get(Chat, seen["chat_id"]).model_id == "big-model"
async def test_the_friend_does_not_inherit_the_askers_reasoning_effort(db, monkeypatch):
"""The 1.3.0 bug with a new door: the vocabularies differ per model, and an
effort a model does not take is rendered into its chat template and raises
there. `high` from the asker must not follow the question to a model whose
list says low/medium/xhigh."""
parent = _chat(db)
parent.params_json = {"reasoning_effort": "high"}
friend = db.scalar(select(Model).where(Model.model_id == "big-model"))
friend.reasoning_efforts = ["low", "medium", "xhigh"]
friend.params_json = {"reasoning_effort": "xhigh"}
db.commit()
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "Is this right?"})
child = db.get(Chat, seen["chat_id"])
assert chat_service.resolved_effort(child) == "xhigh"
async def test_an_effort_the_friend_does_not_take_is_not_sent_at_all(db, monkeypatch):
parent = _chat(db)
friend = db.scalar(select(Model).where(Model.model_id == "big-model"))
friend.reasoning_efforts = ["low", "medium"]
friend.params_json = {"reasoning_effort": "high"}
db.commit()
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "?"})
assert chat_service.resolved_effort(db.get(Chat, seen["chat_id"])) == ""
async def test_a_friend_of_an_agent_chat_is_not_given_the_machine(db, monkeypatch):
"""A peer is asked what it thinks, not put to work. An agent chat's harness
is about the box it is working on, and handing that to somebody asked a
question invites it to plan around a shell it has not got."""
parent = _chat(db, kind=KIND_AGENT, project_dir="/srv/app", ssh_profile_id="nope")
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "?"})
child = db.get(Chat, seen["chat_id"])
assert child.kind == KIND_CHAT
assert not child.ssh_profile_id
assert not child.project_dir
# And the consequence, which is the thing that actually matters: an
# ordinary chat resolves no agent tools, whatever the mode column says.
offered = tools_service.resolve_tools(db, child, _user(db))
assert not [name for name in offered.by_name if name.startswith(("shell_", "file_"))]
# --- What it may not do -------------------------------------------------------
async def test_a_friend_cannot_ask_a_friend(db, monkeypatch):
"""Otherwise one question is a fan-out with no bound anybody set. Both halves:
the family is withdrawn from the offered set, and the runner refuses a call
that arrived by any other route."""
parent = _chat(db)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "?"})
child = db.get(Chat, seen["chat_id"])
offered = tools_service.resolve_tools(db, child, _user(db))
assert "ask_friend" not in offered.by_name
assert "subagent_run" not in offered.by_name
assert "ask_user" not in offered.by_name
# And the runner's own guard, reached by offering it the tool anyway --
# which is what "a call that arrived by some other route" means. Two halves,
# because the withdrawal is the one a prompt cannot argue with and this is
# the one that holds if the withdrawal is ever got round.
forced = tools_service.ToolSet(tuple(subagent_service.friend_tool_defs()))
context = tools_service.context_for(db, _user(db), child, tools=forced)
outcome = await tools_service.run_tool(
context, "ask_friend", json.dumps({"model": "small-model", "question": "?"})
)
assert "may not pass it on" in outcome.content
async def test_a_friend_cannot_rewrite_its_own_personality(db, monkeypatch):
"""A question is written by a model that may have been reading a page, and
the persona is carried into every conversation it will ever have."""
settings_store.update(db, {"default_permissions": {"tools.persona": True}})
parent = _chat(db)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _ask(db, parent, {"model": "big-model", "question": "?"})
offered = tools_service.resolve_tools(db, db.get(Chat, seen["chat_id"]), _user(db))
assert "persona_write" not in offered.by_name
assert "impression_write" not in offered.by_name
# And it is genuinely on for the chat somebody is present in.
assert "persona_write" in tools_service.resolve_tools(db, parent, _user(db)).by_name
# --- Refusals that name what could have been asked ----------------------------
async def test_an_unknown_model_is_refused_with_the_list_of_real_ones(db, monkeypatch):
"""The name arrives in a tool call, so it is model-written input. A refusal
that does not say what the valid answers are costs another round."""
parent = _chat(db)
_spawn(monkeypatch)
outcome = await _ask(db, parent, {"model": "gpt-9", "question": "?"})
assert outcome.event["status"] == "error"
assert "big-model" in outcome.content
assert "small-model" in outcome.content
async def test_asking_itself_is_refused_in_those_words(db, monkeypatch):
parent = _chat(db)
_spawn(monkeypatch)
outcome = await _ask(db, parent, {"model": "test-model", "question": "?"})
assert "That is you" in outcome.content
async def test_a_model_the_reader_cannot_use_is_neither_listed_nor_reachable(db, monkeypatch):
"""A roster is filtered through what this account can see, so naming a
restricted model must fail for the same reason it is absent — not by a
second, looser check."""
group = Group(name="Wheel")
db.add(group)
restricted = db.scalar(select(Model).where(Model.model_id == "big-model"))
restricted.public = False
restricted.groups = [group]
db.commit()
user = _user(db)
user.role = ROLE_USER
db.commit()
parent = _chat(db)
_spawn(monkeypatch)
assert "big-model" not in chat_service.roster_block(db, user, exclude="test-model")
outcome = await _ask(db, parent, {"model": "big-model", "question": "?"})
assert outcome.event["status"] == "error"
assert "no model called" in outcome.content
async def test_an_empty_question_is_refused_before_anything_is_created(db, monkeypatch):
parent = _chat(db)
seen = _spawn(monkeypatch)
outcome = await _ask(db, parent, {"model": "big-model", "question": " "})
assert outcome.event["status"] == "error"
assert "chat_id" not in seen, "a chat was created for a call that could not work"
# --- The budget ---------------------------------------------------------------
async def test_questions_and_helpers_share_one_allowance(db, monkeypatch):
"""Two counters would let one reply spend both. `Generation.subagents` is the
only object that knows what "this reply" means."""
parent = _chat(db)
_spawn(monkeypatch)
settings_store.update(db, {"max_per_reply": 1}, key=settings_store.SUBAGENTS)
generation = _Fake(spawned=1)
outcome = await _ask(
db, parent, {"model": "big-model", "question": "?"}, generation=generation
)
assert outcome.event["status"] == "error"
assert "already used its 1 helpers" in outcome.content
async def test_a_successful_question_spends_one_of_the_allowance(db, monkeypatch):
parent = _chat(db)
_spawn(monkeypatch)
generation = _Fake()
await _ask(db, parent, {"model": "big-model", "question": "?"}, generation=generation)
assert generation.subagents == 1
# --- The gates ----------------------------------------------------------------
def test_the_tool_needs_the_permission_and_the_instance_switch(db):
parent = _chat(db)
user = _user(db)
assert "ask_friend" in tools_service.resolve_tools(db, parent, user).by_name
settings_store.update(db, {"enabled": False}, key=settings_store.SUBAGENTS)
assert "ask_friend" not in tools_service.resolve_tools(db, parent, user).by_name
settings_store.update(db, {"enabled": True}, key=settings_store.SUBAGENTS)
# An administrator bypasses every permission, so the permission half can
# only be asserted on somebody who is not one.
user.role = ROLE_USER
settings_store.update(db, {"default_permissions": {"tools.friend": False}})
db.commit()
assert "ask_friend" not in tools_service.resolve_tools(db, parent, user).by_name
def test_the_model_switch_turns_it_off_for_that_model_alone(db):
parent = _chat(db)
asker = db.scalar(select(Model).where(Model.model_id == "test-model"))
asker.capabilities_json = {"tools": True, "tool_friend": False}
db.commit()
assert "ask_friend" not in tools_service.resolve_tools(db, parent, _user(db)).by_name
# --- The answer ---------------------------------------------------------------
async def test_the_answer_comes_back_named_and_marked_as_an_opinion(db, monkeypatch):
"""A model handing on another's answer as its own is the failure worth
wording against, so the tool result says whose it is."""
parent = _chat(db)
_spawn(monkeypatch, answer="No. The second premise is wrong.")
outcome = await _ask(db, parent, {"model": "big-model", "question": "Is this right?"})
assert outcome.event["status"] == "ok"
assert "Big answered" in outcome.content
assert "The second premise is wrong." in outcome.content
assert "opinion" in outcome.content
assert outcome.event["why"] == "Big"
async def test_the_question_says_who_is_asking_and_that_nobody_is_reading(db, monkeypatch):
parent = _chat(db)
seen = _spawn(monkeypatch)
await _ask(db, parent, {"model": "big-model", "question": "Is this right?", "context": "ctx"})
assert "test-model" in seen["turn"]
assert "Nobody is reading" in seen["turn"]
assert "Is this right?" in seen["turn"]
assert "ctx" in seen["turn"]