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
+297 -15
View File
@@ -74,7 +74,7 @@ import logging
import time
from typing import TYPE_CHECKING, Any
from lembas.db.models import KIND_AGENT, Chat, User
from lembas.db.models import KIND_AGENT, KIND_CHAT, Chat, Model, User
from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import settings_store
@@ -144,6 +144,13 @@ MODE_WRITING = agent_policy.MODE_EDIT
# on the model's own authority would be that rule going through a side door.
WRITING_ALLOWED_FROM = (agent_policy.MODE_EDIT, agent_policy.MODE_AUTO)
# What `scope_json["role"]` says on the chat of a model that has been asked a
# question rather than given a job. A key on the scope and not a column: it is
# read in one place, to pick which of two sentences the child's own system
# prompt carries, and `Chat.unattended` already carries every *behavioural*
# consequence of being somebody's child.
ROLE_FRIEND = "friend"
# Helpers running right now, across the instance, by child chat id. In-process
# and cleared by a restart, which is correct: a restart abandons replies in
# flight, so there is nothing for a durable count to describe.
@@ -173,7 +180,7 @@ def _child_scope(parent: Chat, *, write: bool) -> dict[str, Any]:
switched off must not be able to reach it by delegating.
"""
inherited = dict((parent.scope_json or {}).get("families") or {})
inherited.update({"ask": False, "subagent": False})
inherited.update({"ask": False, "subagent": False, "friend": False})
return {
"families": inherited,
"skills": dict((parent.scope_json or {}).get("skills") or {}),
@@ -182,34 +189,67 @@ def _child_scope(parent: Chat, *, write: bool) -> dict[str, Any]:
}
def _create_child(db, parent: Chat, *, title: str, write: bool) -> Chat:
"""The hidden chat one helper runs in.
def _create_child(
db,
parent: Chat,
*,
title: str,
write: bool,
friend: Model | None = None,
) -> Chat:
"""The hidden chat one helper or one friend runs in.
It inherits the parent's model, connection, directory and reasoning effort,
and nothing else. The effort has to be **seeded onto the row** rather than
left to be inherited at request time: `chat_service.resolved_effort` reads
the chat's own `params_json` and deliberately consults no fallback, so a
helper of a high-effort reply would otherwise quietly run at none.
A helper inherits the parent's model, connection, directory and reasoning
effort, and nothing else. The effort has to be **seeded onto the row** rather
than left to be inherited at request time: `chat_service.resolved_effort`
reads the chat's own `params_json` and deliberately consults no fallback, so
a helper of a high-effort reply would otherwise quietly run at none.
`friend` makes it somebody else's chat instead, and changes three things.
**The model and the connection are the friend's**, as a pair rather than an
id: `Model` is unique on `(connection_id, model_id)`, so the same name can
live behind two endpoints and an id alone does not say which.
**The effort is the friend's own default, never the parent's.** Inheriting it
across models is the 1.3.0 bug with a new door: the vocabularies differ, and
`high` handed to a Bonsai raises inside its chat template rather than being
ignored. A level the friend does not take is simply not sent.
**It is not put to work on a machine.** A friend is asked what it thinks, so
it gets no SSH profile, no project directory and no agent mode even when the
asking chat has all three -- and `scope_json["role"]` marks it so its own
system prompt can say it is answering a peer rather than running an errand.
"""
from lembas.services import chat as chat_service
peer = friend is not None
child = Chat(
user_id=parent.user_id,
kind=parent.kind,
title=title[:200] or "Helper",
model_id=parent.model_id,
connection_id=parent.connection_id,
# An ordinary chat for a friend even when the asking one is an agent
# chat: KIND_AGENT brings a harness about the machine it is working on,
# and a peer being asked a question is not working on one.
kind=KIND_CHAT if peer else parent.kind,
title=title[:200] or ("Question" if peer else "Helper"),
model_id=friend.model_id if peer else parent.model_id,
connection_id=friend.connection_id if peer else parent.connection_id,
# Never in a listing, and swept a day later even if it is kept.
temporary=True,
parent_chat_id=parent.id,
unattended=True,
scope_json=_child_scope(parent, write=write),
)
if parent.kind == KIND_AGENT:
if not peer and parent.kind == KIND_AGENT:
child.ssh_profile_id = parent.ssh_profile_id
child.project_dir = parent.project_dir
child.agent_mode = MODE_WRITING if write else MODE_READING
effort = chat_service.resolved_effort(parent)
if peer:
child.scope_json = {**(child.scope_json or {}), "role": ROLE_FRIEND}
effort = str((friend.params_json or {}).get("reasoning_effort") or "")
if effort not in chat_service.efforts_for(friend):
effort = ""
else:
effort = chat_service.resolved_effort(parent)
if effort:
child.params_json = {"reasoning_effort": effort}
# The bases the parent is scoped to, or the helper searches everything its
@@ -511,6 +551,246 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
)
# --- Asking a friend -----------------------------------------------------------
def _friend_error(message: str, *, question: str = "") -> ToolOutcome:
return _outcome(
message,
{"name": "ask_friend", "status": "error", "query": question[:120], "error": message},
)
def _resolve_friend(db, owner: User, wanted: str, *, asking: str) -> tuple[Model | None, str]:
"""The model a call named, or a refusal that says what it could have named.
The name arrives in a tool call, which is to say it was written by a model
that may have been reading a web page, so it is matched against what **this
account** can reach rather than against the table. `roster_models` is the
same list the prompt was built from, so a refusal here cannot disagree with
what the model was told.
Matched on `model_id` first and on the label second, because the roster
prints both and a model will sometimes type back the pretty one.
"""
from lembas.services import chat as chat_service
question_for = wanted.strip()
candidates = chat_service.roster_models(db, owner, exclude=asking)
if not candidates:
return None, (
"There is no other model here to ask. Answer from what you know."
)
if not question_for:
return None, (
"Name the model to ask, exactly as it is written in brackets in the "
"list you were given:\n"
+ chat_service.roster_block(db, owner, exclude=asking)
)
lowered = question_for.lower()
for model in candidates:
if model.model_id.lower() == lowered:
return model, ""
for model in candidates:
if model.label.lower() == lowered:
return model, ""
# `candidates` already excludes the asker, so its own name would otherwise
# fall through to "there is no model called that", which is both untrue and
# unhelpful.
if lowered == asking.lower():
return None, "That is you. Ask somebody else, or answer it yourself."
return None, (
f"There is no model called {question_for!r} that you can reach. "
"These are the ones you can:\n"
+ chat_service.roster_block(db, owner, exclude=asking)
)
def _question_turn(question: str, context: str, asker: str) -> str:
"""The one turn a friend is given.
Deliberately not `_task_turn`. A helper is told it is doing a job nobody is
reading; a friend is told another model wants its opinion, which is a
different thing to be and produces a different answer -- a helper reports,
a peer disagrees. The framing lives in words for the reason `wake.py` sets
out: the role has to stay `user`, because `build_messages` requires a user
turn there.
"""
lines = [
f"Another model ({asker}) is asking you a question, on behalf of the "
"person it is talking to. Nobody is reading this conversation directly: "
"your reply is handed back whole as the answer.",
"",
"Answer it as yourself. If you think the question rests on something "
"wrong, say so — that is usually why you were asked. If you do not know, "
"say that rather than guessing; a confident wrong answer is worse than "
"no answer, because it will be relied on.",
"",
"## The question",
question.strip(),
]
if context.strip():
lines += ["", "## What you have been told about it", context.strip()]
return "\n".join(lines)
async def _run_ask_friend(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
from lembas.services import generation as generation_service
from lembas.services import wake as wake_service
question = str(args.get("question") or "").strip()
wanted = str(args.get("model") or "")
briefing = str(args.get("context") or "")
if not question:
return _friend_error(
"Ask something. The model you are asking sees none of this "
"conversation, so the question has to stand on its own."
)
parent_id = context.chat_id
if not parent_id:
return _friend_error("There is no conversation to ask from.", question=question)
with session_scope() as db:
parent = db.get(Chat, parent_id)
if parent is None:
return _friend_error("That conversation no longer exists.", question=question)
# The same belt-and-braces as `_run_subagent`: the family is withdrawn
# from an unattended chat, and a call arriving by any other route is
# refused here rather than opening a third level.
if parent.parent_chat_id or parent.unattended:
return _friend_error(
"You are answering a question yourself. Answer it, or say you "
"cannot — you may not pass it on.",
question=question,
)
owner = db.get(User, parent.user_id)
if owner is None: # pragma: no cover - a chat outliving its owner
return _friend_error("That account no longer exists.", question=question)
friend, refusal = _resolve_friend(db, owner, wanted, asking=parent.model_id)
if friend is None:
return _friend_error(refusal, question=question)
# Bounded by the same allowance as a helper, and counted on the same
# counter: both spend one reply to get another, and two separate budgets
# would let one reply spend both.
values = settings_store.subagents(db)
allowance = permissions.limit(db, owner, "helpers_per_reply")
if allowance:
values = {**values, "max_per_reply": min(int(values["max_per_reply"]), allowance)}
refusal = _budget(generation_service.running_for(parent_id), values)
if refusal:
return _friend_error(refusal, question=question)
asker = parent.model_id
label = friend.label
child = _create_child(
db, parent, title=f"Asking {label}"[:200], write=False, friend=friend
)
child_id = child.id
_LIVE.add(child_id)
started = time.monotonic()
try:
message_id = await wake_service.wake_chat(
child_id, _question_turn(question, briefing, asker)
)
if not message_id:
_cleanup(child_id, keep=False)
return _friend_error(f"{label} could not be reached.", question=question)
finished = await _await_reply(
child_id, message_id, started + float(values["wall_seconds"])
)
if not finished:
await _stop(child_id, message_id)
with session_scope() as db:
answer, problem = _harvest(db, child_id, message_id)
finally:
_LIVE.discard(child_id)
elapsed = time.monotonic() - started
_cleanup(child_id, keep=bool(values.get("keep_transcript")))
if not answer:
return _friend_error(problem or f"{label} did not answer.", question=question)
note = "" if finished else "\n\n(It ran out of time; this is as far as it got.)"
return _outcome(
f"{label} answered:\n\n{answer}{note}\n\n"
"That is another model's opinion, not a fact and not the reader's. Say "
"whose it is when you use it, and say so too if you disagree with it.",
{
"name": "ask_friend",
"status": "ok" if finished else "error",
"query": f"{label}: {question}"[:160],
"detail": f"{elapsed:.0f}s" + ("" if finished else ", stopped at the time limit"),
"text": answer,
"why": label,
},
)
def friend_tool_defs() -> list[ToolDef]:
"""The ask-a-friend tool. Its own family; see `services/tools.py`."""
from lembas.services.tools import FAMILY_FRIEND, RISK_READ, ToolDef
return [
ToolDef(
name="ask_friend",
family=FAMILY_FRIEND,
description=(
"Put one question to another model here and get its answer. Use "
"it for a second opinion, for something outside what you are good "
"at, or to have your own reasoning checked by something that "
"thinks differently — the list of models you can ask, and what "
"each is for, is in your instructions. It answers as itself and "
"sees none of this conversation, so the question must stand on "
"its own. Its answer is an opinion: say whose it is, and say so "
"if you disagree. Do not ask for something you can work out "
"yourself, and do not ask the same thing of several models hoping "
"one agrees with you."
),
parameters={
"type": "object",
"properties": {
"model": {
"type": "string",
"description": (
"Which model to ask, written exactly as the id in "
"brackets in the list you were given."
),
},
"question": {
"type": "string",
"description": (
"The question, written out in full. It is read on its "
"own, with none of this conversation around it."
),
},
"context": {
"type": "string",
"description": (
"Anything it needs to answer — the code in question, "
"the constraint, what has already been tried. Not a "
"summary of the conversation."
),
},
},
"required": ["model", "question"],
},
run=_run_ask_friend,
# A read, for the reason `subagent_run` is one: what the answer costs
# is another reply, and nothing in this instance is changed by it.
risk=RISK_READ,
),
]
def tool_defs() -> list[ToolDef]:
"""The one tool, built here so `services/tools.py` need not know the wording."""
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
@@ -584,10 +864,12 @@ def tool_defs() -> list[ToolDef]:
__all__ = [
"MODE_READING",
"ROLE_FRIEND",
"MODE_WRITING",
"SAFE_COMMANDS",
"WRITING_ALLOWED_FROM",
"clear",
"friend_tool_defs",
"live_count",
"tool_defs",
]