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:
+297
-15
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user