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
+48
View File
@@ -595,6 +595,54 @@ def available_models(db: DBSession, user=None) -> list[Model]:
return sorted(reachable, key=lambda m: (m.position, m.model_id))
# How much of the roster one request will carry. Every model an instance has
# multiplies this, and the harness has a budget the whole of it shares
# (`MAX_HARNESS_CHARS`, and `tests/test_harness.py` fails if the shipped
# defaults grow past the margin) -- so a hundred-model instance has to be
# bounded here rather than found out about later.
MAX_ROSTER_MODELS = 24
MAX_ROSTER_CHARS = 2400
# Per model, so one very long note cannot crowd out the rest of the list.
MAX_ROSTER_ENTRY = 300
def roster_models(db: DBSession, user=None, *, exclude: str = "") -> list[Model]:
"""The other models this person could reach, in the administrator's order.
`exclude` is a `model_id` and is normally the chat's own: a model does not
need telling that it exists. Resolved through `available_models`, so a model
restricted to a group nobody here belongs to is not named -- listing one
would be both a leak and a dead end, since asking it anything is refused by
the same check.
"""
return [model for model in available_models(db, user) if model.model_id != exclude]
def roster_block(db: DBSession, user=None, *, exclude: str = "") -> str:
"""The roster as the models read it: one line each, name, id, what it is for.
The id is in brackets because it is what has to be typed back into
`ask_friend`, and the label alone is not unique enough to be an argument.
`notes` follows the description rather than replacing it -- the description
says what it is for and the notes say what it is, and a model choosing whom
to ask wants both.
"""
lines: list[str] = []
budget = MAX_ROSTER_CHARS
for model in roster_models(db, user, exclude=exclude)[:MAX_ROSTER_MODELS]:
parts = ((model.description or "").strip(), (model.notes or "").strip())
about = " ".join(part for part in parts if part)
about = " ".join(about.split())[:MAX_ROSTER_ENTRY]
line = f"- {model.label} ({model.model_id})"
if about:
line = f"{line} — {about}"
if len(line) > budget:
break
budget -= len(line)
lines.append(line)
return "\n".join(lines)
def fallback_title(text: str) -> str:
"""Derive a chat title from the opening message, without calling a model."""
cleaned = " ".join(text.split())