1 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
24 changed files with 1385 additions and 48 deletions
+37
View File
@@ -16,6 +16,43 @@ 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;
+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.11.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,
+7
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)
@@ -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"):
+56
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
@@ -231,6 +232,22 @@ def _talk_settings(db: DBSession, user: User) -> dict:
"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),
}
@@ -298,6 +315,42 @@ def describe_verdict(verdict) -> str:
}.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:
@@ -902,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=",
+49
View File
@@ -412,3 +412,52 @@ async def delete_talk_rule(db: Db, user: RequiredUser, rule_id: str) -> Response
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)
+4
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,
@@ -166,7 +168,9 @@ __all__ = [
"Report",
"Schedule",
"Chat",
"ChatHelper",
"CrowdMember",
"HelperDesignation",
"Job",
"Connection",
"DEFAULT_GROUP",
+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}>"
+12
View File
@@ -347,6 +347,18 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
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",
+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",
+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."
),
},
}
+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
+25
View File
@@ -1906,3 +1906,28 @@ MESSAGES.update(
'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.',
}
)
@@ -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"
@@ -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">
+57 -1
View File
@@ -314,6 +314,59 @@
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{#
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
@@ -625,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. #}
+63
View File
@@ -216,6 +216,69 @@
{% 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 --- #}
+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"]