2 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 216e3a20d1 Helpers on another model, chosen by logic rather than by the model
A helper still runs on the chat's own model by default. Other models are
designated per main model -- offered to it, or by hand only -- by the
instance or, with helpers.designate, by a person. subagent_run gains a
model argument whose enum is exactly the candidates that passed two
checks: capacity (a model that serves one request at a time cannot be
its own helper; a connection that holds one model at a time cannot serve
a helper on another of its models) and the model rules. A helper on
another model takes that model's own effort and data group. The composer
gains a Helpers picker; the model page, the connection form and Settings
gain their switches and lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:37:53 +00:00
HomerandClaude Opus 5.5 c27fe47d4d Model rules: who a chat's model may bring into a conversation
Rules read from the main model decide who it is offered as a crowd member,
a friend and on its roster; any-to-any with denies by default, or
none-to-none with allows. The crowd picker names what it holds back and
why, and a model held back only by a person's own rule -- or by anything,
with the new rules.override -- can still be added by hand. Another data
group is now a deny that an explicit rule opens. Admin -> Model rules and
a card in Settings, each with a matrix drawn by the enforcing function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:20:40 +00:00
38 changed files with 2708 additions and 76 deletions
+70
View File
@@ -16,6 +16,76 @@ for 1.0.0 have something to be assembled from.
## Unreleased
## 1.12.0
Helpers on another model. This is the last of the three releases.
- **A helper can run on a different model.** A helper is still the chat's own
model by default. On each model's page, **Helpers** lists other models this
one may send helpers to. Each is either *offered to the model*, so it can
choose that model itself, or *by hand only*, so it is used once somebody adds
it to a chat. When there is more than one choice, `subagent_run` gains a
`model` argument limited to exactly those models, and the model is told what
each is for. A helper on another model runs with that model's own default
reasoning effort, never the parent's, which it might refuse. It reads its own
data group's memories and notes.
- **The composer's Helpers button** lists the models designated for the chat's
model. Tick one to add it to this chat, on the new-chat screen as well.
- **Capacity is decided by logic, not by the model.** Two new switches, both off
by default, so nothing changes until they are set:
- **Serves one request at a time**, on a model. It cannot be its own helper,
because the helper would wait behind the reply that is waiting for it.
- **Holds one model at a time**, on a connection (llama-swap in front of one
GPU). Its models may still be their own helpers, but never send one to
another model on the same connection, because loading it would unload the
model whose reply is waiting.
A model with no helper it can use is no longer offered `subagent_run` at all,
instead of being offered a tool that refuses every call. Asking a friend and
the crowd are not affected: both are sequential, and the wait for a model to
load is accepted there.
- **The model rules apply to helpers too.** A designation the model chooses
itself must be allowed by the rules. One added to a chat by hand needs what a
crowd member added by hand needs. A model in another data group needs a rule
that allows it.
- **Your own helper models.** With the new *Choose their own helper models*
permission (off by default), Settings → Models lets a person designate helpers
for their models. These are added to the instance's, and a row of theirs for
the same pair replaces the instance's.
## 1.11.0
Rules for which model may talk to which. This is the second of three releases;
the third adds helpers on a different model.
- **Model rules.** The new **Admin → Model rules** page sets who a chat's model
may bring into the conversation: as a crowd member, as a friend it asks, and
on its list of other models. A rule names two models, or *any model* on either
side, and allows or forbids. The starting point is either *any model may talk
to any other* (the default, so nothing changes) or *no model may talk to
another*. The most specific rule wins. The page ends with a table of every
model against every other, drawn by the same rules that are enforced.
- **A rule is read from the chat's own model.** If gpt-oss may not talk to
qwen38, then in a gpt-oss chat qwen38 is not on its list of other models, it
cannot be asked as a friend, and the crowd picker does not offer it. Members
of a crowd are not checked against each other.
- **The crowd picker names what it holds back, and why.** Models the rules do
not offer are listed under *Not offered to this model* with the reason. You
can still tick one by hand when the rule holding it back is your own, or when
you may override the instance's rules. A member added by hand keeps speaking
when the round runs; one the instance later forbids is skipped and shown
crossed out, as a member that cannot be reached always was.
- **Your own rules.** Settings → Models has a card for your own starting point
and rules, and the same table for you. Anybody can narrow the instance's
rules for themselves. With the new *Override the model rules for themselves*
permission (off by default), your rules and starting point win over the
instance's, and you can add any model to a crowd by hand.
- **Another data group is a rule, not a wall.** In 1.10.0 a model from another
data group could never join a conversation. Now it is not offered unless a
rule explicitly allows it: the instance's, or your own with the override.
When it does join, it reads its own group's memories and notes, never the
chat's.
## 1.10.0
Data groups: a provider's models read only the data of the group their
+6
View File
@@ -324,6 +324,12 @@ def build_client():
owner = db.query(User).first()
db.add(Note(owner_id=owner.id, title="A note at home", body="x", data_group_id="default"))
db.add(Note(owner_id=owner.id, title="A hosted note", body="x", data_group_id="hosted"))
# A rule and a designation, so the rules page, the matrix and the
# model page's Helpers card render with something in them.
from lembas.db.models import HelperDesignation, TalkRule
db.add(TalkRule(from_model="gemma4-moe", to_model="qwen3-coder", effect="deny"))
db.add(HelperDesignation(main_model="gemma4-moe", helper_model="qwen3-coder"))
local = db.query(Connection).filter_by(name="local").first()
db.add(
Chat(
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.10.0"
__version__ = "1.12.0"
+2
View File
@@ -177,8 +177,10 @@ async def update_connection(
unload_method: str = Form("POST"),
extra_headers: str = Form(""),
data_group_id: str = Form(""),
one_model_at_a_time: bool = Form(False),
) -> Response:
connection = _connection(db, connection_id)
connection.one_model_at_a_time = one_model_at_a_time
# Which of the instance's data groups this provider reads. Empty is "not
# submitted" -- an older page -- and leaves it alone; a personal group is
# somebody else's arrangement and cannot be chosen here.
+42
View File
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db, RequiredUser
from lembas.db.models import AUTHOR_USER, Connection, Group, Model, PersonaRevision
from lembas.services import chat as chat_service
from lembas.services import helpers as helpers_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
@@ -209,6 +210,16 @@ async def model_detail(
# 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),
# Helpers designated for this model by the instance, and every other
# model id that could be one.
"designations": [
d for d in helpers_service.own_designations(db, None)
if d.main_model == model.model_id
],
"helper_choices": sorted(
{m.model_id for m in chat_service.available_models(db, user)}
- {model.model_id}
),
"persona_limit": personas_service.MAX_PERSONA_CHARS,
"position_of": index + 1,
"total": len(ordered),
@@ -261,6 +272,7 @@ async def update_model(
enabled: bool = Form(False),
pinned: bool = Form(False),
public: bool = Form(False),
single_session: bool = Form(False),
position: str = Form(""),
context_length: str = Form(""),
default_effort: str = Form(""),
@@ -284,6 +296,7 @@ async def update_model(
model.enabled = enabled
model.pinned = pinned
model.public = public
model.single_session = single_session
# Merged rather than rebuilt, unlike the capabilities below: params_json
# holds whatever sampling defaults an administrator has set and this form
@@ -338,6 +351,35 @@ async def update_model(
)
@router.post("/admin/models/{model_id}/helpers")
async def add_helper(
db: Db,
user: AdminUser,
model_id: str,
helper_model: str = Form(""),
offer: bool = Form(False),
) -> Response:
"""Designate a model this one may send helpers to, for everybody.
Its own form, for the persona's reason: a list edited row by row, not a field
carried by the big save.
"""
model = _model(db, model_id)
helpers_service.set_designation(db, None, model.model_id, helper_model, offer=offer)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/helpers/{designation_id}/delete")
async def remove_helper(db: Db, user: AdminUser, model_id: str, designation_id: str) -> Response:
model = _model(db, model_id)
helpers_service.delete_designation(db, None, designation_id)
return RedirectResponse(
f"/admin/models/{model.id}/edit?saved=Helpers+updated.", status_code=303
)
@router.post("/admin/models/{model_id}/persona")
async def update_persona(
db: Db,
+85
View File
@@ -0,0 +1,85 @@
"""Model rules: which model may bring which into a conversation.
The instance's layer. A person's own rules and mode are on their settings page
(`api/preferences.py`), and `services/talk.py` is where the two are combined.
The page ends in a matrix -- every main model against every other -- drawn by
`talk.matrix`, which calls the same `decide` that enforces the rules. A preview
computed any other way would be a second copy of the logic, and a preview that
disagrees with enforcement is worse than none: it is believed.
"""
from __future__ import annotations
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.api.pages import describe_verdict
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import chat as chat_service
from lembas.services import settings_store, talk
from lembas.web.templating import render
router = APIRouter(prefix="/admin/rules", tags=["admin-rules"])
def model_ids(db, user) -> list[str]:
"""Every model id this person can reach, once each, in the admin's order."""
seen: list[str] = []
for model in chat_service.available_models(db, user):
if model.model_id not in seen:
seen.append(model.model_id)
return seen
@router.get("")
async def rules_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
models = chat_service.available_models(db, user)
return render(
request,
"admin/rules.html",
{
"mode": settings_store.rules(db)["mode"],
"rules": talk.rules_of(db, None),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"matrix_models": models,
# The instance's own view: no person's layer and no override, which
# is what somebody without `rules.override` gets unless they narrow.
"matrix": talk.matrix(db, None, models),
"describe_verdict": describe_verdict,
"saved": saved,
},
)
@router.post("/mode")
async def save_mode(db: Db, user: AdminUser, mode: str = Form(talk.MODE_OPEN)) -> Response:
settings_store.update(
db,
{"mode": talk.MODE_CLOSED if mode == talk.MODE_CLOSED else talk.MODE_OPEN},
key=settings_store.RULES,
)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("")
async def add_rule(
db: Db,
user: AdminUser,
from_model: str = Form(ANY_MODEL),
to_model: str = Form(ANY_MODEL),
effect: str = Form(EFFECT_DENY),
both: bool = Form(False),
) -> Response:
talk.set_rule(db, None, from_model, to_model, effect, both=both)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/{rule_id}/delete")
async def delete_rule(db: Db, user: AdminUser, rule_id: str) -> Response:
talk.delete_rule(db, None, rule_id)
return RedirectResponse("/admin/rules?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+12 -5
View File
@@ -37,6 +37,7 @@ from lembas.services import compaction as compaction_service
from lembas.services import data_groups, interaction, settings_store, sse
from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services import helpers as helpers_service
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import reports as reports_service
@@ -309,6 +310,7 @@ async def start_chat(
# -- the same mechanism the scope switches above use, and the reason the control
# lives inside the composer's form rather than in the topbar.
crowd_model_ids: list[str] = Form(default=[]),
helper_model_ids: list[str] = Form(default=[]),
) -> Response:
"""Create a chat from its first message.
@@ -343,6 +345,7 @@ async def start_chat(
)
_apply_crowd(db, chat, user, crowd_model_ids)
helpers_service.apply_chat_helpers(db, chat, user, helper_model_ids)
_adopt_draft(db, user, draft_id, chat)
@@ -1546,15 +1549,15 @@ def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> No
and a model offered by two connections is two rows with different capabilities.
"""
from lembas.db.models import CrowdMember
from lembas.services import talk
settings = settings_store.crowd(db)
# Only models in the chat's own data group: a member is sent the whole
# conversation, so one from another group would carry it to that provider.
# What this person may add by hand, by the talk rules from the chat's main
# model. A member is sent the whole conversation, so one in another data
# group comes in only when a rule explicitly lets it.
reachable = {
model.model_id: model
for model in chat_service.available_models(
db, user, data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
wanted: list[str] = []
for value in values:
@@ -2200,6 +2203,10 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
# every box clears the crowd.
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
if "helper_model_ids" in form:
# The helpers picker, the same shape again.
helpers_service.apply_chat_helpers(db, chat, user, form.getlist("helper_model_ids"))
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
if submitted_params:
if not allowed.get("chat.params"):
+130 -10
View File
@@ -100,6 +100,7 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
**_crowd_context(
db, user, chat, in_group, current.model_id if current is not None else ""
),
**_helper_context(db, user, chat, current.model_id if current is not None else ""),
# What *this* model takes, not the three every model used to be assumed
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
# Bonsai has no `high`, and sending the wrong one does not degrade, it
@@ -213,6 +214,43 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
}
def _talk_settings(db: DBSession, user: User) -> dict:
"""The person's own talk rules, and the matrix as it applies to them."""
from lembas.api.admin_rules import model_ids
from lembas.db.models import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY
from lembas.services import talk
models = chat_service.available_models(db, user)
return {
"talk_mode": talk.user_mode(user),
"talk_override": talk.may_override(db, user),
"talk_rules": talk.rules_of(db, user),
"talk_matrix_models": models,
"talk_matrix": talk.matrix(db, user, models),
"model_ids": model_ids(db, user),
"any_model": ANY_MODEL,
"allow": EFFECT_ALLOW,
"deny": EFFECT_DENY,
"describe_verdict": describe_verdict,
**_helper_settings(db, user),
}
def _helper_settings(db: DBSession, user: User) -> dict:
"""The person's own helper designations, and whether they may add any."""
from lembas.services import helpers as helpers_service
may = helpers_service.may_designate(db, user)
shown = bool(settings_store.subagents(db).get("enabled")) and permissions.has(
db, user, "tools.subagent"
)
return {
"helper_settings_shown": shown,
"may_designate": may,
"own_designations": helpers_service.own_designations(db, user),
}
def _data_group_settings(db: DBSession, user: User) -> dict:
"""What the Data tab on /settings shows: which group each connection reads.
@@ -251,6 +289,68 @@ def _data_group_settings(db: DBSession, user: User) -> dict:
}
def describe_verdict(verdict) -> str:
"""Why a model is not offered, in the reader's language.
`talk.Verdict` carries a code so this can be said here with `t()`, while a
model refused a friend is told the same thing in English.
"""
from lembas.services import talk
rule = verdict.rule
if verdict.why in (talk.WHY_INSTANCE_RULE, talk.WHY_YOUR_RULE) and rule is not None:
frm = i18n.t("any model") if rule.from_model == "*" else rule.from_model
to = i18n.t("any model") if rule.to_model == "*" else rule.to_model
if verdict.why == talk.WHY_INSTANCE_RULE:
if rule.effect == "allow":
return i18n.t("The instance's rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("The instance's rule %(a)s → %(b)s forbids it.", a=frm, b=to)
if rule.effect == "allow":
return i18n.t("Your rule %(a)s → %(b)s allows it.", a=frm, b=to)
return i18n.t("Your rule %(a)s → %(b)s forbids it.", a=frm, b=to)
return {
talk.WHY_GROUP: i18n.t("It is in another data group."),
talk.WHY_INSTANCE_CLOSED: i18n.t("The instance lets no model talk to another."),
talk.WHY_YOUR_CLOSED: i18n.t("Your setting lets no model talk to another."),
}.get(verdict.why, "")
def _helper_context(db: DBSession, user: User, chat: Chat | None, own: str) -> dict:
"""The helpers picker: models designated for this chat's model, to add by hand.
Only when helpers are on and this person may send one, and only when
something is designated -- with nothing designated the model is its own
helper and there is nothing to choose. On the new-chat screen the choice
rides along with the first message, exactly as the crowd's does.
"""
from lembas.services import helpers as helpers_service
empty = {"helper_choices": [], "helper_member_ids": []}
if not settings_store.subagents(db).get("enabled"):
return empty
if not permissions.has(db, user, "tools.subagent"):
return empty
main = chat if chat is not None else own
if not main:
return empty
choices = helpers_service.picker(db, main, user)
return {
"helper_choices": choices,
"helper_reasons": {
choice.model.model_id: (
# One literal, not two adjacent ones: the catalogue extractor
# reads the first string of a `t()` call only, so a sentence split
# across two would be looked up under a key that exists nowhere.
i18n.t("It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.") # noqa: E501
if choice.capacity
else (describe_verdict(choice.verdict) if not choice.addable else "")
)
for choice in choices
},
"helper_member_ids": [row.model_id for row in chat.helpers] if chat else [],
}
def _crowd_context(
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
) -> dict:
@@ -271,24 +371,39 @@ def _crowd_context(
settings = settings_store.crowd(db)
if not settings["enabled"]:
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
return {
"crowd_available": [],
"crowd_held_back": [],
"crowd_member_ids": [],
"crowd_skipped": [],
}
# On the new-chat screen the "own" model is whichever one the picker is
# showing, so the list excludes it for the same reason it does in a chat:
# adding it would have it answer twice in a row.
own = chat.model_id if chat is not None else default_model_id
# Only the main model's data group: a member is sent the whole conversation.
# On the new-chat screen that is the group of the model the picker shows,
# which is the one the chat will be pinned to.
# The talk rules, evaluated from the main model -- on the new-chat screen the
# one the picker shows, whose data group the chat will be pinned to. Offered
# models are the main list; the rest are named below it with the reason, and
# may still be ticked by hand where the rules let this person do that.
from lembas.services import talk
group = (
data_groups.for_chat(db, chat)
if chat is not None
else (data_groups.for_pair(db, user, own) if own else None)
)
if group is not None:
allowed = {m.id for m in chat_service.available_models(db, user, group)}
models = [model for model in models if model.id in allowed]
others = [model for model in models if model.model_id != own]
if own and group is not None:
pairs = talk.candidates(db, user, own, group)
else:
pairs = [(model, talk.Verdict(offered=True, addable=True)) for model in models]
pairs = [(model, verdict) for model, verdict in pairs if model.model_id != own]
others = [model for model, verdict in pairs if verdict.offered]
held_back = [
{"model": model, "addable": verdict.addable, "reason": describe_verdict(verdict)}
for model, verdict in pairs
if not verdict.offered
]
members = (
[
row.model_id
@@ -297,16 +412,17 @@ def _crowd_context(
if chat is not None
else []
)
reachable = {model.model_id for model in others}
reachable = {model.model_id for model, verdict in pairs if verdict.addable}
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
rounds = int(settings["max_rounds"])
return {
"crowd_available": others,
"crowd_held_back": held_back,
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
"crowd_skipped": (
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
),
# One round is out and back: everybody answers, everybody but the last is
# One round is out and back: everybody answers, everybody but the last is
# asked whether they disagree, and the main model closes.
"crowd_replies": max(1, speakers * 2 - 1),
"crowd_rounds": rounds,
@@ -839,6 +955,9 @@ async def chat_index(
context["models"],
preselected.model_id if preselected is not None else "",
),
**_helper_context(
db, user, None, preselected.model_id if preselected is not None else ""
),
"starting_temporary": temporary,
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
@@ -1034,6 +1153,7 @@ async def settings_page(
# template shows the two apart rather than printing the raw key.
"split_key": personas_service.split_key,
**_data_group_settings(db, user),
**_talk_settings(db, user),
# Sorted rather than left in set order, because a list of six
# hundred zones that is not alphabetical is one nobody can use.
"languages": i18n.LANGUAGES,
+90
View File
@@ -371,3 +371,93 @@ async def delete_personal_group(db: Db, user: RequiredUser, group_id: str) -> Re
except ValueError as exc:
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
return RedirectResponse("/settings?saved=Data+group+deleted.", status_code=303)
# --- Talk rules ----------------------------------------------------------------
# A person's own layer of who may talk to whom. Anybody may keep one: without
# `rules.override` it can only narrow what the instance allows, which is theirs
# to decide; with it, it wins. See services/talk.py.
@router.post("/talk-mode")
async def set_talk_mode(db: Db, user: RequiredUser, mode: str = Form("")) -> Response:
from lembas.services import talk
settings_map = {**(user.settings_json or {})}
if mode in talk.MODES:
settings_map[talk.SETTING_KEY] = mode
else:
settings_map.pop(talk.SETTING_KEY, None)
user.settings_json = settings_map
db.commit()
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules")
async def add_talk_rule(
db: Db,
user: RequiredUser,
from_model: str = Form("*"),
to_model: str = Form("*"),
effect: str = Form("deny"),
both: bool = Form(False),
) -> Response:
from lembas.services import talk
talk.set_rule(db, user, from_model, to_model, effect, both=both)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
@router.post("/talk-rules/{rule_id}/delete")
async def delete_talk_rule(db: Db, user: RequiredUser, rule_id: str) -> Response:
from lembas.services import talk
talk.delete_rule(db, user, rule_id)
return RedirectResponse("/settings?saved=Model+rules+updated.", status_code=303)
# --- Helper models -------------------------------------------------------------
# A person's own designations: for each of their models, others it may send
# helpers to. Added to the instance's, for them alone. Needs `helpers.designate`.
def _refuse_without_designate(db, user) -> Response | None:
from lembas.services import helpers
if helpers.may_designate(db, user):
return None
return RedirectResponse(
"/settings?error=You+may+not+choose+your+own+helper+models.", status_code=303
)
@router.post("/helpers")
async def add_own_helper(
db: Db,
user: RequiredUser,
main_model: str = Form(""),
helper_model: str = Form(""),
offer: bool = Form(False),
) -> Response:
from lembas.security import permissions
from lembas.services import helpers
refused = _refuse_without_designate(db, user)
if refused is not None:
return refused
# Only models this person can reach, on both sides: a designation naming one
# they cannot use would never be offered and would sit there unexplained.
if main_model == helper_model or not (
permissions.can_use_model(db, user, main_model)
and permissions.can_use_model(db, user, helper_model)
):
return RedirectResponse("/settings?error=Choose+two+different+models.", status_code=303)
helpers.set_designation(db, user, main_model, helper_model, offer=offer)
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
@router.post("/helpers/{designation_id}/delete")
async def delete_own_helper(db: Db, user: RequiredUser, designation_id: str) -> Response:
from lembas.services import helpers
refused = _refuse_without_designate(db, user)
if refused is not None:
return refused
helpers.delete_designation(db, user, designation_id)
return RedirectResponse("/settings?saved=Helper+models+updated.", status_code=303)
+10
View File
@@ -31,12 +31,14 @@ from lembas.db.models.chat import (
ROLE_TOOL,
ROLE_USER,
Chat,
ChatHelper,
CrowdMember,
Folder,
Message,
)
from lembas.db.models.connection import Connection, Model, model_groups
from lembas.db.models.data_group import DEFAULT_GROUP, DataGroup, InDataGroup
from lembas.db.models.helper import HelperDesignation
from lembas.db.models.image import ImageWorkflow
from lembas.db.models.library import (
AUTHOR_MODEL,
@@ -84,6 +86,7 @@ from lembas.db.models.schedule import (
)
from lembas.db.models.setting import Setting
from lembas.db.models.suggestion import Suggestion
from lembas.db.models.talk import ANY_MODEL, EFFECT_ALLOW, EFFECT_DENY, EFFECTS, TalkRule
from lembas.db.models.tool import (
RESPONSE_JSON,
RESPONSE_MODES,
@@ -165,11 +168,18 @@ __all__ = [
"Report",
"Schedule",
"Chat",
"ChatHelper",
"CrowdMember",
"HelperDesignation",
"Job",
"Connection",
"DEFAULT_GROUP",
"DataGroup",
"ANY_MODEL",
"EFFECT_ALLOW",
"EFFECT_DENY",
"EFFECTS",
"TalkRule",
"InDataGroup",
"CustomTool",
"CHUNK_DOCUMENT",
+30
View File
@@ -325,6 +325,12 @@ class Chat(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
cascade="all, delete-orphan",
order_by="CrowdMember.position",
)
# Helper models somebody added to this chat by hand. See `ChatHelper`.
helpers: Mapped[list[ChatHelper]] = relationship(
back_populates="chat",
cascade="all, delete-orphan",
order_by="ChatHelper.position",
)
def __repr__(self) -> str:
return f"<Chat {self.title!r}>"
@@ -372,6 +378,30 @@ class CrowdMember(UUIDPrimaryKey, Timestamps, Base):
return f"<CrowdMember {self.model_id} at {self.position}>"
class ChatHelper(UUIDPrimaryKey, Timestamps, Base):
"""A model added to this chat by hand, for its model to send helpers to.
`CrowdMember`'s shape and `CrowdMember`'s reasoning: the model is text with
no foreign key, so a "Test & refresh" cannot silently empty the list. A
helper that no longer resolves is simply not offered.
"""
__tablename__ = "chat_helpers"
__table_args__ = (UniqueConstraint("chat_id", "model_id"),)
chat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
)
model_id: Mapped[str] = mapped_column(String(300), nullable=False)
connection_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="helpers")
def __repr__(self) -> str:
return f"<ChatHelper {self.model_id} at {self.position}>"
class Message(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "messages"
+14
View File
@@ -71,6 +71,14 @@ class Connection(UUIDPrimaryKey, Timestamps, InDataGroup, Base):
unload_url: Mapped[str] = mapped_column(String(500), default="")
unload_method: Mapped[str] = mapped_column(String(8), default="POST")
# Whether this endpoint holds one model at a time -- llama-swap in front of
# one GPU, which swaps the model out to serve another. A model here may then
# be its own helper, never another model from this connection: the helper
# would evict the model whose reply is waiting on it. Named for the
# *restrictive* state on purpose: `sync_schema` backfills a NOT NULL boolean
# with False, so False has to mean "as before" (any number of models at once).
one_model_at_a_time: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Result of the most recent "Test & refresh", surfaced in the admin list.
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str] = mapped_column(Text, default="")
@@ -119,6 +127,12 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# Pinned models are offered first, before the full list.
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Whether this model serves one request at a time, so it cannot be its own
# helper: the helper's request would queue behind the reply that is waiting
# for it. The restrictive state, for the backfill reason on
# `Connection.one_model_at_a_time`. A column and not a `capabilities_json`
# key, because that dict is rebuilt from the checkboxes on every save.
single_session: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Public models are usable by anyone; otherwise access comes from `groups`.
public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+37
View File
@@ -0,0 +1,37 @@
"""Which models a main model may send helpers to, besides itself.
Designated **per main model**, on the owner's word: gpt-oss may use qwen35,
bonsai may use deepseek-flash, and neither says anything about the other. The
instance designates (`owner_id` NULL); a person holding `helpers.designate` may
add their own, and theirs are added to the instance's -- a row of theirs for the
same pair wins, which is how they change whether it is offered.
`offer` says whether the main model may choose the helper itself. Off, it can be
added to a chat only by hand, and is then the chat's.
Text model ids and no foreign key, for the reason every such reference here has:
"Test & refresh" recreates `Model` rows.
"""
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
class HelperDesignation(UUIDPrimaryKey, Timestamps, Base):
__tablename__ = "helper_designations"
__table_args__ = (UniqueConstraint("owner_id", "main_model", "helper_model"),)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
main_model: Mapped[str] = mapped_column(String(300), nullable=False)
helper_model: Mapped[str] = mapped_column(String(300), nullable=False)
helper_connection_id: Mapped[str] = mapped_column(String(32), default="")
offer: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
def __repr__(self) -> str:
return f"<HelperDesignation {self.main_model} -> {self.helper_model}>"
+43
View File
@@ -0,0 +1,43 @@
"""Talk rules: which model may talk to which.
A rule names a *main* model -- the one a chat belongs to -- and a *target*, and
says allow or deny. It governs who a main model is offered as a crowd member, as
a friend to ask, and on its roster of peers. Keyed on the models' text ids, never
a `Model` primary key, for the reason every such reference here is: "Test &
refresh" recreates the row, and a rule that silently stopped applying after a
refresh is the worst kind of rule.
`owner_id` NULL is an instance rule, written by an administrator. Set, it is one
person's own. `*` on either side means any model. See services/talk.py for how
the two layers and the instance's mode are combined.
"""
from __future__ import annotations
from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
ANY_MODEL = "*"
EFFECT_ALLOW = "allow"
EFFECT_DENY = "deny"
EFFECTS = (EFFECT_ALLOW, EFFECT_DENY)
class TalkRule(UUIDPrimaryKey, Timestamps, Base):
"""One rule: may `from_model` talk to `to_model`, for everybody or one person."""
__tablename__ = "talk_rules"
__table_args__ = (UniqueConstraint("owner_id", "from_model", "to_model"),)
owner_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
)
from_model: Mapped[str] = mapped_column(String(300), nullable=False)
to_model: Mapped[str] = mapped_column(String(300), nullable=False)
effect: Mapped[str] = mapped_column(String(8), nullable=False, default=EFFECT_DENY)
def __repr__(self) -> str:
whose = self.owner_id or "instance"
return f"<TalkRule {whose} {self.from_model} -> {self.to_model} {self.effect}>"
+2
View File
@@ -23,6 +23,7 @@ from lembas.api import (
admin_images,
admin_models,
admin_prompts,
admin_rules,
admin_schedules,
admin_search,
admin_suggestions,
@@ -231,6 +232,7 @@ def create_app() -> FastAPI:
app.include_router(admin_agents.router)
app.include_router(admin_crowd.router)
app.include_router(admin_data_groups.router)
app.include_router(admin_rules.router)
app.include_router(push.router)
app.include_router(branding.router)
+25
View File
@@ -334,6 +334,31 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
# themselves, and move their own records between groups. Off by default,
# because it moves what a provider can read -- and an instance that never
# looks should keep the arrangement its administrator made.
# Talk rules: which model may bring which into a conversation. The
# instance's rules apply to everybody; a person may always narrow them for
# themselves, and with this they may also widen them -- their own rules and
# mode then win, and they may add any model to a crowd by hand. Off by
# default, because an instance rule is usually there for a reason.
PermissionDef(
"rules.override",
"Override the model rules for themselves",
"Let this person's own rules about which model may talk to which win over "
"the instance's, and let them add any model to a crowd by hand.",
False,
"Chat",
),
# Helpers on another model. The instance designates which models each main
# model may send helpers to; this lets a person add their own designations
# for themselves. Off by default: a designation decides where a person's
# tasks -- and whatever context a model writes into them -- are sent.
PermissionDef(
"helpers.designate",
"Choose their own helper models",
"Let this person designate, for each model, other models it may send "
"helpers to -- added to the instance's designations, for them alone.",
False,
"Chat",
),
PermissionDef(
"data.manage",
"Manage their own data groups",
+8 -1
View File
@@ -906,7 +906,14 @@ def roster_models(
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, group) if model.model_id != exclude]
if group is not None:
# Who the main model may talk to, by the talk rules -- a different data
# group counting as a deny that only an explicit rule opens. `exclude`
# is the main model at every call site that passes a group.
from lembas.services import talk
return talk.offered(db, user, exclude, group)
return [model for model in available_models(db, user) if model.model_id != exclude]
def roster_block(
+9 -11
View File
@@ -273,17 +273,18 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
Deduplicated against the main model: adding the chat's own model to the crowd
would have it answer twice in a row, which is not what anybody meant by it.
And narrowed to the chat's data group: a member from another group would be
handed this conversation, which is exactly what groups exist to prevent.
And narrowed by the talk rules, evaluated from the main model -- which is
also where a different data group counts as a deny that only a rule opens.
"""
from lembas.services import chat as chat_service
from lembas.services import data_groups
from lembas.services import data_groups, talk
# `addable`, not `offered`: a member somebody added by hand is exactly one the
# rules would not have offered, and skipping it when the round runs would
# quietly undo their choice.
reachable = {
model.model_id: model
for model in chat_service.roster_models(
db, user, exclude="", group=data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
speakers = [chat_service.Speaker(chat.model_id, chat.connection_id)]
seen = {chat.model_id}
@@ -297,14 +298,11 @@ def member_speakers(db: DBSession, chat: Chat, user=None) -> list:
def unreachable_members(db: DBSession, chat: Chat, user=None) -> list[str]:
"""Members that will be skipped, so a screen can say so rather than lie."""
from lembas.services import chat as chat_service
from lembas.services import data_groups
from lembas.services import data_groups, talk
reachable = {
model.model_id
for model in chat_service.roster_models(
db, user, exclude="", group=data_groups.for_chat(db, chat)
)
for model in talk.addable(db, user, chat.model_id, data_groups.for_chat(db, chat))
}
return [
member.model_id
+37
View File
@@ -292,6 +292,9 @@ def context_variables(
# opinion. One fragment covering all three would say nothing useful to
# any of them.
"friend": "",
# Where a helper may run, when that is more than the answering model.
# Filled below, from the same candidates the tool's enum was built from.
"helper_models": "",
# Who else is here. Filled below, where the chat's own model is known --
# a model does not need telling that it exists.
"model_roster": "",
@@ -364,6 +367,9 @@ def context_variables(
db, user, exclude=speaking.model_id, group=group
)
if "subagent" in families and speaking.model_id == chat.model_id:
values["helper_models"] = _helper_models(db, chat, user)
if "persona" in families:
# This person's own personality for this model, falling back to the
# administrator's default until the model has written one with them;
@@ -376,6 +382,37 @@ def context_variables(
return values
def _helper_models(db: DBSession, chat, user) -> str:
"""One line per model a helper may run on, or "" when it is only this one.
The same candidates `tools._subagent_defs` built the `model` enum from, so
the list the model reads and the values the call accepts cannot disagree.
Roster-shaped and capped the same way.
"""
from lembas.services import chat as chat_service
from lembas.services import helpers as helpers_service
found = helpers_service.candidates(db, chat, user)
if len(found) < 2:
return ""
lines: list[str] = []
budget = chat_service.MAX_ROSTER_CHARS
for candidate in found[: chat_service.MAX_ROSTER_MODELS]:
model = candidate.model
parts = ((model.description or "").strip(), (model.notes or "").strip())
about = " ".join(" ".join(p for p in parts if p).split())[: chat_service.MAX_ROSTER_ENTRY]
line = f"- {model.label} ({model.model_id})"
if candidate.source == helpers_service.SOURCE_SELF:
line += " — you"
elif about:
line += f" — {about}"
if len(line) > budget:
break
budget -= len(line)
lines.append(line)
return "\n".join(lines)
def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
"""What a scheduled task's chat is for, and how often it comes round.
+363
View File
@@ -0,0 +1,363 @@
"""Which model a helper runs on: the main model itself, or one designated for it.
`subagent_run` used to have one answer -- the parent's own model. It has three
sources now, in this order, and they are decided here by logic, never by the
model:
1. **The main model itself**, unless it cannot run two requests at once.
2. **Helpers somebody added to this chat by hand** (`ChatHelper`).
3. **Helpers designated for the main model with `offer` on** -- by the instance,
or by a person holding `helpers.designate` -- which the model may choose.
Each candidate passes two checks before it is offered:
* **Capacity.** A model marked *serves one request at a time* cannot be its own
helper: the helper's request would queue behind the very reply waiting for it.
A connection marked *holds one model at a time* (llama-swap in front of one
GPU) cannot serve a helper on another of its models: loading it evicts the
model whose reply is waiting. The main model may still help itself there.
Both flags default off, so an instance that never sets them behaves exactly as
before -- the main model is its own helper.
* **The talk rules**, read from the main model: a chat's hand-added helpers need
to be `addable`, a designation the model chooses itself needs to be `offered`.
A different data group is the deny a rule has to open, exactly as for a crowd.
`ask_friend` and the crowd do not take the capacity check, on the owner's word:
both are sequential, and waiting for a model to load is accepted there.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import Chat, ChatHelper, HelperDesignation, Model, User
PERMISSION = "helpers.designate"
SOURCE_SELF = "self"
SOURCE_CHAT = "chat"
SOURCE_OFFERED = "offered"
@dataclass(frozen=True)
class Candidate:
"""A model a helper may run on, and why it is on the list."""
model: Model
source: str
@dataclass(frozen=True)
class Choice:
"""One designation as the helpers picker shows it.
`reason` is in English, for a model; `capacity` and `verdict` are what a
screen needs to say the same thing in the reader's language.
"""
model: Model
offer: bool
addable: bool
reason: str
capacity: bool = False
verdict: object = None
def may_designate(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
def main_row(db: DBSession, chat: Chat) -> Model | None:
"""The chat's own model as a row, on the connection it is reached through."""
from lembas.services import chat as chat_service
return chat_service.model_row(db, chat_service.speaker_for(db, chat))
def capacity_refusal(main: Model | None, helper: Model) -> str:
"""Why this helper cannot run beside the main model's waiting reply, or ""."""
if main is None:
return ""
if helper.model_id == main.model_id and helper.connection_id == main.connection_id:
if helper.single_session:
return (
f"{helper.label} serves one request at a time, so it cannot be its own "
f"helper: the helper would wait behind the reply that is waiting for it."
)
return ""
if helper.connection_id == main.connection_id:
connection = helper.connection
if connection is not None and connection.one_model_at_a_time:
return (
f"{connection.name} holds one model at a time, so a helper on "
f"{helper.label} would unload {main.label} while its reply waits."
)
return ""
def designations(db: DBSession, user: User | None, main_model: str) -> list[HelperDesignation]:
"""The helpers designated for one main model: the instance's, then the person's.
A person's own row for the same helper replaces the instance's -- that is
how they change whether it is offered -- and theirs count only while they
hold `helpers.designate`.
"""
rows = list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id.is_(None),
HelperDesignation.main_model == main_model,
)
.order_by(HelperDesignation.helper_model)
)
)
if user is not None and may_designate(db, user):
own = list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id == user.id,
HelperDesignation.main_model == main_model,
)
.order_by(HelperDesignation.helper_model)
)
)
replaced = {row.helper_model for row in own}
rows = [row for row in rows if row.helper_model not in replaced] + own
return rows
def _reachable(db: DBSession, user: User | None, model_id: str, connection_id: str | None):
"""The row this person can reach for a model id, preferring a named connection."""
from lembas.services import chat as chat_service
rows = [m for m in chat_service.available_models(db, user) if m.model_id == model_id]
rows.sort(key=lambda m: m.connection_id != (connection_id or ""))
return rows[0] if rows else None
def candidates(db: DBSession, chat: Chat, user: User | None) -> list[Candidate]:
"""Every model a helper of this chat may run on, in the order it is chosen."""
from lembas.services import data_groups, talk
main = main_row(db, chat)
group = data_groups.for_chat(db, chat)
judge = talk.Judge(db, user)
found: list[Candidate] = []
seen: set[str] = set()
if main is not None and not capacity_refusal(main, main):
found.append(Candidate(main, SOURCE_SELF))
seen.add(main.model_id)
for row in chat.helpers:
model = _reachable(db, user, row.model_id, row.connection_id)
if model is None or model.model_id in seen:
continue
if not judge.verdict(chat.model_id, group, model).addable:
continue
if capacity_refusal(main, model):
continue
found.append(Candidate(model, SOURCE_CHAT))
seen.add(model.model_id)
for row in designations(db, user, chat.model_id):
if not row.offer or row.helper_model in seen:
continue
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
if model is None:
continue
if not judge.verdict(chat.model_id, group, model).offered:
continue
if capacity_refusal(main, model):
continue
found.append(Candidate(model, SOURCE_OFFERED))
seen.add(model.model_id)
return found
def choose(
db: DBSession, chat: Chat, user: User | None, wanted: str
) -> tuple[Model | None, str]:
"""The model a helper runs on, or a refusal saying what could have been named.
`wanted` comes from a tool call, so it is matched against the candidates and
nothing else -- by model id, then by label. Empty means the default: the main
model itself, or failing that the first helper added to this chat by hand.
"""
found = candidates(db, chat, user)
names = ", ".join(c.model.model_id for c in found)
wanted = (wanted or "").strip()
if not wanted:
for source in (SOURCE_SELF, SOURCE_CHAT):
for candidate in found:
if candidate.source == source:
return candidate.model, ""
if found:
return None, f"Name the model to send the helper to. You may use: {names}."
return None, _why_none(db, chat)
lowered = wanted.lower()
for candidate in found:
if lowered in (candidate.model.model_id.lower(), candidate.model.label.lower()):
return candidate.model, ""
main = main_row(db, chat)
target = _reachable(db, user, wanted, None)
if target is not None and capacity_refusal(main, target):
reason = capacity_refusal(main, target)
else:
reason = f"{wanted!r} is not a helper you may use."
return None, f"{reason} You may use: {names}." if names else f"{reason} {_why_none(db, chat)}"
def _why_none(db: DBSession, chat: Chat) -> str:
main = main_row(db, chat)
if main is not None and capacity_refusal(main, main):
return capacity_refusal(main, main) + " No other helper is set up for it."
return "No helper model is available here. Do this part yourself."
def picker(db: DBSession, chat_or_main: Chat | str, user: User | None) -> list[Choice]:
"""The designations for a main model, as the composer's helpers picker lists them.
Every designation, offered or not, with whether this person may add it to
the chat by hand and, where not, why. On the new-chat screen there is no
chat yet, so a main model id is passed instead.
"""
from lembas.services import data_groups, talk
if isinstance(chat_or_main, Chat):
main_model = chat_or_main.model_id
main = main_row(db, chat_or_main)
group = data_groups.for_chat(db, chat_or_main)
else:
main_model = chat_or_main
main = _reachable(db, user, main_model, None)
group = data_groups.for_pair(db, user, main_model)
judge = talk.Judge(db, user)
shown: list[Choice] = []
for row in designations(db, user, main_model):
model = _reachable(db, user, row.helper_model, row.helper_connection_id)
if model is None:
continue
verdict = judge.verdict(main_model, group, model)
capacity = capacity_refusal(main, model)
reason = capacity or ("" if verdict.addable else verdict.reason)
shown.append(
Choice(
model,
row.offer,
bool(verdict.addable and not capacity),
reason,
capacity=bool(capacity),
verdict=verdict,
)
)
return shown
def set_designation(
db: DBSession,
owner: User | None,
main_model: str,
helper_model: str,
*,
offer: bool,
connection_id: str = "",
) -> None:
"""Designate a helper for a main model, or change whether it is offered."""
main_model = (main_model or "").strip()[:300]
helper_model = (helper_model or "").strip()[:300]
if not main_model or not helper_model:
return
existing = db.scalar(
select(HelperDesignation).where(
(
HelperDesignation.owner_id.is_(None)
if owner is None
else HelperDesignation.owner_id == owner.id
),
HelperDesignation.main_model == main_model,
HelperDesignation.helper_model == helper_model,
)
)
if existing is not None:
existing.offer = offer
existing.helper_connection_id = connection_id or existing.helper_connection_id
else:
db.add(
HelperDesignation(
owner_id=owner.id if owner is not None else None,
main_model=main_model,
helper_model=helper_model,
helper_connection_id=connection_id or "",
offer=offer,
)
)
db.commit()
def delete_designation(db: DBSession, owner: User | None, designation_id: str) -> bool:
row = db.get(HelperDesignation, designation_id)
if row is None or row.owner_id != (owner.id if owner is not None else None):
return False
db.delete(row)
db.commit()
return True
def own_designations(db: DBSession, owner: User | None) -> list[HelperDesignation]:
return list(
db.scalars(
select(HelperDesignation)
.where(
HelperDesignation.owner_id.is_(None)
if owner is None
else HelperDesignation.owner_id == owner.id
)
.order_by(HelperDesignation.main_model, HelperDesignation.helper_model)
)
)
def apply_chat_helpers(db: DBSession, chat: Chat, user: User | None, values: list[str]) -> None:
"""Replace a chat's hand-added helpers with the models named.
Only designations for the chat's model this person may add -- the talk rules
and the capacity check, the same as the picker shows -- and never the chat's
own model, which is its own helper already.
"""
allowed = {c.model.model_id: c.model for c in picker(db, chat, user) if c.addable}
wanted: list[str] = []
for value in values:
value = str(value).strip()
if value and value in allowed and value != chat.model_id and value not in wanted:
wanted.append(value)
chat.helpers = [
ChatHelper(model_id=model_id, connection_id=allowed[model_id].connection_id, position=i)
for i, model_id in enumerate(wanted)
]
__all__ = [
"PERMISSION",
"Candidate",
"Choice",
"apply_chat_helpers",
"candidates",
"capacity_refusal",
"choose",
"delete_designation",
"designations",
"may_designate",
"own_designations",
"picker",
"set_designation",
]
+30
View File
@@ -163,6 +163,14 @@ VARIABLES: tuple[Variable, ...] = (
"page, bounded, and empty unless this model may ask one of them a "
"question — a list of peers it cannot reach is context spent on nothing.",
),
Variable(
"helper_models",
"The models a helper may run on",
"One line per model subagent_run may send a helper to, the answering model "
"first when it may be its own helper: its name, the id to pass as model, "
"and what it is for. Empty when the only choice is the model itself, so the "
"section vanishes and the tool reads as it always did.",
),
Variable(
"persona",
"Its personality with this person",
@@ -1366,6 +1374,28 @@ BUILTIN: tuple[Fragment, ...] = (
"because a helper made it."
),
),
Fragment(
key="tool.subagent_models",
label="Which model a helper runs on",
group=GROUP_TOOLS,
order=252,
families=("subagent",),
variables=("helper_models",),
requires=("helper_models",),
hint="Appears only when a helper may run on a model other than the one "
"answering -- the chat's own helpers added by hand, or ones designated for "
"this model and offered to it. The list is built after the capacity check "
"and the model rules, so every line is one the call will accept.",
default=(
"### Where a helper can run\n"
"\n"
"subagent_run takes a model. Leave it out for the first one below; name "
"another, by the id in brackets, when its strengths suit the piece of work "
"better. Only these are accepted:\n"
"\n"
"{{helper_models}}"
),
),
Fragment(
key="tool.subagent_agent",
label="Helpers on a machine",
+19
View File
@@ -33,6 +33,7 @@ IMAGES = "images"
SCHEDULES = "schedules"
SUBAGENTS = "subagents"
CROWD = "crowd"
RULES = "rules"
BRANDING = "branding"
EXTRACTION = "extraction"
@@ -349,6 +350,16 @@ def _schedules_defaults() -> dict[str, Any]:
}
def _rules_defaults() -> dict[str, Any]:
"""Who may talk to whom, instance-wide. See services/talk.py.
`open` is any model to any model, with deny rules; `closed` is none to none,
with allow rules. Open by default, so an instance that never looks behaves
exactly as it did before rules existed.
"""
return {"mode": "open"}
def _crowd_defaults() -> dict[str, Any]:
"""Several models answering one turn, in order, then again in reverse.
@@ -431,6 +442,7 @@ _DEFAULTS: dict[str, Any] = {
SCHEDULES: _schedules_defaults,
SUBAGENTS: _subagents_defaults,
CROWD: _crowd_defaults,
RULES: _rules_defaults,
# Whose instance this is. The defaults live in `services/branding.py`
# beside the code that reads them, because every one of them is paired with
# a label and a hint for the admin page and splitting the three across two
@@ -726,6 +738,13 @@ def crowd(db: DBSession) -> dict[str, Any]:
return values
def rules(db: DBSession) -> dict[str, Any]:
"""The talk-rules group, with the mode clamped to the two that exist."""
values = get_group(db, RULES)
values["mode"] = "closed" if values.get("mode") == "closed" else "open"
return values
def images_ready(db: DBSession) -> bool:
"""Whether image generation can actually happen.
+88 -42
View File
@@ -196,6 +196,7 @@ def _create_child(
title: str,
write: bool,
friend: Model | None = None,
helper: Model | None = None,
) -> Chat:
"""The hidden chat one helper or one friend runs in.
@@ -225,6 +226,10 @@ def _create_child(
peer = friend is not None
owner = db.get(User, parent.user_id) if parent.user_id else None
# A helper on another model runs on that model -- as a pair, for the reason
# the friend is -- and reads that model's data group, exactly as a friend
# does. It is still a helper: the parent's kind, machine and scope.
other = friend or helper
child = Chat(
user_id=parent.user_id,
# An ordinary chat for a friend even when the asking one is an agent
@@ -232,14 +237,14 @@ def _create_child(
# 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,
model_id=other.model_id if other is not None else parent.model_id,
connection_id=other.connection_id if other is not None else parent.connection_id,
# The helper is in its parent's group, doing its parent's work. A friend
# is in its own model's, so it reads its own group's data and never the
# asker's.
data_group_id=(
data_groups.for_pair(db, owner, friend.model_id, friend.connection_id)
if peer
data_groups.for_pair(db, owner, other.model_id, other.connection_id)
if other is not None
else data_groups.for_chat(db, parent)
),
# Never in a listing, and swept a day later even if it is kept.
@@ -254,8 +259,11 @@ def _create_child(
child.agent_mode = MODE_WRITING if write else MODE_READING
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):
if other is not None:
# The other model's own default, never the parent's: the vocabularies
# differ, and `high` handed to a Bonsai raises inside its chat template.
effort = str((other.params_json or {}).get("reasoning_effort") or "")
if effort not in chat_service.efforts_for(other):
effort = ""
else:
effort = chat_service.resolved_effort(parent)
@@ -455,6 +463,7 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
title = str(args.get("title") or "").strip() or task[:60]
briefing = str(args.get("context") or "")
want_write = bool(args.get("write"))
wanted_model = str(args.get("model") or "")
if not task:
return _error(
@@ -493,6 +502,15 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
if owner is None: # pragma: no cover - a chat outliving its owner
return _error("That account no longer exists.", task=task)
# Which model the helper runs on -- decided by logic, never taken on the
# model's word: the name is matched against the candidates the tool was
# built from, which already passed the capacity check and the talk rules.
from lembas.services import helpers as helpers_service
helper, refusal = helpers_service.choose(db, parent, owner, wanted_model)
if helper is None:
return _error(refusal, task=task)
# After the refusals above and before anything is created. The order is
# the design: a call that could never have worked should be told *why*
# rather than told it has run out of helpers, and the counter should
@@ -508,7 +526,13 @@ async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutco
if refusal:
return _error(refusal, task=task)
child = _create_child(db, parent, title=title, write=write)
own = (
helper.model_id == parent.model_id
and (not parent.connection_id or helper.connection_id == parent.connection_id)
)
child = _create_child(
db, parent, title=title, write=write, helper=None if own else helper
)
child_id = child.id
_LIVE.add(child_id)
@@ -812,10 +836,27 @@ def friend_tool_defs() -> list[ToolDef]:
]
def tool_defs() -> list[ToolDef]:
"""The one tool, built here so `services/tools.py` need not know the wording."""
def tool_defs(models: list[str] | None = None) -> list[ToolDef]:
"""The one tool, built here so `services/tools.py` need not know the wording.
`models` is who a helper may run on, from `helpers.candidates`, the main
model first when it is its own helper. One of them, or none given, is the
tool as it always was; more adds a `model` argument whose enum is exactly
that list, so a small model cannot type a name that was never offered.
"""
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
properties = _subagent_properties()
if models and len(models) > 1:
properties["model"] = {
"type": "string",
"enum": list(models),
"description": (
"Which model the helper runs on. Leave it out for the default "
f"({models[0]}). Choose another when its strengths suit the task "
"better -- the list of helpers you were given says what each is."
),
}
return [
ToolDef(
name="subagent_run",
@@ -836,39 +877,7 @@ def tool_defs() -> list[ToolDef]:
),
parameters={
"type": "object",
"properties": {
"task": {
"type": "string",
"description": (
"What the helper is to do, written out in full and as "
"an instruction. Say what a good answer contains and "
"how long it should be. It is read on its own, with "
"none of this conversation around it."
),
},
"title": {
"type": "string",
"description": "A few words naming this piece of work.",
},
"context": {
"type": "string",
"description": (
"Facts the helper needs that it cannot look up — what "
"the reader asked for, decisions already made, names "
"and paths. Not a summary of the conversation."
),
},
"write": {
"type": "boolean",
"description": (
"True if the helper must change something: write a "
"file, keep a note, file a report. Leave it out for "
"anything that only reads, which is nearly always. A "
"writing helper is refused where you would have been "
"stopped for approval yourself."
),
},
},
"properties": properties,
"required": ["task"],
},
run=_run_subagent,
@@ -894,3 +903,40 @@ __all__ = [
"live_count",
"tool_defs",
]
def _subagent_properties() -> dict[str, Any]:
"""The arguments every `subagent_run` has; `tool_defs` may add `model`."""
return {
"task": {
"type": "string",
"description": (
"What the helper is to do, written out in full and as "
"an instruction. Say what a good answer contains and "
"how long it should be. It is read on its own, with "
"none of this conversation around it."
),
},
"title": {
"type": "string",
"description": "A few words naming this piece of work.",
},
"context": {
"type": "string",
"description": (
"Facts the helper needs that it cannot look up — what "
"the reader asked for, decisions already made, names "
"and paths. Not a summary of the conversation."
),
},
"write": {
"type": "boolean",
"description": (
"True if the helper must change something: write a "
"file, keep a note, file a report. Leave it out for "
"anything that only reads, which is nearly always. A "
"writing helper is refused where you would have been "
"stopped for approval yourself."
),
},
}
+354
View File
@@ -0,0 +1,354 @@
"""Who may talk to whom: the rules behind the crowd, `ask_friend` and the roster.
Always evaluated **from the chat's main model**. If the main model may not talk
to a target, the target is not *offered* -- not listed on its roster, not named
as a friend it may ask, not in the crowd picker's main list. Members of a crowd
are not checked against each other: the rule is about who a conversation's own
model brings in, and a member loses `friend` and `subagent` anyway.
Two answers, because the owner asked for two things:
* **offered** -- what happens on its own: the roster, a friend a model names, the
picker's main list.
* **addable** -- what a person may do by hand in the crowd picker. A rule a
person wrote for themselves is soft for them; an instance rule is hard, unless
they hold `rules.override`.
`decide` is pure -- plain values in, a `Verdict` out -- so every combination is
tested without a database, and the admin page's matrix is drawn by the same
function that enforces the rules, which is what makes the matrix trustworthy.
**A different data group is an implicit deny.** A crowd member or a friend is
sent the conversation, so a model in another group joins only when a rule says
so explicitly: the administrator's, or a person's own when they hold the
override. It then reads its own group's stores, never the chat's.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import (
ANY_MODEL,
EFFECT_ALLOW,
EFFECT_DENY,
EFFECTS,
Model,
TalkRule,
User,
)
MODE_OPEN = "open"
MODE_CLOSED = "closed"
MODES = (MODE_OPEN, MODE_CLOSED)
# Where a person's own mode is kept in `settings_json`. Empty follows the instance.
SETTING_KEY = "talk_mode"
# Lets a person's own rules and mode win over the instance's, for them alone.
PERMISSION = "rules.override"
@dataclass(frozen=True)
class Rule:
from_model: str
to_model: str
effect: str
# Why something is not offered. A code rather than a sentence, so a screen can
# say it in the reader's language (`api/admin_rules.py:describe`) while a model
# refused a friend is told it in English (`Verdict.reason`).
WHY_INSTANCE_RULE = "instance_rule"
WHY_YOUR_RULE = "your_rule"
WHY_GROUP = "group"
WHY_INSTANCE_CLOSED = "instance_closed"
WHY_YOUR_CLOSED = "your_closed"
@dataclass(frozen=True)
class Verdict:
offered: bool
addable: bool
why: str = ""
rule: Rule | None = None
@property
def reason(self) -> str:
"""The reason in English, for a model -- or "" when it is offered."""
if self.offered or not self.why:
return ""
if self.why in (WHY_INSTANCE_RULE, WHY_YOUR_RULE) and self.rule is not None:
whose = "the instance's" if self.why == WHY_INSTANCE_RULE else "your"
return _named(self.rule, whose)
return {
WHY_GROUP: "it is in another data group",
WHY_INSTANCE_CLOSED: "the instance allows no model to talk to another",
WHY_YOUR_CLOSED: "your setting allows no model to talk to another",
}.get(self.why, "")
def match(rules: list[Rule], main: str, target: str) -> Rule | None:
"""The most specific rule for a pair: exact, then `main -> *`, `* -> target`, `* -> *`."""
for wanted in ((main, target), (main, ANY_MODEL), (ANY_MODEL, target), (ANY_MODEL, ANY_MODEL)):
for rule in rules:
if (rule.from_model, rule.to_model) == wanted:
return rule
return None
def _named(rule: Rule, whose: str) -> str:
frm = "any model" if rule.from_model == ANY_MODEL else rule.from_model
to = "any model" if rule.to_model == ANY_MODEL else rule.to_model
verb = "allows" if rule.effect == EFFECT_ALLOW else "forbids"
return f"{whose} rule {frm} → {to} {verb} it"
def decide(
*,
instance_mode: str,
instance_rule: Rule | None,
user_mode: str = "",
user_rule: Rule | None = None,
override: bool = False,
same_group: bool = True,
) -> Verdict:
"""Whether a main model may talk to a target, offered and by hand.
The instance's verdict is its most specific rule, or failing that its mode
-- with a different data group counting as a deny that only an explicit
allow opens.
Without the override a person can only narrow: their own rule or their
`closed` mode can take something off what is offered, and since those are
theirs, they may still add it by hand. With the override, their explicit
rule wins outright, then their mode, then the instance's verdict; and they
may add anything by hand, because doing so is their explicit decision.
"""
if instance_rule is not None:
instance_ok = instance_rule.effect == EFFECT_ALLOW
instance_why: tuple[str, Rule | None] = (WHY_INSTANCE_RULE, instance_rule)
elif not same_group:
instance_ok, instance_why = False, (WHY_GROUP, None)
else:
instance_ok = instance_mode != MODE_CLOSED
instance_why = (WHY_INSTANCE_CLOSED, None)
if not override:
if user_rule is not None:
user_ok = user_rule.effect == EFFECT_ALLOW
user_why: tuple[str, Rule | None] = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
user_ok, user_why = False, (WHY_YOUR_CLOSED, None)
else:
user_ok, user_why = True, ("", None)
offered = instance_ok and user_ok
why, rule = ("", None) if offered else (instance_why if not instance_ok else user_why)
return Verdict(offered=offered, addable=instance_ok, why=why, rule=rule)
if user_rule is not None:
ok = user_rule.effect == EFFECT_ALLOW
why, rule = (WHY_YOUR_RULE, user_rule)
elif user_mode == MODE_CLOSED:
ok, (why, rule) = False, (WHY_YOUR_CLOSED, None)
elif user_mode == MODE_OPEN:
allowed = instance_rule is not None and instance_rule.effect == EFFECT_ALLOW
ok = same_group or allowed
why, rule = (WHY_GROUP, None)
else:
ok = instance_ok
why, rule = instance_why
if ok:
why, rule = "", None
return Verdict(offered=ok, addable=True, why=why, rule=rule)
# --- Loading what `decide` needs ---------------------------------------------------
def _rules(db: DBSession, owner_id: str | None) -> list[Rule]:
rows = db.scalars(
select(TalkRule).where(
TalkRule.owner_id.is_(None) if owner_id is None else TalkRule.owner_id == owner_id
)
)
return [Rule(r.from_model, r.to_model, r.effect) for r in rows]
def user_mode(user: User | None) -> str:
if user is None:
return ""
value = str((user.settings_json or {}).get(SETTING_KEY) or "")
return value if value in MODES else ""
def may_override(db: DBSession, user: User | None) -> bool:
from lembas.security import permissions
return user is not None and permissions.has(db, user, PERMISSION)
class Judge:
"""Everything `decide` needs for one person, loaded once per request.
The model lists ask about every model they show; loading the rules and the
connection groups per question would be a query per row of every picker.
`user=None` is the instance's own view, for the admin page's matrix.
"""
def __init__(self, db: DBSession, user: User | None) -> None:
from lembas.services import data_groups, settings_store
self.instance_mode = settings_store.rules(db)["mode"]
self.instance_rules = _rules(db, None)
self.user_rules = _rules(db, user.id) if user is not None else []
self.user_mode = user_mode(user)
self.override = may_override(db, user)
self.groups = data_groups.connection_groups(db, user)
self.default = data_groups.DEFAULT_GROUP
def group_of(self, model: Model) -> str:
return self.groups.get(model.connection_id, self.default)
def verdict(self, main_model: str, main_group: str, target: Model) -> Verdict:
return decide(
instance_mode=self.instance_mode,
instance_rule=match(self.instance_rules, main_model, target.model_id),
user_mode=self.user_mode,
user_rule=match(self.user_rules, main_model, target.model_id),
override=self.override,
same_group=self.group_of(target) == main_group,
)
def candidates(
db: DBSession, user: User | None, main_model: str, main_group: str
) -> list[tuple[Model, Verdict]]:
"""Every model this person can reach other than the main one, with its verdict."""
from lembas.services import chat as chat_service
judge = Judge(db, user)
return [
(model, judge.verdict(main_model, main_group, model))
for model in chat_service.available_models(db, user)
if model.model_id != main_model
]
def offered(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a main model is offered on its own: the roster, a friend, the picker."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.offered]
def addable(db: DBSession, user: User | None, main_model: str, main_group: str) -> list[Model]:
"""The models a person may add to a crowd by hand."""
found = candidates(db, user, main_model, main_group)
return [model for model, verdict in found if verdict.addable]
# --- Changing rules --------------------------------------------------------------------
def rules_of(db: DBSession, owner: User | None) -> list[TalkRule]:
return list(
db.scalars(
select(TalkRule)
.where(
TalkRule.owner_id.is_(None)
if owner is None
else TalkRule.owner_id == owner.id
)
.order_by(TalkRule.from_model, TalkRule.to_model)
)
)
def set_rule(
db: DBSession,
owner: User | None,
from_model: str,
to_model: str,
effect: str,
*,
both: bool = False,
) -> None:
"""Write one rule, or a pair in both directions. Replaces one for the same pair."""
from_model = (from_model or "").strip()[:300] or ANY_MODEL
to_model = (to_model or "").strip()[:300] or ANY_MODEL
if effect not in EFFECTS:
effect = EFFECT_DENY
pairs = [(from_model, to_model)]
if both and from_model != to_model:
pairs.append((to_model, from_model))
for frm, to in pairs:
existing = db.scalar(
select(TalkRule).where(
(TalkRule.owner_id.is_(None) if owner is None else TalkRule.owner_id == owner.id),
TalkRule.from_model == frm,
TalkRule.to_model == to,
)
)
if existing is not None:
existing.effect = effect
else:
db.add(
TalkRule(
owner_id=owner.id if owner is not None else None,
from_model=frm,
to_model=to,
effect=effect,
)
)
db.commit()
def delete_rule(db: DBSession, owner: User | None, rule_id: str) -> bool:
"""Remove one rule, only from the layer it belongs to."""
rule = db.get(TalkRule, rule_id)
if rule is None or rule.owner_id != (owner.id if owner is not None else None):
return False
db.delete(rule)
db.commit()
return True
def matrix(db: DBSession, user: User | None, models: list[Model]) -> list[dict]:
"""Main x target, drawn by the same `decide` that enforces the rules.
Evaluated as if the main model's chat were in the main model's own group,
which is what a chat started on it is.
"""
judge = Judge(db, user)
rows = []
for main in models:
group = judge.group_of(main)
cells = []
for target in models:
if target.model_id == main.model_id:
cells.append(None)
continue
cells.append(judge.verdict(main.model_id, group, target))
rows.append({"main": main, "cells": cells})
return rows
__all__ = [
"MODES",
"MODE_CLOSED",
"MODE_OPEN",
"PERMISSION",
"Judge",
"Rule",
"Verdict",
"addable",
"candidates",
"decide",
"delete_rule",
"match",
"matrix",
"may_override",
"offered",
"rules_of",
"set_rule",
"user_mode",
]
+24 -4
View File
@@ -1750,13 +1750,31 @@ def _schedule_defs() -> list[ToolDef]:
return schedule_tool.tool_defs()
def _subagent_defs() -> list[ToolDef]:
"""The subagent tool. Imported inside the call for the reason above."""
def _subagent_registry_defs() -> list[ToolDef]:
from lembas.services import subagent as subagent_service
return subagent_service.tool_defs()
def _subagent_defs(db: DBSession, chat: Chat, user: User | None) -> list[ToolDef]:
"""The subagent tool, shaped by which models a helper may run on.
Withdrawn when there are none -- a main model that cannot help itself and
has no other helper set up would be offered a tool every call of which is
refused, and a model finds that out one wasted round at a time. With only
itself it is the tool it always was; with more, it gains a `model`
argument listing exactly those. Imported inside the call for the reason
above.
"""
from lembas.services import helpers as helpers_service
from lembas.services import subagent as subagent_service
found = helpers_service.candidates(db, chat, user)
if not found:
return []
return subagent_service.tool_defs([c.model.model_id for c in found])
def _friend_defs() -> list[ToolDef]:
"""The ask-a-friend tool. Same module, same reason for the late import."""
from lembas.services import subagent as subagent_service
@@ -1824,7 +1842,9 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
# reaches the model. That omission has cost two features their
# instructions already.
*_schedule_defs(),
*_subagent_defs(),
# The registry wants the tool's name and family, not this chat's
# candidates -- so the bare definition, which needs no chat.
*_subagent_registry_defs(),
*_friend_defs(),
*_crowd_defs(),
]
@@ -1886,7 +1906,7 @@ def resolve_tools(
*_agent_defs(db, chat, user),
*(_image_defs(db, image_values) if images_ready else []),
*(_schedule_defs() if schedules_on else []),
*(_subagent_defs() if subagents_on else []),
*(_subagent_defs(db, chat, user) if subagents_on else []),
*(_friend_defs() if subagents_on else []),
# Only on the closing turn, and only with a round left. Not gated on a
# capability or a permission: a tool that exists on exactly one turn of
+72
View File
@@ -1859,3 +1859,75 @@ MESSAGES.update(
'Image review': 'Posudzovanie obrázkov',
}
)
# --- Model rules (1.11.0) ------------------------------------------------------
MESSAGES.update(
{
'Model rules': 'Pravidlá modelov',
"Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.": 'Ktorý model môže priviesť ktorý do konverzácie: ako člena skupiny, ako priateľa, ktorého sa pýta, a na zozname ďalších modelov, o ktorých sa dozvie. Pravidlo sa číta od vlastného modelu konverzácie. Model v inej dátovej oblasti sa neponúka, kým to pravidlo nepovolí.',
'The starting point': 'Východisko',
'Any model may talk to any other, except where a rule forbids it': 'Každý model môže hovoriť s každým, okrem prípadov, keď to pravidlo zakazuje',
'No model may talk to another, except where a rule allows it': 'Žiadny model nesmie hovoriť s iným, okrem prípadov, keď to pravidlo povoľuje',
'People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.': 'Ľudia si to pre seba môžu vždy zúžiť. Rozšíriť si to pre seba môžu len s oprávnením prekonať pravidlá modelov.',
'Rules': 'Pravidlá',
'Not offered to this model': 'Tomuto modelu sa neponúkajú',
'You may still add it yourself.': 'Môžete ho aj tak pridať sami.',
'any model': 'ľubovoľný model',
'may talk': 'smie hovoriť',
'may not talk': 'nesmie hovoriť',
'Delete this rule': 'Zmazať toto pravidlo',
'No rules yet.': 'Zatiaľ žiadne pravidlá.',
'This model': 'Tento model',
"The chat's own model.": 'Vlastný model konverzácie.',
'May': 'Smie',
'may not talk to': 'nesmie hovoriť s',
'may talk to': 'smie hovoriť s',
'That model': 'Tamten model',
'A crowd member, a friend it asks, a model on its roster.': 'Člen skupiny, priateľ, ktorého sa pýta, model na jeho zozname.',
'Both directions': 'Oboma smermi',
'Add rule': 'Pridať pravidlo',
'Who may talk to whom': 'Kto smie hovoriť s kým',
"Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.": 'Riadky sú vlastný model konverzácie, stĺpce model, ktorý by priviedol. Kreslí to tie isté pravidlá, ktoré sa uplatňujú, takže čo tu vidíte, to sa aj stane.',
'itself': 'on sám',
'Offered': 'Ponúka sa',
'Who your models may talk to': 'S kým smú hovoriť vaše modely',
"Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.": 'Vaše pravidlá a vaše nastavenie majú pre vás prednosť pred pravidlami inštancie. Do skupiny môžete ručne pridať aj ľubovoľný model.',
"Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.": 'Vaše pravidlá môžu pravidlá inštancie len zúžiť. Model, ktorý zadrží vaše vlastné pravidlo, môžete do skupiny aj tak pridať ručne; model, ktorý zadrží inštancia, nie.',
'Your starting point': 'Vaše východisko',
'Follow the instance': 'Podľa inštancie',
'Any model may talk to any other': 'Každý model môže hovoriť s každým',
'No model may talk to another': 'Žiadny model nesmie hovoriť s iným',
"The instance's rule %(a)s → %(b)s allows it.": 'Pravidlo inštancie %(a)s → %(b)s to povoľuje.',
"The instance's rule %(a)s → %(b)s forbids it.": 'Pravidlo inštancie %(a)s → %(b)s to zakazuje.',
'Your rule %(a)s → %(b)s allows it.': 'Vaše pravidlo %(a)s → %(b)s to povoľuje.',
'Your rule %(a)s → %(b)s forbids it.': 'Vaše pravidlo %(a)s → %(b)s to zakazuje.',
'It is in another data group.': 'Je v inej dátovej oblasti.',
'The instance lets no model talk to another.': 'Inštancia nedovoľuje žiadnemu modelu hovoriť s iným.',
'Your setting lets no model talk to another.': 'Vaše nastavenie nedovoľuje žiadnemu modelu hovoriť s iným.',
}
)
# --- Helpers on another model (1.12.0) -----------------------------------------
MESSAGES.update(
{
'Holds one model at a time': 'Drží naraz jeden model',
'Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.': 'Označte pri llama-swap pred jednou GPU alebo čomkoľvek, čo jeden model uvoľní, aby obslúžilo iný. Model tu potom môže byť vlastným pomocníkom, ale nikdy nepošle pomocníka inému modelu na tomto spojení: jeho načítanie by uvoľnilo model, ktorého odpoveď čaká.',
'Serves one request at a time': 'Obsluhuje naraz jednu požiadavku',
'Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.': 'Označte pri modeli s jedným slotom. Potom nemôže byť vlastným pomocníkom, lebo pomocník by čakal za odpoveďou, ktorá čaká naňho. Iné modely môžu byť jeho pomocníkmi aj tak.',
"Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.": 'Iné modely, ku ktorým môže tento posielať pomocníkov, pre všetkých. Ponúkané si môže vybrať sám; ostatné sa dajú do konverzácie pridať len ručne. Vždy je aj vlastným pomocníkom, pokiaľ neobsluhuje naraz jednu požiadavku. Pravidlá modelov a kapacita spojenia sa kontrolujú pri odoslaní pomocníka.',
'offered to the model': 'ponúkané modelu',
'by hand only': 'len ručne',
'Remove this helper': 'Odstrániť tohto pomocníka',
'Helper model': 'Model pomocníka',
'Offer it to the model': 'Ponúknuť ho modelu',
'Add helper': 'Pridať pomocníka',
'Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.': 'Modely, ku ktorým môže tento posielať pomocníkov. Označením ho pridáte do tejto konverzácie; ponúkaný si model môže vybrať aj tak.',
'Offered to the model.': 'Ponúkaný modelu.',
'Only when added here.': 'Len keď ho pridáte tu.',
'Your helper models': 'Vaše modely pomocníkov',
"Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.": 'Modely, ku ktorým môže každý z vašich modelov posielať pomocníkov, len pre vás, navyše k tým od inštancie. Váš riadok pre tú istú dvojicu nahradí riadok inštancie.',
"Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.": 'Nastavuje správca na stránke každého modelu. Na pridanie vlastných potrebujete oprávnenie vyberať si vlastné modely pomocníkov.',
'May send helpers to': 'Smie posielať pomocníkov k',
"It cannot run beside this model's reply: one of them serves one request at a time, or their connection holds one model at a time.": 'Nemôže bežať popri odpovedi tohto modelu: jeden z nich obsluhuje naraz jednu požiadavku alebo ich spojenie drží naraz jeden model.',
}
)
+44
View File
@@ -1954,3 +1954,47 @@ body.is-resizing .canvas__body { pointer-events: none; }
}
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
box-shadow var(--dur-2) var(--ease-out); }
/* --- Who may talk to whom --------------------------------------------------
The talk-rules matrix: one row per main model, one column per model it would
bring in. Its own scroller, because a dozen model ids across is wider than a
phone and the page must never scroll sideways. */
.talk-matrix-scroll {
overflow-x: auto;
max-width: 100%;
}
.talk-matrix {
border-collapse: collapse;
font-size: var(--text-xs);
}
.talk-matrix th,
.talk-matrix td {
padding: var(--sp-1) var(--sp-2);
border: var(--border-w) solid var(--border);
text-align: center;
white-space: nowrap;
}
.talk-matrix thead th { color: var(--ink-muted); font-weight: 500; }
.talk-matrix tbody th { text-align: left; color: var(--ink-muted); font-weight: 500; }
.talk-matrix__yes { color: var(--leaf); }
.talk-matrix__no { color: var(--danger); }
.talk-matrix__self { color: var(--ink-faint); }
/* The add-a-rule form: three fields on a shared track so the label, the
control and the hint of each line up whatever the text does -- the tree's
subgrid rule. Stacks below a phone's width. */
.talk-rule-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
grid-template-rows: auto auto auto;
column-gap: var(--sp-3);
min-width: 0;
margin-top: var(--sp-4);
}
.talk-rule-form > .field {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
min-width: 0;
}
.talk-rule-form > .btn-row { grid-column: 1 / -1; }
@@ -121,6 +121,15 @@
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="one_model_at_a_time" value="true"
{{ 'checked' if connection.one_model_at_a_time }}>
<span>{{ t("Holds one model at a time") }}</span>
</label>
<p class="field__hint">{{ t("Tick this for llama-swap in front of one GPU, or anything else that unloads one model to serve another. A model here may then be its own helper, but never send a helper to another model on this connection: loading it would unload the model whose reply is waiting.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
@@ -63,6 +63,10 @@
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("A crowd") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'rules' }}" href="/admin/rules">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("Model rules") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">{{ t("Audio") }}</span>
@@ -306,6 +306,15 @@
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="single_session" value="true"
{{ 'checked' if model.single_session }}>
<span>{{ t("Serves one request at a time") }}</span>
</label>
<p class="field__hint">{{ t("Tick this for a model with a single slot. It then cannot be its own helper, because the helper would wait behind the reply that is waiting for it. Other models can still be its helpers.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
@@ -343,6 +352,47 @@
{# Outside the form above, and it has to be: two forms cannot nest, and this one
posts somewhere else. See the note beside the Detect button. #}
<section class="card">
<h2 class="card__title">{{ t("Helpers") }}</h2>
<p class="card__lede">{{ t("Other models this one may send helpers to, for everybody. Offered ones it may choose itself; the rest can only be added to a chat by hand. It is always its own helper too, unless it serves one request at a time. The model rules and the connection's capacity are checked when a helper is sent.") }}</p>
{% if designations %}
<ul class="model-list">
{% for row in designations %}
<li class="model-list__item">
<strong class="mono">{{ row.helper_model }}</strong>
<div class="btn-row">
{% if row.offer %}
<span class="badge badge--leaf">{{ t("offered to the model") }}</span>
{% else %}
<span class="badge">{{ t("by hand only") }}</span>
{% endif %}
<form id="remove-helper-{{ row.id }}" method="post"
action="/admin/models/{{ model.id }}/helpers/{{ row.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="remove-helper-{{ row.id }}"
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% if helper_choices %}
<form method="post" action="/admin/models/{{ model.id }}/helpers" class="row"
style="gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-4)">
<label class="visually-hidden" for="helper-model">{{ t("Helper model") }}</label>
<select class="select" id="helper-model" name="helper_model" style="flex: 1; min-width: 0">
{% for choice in helper_choices %}<option value="{{ choice }}">{{ choice }}</option>{% endfor %}
</select>
<label class="checkbox">
<input type="checkbox" name="offer" value="true" checked>
<span>{{ t("Offer it to the model") }}</span>
</label>
<button class="btn" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
</form>
{% endif %}
</section>
<section class="card">
<h2 class="card__title">{{ t("Default personality") }}</h2>
<p class="card__lede">
+38
View File
@@ -0,0 +1,38 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "rules" %}
{% block title %}{{ t("Model rules") }} - {{ brand.name }}{% endblock %}
{% block heading %}{{ t("Model rules") }}{% endblock %}
{% block admin_content %}
<p class="admin-lede">{{ t("Which model may bring which into a conversation: as a crowd member, as a friend it asks, and on the list of other models it is told about. A rule is read from the chat's own model. A model in another data group is not offered unless a rule allows it.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<section class="card">
<h2 class="card__title">{{ t("The starting point") }}</h2>
<form method="post" action="/admin/rules/mode">
<div class="field">
<label class="checkbox">
<input type="radio" name="mode" value="open" {{ 'checked' if mode == 'open' }}>
<span>{{ t("Any model may talk to any other, except where a rule forbids it") }}</span>
</label>
<label class="checkbox">
<input type="radio" name="mode" value="closed" {{ 'checked' if mode == 'closed' }}>
<span>{{ t("No model may talk to another, except where a rule allows it") }}</span>
</label>
<p class="field__hint">{{ t("People can always narrow this for themselves. Widening it for themselves needs the permission to override the model rules.") }}</p>
</div>
<div class="btn-row"><button class="btn btn--primary" type="submit">{{ t("Save") }}</button></div>
</form>
</section>
<section class="card">
<h2 class="card__title">{{ t("Rules") }}</h2>
{% set rules_action = "/admin/rules" %}
{% include "partials/_talk_rules.html" %}
</section>
{% endblock %}
+90 -2
View File
@@ -314,7 +314,60 @@
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{% if crowd_available %}
{#
Helpers: models designated for this chat's model that it may send a
helper to. Ticking one adds it to this chat by hand; an offered one
the model may use anyway. The crowd picker's shape and its reasons:
a sibling empty form reached by `form=`, one hidden field always
sent, and the verb on the checkbox.
#}
{% if helper_choices %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="{{ t('Helpers') }}" title="{{ t('Helpers') }}">
{{ icon("bolt") }}
{% if helper_member_ids %}
<span class="composer__count">{{ helper_member_ids|length }}</span>
{% endif %}
</button>
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
hidden aria-label="{{ t('Helpers') }}">
<p class="picker__lede">
{{ t("Models this one may send helpers to. Tick one to add it to this chat; one offered to the model it may choose anyway.") }}
</p>
{% if chat %}
<input type="hidden" name="helper_model_ids" value="" form="helpers-form">
{% else %}
<input type="hidden" name="helper_model_ids" value="">
{% endif %}
{% for choice in helper_choices %}
<label class="picker__option picker__option--toggle">
{% if chat %}
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
{{ 'checked' if choice.model.model_id in helper_member_ids }}
{{ 'disabled' if not choice.addable }}
form="helpers-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="helper_model_ids" value="{{ choice.model.model_id }}"
{{ 'disabled' if not choice.addable }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ choice.model.label }}</span>
<span class="picker__option-note">
{%- if helper_reasons.get(choice.model.model_id) %}{{ helper_reasons[choice.model.model_id] }}
{%- elif choice.offer %}{{ t("Offered to the model.") }}
{%- else %}{{ t("Only when added here.") }}{% endif -%}
</span>
</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if crowd_available or crowd_held_back %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
@@ -366,6 +419,38 @@
</span>
</label>
{% endfor %}
{#
Models the talk rules do not offer to this chat's model, named
with the reason rather than left out. Tickable where the person
may still add them by hand -- their own rule, or the override --
and shown disabled where they may not, so the reason is visible
either way. Same field and same verb as the list above.
#}
{% if crowd_held_back %}
<p class="picker__group">{{ t("Not offered to this model") }}</p>
{% for row in crowd_held_back %}
<label class="picker__option picker__option--toggle">
{% if chat %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ row.model.model_id }}"
{{ 'checked' if row.model.model_id in crowd_member_ids }}
{{ 'disabled' if not row.addable }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ row.model.label }}</span>
<span class="picker__option-note">
{{ row.reason }}
{% if row.addable %}{{ t("You may still add it yourself.") }}{% endif %}
</span>
</span>
</label>
{% endfor %}
{% endif %}
{% if crowd_member_ids %}
{# One sentence and not three, with the numbers as placeholders: a
translation puts the parts in its own order, and two of these
@@ -593,7 +678,10 @@
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #}
{% if chat and crowd_available %}
{% if chat and helper_choices %}
<form id="helpers-form"></form>
{% endif %}
{% if chat and (crowd_available or crowd_held_back) %}
{# Empty, and a sibling of the composer's form rather than inside it. See the
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
of that sentence matter. #}
@@ -0,0 +1,104 @@
{#
The rules list, the add form and the matrix, shared by the admin page and a
person's own settings. The caller sets `rules_action` (where the add form and
the deletes post), and passes `rules`, `model_ids`, `matrix`, `matrix_models`,
`any_model`, `allow` and `deny`.
The delete buttons reach one empty form each by `form=`, declared outside the
add form, so no form is ever nested inside another -- a nested <form>
truncates its parent, the 1.3.0 bug.
#}
{% from "_macros.html" import icon %}
{% if rules %}
<ul class="model-list">
{% for rule in rules %}
<li class="model-list__item">
<div style="min-width: 0">
<strong class="mono">{{ t("any model") if rule.from_model == any_model else rule.from_model }}</strong>
→
<strong class="mono">{{ t("any model") if rule.to_model == any_model else rule.to_model }}</strong>
</div>
<div class="btn-row">
{% if rule.effect == allow %}
<span class="badge badge--leaf">{{ t("may talk") }}</span>
{% else %}
<span class="badge badge--danger">{{ t("may not talk") }}</span>
{% endif %}
<form id="delete-rule-{{ rule.id }}" method="post" action="{{ rules_action }}/{{ rule.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="delete-rule-{{ rule.id }}"
aria-label="{{ t('Delete this rule') }}" title="{{ t('Delete this rule') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="field__hint">{{ t("No rules yet.") }}</p>
{% endif %}
<form method="post" action="{{ rules_action }}" class="talk-rule-form">
<div class="field">
<label class="field__label" for="rule-from">{{ t("This model") }}</label>
<select class="select" id="rule-from" name="from_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("The chat's own model.") }}</p>
</div>
<div class="field">
<label class="field__label" for="rule-effect">{{ t("May") }}</label>
<select class="select" id="rule-effect" name="effect">
<option value="{{ deny }}">{{ t("may not talk to") }}</option>
<option value="{{ allow }}">{{ t("may talk to") }}</option>
</select>
<p class="field__hint"></p>
</div>
<div class="field">
<label class="field__label" for="rule-to">{{ t("That model") }}</label>
<select class="select" id="rule-to" name="to_model">
<option value="{{ any_model }}">{{ t("any model") }}</option>
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("A crowd member, a friend it asks, a model on its roster.") }}</p>
</div>
<div class="btn-row">
<label class="checkbox">
<input type="checkbox" name="both" value="true">
<span>{{ t("Both directions") }}</span>
</label>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add rule") }}</button>
</div>
</form>
{% if matrix_models|length > 1 %}
<h3 class="card__title" style="margin-top: var(--sp-6)">{{ t("Who may talk to whom") }}</h3>
<p class="field__hint">{{ t("Rows are the chat's own model, columns the model it would bring in. Drawn by the same rules that are enforced, so what this shows is what happens.") }}</p>
<div class="talk-matrix-scroll">
<table class="talk-matrix">
<thead>
<tr>
<th scope="col"></th>
{% for model in matrix_models %}<th scope="col" class="mono">{{ model.model_id }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in matrix %}
<tr>
<th scope="row" class="mono">{{ row.main.model_id }}</th>
{% for cell in row.cells %}
{% if cell is none %}
<td class="talk-matrix__self" aria-label="{{ t('itself') }}">·</td>
{% elif cell.offered %}
<td class="talk-matrix__yes" title="{{ t('Offered') }}">✓</td>
{% else %}
<td class="talk-matrix__no" title="{{ describe_verdict(cell) }}">✗</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
+94
View File
@@ -185,6 +185,100 @@
</ul>
</div>
{% endif %}
{#
Who your models may talk to. Anybody may narrow the instance's rules
for themselves; widening them takes `rules.override`, and the text
below says which of the two this person has, so a rule that does
nothing is not a mystery.
#}
<div class="card">
<h2 class="card__title">{{ t("Who your models may talk to") }}</h2>
<p class="card__lede">
{% if talk_override %}
{{ t("Your rules and your setting win over the instance's, for you. You can also add any model to a crowd by hand.") }}
{% else %}
{{ t("Your rules can only narrow the instance's. A model your own rule holds back can still be added to a crowd by hand; one the instance holds back cannot.") }}
{% endif %}
</p>
<form method="post" action="/api/preferences/talk-mode" class="row"
style="gap: var(--sp-2); margin-bottom: var(--sp-4)">
<label class="visually-hidden" for="talk-mode">{{ t("Your starting point") }}</label>
<select class="select" id="talk-mode" name="mode" style="flex: 1; min-width: 0">
<option value="" {{ 'selected' if not talk_mode }}>{{ t("Follow the instance") }}</option>
<option value="open" {{ 'selected' if talk_mode == 'open' }}>{{ t("Any model may talk to any other") }}</option>
<option value="closed" {{ 'selected' if talk_mode == 'closed' }}>{{ t("No model may talk to another") }}</option>
</select>
<button class="btn" type="submit">{{ t("Save") }}</button>
</form>
{% set rules_action = "/api/preferences/talk-rules" %}
{% set rules = talk_rules %}
{% set matrix = talk_matrix %}
{% set matrix_models = talk_matrix_models %}
{% include "partials/_talk_rules.html" %}
</div>
{#
A person's own helper designations. Shown to everybody who could
send a helper, so the instance's arrangement is visible; editable
only with `helpers.designate`, because a designation decides where
a person's tasks are sent.
#}
{% if helper_settings_shown %}
<div class="card">
<h2 class="card__title">{{ t("Your helper models") }}</h2>
<p class="card__lede">
{% if may_designate %}
{{ t("Models each of your models may send helpers to, for you alone, on top of the instance's. Your row for the same pair replaces the instance's.") }}
{% else %}
{{ t("Set by the administrator on each model's page. Adding your own needs the permission to choose your own helper models.") }}
{% endif %}
</p>
{% if own_designations %}
<ul class="model-list">
{% for row in own_designations %}
<li class="model-list__item">
<div style="min-width: 0">
<strong class="mono">{{ row.main_model }}</strong> → <strong class="mono">{{ row.helper_model }}</strong>
</div>
<div class="btn-row">
<span class="badge {{ 'badge--leaf' if row.offer }}">{{ t("offered to the model") if row.offer else t("by hand only") }}</span>
<form id="del-helper-{{ row.id }}" method="post" action="/api/preferences/helpers/{{ row.id }}/delete"></form>
<button class="btn btn--icon btn--sm btn--danger" type="submit" form="del-helper-{{ row.id }}"
aria-label="{{ t('Remove this helper') }}" title="{{ t('Remove this helper') }}">
{{ icon("trash", "icon--sm") }}
</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% if may_designate %}
<form method="post" action="/api/preferences/helpers" class="talk-rule-form">
<div class="field">
<label class="field__label" for="helper-main">{{ t("This model") }}</label>
<select class="select" id="helper-main" name="main_model">
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint">{{ t("The chat's own model.") }}</p>
</div>
<div class="field">
<label class="field__label" for="helper-model">{{ t("May send helpers to") }}</label>
<select class="select" id="helper-model" name="helper_model">
{% for model_id in model_ids %}<option value="{{ model_id }}">{{ model_id }}</option>{% endfor %}
</select>
<p class="field__hint"></p>
</div>
<div class="btn-row">
<label class="checkbox">
<input type="checkbox" name="offer" value="true" checked>
<span>{{ t("Offer it to the model") }}</span>
</label>
<button class="btn btn--primary" type="submit">{{ icon("plus", "icon--sm") }} {{ t("Add helper") }}</button>
</div>
</form>
{% endif %}
</div>
{% endif %}
</section>
{# --- Appearance --- #}
+6
View File
@@ -144,7 +144,11 @@ def test_a_chat_whose_model_moved_is_refused_rather_than_sent(db, owner, setup):
# --- Other models reaching the conversation ------------------------------------------
def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
"""For somebody without `rules.override`: a different group is a deny only a
rule opens. (An administrator holds every permission, so the owner is demoted.)"""
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
owner.role = "user"
db.commit()
local, _ = setup
chat = _chat(db, owner, connection=local)
client.patch(f"/api/chats/{chat.id}", data={"crowd_model_ids": ["local-b", "cloud-model"]})
@@ -153,6 +157,8 @@ def test_a_crowd_member_from_another_group_is_refused(client, db, owner, setup):
def test_a_crowd_member_that_left_the_group_does_not_speak(db, owner, setup):
owner.role = "user"
db.commit()
local, cloud = setup
chat = _chat(db, owner, connection=local)
db.add(CrowdMember(chat_id=chat.id, model_id="cloud-model", connection_id=cloud.id))
+342
View File
@@ -0,0 +1,342 @@
"""Helpers on another model: capacity, designations, the choice, and the screens.
`test_subagent.py` covers what a helper is *not* given; this covers which model
it runs on. The generation loop is stubbed the way that file stubs it, and the
tool is always reached through `resolve_tools`, because what may be run is what
was offered.
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import select
from lembas.db.models import (
Chat,
ChatHelper,
Connection,
DataGroup,
HelperDesignation,
Model,
User,
)
from lembas.services import helpers, settings_store, talk
from lembas.services import subagent as subagent_service
from lembas.services import tools as tools_service
from lembas.services.crypto import encrypt
@pytest.fixture(autouse=True)
def setup(db, registered):
"""Helpers on, three models on one local connection and one hosted model."""
settings_store.update(db, {"enabled": True}, key=settings_store.SUBAGENTS)
settings_store.update(db, {"default_permissions": {"tools.subagent": True}})
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(name="Cloud", base_url="http://127.0.0.1:2", api_key_encrypted=encrypt(""))
db.add_all([local, cloud])
db.flush()
tools = {"tools": True}
db.add_all(
[
Model(connection_id=local.id, model_id="gpt-oss", capabilities_json=tools,
position=0, params_json={"reasoning_effort": "high"}),
Model(connection_id=local.id, model_id="qwen35", capabilities_json=tools,
position=1),
Model(connection_id=local.id, model_id="bonsai", capabilities_json=tools,
position=2, params_json={"reasoning_effort": "xhigh"},
reasoning_efforts=["low", "medium", "xhigh"]),
Model(connection_id=cloud.id, model_id="deepseek", capabilities_json=tools,
position=3),
]
)
db.commit()
subagent_service.clear()
yield {"local": local, "cloud": cloud}
subagent_service.clear()
def _user(db) -> User:
return db.scalars(select(User).order_by(User.created_at)).first()
def _chat(db, model_id="gpt-oss", connection=None) -> Chat:
connection_id = connection.id if connection else db.scalar(
select(Model.connection_id).where(Model.model_id == model_id)
)
chat = Chat(user_id=_user(db).id, title="t", model_id=model_id, connection_id=connection_id)
db.add(chat)
db.commit()
return chat
def _row(db, model_id) -> Model:
return db.scalar(select(Model).where(Model.model_id == model_id))
def _ids(found) -> list[str]:
return [c.model.model_id for c in found]
def _spawn(monkeypatch):
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER
from lembas.db.session import session_scope
from lembas.services import chat as chat_service
seen: dict[str, str] = {}
async def fake_wake(chat_id: str, content: str, *, model_id: str = "") -> str:
seen["chat_id"] = chat_id
with session_scope() as db:
child = db.get(Chat, chat_id)
chat_service.create_message(db, child, ROLE_USER, content)
reply = chat_service.create_message(db, child, ROLE_ASSISTANT, "Done.")
return reply.id
monkeypatch.setattr("lembas.services.wake.wake_chat", fake_wake)
monkeypatch.setattr("lembas.services.generation.running_for", lambda chat_id: None)
return seen
async def _run(db, chat: Chat, args: dict):
from lembas.services import generation as generation_service
class _Fake:
subagents = 0
user = _user(db)
resolved = tools_service.resolve_tools(db, chat, user)
context = tools_service.context_for(db, user, chat, tools=resolved)
original = generation_service.running_for
generation_service.running_for = (
lambda chat_id: _Fake() if chat_id == chat.id else original(chat_id)
)
try:
return await tools_service.run_tool(context, "subagent_run", json.dumps(args))
finally:
generation_service.running_for = original
def _schema(db, chat):
resolved = tools_service.resolve_tools(db, chat, _user(db))
tool = resolved.by_name.get("subagent_run")
return tool.parameters["properties"] if tool else None
# --- Capacity ---------------------------------------------------------------------------
def test_by_default_a_model_is_its_own_helper_and_the_tool_is_unchanged(db):
"""Both switches off, nothing designated: exactly the 1.11 behaviour."""
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
assert "model" not in _schema(db, chat)
def test_a_single_session_model_cannot_help_itself(db):
_row(db, "gpt-oss").single_session = True
db.commit()
chat = _chat(db)
assert helpers.candidates(db, chat, _user(db)) == []
# Nothing to send a helper to, so the tool is not offered at all.
assert _schema(db, chat) is None
def test_one_model_at_a_time_forbids_another_model_on_that_connection(db, setup):
main, other = _row(db, "gpt-oss"), _row(db, "qwen35")
assert helpers.capacity_refusal(main, other) == ""
setup["local"].one_model_at_a_time = True
db.commit()
assert "holds one model at a time" in helpers.capacity_refusal(main, other)
# ...but itself, and a model on another connection, are fine.
assert helpers.capacity_refusal(main, main) == ""
assert helpers.capacity_refusal(main, _row(db, "deepseek")) == ""
# --- Designations ------------------------------------------------------------------------
def test_an_offered_designation_joins_the_candidates_and_the_enum(db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
assert _schema(db, chat)["model"]["enum"] == ["gpt-oss", "qwen35"]
def test_a_by_hand_designation_is_used_only_once_added_to_the_chat(db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
chat = _chat(db)
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss"]
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
db.commit()
assert _ids(helpers.candidates(db, chat, _user(db))) == ["gpt-oss", "qwen35"]
def test_designations_are_per_main_model(db):
helpers.set_designation(db, None, "bonsai", "qwen35", offer=True)
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
def test_a_persons_designations_need_the_permission_and_replace_the_instances(db):
user = _user(db)
user.role = "user"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
helpers.set_designation(db, user, "gpt-oss", "qwen35", offer=False)
assert [d.owner_id for d in helpers.designations(db, user, "gpt-oss")] == [None]
settings_store.update(
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
)
rows = helpers.designations(db, user, "gpt-oss")
assert [(d.owner_id, d.offer) for d in rows] == [(user.id, False)]
def test_the_talk_rules_filter_designations(db):
user = _user(db)
user.role = "user"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
talk.set_rule(db, None, "gpt-oss", "qwen35", "deny")
assert _ids(helpers.candidates(db, _chat(db), user)) == ["gpt-oss"]
def test_another_data_group_needs_a_rule(db, setup):
user = _user(db)
user.role = "user"
db.add(DataGroup(id="hosted", name="Hosted"))
setup["cloud"].data_group_id = "hosted"
db.commit()
helpers.set_designation(db, None, "gpt-oss", "deepseek", offer=True)
assert "deepseek" not in _ids(helpers.candidates(db, _chat(db), user))
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
assert "deepseek" in _ids(helpers.candidates(db, _chat(db), user))
# --- The choice, at call time -------------------------------------------------------------
async def test_a_named_helper_runs_on_that_model_with_its_own_effort(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
chat = _chat(db)
chat.params_json = {"reasoning_effort": "high"}
db.commit()
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
outcome = await _run(db, chat, {"task": "Check it.", "model": "bonsai"})
assert outcome.event["status"] == "ok"
child = db.get(Chat, seen["chat_id"])
assert child.model_id == "bonsai"
# Bonsai's own default, never the parent's `high`, which it would refuse.
assert child.params_json["reasoning_effort"] == "xhigh"
assert child.parent_chat_id == chat.id
async def test_no_model_named_is_the_main_model_itself(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
seen = _spawn(monkeypatch)
settings_store.update(db, {"keep_transcript": True}, key=settings_store.SUBAGENTS)
await _run(db, _chat(db), {"task": "Check it."})
assert db.get(Chat, seen["chat_id"]).model_id == "gpt-oss"
async def test_a_model_that_is_not_a_candidate_is_refused_with_the_list(db, monkeypatch):
helpers.set_designation(db, None, "gpt-oss", "bonsai", offer=True)
_spawn(monkeypatch)
outcome = await _run(db, _chat(db), {"task": "Check it.", "model": "deepseek"})
assert outcome.event["status"] == "error"
assert "bonsai" in outcome.content
def test_without_itself_the_default_is_the_first_hand_added_helper(db):
_row(db, "gpt-oss").single_session = True
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
db.commit()
chat = _chat(db)
helpers.apply_chat_helpers(db, chat, _user(db), ["qwen35"])
db.commit()
model, refusal = helpers.choose(db, chat, _user(db), "")
assert model.model_id == "qwen35" and refusal == ""
def test_the_harness_lists_where_a_helper_can_run(db):
from lembas.services import harness
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=True)
chat = _chat(db)
resolved = tools_service.resolve_tools(db, chat, _user(db))
text = harness.compose(db, _user(db), resolved.schemas, chat=chat)
assert "Where a helper can run" in text
assert "qwen35 (qwen35)" in text
# --- The screens -----------------------------------------------------------------------------
def test_the_model_page_designates_a_helper(client, db):
model = _row(db, "gpt-oss")
client.post(f"/admin/models/{model.id}/helpers", data={"helper_model": "qwen35",
"offer": "true"})
row = db.scalar(select(HelperDesignation))
assert (row.main_model, row.helper_model, row.offer) == ("gpt-oss", "qwen35", True)
assert "offered to the model" in client.get(f"/admin/models/{model.id}/edit").text
def test_the_model_page_saves_single_session(client, db):
model = _row(db, "gpt-oss")
page = client.get(f"/admin/models/{model.id}/edit").text
assert 'name="single_session"' in page
client.post(f"/admin/models/{model.id}", data={"display_name": "", "enabled": "true",
"public": "true", "single_session": "true"})
db.expire_all()
assert _row(db, "gpt-oss").single_session is True
def test_the_connection_form_saves_one_model_at_a_time(client, db, setup):
local = setup["local"]
client.post(f"/admin/connections/{local.id}", data={"name": "Local",
"base_url": local.base_url, "one_model_at_a_time": "true"})
db.expire_all()
assert db.get(Connection, local.id).one_model_at_a_time is True
def test_the_composer_picker_adds_a_helper_by_hand(client, db):
helpers.set_designation(db, None, "gpt-oss", "qwen35", offer=False)
chat = _chat(db)
page = client.get(f"/chat/{chat.id}").text
assert 'name="helper_model_ids"' in page and 'id="helpers-form"' in page
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": ["qwen35"]})
assert [h.model_id for h in db.scalars(select(ChatHelper))] == ["qwen35"]
client.patch(f"/api/chats/{chat.id}", data={"helper_model_ids": [""]})
db.expire_all()
assert list(db.scalars(select(ChatHelper))) == []
def test_a_person_adds_their_own_designation_with_the_permission(client, db):
user = _user(db)
user.role = "user"
db.commit()
data = {"main_model": "gpt-oss", "helper_model": "qwen35", "offer": "true"}
client.post("/api/preferences/helpers", data=data)
assert list(db.scalars(select(HelperDesignation))) == []
settings_store.update(
db, {"default_permissions": {"tools.subagent": True, helpers.PERMISSION: True}}
)
client.post("/api/preferences/helpers", data=data)
assert db.scalar(select(HelperDesignation)).owner_id == user.id
# --- Upgrading ---------------------------------------------------------------------------------
def test_an_upgraded_database_reads_both_switches_as_off(db, setup):
"""Both are NOT NULL booleans, which `sync_schema` backfills with False -- which
is why they name the restrictive state: False has to mean "as before"."""
from sqlalchemy import text
from lembas.db.migrations import sync_schema
from lembas.db.session import get_engine
engine = get_engine()
db.close()
with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS chat_helpers"))
connection.execute(text("DROP TABLE IF EXISTS helper_designations"))
connection.execute(text("ALTER TABLE models DROP COLUMN single_session"))
connection.execute(text("ALTER TABLE connections DROP COLUMN one_model_at_a_time"))
sync_schema(engine)
assert _row(db, "gpt-oss").single_session is False
assert db.get(Connection, setup["local"].id).one_model_at_a_time is False
assert _ids(helpers.candidates(db, _chat(db), _user(db))) == ["gpt-oss"]
+121
View File
@@ -0,0 +1,121 @@
"""`talk.decide`, the one function behind every talk rule, as a table.
Pure -- plain values in, a verdict out -- so every combination is asserted here
with no database, and the admin matrix, which calls the same function, cannot
disagree with what is enforced.
"""
from __future__ import annotations
import pytest
from lembas.services import talk
from lembas.services.talk import Rule, decide, match
ALLOW = Rule("main", "target", "allow")
DENY = Rule("main", "target", "deny")
def _v(**kwargs):
kwargs.setdefault("instance_mode", talk.MODE_OPEN)
kwargs.setdefault("instance_rule", None)
return decide(**kwargs)
# --- The instance's layer ------------------------------------------------------------
@pytest.mark.parametrize(
("mode", "rule", "offered"),
[
(talk.MODE_OPEN, None, True),
(talk.MODE_OPEN, DENY, False),
(talk.MODE_OPEN, ALLOW, True),
(talk.MODE_CLOSED, None, False),
(talk.MODE_CLOSED, ALLOW, True),
(talk.MODE_CLOSED, DENY, False),
],
)
def test_the_instance_mode_and_its_rules(mode, rule, offered):
verdict = _v(instance_mode=mode, instance_rule=rule)
assert verdict.offered is offered
assert verdict.addable is offered
def test_another_data_group_is_a_deny_only_an_explicit_allow_opens():
assert _v(same_group=False).offered is False
assert _v(same_group=False).why == talk.WHY_GROUP
assert _v(same_group=False, instance_rule=ALLOW).offered is True
# An open mode is not an explicit allow.
assert _v(same_group=False, instance_mode=talk.MODE_OPEN).offered is False
# --- A person without the override can only narrow ---------------------------------
def test_a_persons_own_deny_takes_it_off_what_is_offered_but_not_off_what_is_addable():
verdict = _v(user_rule=DENY)
assert verdict.offered is False
assert verdict.addable is True
assert verdict.why == talk.WHY_YOUR_RULE
def test_a_persons_closed_mode_narrows_too():
verdict = _v(user_mode=talk.MODE_CLOSED)
assert (verdict.offered, verdict.addable, verdict.why) == (False, True, talk.WHY_YOUR_CLOSED)
def test_without_the_override_a_persons_allow_cannot_widen():
verdict = _v(instance_rule=DENY, user_rule=ALLOW)
assert (verdict.offered, verdict.addable) == (False, False)
assert verdict.why == talk.WHY_INSTANCE_RULE
def test_without_the_override_open_mode_cannot_widen_a_closed_instance():
verdict = _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN)
assert (verdict.offered, verdict.addable) == (False, False)
# --- With the override, the person's layer wins ---------------------------------------
def test_with_the_override_a_persons_allow_beats_the_instances_deny():
verdict = _v(instance_rule=DENY, user_rule=ALLOW, override=True)
assert (verdict.offered, verdict.addable) == (True, True)
def test_with_the_override_a_persons_rule_opens_another_group():
assert _v(same_group=False, user_rule=ALLOW, override=True).offered is True
def test_with_the_override_open_mode_widens_a_closed_instance_but_not_across_groups():
assert _v(instance_mode=talk.MODE_CLOSED, user_mode=talk.MODE_OPEN, override=True).offered
across = _v(same_group=False, user_mode=talk.MODE_OPEN, override=True)
assert across.offered is False and across.why == talk.WHY_GROUP
# ...unless some rule explicitly allows it.
assert _v(
same_group=False, user_mode=talk.MODE_OPEN, override=True, instance_rule=ALLOW
).offered
def test_with_the_override_and_no_layer_of_their_own_the_instance_decides():
assert _v(instance_rule=DENY, override=True).offered is False
def test_with_the_override_anything_may_be_added_by_hand():
assert _v(instance_rule=DENY, override=True).addable is True
assert _v(same_group=False, override=True).addable is True
# --- Specificity -------------------------------------------------------------------------
def test_the_most_specific_rule_wins():
rules = [
Rule("*", "*", "deny"),
Rule("*", "b", "allow"),
Rule("a", "*", "deny"),
Rule("a", "b", "allow"),
]
assert match(rules, "a", "b").effect == "allow"
assert match(rules, "a", "c") == Rule("a", "*", "deny")
assert match(rules, "x", "b") == Rule("*", "b", "allow")
assert match(rules, "x", "y") == Rule("*", "*", "deny")
assert match([], "a", "b") is None
def test_a_verdict_says_why_in_english_for_a_model():
assert "forbids" in _v(instance_rule=DENY).reason
assert _v().reason == ""
+203
View File
@@ -0,0 +1,203 @@
"""Talk rules end to end: stored, edited, and obeyed by the roster, a friend and a crowd.
The table of what `decide` answers is `test_talk_decide.py`. This file is where
those answers reach something: the roster a model is told, the friend it may
ask, the crowd picker's two lists, a member added by hand that must still speak,
and a rule that lets a model from another data group in.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from lembas.db.models import (
DEFAULT_GROUP,
Chat,
Connection,
CrowdMember,
DataGroup,
Model,
TalkRule,
User,
)
from lembas.services import chat as chat_service
from lembas.services import crowd, settings_store, talk
from lembas.services import subagent as subagent_service
from lembas.services.crypto import encrypt
@pytest.fixture
def owner(db, registered) -> User:
"""The first account, demoted: an administrator holds `rules.override`."""
user = db.scalars(select(User).order_by(User.created_at)).first()
user.role = "user"
db.commit()
return user
@pytest.fixture
def setup(db, owner):
settings_store.update(db, {"enabled": True}, key=settings_store.CROWD)
db.add(DataGroup(id="hosted", name="Hosted"))
local = Connection(name="Local", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
cloud = Connection(
name="Cloud",
base_url="http://127.0.0.1:2",
api_key_encrypted=encrypt(""),
data_group_id="hosted",
)
db.add_all([local, cloud])
db.flush()
for position, (connection, name) in enumerate(
[(local, "gpt-oss"), (local, "qwen38"), (local, "bonsai"), (cloud, "deepseek")]
):
db.add(Model(connection_id=connection.id, model_id=name, position=position))
chat = Chat(
user_id=owner.id,
model_id="gpt-oss",
connection_id=local.id,
data_group_id=DEFAULT_GROUP,
title="t",
)
db.add(chat)
db.commit()
return chat
def _ids(models) -> list[str]:
return [m.model_id for m in models]
def _grant_override(db):
settings_store.update(db, {"default_permissions": {talk.PERMISSION: True}})
# --- The roster and the friend ------------------------------------------------------
def test_an_instance_deny_takes_a_model_off_the_roster(db, owner, setup):
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
roster = chat_service.roster_block(db, owner, exclude="gpt-oss", group=DEFAULT_GROUP)
assert "qwen38" not in roster and "bonsai" in roster
def test_a_friend_the_rules_forbid_is_refused_with_the_reason(db, owner, setup):
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
friend, refusal = subagent_service._resolve_friend(
db, owner, "qwen38", asking="gpt-oss", group=DEFAULT_GROUP
)
assert friend is None
listed = refusal.split("These are the ones you can:")[-1]
assert "qwen38" not in listed
def test_the_rule_is_read_from_the_main_model_only(db, owner, setup):
"""bonsai may not talk to qwen38 -- which says nothing about gpt-oss's chats."""
talk.set_rule(db, None, "bonsai", "qwen38", "deny")
assert "qwen38" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
assert "qwen38" not in _ids(talk.offered(db, owner, "bonsai", DEFAULT_GROUP))
def test_a_closed_instance_offers_only_what_is_allowed(db, owner, setup):
settings_store.update(db, {"mode": "closed"}, key=settings_store.RULES)
talk.set_rule(db, None, "gpt-oss", "bonsai", "allow")
assert _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]
# --- Other data groups -----------------------------------------------------------------
def test_another_group_is_not_offered_until_a_rule_allows_it(db, owner, setup):
assert "deepseek" not in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
talk.set_rule(db, None, "gpt-oss", "deepseek", "allow")
assert "deepseek" in _ids(talk.offered(db, owner, "gpt-oss", DEFAULT_GROUP))
def test_a_member_from_another_group_reads_its_own_groups_data(db, owner, setup):
from lembas.services import data_groups
speaker = chat_service.Speaker("deepseek", None)
assert data_groups.for_speaker(db, owner, setup, speaker) == "hosted"
# --- The crowd: offered, and by hand -------------------------------------------------------
def test_the_picker_names_what_it_holds_back_and_why(client, db, owner, setup):
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
page = client.get(f"/chat/{setup.id}").text
held = page.split("Not offered to this model")[1]
assert "qwen38" in held and "bonsai" in held
assert "The instance&#39;s rule" in held or "The instance's rule" in held
assert "You may still add it yourself." in held
def test_a_persons_own_rule_is_soft_for_them(client, db, owner, setup):
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["bonsai"]})
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["bonsai"]
def test_an_instance_rule_is_hard_without_the_override(client, db, owner, setup):
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
assert list(db.scalars(select(CrowdMember))) == []
def test_with_the_override_an_instance_rule_can_be_passed_by_hand(client, db, owner, setup):
_grant_override(db)
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
client.patch(f"/api/chats/{setup.id}", data={"crowd_model_ids": ["qwen38"]})
assert [m.model_id for m in db.scalars(select(CrowdMember))] == ["qwen38"]
def test_a_member_added_by_hand_still_speaks(db, owner, setup):
"""Send time asks `addable`, not `offered`, or the choice would be undone."""
talk.set_rule(db, owner, "gpt-oss", "bonsai", "deny")
db.add(CrowdMember(chat_id=setup.id, model_id="bonsai"))
db.commit()
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss", "bonsai"]
def test_a_member_the_instance_later_forbids_is_skipped_and_named(db, owner, setup):
db.add(CrowdMember(chat_id=setup.id, model_id="qwen38"))
db.commit()
talk.set_rule(db, None, "gpt-oss", "qwen38", "deny")
assert _ids(crowd.member_speakers(db, setup, owner)) == ["gpt-oss"]
assert crowd.unreachable_members(db, setup, owner) == ["qwen38"]
# --- Storing and editing ------------------------------------------------------------------------
def test_both_directions_writes_two_rules_and_a_second_write_replaces(db, setup):
talk.set_rule(db, None, "a", "b", "deny", both=True)
assert {(r.from_model, r.to_model) for r in db.scalars(select(TalkRule))} == {
("a", "b"),
("b", "a"),
}
talk.set_rule(db, None, "a", "b", "allow")
assert db.scalar(select(TalkRule).where(TalkRule.from_model == "a")).effect == "allow"
def test_a_person_cannot_delete_an_instance_rule(db, owner, setup):
talk.set_rule(db, None, "a", "b", "deny")
rule = db.scalar(select(TalkRule))
assert talk.delete_rule(db, owner, rule.id) is False
assert talk.delete_rule(db, None, rule.id) is True
def test_the_admin_page_adds_a_rule_and_draws_the_matrix(client, db, registered, setup):
# The fixture demoted the first account; the page is an administrator's.
user = db.scalars(select(User).order_by(User.created_at)).first()
user.role = "admin"
db.commit()
client.post("/admin/rules", data={"from_model": "gpt-oss", "to_model": "bonsai",
"effect": "deny"})
page = client.get("/admin/rules").text
assert "Who may talk to whom" in page
assert page.count("talk-matrix__no") >= 1
def test_the_settings_card_sets_a_persons_mode_and_rules(client, db, owner, setup):
client.post("/api/preferences/talk-mode", data={"mode": "closed"})
client.post("/api/preferences/talk-rules", data={"from_model": "gpt-oss",
"to_model": "bonsai", "effect": "allow"})
db.expire_all()
user = db.get(User, owner.id)
assert talk.user_mode(user) == "closed"
assert _ids(talk.offered(db, user, "gpt-oss", DEFAULT_GROUP)) == ["bonsai"]