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:
@@ -12,8 +12,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||
from lembas.db.models import Connection, Group, Model
|
||||
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import personas as personas_service
|
||||
from lembas.services import settings_store, uploads
|
||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
||||
from lembas.web.templating import render
|
||||
@@ -53,6 +54,8 @@ TOOL_CAPABILITIES = (
|
||||
("tool_scratch", "Canvas"),
|
||||
("tool_schedule", "Scheduling"),
|
||||
("tool_subagent", "Helpers"),
|
||||
("tool_friend", "Ask another model"),
|
||||
("tool_persona", "Edit its own personality"),
|
||||
("tool_agent", "Agent execution"),
|
||||
)
|
||||
|
||||
@@ -201,6 +204,12 @@ async def model_detail(
|
||||
"tool_default": bool((model.capabilities_json or {}).get("tools")),
|
||||
"default_model": settings_store.get(db, "default_model") or "",
|
||||
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||
# Who this model is, and everything it has been before. Passed even
|
||||
# when the capability is off: an administrator has to be able to read
|
||||
# and undo what a model wrote *before* they switched it off, which is
|
||||
# exactly when they would come looking.
|
||||
"persona": personas_service.get(db, model.model_id, None),
|
||||
"persona_limit": personas_service.MAX_PERSONA_CHARS,
|
||||
"position_of": index + 1,
|
||||
"total": len(ordered),
|
||||
"previous": ordered[index - 1] if index > 0 else None,
|
||||
@@ -247,6 +256,7 @@ async def update_model(
|
||||
model_id: str,
|
||||
display_name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
notes: str = Form(""),
|
||||
system_prompt: str = Form(""),
|
||||
enabled: bool = Form(False),
|
||||
pinned: bool = Form(False),
|
||||
@@ -262,6 +272,7 @@ async def update_model(
|
||||
|
||||
model.display_name = display_name.strip()[:300]
|
||||
model.description = description.strip()[:2000]
|
||||
model.notes = notes.strip()[:2000]
|
||||
model.system_prompt = system_prompt.strip()[:8000]
|
||||
# A string, so an emptied field is distinguishable and junk can be ignored
|
||||
# rather than becoming a 422 -- the same shape `position` uses below.
|
||||
@@ -327,6 +338,69 @@ async def update_model(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/persona")
|
||||
async def update_persona(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
model_id: str,
|
||||
content: str = Form(""),
|
||||
) -> Response:
|
||||
"""Write or clear this model's own personality.
|
||||
|
||||
Its own form and its own route rather than a field on the big save, for the
|
||||
reason the effort detection has one: the text can be rewritten by the model
|
||||
itself between two page loads, and a field carried along by an unrelated save
|
||||
would put a stale copy back without anybody meaning to.
|
||||
"""
|
||||
model = _model(db, model_id)
|
||||
text = content.strip()
|
||||
existing = personas_service.get(db, model.model_id, None)
|
||||
|
||||
if not text:
|
||||
if existing is not None:
|
||||
personas_service.clear(db, existing)
|
||||
log.info("persona for %s cleared by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Personality+cleared.", status_code=303
|
||||
)
|
||||
|
||||
personas_service.write(
|
||||
db,
|
||||
model_key=model.model_id,
|
||||
owner=None,
|
||||
content=text,
|
||||
author=AUTHOR_USER,
|
||||
note="edited here",
|
||||
)
|
||||
log.info("persona for %s written by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Personality+saved.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/persona/revert")
|
||||
async def revert_persona(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
model_id: str,
|
||||
revision_id: str = Form(""),
|
||||
) -> Response:
|
||||
"""Put an earlier text back. The text being replaced is itself kept."""
|
||||
model = _model(db, model_id)
|
||||
row = personas_service.get(db, model.model_id, None)
|
||||
revision = db.get(PersonaRevision, revision_id) if revision_id else None
|
||||
# Checked against *this* persona rather than merely existing: a revision id
|
||||
# from another model's history would otherwise transplant its personality.
|
||||
if row is None or revision is None or revision.persona_id != row.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such version")
|
||||
|
||||
personas_service.revert(db, row, revision)
|
||||
log.info("persona for %s reverted by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Earlier+version+restored.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/move")
|
||||
async def move_model(
|
||||
db: Db,
|
||||
|
||||
Reference in New Issue
Block a user