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>
This commit is contained in:
2026-09-26 02:04:55 +00:00
co-authored by Claude Opus 5
parent 54fee49810
commit df52ec9d96
30 changed files with 2710 additions and 32 deletions
+450
View File
@@ -0,0 +1,450 @@
"""A model's own character, and what it makes of the person in front of it.
Two things in one table, and the discriminator is a nullable column — so the
assertions that matter most are about the boundary between them: an instance-wide
persona must not be reachable as somebody's reflection, and one account's
reflection must never be visible or deletable by another. A model-written note
about a person that the person cannot read is the thing this must not become.
The safety story for self-modification is a record and a way back rather than a
gate, which is `SkillRevision`'s argument; the revision tests are where that is
pinned.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import select
from lembas.db.models import (
AUTHOR_MODEL,
AUTHOR_USER,
ROLE_USER,
Chat,
Connection,
Model,
Persona,
User,
)
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 personality_allowed(db, registered):
settings_store.update(db, {"default_permissions": {"tools.persona": 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 in enumerate(("test-model", "other-model")):
db.add(
Model(
connection_id=connection.id,
model_id=name,
display_name=name,
position=index,
capabilities_json={"tools": True},
)
)
db.commit()
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _second_user(db) -> User:
"""A row directly, the way `test_sharing.py` makes its three accounts."""
from lembas.security.passwords import hash_password
user = User(
name="Sam", email="s@example.test", password_hash=hash_password("x"), role="user"
)
db.add(user)
db.commit()
return user
def _chat(db, model_id: str = "test-model", user: User | None = None) -> Chat:
chat = Chat(user_id=(user or _user(db)).id, title="t", model_id=model_id)
db.add(chat)
db.commit()
return chat
async def _run(db, chat: Chat, name: str, args: dict):
user = db.get(User, chat.user_id)
resolved = tools_service.resolve_tools(db, chat, user)
context = tools_service.context_for(db, user, chat, tools=resolved)
return await tools_service.run_tool(context, name, json.dumps(args))
# --- The two halves are not the same row --------------------------------------
def test_a_persona_and_a_reflection_are_separate_rows_for_one_model(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
personas_service.write(db, model_key="test-model", owner=user, content="They test things.")
assert personas_service.block(db, "test-model", None) == "I am terse."
assert personas_service.block(db, "test-model", user) == "They test things."
def test_a_missing_persona_does_not_fall_back_to_a_reflection(db):
"""They answer different questions. A fallback between them would put "what
it makes of you" where "who it is" belongs, in the first person."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="They test things.")
assert personas_service.block(db, "test-model", None) == ""
def test_each_model_keeps_its_own_read_of_the_same_person(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="Impatient.")
personas_service.write(db, model_key="other-model", owner=user, content="Thorough.")
assert personas_service.block(db, "test-model", user) == "Impatient."
assert personas_service.block(db, "other-model", user) == "Thorough."
def test_one_accounts_reflection_is_invisible_to_another(db):
"""The whole reason the reflection is keyed on the person and not only on the
model. On an instance with two accounts, inheriting somebody else's is both
wrong and a disclosure."""
first = _user(db)
second = _second_user(db)
personas_service.write(db, model_key="test-model", owner=first, content="Writes tests.")
assert personas_service.block(db, "test-model", second) == ""
assert [row.content for row in personas_service.reflections_for(db, second)] == []
assert [row.content for row in personas_service.reflections_for(db, first)] == [
"Writes tests."
]
# --- Writing, keeping, and going back -----------------------------------------
def test_every_change_keeps_what_was_there(db):
personas_service.write(db, model_key="test-model", owner=None, content="First.")
personas_service.write(
db, model_key="test-model", owner=None, content="Second.", note="thought again"
)
row = personas_service.get(db, "test-model", None)
assert row.content == "Second."
assert [r.content for r in row.revisions] == ["First."]
assert row.revisions[0].note == "thought again"
def test_writing_the_same_text_again_keeps_no_revision(db):
"""Otherwise a model that rewrites itself identically every turn fills the
history and pushes the real "before" out of it."""
personas_service.write(db, model_key="test-model", owner=None, content="Same.")
personas_service.write(db, model_key="test-model", owner=None, content="Same.")
assert personas_service.get(db, "test-model", None).revisions == []
def test_reverting_keeps_the_text_it_replaced(db):
"""An undo that cannot be undone is a second way to lose the same work."""
personas_service.write(db, model_key="test-model", owner=None, content="First.")
personas_service.write(db, model_key="test-model", owner=None, content="Second.")
row = personas_service.get(db, "test-model", None)
personas_service.revert(db, row, row.revisions[0])
# The session is built with `expire_on_commit=False`, so a committed change
# is not visible through an object already loaded here until it is expired.
db.expire_all()
row = personas_service.get(db, "test-model", None)
assert row.content == "First."
assert "Second." in [r.content for r in row.revisions]
assert row.author == AUTHOR_USER
def test_the_history_is_bounded(db):
for index in range(personas_service.MAX_REVISIONS + 8):
personas_service.write(db, model_key="test-model", owner=None, content=f"v{index}")
db.expire_all()
row = personas_service.get(db, "test-model", None)
assert len(row.revisions) <= personas_service.MAX_REVISIONS
def test_an_over_long_text_is_trimmed_rather_than_refused(db):
"""`memories.py`'s rule: a write the model could not have known was too long
should not cost it the turn."""
row = personas_service.write(
db, model_key="test-model", owner=None, content="x" * 5000
)
assert len(row.content) == personas_service.MAX_PERSONA_CHARS
def test_a_reflection_is_held_to_the_shorter_limit(db):
row = personas_service.write(
db, model_key="test-model", owner=_user(db), content="y" * 5000
)
assert len(row.content) == personas_service.MAX_VIEW_CHARS
def test_the_row_survives_the_model_row_being_replaced(db):
"""Keyed on the model's own id and not on the `Model` primary key, because
"Test & refresh" deletes a model the endpoint has stopped listing and gives
it a new primary key when it returns. A personality must not be collateral."""
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
row = db.scalar(select(Model).where(Model.model_id == "test-model"))
connection_id = row.connection_id
db.delete(row)
db.commit()
db.add(Model(connection_id=connection_id, model_id="test-model"))
db.commit()
assert personas_service.block(db, "test-model", None) == "I am terse."
# --- What the tools write -----------------------------------------------------
async def test_persona_write_can_only_rewrite_the_answering_model(db):
"""There is deliberately no argument naming a model: the key is the model
this reply is being written by, so a call cannot reach another one's."""
chat = _chat(db, "test-model")
outcome = await _run(db, chat, "persona_write", {"content": "I am blunt.", "why": "learnt"})
assert outcome.event["status"] == "ok"
assert personas_service.block(db, "test-model", None) == "I am blunt."
assert personas_service.block(db, "other-model", None) == ""
async def test_persona_write_is_recorded_as_the_models_own_work(db):
chat = _chat(db)
await _run(db, chat, "persona_write", {"content": "Mine."})
assert personas_service.get(db, "test-model", None).author == AUTHOR_MODEL
async def test_an_empty_persona_write_is_refused_rather_than_erasing(db):
"""It replaces rather than appends, so an empty call would be a wipe — and a
model that has been talked into one turn of nonsense should not be able to
end its own character in it."""
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
chat = _chat(db)
outcome = await _run(db, chat, "persona_write", {"content": " "})
assert outcome.event["status"] == "error"
assert personas_service.block(db, "test-model", None) == "I am terse."
async def test_impression_write_is_keyed_on_the_person_as_well_as_the_model(db):
chat = _chat(db)
await _run(db, chat, "impression_write", {"content": "They want the short answer."})
user = _user(db)
assert personas_service.block(db, "test-model", user) == "They want the short answer."
# Not the model's own persona, which is the row next to it.
assert personas_service.block(db, "test-model", None) == ""
async def test_an_empty_impression_write_clears_it(db):
"""The opposite of the persona, on purpose: "I have no standing view of this
person" is a legitimate state, and "I have no character" is not."""
chat = _chat(db)
await _run(db, chat, "impression_write", {"content": "Something."})
await _run(db, chat, "impression_write", {"content": ""})
assert personas_service.block(db, "test-model", _user(db)) == ""
async def test_the_tool_is_offered_only_with_the_capability_and_the_permission(db):
chat = _chat(db)
user = _user(db)
assert "persona_write" in tools_service.resolve_tools(db, chat, user).by_name
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
model.capabilities_json = {"tools": True, "tool_persona": False}
db.commit()
assert "persona_write" not in tools_service.resolve_tools(db, chat, user).by_name
model.capabilities_json = {"tools": True}
user.role = ROLE_USER
settings_store.update(db, {"default_permissions": {"tools.persona": False}})
db.commit()
assert "persona_write" not in tools_service.resolve_tools(db, chat, user).by_name
# --- What reaches the prompt --------------------------------------------------
def _values(db, chat: Chat, *, families: list[str]) -> dict[str, str]:
offered = [
tool.schema
for tool in tools_service.registry(db).values()
if tools_service.gate_of(tool.family) in families
]
return harness_service.context_variables(db, db.get(User, chat.user_id), offered, chat)
def test_both_variables_are_gated_on_the_family(db):
"""A model that may not keep either has no business being handed them, and
the query should not happen at all on an instance that does not use this."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
personas_service.write(db, model_key="test-model", owner=user, content="Impatient.")
chat = _chat(db)
without = _values(db, chat, families=["memory"])
assert without["persona"] == ""
assert without["person_view"] == ""
with_it = _values(db, chat, families=["persona"])
assert with_it["persona"] == "I am terse."
assert with_it["person_view"] == "Impatient."
def test_a_switched_off_persona_reads_as_absent(db):
row = personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
row.enabled = False
db.commit()
assert personas_service.block(db, "test-model", None) == ""
def test_the_fragments_vanish_when_there_is_nothing_to_say(db):
chat = _chat(db)
preamble = harness_service.compose(
db,
_user(db),
[
tool.schema
for tool in tools_service.registry(db).values()
if tools_service.gate_of(tool.family) == "persona"
],
chat,
)
assert "Who you are" not in preamble
assert "What you have made of them" not in preamble
def test_the_fragments_carry_the_texts_when_there_are_some(db):
user = _user(db)
personas_service.write(db, model_key="test-model", owner=None, content="I argue back.")
personas_service.write(db, model_key="test-model", owner=user, content="Likes brevity.")
chat = _chat(db)
preamble = harness_service.compose(
db,
user,
[
tool.schema
for tool in tools_service.registry(db).values()
if tools_service.gate_of(tool.family) == "persona"
],
chat,
)
assert "I argue back." in preamble
assert "Likes brevity." in preamble
# The persona comes before the impression: a fact the person stated should be
# read before an opinion the model formed about them.
assert preamble.index("I argue back.") < preamble.index("Likes brevity.")
# --- The screens --------------------------------------------------------------
def test_the_person_can_read_and_delete_what_a_model_makes_of_them(client, db, registered):
"""The whole reason writing one is acceptable. A model-written note about
somebody that they cannot see is not something this should hold."""
user = _user(db)
personas_service.write(db, model_key="test-model", owner=user, content="Wants brevity.")
page = client.get("/settings")
assert "Wants brevity." in page.text
assert "What models make of you" in page.text
row = personas_service.reflections_for(db, user)[0]
client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
db.expire_all()
assert personas_service.reflections_for(db, user) == []
def test_nobody_can_delete_somebody_elses_reflection(client, db):
second = _second_user(db)
row = personas_service.write(
db, model_key="test-model", owner=second, content="Theirs."
)
response = client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
assert response.status_code == 404
db.expire_all()
assert personas_service.get(db, "test-model", second) is not None
def test_a_models_own_persona_cannot_be_deleted_from_the_settings_page(client, db):
"""`owner_id IS NULL` is the instance's, not this person's. An id from that
half arriving at the reader's route must be refused on ownership rather than
found by existence."""
row = personas_service.write(db, model_key="test-model", owner=None, content="Instance.")
response = client.post(f"/api/library/reflections/{row.id}/delete", follow_redirects=False)
assert response.status_code == 404
db.expire_all()
assert personas_service.block(db, "test-model", None) == "Instance."
def test_an_administrator_can_read_write_and_revert_a_persona(client, db):
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
client.post(
f"/admin/models/{model.id}/persona",
data={"content": "I am terse."},
follow_redirects=False,
)
client.post(
f"/admin/models/{model.id}/persona",
data={"content": "I am not terse at all."},
follow_redirects=False,
)
db.expire_all()
row = personas_service.get(db, "test-model", None)
assert row.content == "I am not terse at all."
assert row.author == AUTHOR_USER
page = client.get(f"/admin/models/{model.id}/edit")
assert "I am not terse at all." in page.text
assert "Earlier personalities" in page.text
client.post(
f"/admin/models/{model.id}/persona/revert",
data={"revision_id": row.revisions[0].id},
follow_redirects=False,
)
db.expire_all()
assert personas_service.get(db, "test-model", None).content == "I am terse."
def test_a_revision_of_another_model_cannot_be_restored_onto_this_one(client, db):
"""Checked against this persona rather than merely existing, or an id from
another model's history transplants its personality."""
personas_service.write(db, model_key="other-model", owner=None, content="Theirs first.")
personas_service.write(db, model_key="other-model", owner=None, content="Theirs second.")
personas_service.write(db, model_key="test-model", owner=None, content="Mine.")
foreign = personas_service.get(db, "other-model", None).revisions[0]
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
response = client.post(
f"/admin/models/{model.id}/persona/revert",
data={"revision_id": foreign.id},
follow_redirects=False,
)
assert response.status_code == 404
db.expire_all()
assert personas_service.block(db, "test-model", None) == "Mine."
def test_clearing_the_persona_from_the_admin_page_removes_it(client, db):
model = db.scalar(select(Model).where(Model.model_id == "test-model"))
personas_service.write(db, model_key="test-model", owner=None, content="I am terse.")
client.post(f"/admin/models/{model.id}/persona", data={"content": ""}, follow_redirects=False)
db.expire_all()
assert personas_service.get(db, "test-model", None) is None
assert db.scalars(select(Persona)).all() == []