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
+38
View File
@@ -652,6 +652,44 @@ def test_editing_rewinds_and_discards_later_messages(
assert remaining[1].complete is False
def test_a_rewind_takes_a_message_written_in_the_same_microsecond(
client: TestClient, db, registered, make_chat
):
"""`_messages_after` compared timestamps with a bare `>`, so a row sharing the
edited turn's microsecond was never "after" it and survived the rewind -- an
orphan below the message being edited, in the transcript and in every later
request. `_send` writes a user turn and its assistant placeholder back to
back, so that pair is precisely what ties.
Not fixed with `thread_tail`'s `(created_at, id)` tiebreak: `Message.id` is a
random UUID, so that would settle a tie by coin toss. A tie is read as
"later" instead, which is the safe direction for an operation whose purpose
is to discard what follows.
"""
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "first")
_exchange(client, db, chat_id, "second")
rows = db.scalars(select(Message).order_by(Message.created_at)).all()
edited = rows[0]
# Every later row now shares the edited turn's timestamp exactly.
for row in rows[1:]:
row.created_at = edited.created_at
db.commit()
client.post(
f"/api/chats/{chat_id}/messages/{edited.id}/edit", data={"content": "first, revised"}
)
db.expire_all()
remaining = db.scalars(select(Message).order_by(Message.created_at, Message.id)).all()
assert [m.role for m in remaining] == ["user", "assistant"], (
"a message sharing the edited turn's microsecond survived the rewind"
)
assert remaining[0].content == "first, revised"
def test_the_edit_form_says_how_much_will_be_lost(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()