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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user