Work handed to a second model, which may not ask
subagent_run gives a self-contained piece of work to a helper carrying the parent's connection, directory, model and effort, and hands its answer back as the tool result. The mechanism is the one scheduled runs already use -- a hidden chat, one turn, wake_chat, and a poll -- so tools, rounds, budgets, metrics and steps all work with no second implementation. The two alternatives were rejected where they had already been rejected once: a nested Generation is two replies writing one transcript, and a one-shot complete() has no tools, which schedule/runner.py records as useless for exactly this case. Every restriction is a property of the child's row, applied by resolve_tools after the gates, because a rule that lives in a system message is one a page the model just read can argue with. No questions, no recursion, nothing that writes unless the call asked for it and the parent's own mode would not have stopped first, and commands only from a fixed read-only list -- in every mode including Auto, because the task text can have come from a page. Withdrawing ask_user turned out to be half of "nobody is watching". An approval still built a card nobody could see and parked the reply until approval_timeout, which from every screen is the feature not working. Chat.unattended is the question now, and not the kind: _authorise answers with a refusal instead. A scheduled task's chat had the same hole and is covered by the same flag. Three bounds, counted where each is knowable: per reply on the parent's Generation, instance-wide in a set a restart clears, and per helper in settings of its own so one runs out of room long before the reply that asked. Past the clock the helper is stopped rather than abandoned, so a partial answer comes back with a sentence saying so. Also: four gates had shipped into the scope menu with no name, taking the first tool's label instead -- the canvas switch read "Canvas written". There is a test that refuses a family without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "0.9.3"
|
||||
|
||||
@@ -60,11 +60,57 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
|
||||
for p in db.scalars(select(SshProfile))
|
||||
if hosts.is_loopback(p.host) or p.resolves_here
|
||||
),
|
||||
# A group of its own, saved by its own form. Subagents are not an
|
||||
# agent-chat feature -- an ordinary chat can delegate too -- but
|
||||
# this is the page somebody looks at when they want to know what a
|
||||
# reply is allowed to set going on its own, and a nav entry for one
|
||||
# card would be worse than the near-miss.
|
||||
"subagents": settings_store.subagents(db),
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subagents")
|
||||
async def save_subagents(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
enabled: bool = Form(False),
|
||||
max_per_reply: int = Form(4),
|
||||
max_concurrent: int = Form(6),
|
||||
max_rounds: int = Form(30),
|
||||
wall_seconds: int = Form(600),
|
||||
max_completion_tokens: int = Form(60_000),
|
||||
keep_transcript: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Its own route because it is its own settings group.
|
||||
|
||||
A single form writing two groups would mean one save handler deciding which
|
||||
key each field belongs to, which is a mapping that goes wrong silently. Two
|
||||
forms, two keys, and the browser posts only the one that was submitted.
|
||||
"""
|
||||
settings_store.update(
|
||||
db,
|
||||
{
|
||||
"enabled": enabled,
|
||||
# Clamped here as well as on read, for the reason the agent settings
|
||||
# give: a number with no bound is a way to break the instance from a
|
||||
# form. Zero is kept only for the token ceiling, where it means "no
|
||||
# ceiling"; everywhere else a zero would be the feature switched off
|
||||
# wearing the switch's clothes.
|
||||
"max_per_reply": min(max(max_per_reply, 1), 20),
|
||||
"max_concurrent": min(max(max_concurrent, 1), 50),
|
||||
"max_rounds": min(max(max_rounds, 1), 200),
|
||||
"wall_seconds": min(max(wall_seconds, 30), 7200),
|
||||
"max_completion_tokens": min(max(max_completion_tokens, 0), 5_000_000),
|
||||
"keep_transcript": keep_transcript,
|
||||
},
|
||||
key=settings_store.SUBAGENTS,
|
||||
)
|
||||
log.info("subagents %s by %s", "enabled" if enabled else "disabled", user.email)
|
||||
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def save_agents(
|
||||
db: Db,
|
||||
|
||||
@@ -190,7 +190,11 @@ _GATE_LABELS = {
|
||||
"memory": "Memory",
|
||||
"skills": "Skills",
|
||||
"ask": "Asking you questions",
|
||||
"scratch": "Writing in the canvas",
|
||||
"image": "Generating images",
|
||||
"report": "Filing reports",
|
||||
"schedule": "Scheduling work",
|
||||
"subagent": "Sending helpers",
|
||||
"agent": "Running commands",
|
||||
"custom": "Custom tools",
|
||||
"mcp": "MCP servers",
|
||||
|
||||
@@ -257,6 +257,21 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
image_workflow_id: Mapped[str | None] = mapped_column(String(32))
|
||||
image_checkpoint: Mapped[str] = mapped_column(String(300), default="")
|
||||
|
||||
# --- Subagents -----------------------------------------------------------
|
||||
# The chat whose reply spawned this one, when a model delegated a piece of
|
||||
# work. A plain id and not a ForeignKey, for the reason the three above
|
||||
# give, and validated on read. Its presence is what makes a chat a
|
||||
# subagent's: `agent/session.py` sizes it smaller, `services/subagent.py`
|
||||
# refuses to spawn from one, and the sweep finds it.
|
||||
parent_chat_id: Mapped[str | None] = mapped_column(String(32))
|
||||
# Nobody is at the keyboard for this conversation, and nothing in it may
|
||||
# stop to ask. Not the same question as `kind`: a scheduled task's chat is
|
||||
# unattended because of what started it, a subagent's because of what it is,
|
||||
# and a future third thing will be unattended for a third reason. Reading
|
||||
# the flag rather than the kind is what stops each of those needing its own
|
||||
# branch in `resolve_tools` and in `_authorise`.
|
||||
unattended: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Which files are open in the canvas panel, and which of them is in front.
|
||||
# {"tabs": [{"key": "agent:/srv/app/main.py", "title": …, "source": …}],
|
||||
# "active": "agent:/srv/app/main.py"}
|
||||
|
||||
@@ -146,6 +146,17 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
|
||||
False,
|
||||
"Agent",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.subagent",
|
||||
"Delegate to a helper",
|
||||
"Let a model hand a self-contained piece of work to a second one that "
|
||||
"runs on its own and reports back — reading and searching in parallel "
|
||||
"rather than one thing at a time. A helper cannot ask questions, "
|
||||
"cannot spawn helpers of its own, and can only do what this chat could "
|
||||
"already do without stopping to ask.",
|
||||
False,
|
||||
"Chat",
|
||||
),
|
||||
PermissionDef(
|
||||
"tools.ask",
|
||||
"Be asked questions",
|
||||
|
||||
@@ -197,6 +197,35 @@ def refresh(db: DBSession, agent: AgentContext) -> AgentContext:
|
||||
return agent
|
||||
|
||||
|
||||
def _limits_for(db: DBSession, chat: Chat, values: dict[str, Any]) -> Limits:
|
||||
"""What this chat's replies may spend.
|
||||
|
||||
A helper's chat is sized by its own settings rather than the instance's,
|
||||
because a reply answering one delegated question is not the same shape of
|
||||
work as the reply that asked it: it should run out of room long before its
|
||||
parent does, and an agent chat's own numbers are deliberately generous
|
||||
enough to run for a quarter of an hour. `output_bytes` is shared, being a
|
||||
property of what a command can hand back rather than of who asked.
|
||||
|
||||
`or 0` is avoided on the completion ceiling in both branches: zero is how an
|
||||
administrator says "no ceiling", and the accessors have already clamped it.
|
||||
"""
|
||||
if chat.parent_chat_id:
|
||||
sub = settings_store.subagents(db)
|
||||
return Limits(
|
||||
steps=int(sub["max_rounds"]),
|
||||
wall_seconds=float(sub["wall_seconds"]),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
completion_tokens=int(sub.get("max_completion_tokens", 60_000) or 0),
|
||||
)
|
||||
return Limits(
|
||||
steps=int(values.get("max_steps") or 200),
|
||||
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
|
||||
)
|
||||
|
||||
|
||||
def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None:
|
||||
"""This chat's agent setup, or None if it has none it can use.
|
||||
|
||||
@@ -233,14 +262,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
|
||||
# regardless.
|
||||
allow=(*(values.get("allow_default") or ()), *_allow_for(chat)),
|
||||
deny=tuple(values.get("deny_default") or ()),
|
||||
limits=Limits(
|
||||
steps=int(values.get("max_steps") or 200),
|
||||
wall_seconds=float(values.get("max_wall_seconds") or 900),
|
||||
output_bytes=int(values.get("max_total_output_bytes") or 1024 * 1024),
|
||||
# `or 0` would turn a deliberate 0 into the default, and 0 is how an
|
||||
# administrator says "no ceiling". `agents()` has already clamped it.
|
||||
completion_tokens=int(values.get("max_completion_tokens", 200_000) or 0),
|
||||
),
|
||||
limits=_limits_for(db, chat, values),
|
||||
timeout=float(values.get("default_timeout") or 60),
|
||||
max_timeout=float(values.get("max_timeout") or 600),
|
||||
max_output=int(values.get("max_output_bytes") or 64 * 1024),
|
||||
|
||||
@@ -228,6 +228,13 @@ class Generation:
|
||||
# and been told to carry on. Reset the moment it calls a tool again, so the
|
||||
# count is of consecutive stops rather than of stops in total.
|
||||
nudges: int = 0
|
||||
# How many helpers this reply has spawned. Here rather than keyed on the
|
||||
# chat because this object is the only one that knows what "this reply"
|
||||
# means -- a chat-keyed counter would need resetting, and every candidate
|
||||
# for doing the resetting is a place to forget. Read and incremented with
|
||||
# nothing awaited in between, which is what makes it safe against the four
|
||||
# calls a round runs together. See services/subagent.py:_budget.
|
||||
subagents: int = 0
|
||||
# A tool this reply must call, set by `/image` and by nothing else. It goes
|
||||
# into the *first* request only -- `_run` rebuilds the payload's messages
|
||||
# per round but keeps this body, and `tool_choice` left in place would make
|
||||
@@ -514,6 +521,14 @@ async def _run(generation: Generation) -> None:
|
||||
# rejects the whole request.
|
||||
vision = chat_service.model_supports(db, chat, "vision")
|
||||
chat_rounds = settings_store.chat_rounds(db)
|
||||
# A helper's chat is bounded by its own number, not the instance's.
|
||||
# Only reached in an *ordinary* helper chat -- an agent one is sized
|
||||
# by `Limits` below, which `agent/session.py` already narrows the
|
||||
# same way. Without this the round ceiling on a helper is whatever
|
||||
# an ordinary chat has, which by default is none at all, and the
|
||||
# only thing left holding it is the parent's wall clock.
|
||||
if chat.parent_chat_id:
|
||||
chat_rounds = int(settings_store.subagents(db)["max_rounds"])
|
||||
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
|
||||
|
||||
limits = tool_context.agent.limits if tool_context.agent else None
|
||||
@@ -1562,6 +1577,20 @@ async def _authorise(
|
||||
if not items:
|
||||
return {}, set(), set()
|
||||
|
||||
# Nobody can answer, so nothing waits. A scheduled task and a subagent both
|
||||
# run with no reader, and a card built for one of them is a reply doing
|
||||
# nothing for fifteen minutes and then giving up -- indistinguishable, from
|
||||
# every screen, from the feature not working. Answered immediately instead,
|
||||
# in the same shape a refusal takes, so the model reads a sentence it can
|
||||
# act on and the round carries on with everything else in it.
|
||||
#
|
||||
# This is what makes the modes usable here at all: a helper runs in Plan or
|
||||
# Edit, both of which resolve a command to ASK, and ASK arriving here means
|
||||
# "not in this chat" rather than "hold everything". See
|
||||
# services/subagent.py.
|
||||
if getattr(context, "unattended", False):
|
||||
return {item.index: _unanswerable(item) for item in items}, set(), set()
|
||||
|
||||
timeout = float(context.interaction_timeout or 900)
|
||||
pause = interaction.build(uuid.uuid4().hex, items, timeout=timeout)
|
||||
generation.status = interaction.summarise(pause.items)
|
||||
@@ -1646,6 +1675,52 @@ def _apply_edit(
|
||||
return edited
|
||||
|
||||
|
||||
def _unanswerable(item: interaction.Item) -> ToolOutcome:
|
||||
"""What a call gets back in a chat where nobody can be asked.
|
||||
|
||||
Deliberately not worded as a refusal by a person: nobody refused, and a
|
||||
model told "they declined" reasons about a reader who is not there. It says
|
||||
the thing that is actually true and the thing that follows from it -- this
|
||||
cannot happen here, so do the rest without it -- because the alternative a
|
||||
model reaches for otherwise is to ask again in different words.
|
||||
|
||||
Two shapes arrive here. An approval, which is a command or a write outside
|
||||
what this chat allows; and a question, which should not exist at all because
|
||||
`ask_user` is withdrawn from an unattended chat -- it is answered anyway, so
|
||||
that a call arriving by some path that skipped `resolve_tools` is refused
|
||||
rather than left to hang.
|
||||
"""
|
||||
if item.kind == interaction.KIND_APPROVAL:
|
||||
return ToolOutcome(
|
||||
"That is not something you may do here: this conversation runs with "
|
||||
"nobody present, so there is no one to approve it. Do what you can "
|
||||
"without it and say plainly in your answer what you could not do.",
|
||||
{
|
||||
"name": item.tool_name,
|
||||
"kind": "agent",
|
||||
"label": item.title,
|
||||
"query": item.detail,
|
||||
"results": [],
|
||||
"status": "error",
|
||||
"error": "Not permitted here — nobody is present to approve it.",
|
||||
},
|
||||
)
|
||||
return ToolOutcome(
|
||||
"There is nobody to ask: this conversation runs on its own. Choose the "
|
||||
"most reasonable reading, carry on, and say in your answer what you "
|
||||
"assumed.",
|
||||
{
|
||||
"name": item.tool_name,
|
||||
"kind": "ask",
|
||||
"label": "Nobody to ask",
|
||||
"query": item.title,
|
||||
"results": [],
|
||||
"status": "error",
|
||||
"error": "Nobody is present to answer.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _not_allowed(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
|
||||
"""What the model is told when a person declined, or never answered.
|
||||
|
||||
|
||||
@@ -247,6 +247,11 @@ def context_variables(
|
||||
# warning to appear without the thing it warns about.
|
||||
"schedule_instruction": "",
|
||||
"schedule_summary": "",
|
||||
# Set only in a helper's own chat, and the gate on `core.subagent`.
|
||||
# Deliberately not the same variable as `schedule_instruction` even
|
||||
# though both mean "nobody is reading": the two say different things to
|
||||
# a model, and one fragment covering both would have to say neither.
|
||||
"subagent": "",
|
||||
}
|
||||
|
||||
if chat is not None:
|
||||
@@ -274,6 +279,11 @@ def context_variables(
|
||||
if chat.kind == KIND_TASK:
|
||||
values.update(_schedule_values(db, chat, user))
|
||||
|
||||
# Not gated on a family either, and for the same reason: what has to
|
||||
# reach a helper is that it is one. A column read, no query.
|
||||
if chat.parent_chat_id:
|
||||
values["subagent"] = "yes"
|
||||
|
||||
return values
|
||||
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
"Schedule",
|
||||
"In a scheduled task's chat: how often it runs, in words.",
|
||||
),
|
||||
Variable(
|
||||
"subagent",
|
||||
"Is a helper",
|
||||
"Set inside the chat of a helper another model sent, and empty "
|
||||
"everywhere else — so it is the gate on the guidance a helper reads "
|
||||
"about being one. It carries no text worth printing; it is a flag "
|
||||
"wearing a variable's clothes, because `requires` is how a fragment "
|
||||
"gates itself and a flag has nowhere else to live.",
|
||||
),
|
||||
Variable(
|
||||
"timezone",
|
||||
"Timezone",
|
||||
@@ -1262,6 +1271,96 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"before the first run."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.subagent",
|
||||
label="Helpers",
|
||||
group=GROUP_TOOLS,
|
||||
order=252,
|
||||
families=("subagent",),
|
||||
hint="Appears when subagent_run is offered. Two things a model gets "
|
||||
"wrong about delegation and neither is in the schema. It under-uses it "
|
||||
"— answering four independent questions one after another when they "
|
||||
"could have run at once — and then over-uses it, sending a helper to "
|
||||
"do a single search. The dividing line is whether the pieces are "
|
||||
"independent, so that is what the wording is built around.",
|
||||
default=(
|
||||
"- You can delegate. subagent_run hands one self-contained piece of work to "
|
||||
"another model that runs on its own and gives you its answer. Several calls "
|
||||
"in the same turn run at the same time, which is the point of it: four "
|
||||
"questions that do not depend on each other take as long as the slowest, "
|
||||
"not as long as all four.\n"
|
||||
"- Delegate when the work splits into independent parts, each worth more "
|
||||
"than one lookup — different sources to read, different areas to survey, "
|
||||
"two approaches to compare. Do it yourself when it is one search, one page "
|
||||
"or one file: a helper costs a whole reply, so using one to save a single "
|
||||
"call is slower than not.\n"
|
||||
"- Write each task as if to somebody who has just walked in. A helper starts "
|
||||
"with none of this conversation, and cannot ask you or the reader anything "
|
||||
"— so say what is wanted, what a good answer contains, and any name, path "
|
||||
"or decision it could not look up. Half a task produces half an answer with "
|
||||
"no sign that anything was missing.\n"
|
||||
"- Give each helper a different piece. Two with the same task come back with "
|
||||
"the same answer twice, at twice the cost.\n"
|
||||
"- What comes back is another model's work. Read it, say where it disagrees "
|
||||
"with what you already had, and do not repeat a claim you cannot check just "
|
||||
"because a helper made it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.subagent_agent",
|
||||
label="Helpers on a machine",
|
||||
group=GROUP_TOOLS,
|
||||
order=253,
|
||||
families=("subagent",),
|
||||
requires=("agent_target",),
|
||||
hint="The agent-chat half, gated on `agent_target` so it appears only "
|
||||
"where there is a machine. What it has to say is what a helper cannot "
|
||||
"do there, because the failure otherwise is a model planning a whole "
|
||||
"phase around a helper that will refuse every step of it — a helper "
|
||||
"reads and may run a short list of read-only commands, and nothing "
|
||||
"else, whatever mode this chat is in.",
|
||||
default=(
|
||||
"- A helper on this machine reads and reports. It can list and read files "
|
||||
"and run the ordinary read-only commands — ls, cat, grep, find, git status, "
|
||||
"git log, git diff — and nothing else, in every mode, because there is "
|
||||
"nobody there to approve anything. Send one to find out where something "
|
||||
"lives, to read a subsystem and describe it, or to check whether a pattern "
|
||||
"holds across a tree; make the changes yourself once it reports.\n"
|
||||
"- Do not send one to build, test, install or run anything: it will be "
|
||||
"refused a step in and come back having done nothing.\n"
|
||||
"- Ask for what you want back, not for a summary. “The three files that "
|
||||
"define X and what each does” is usable; “look into X” comes back as prose "
|
||||
"you have to read the codebase to check."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="core.subagent",
|
||||
label="You are the helper",
|
||||
group=GROUP_CORE,
|
||||
order=36,
|
||||
requires=("subagent",),
|
||||
hint="Only inside a helper's own chat. The three things it cannot work "
|
||||
"out for itself: nobody is reading, there is exactly one reply, and "
|
||||
"the thing that asked is a model rather than a person — so the usual "
|
||||
"moves of asking what was meant, or promising to carry on afterwards, "
|
||||
"both end the run having done nothing. This is the prompt half; the "
|
||||
"enforcement is that ask_user and subagent_run are not offered here at "
|
||||
"all, and that everything which writes has been withdrawn unless the "
|
||||
"task was sent as a writing one.",
|
||||
default=(
|
||||
"- You are answering a request from another model, and you get one reply. "
|
||||
"Nobody is reading this: you cannot ask a question, and there is no next "
|
||||
"turn to carry on in. Do the work now and put everything into this answer.\n"
|
||||
"- Answer the task as asked and stop. Do not open questions beyond it, "
|
||||
"propose next steps, or address the reader — the model that asked will "
|
||||
"decide what happens next, and anything you write to a person here is read "
|
||||
"by nobody.\n"
|
||||
"- Say what you actually found, with the file, the page or the command it "
|
||||
"came from. Where you could not find something, say so plainly rather than "
|
||||
"filling the gap: the model reading this cannot tell a careful answer from "
|
||||
"a confident one, and will act on either."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.knowledge_scope",
|
||||
label="Which knowledge bases",
|
||||
|
||||
@@ -115,6 +115,12 @@ def create(
|
||||
kind=KIND_TASK,
|
||||
title=(title.strip() or "Scheduled task")[:MAX_TITLE_CHARS],
|
||||
model_id=model_id or "",
|
||||
# Said on the row as well as implied by the kind. `tools.unattended`
|
||||
# reads both, because the column was added to a table that already held
|
||||
# task chats and a backfill cannot know which they were -- but every one
|
||||
# written from here on says so for itself, which is what the check on
|
||||
# the kind is there to stop being needed forever.
|
||||
unattended=True,
|
||||
)
|
||||
db.add(chat)
|
||||
db.flush()
|
||||
|
||||
@@ -31,6 +31,7 @@ PROMPTS = "prompts"
|
||||
AGENTS = "agents"
|
||||
IMAGES = "images"
|
||||
SCHEDULES = "schedules"
|
||||
SUBAGENTS = "subagents"
|
||||
|
||||
|
||||
def _general_defaults() -> dict[str, Any]:
|
||||
@@ -335,6 +336,43 @@ def _schedules_defaults() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _subagents_defaults() -> dict[str, Any]:
|
||||
"""Delegating a piece of a reply to a second, unattended model.
|
||||
|
||||
Off until an administrator turns it on, for the reason agent execution and
|
||||
scheduling are: a reply that may spawn helpers spends model time
|
||||
multiplicatively, and on a single local endpoint four of them at once is
|
||||
four times the queue rather than four times the speed.
|
||||
|
||||
Every number below is a **ceiling on one reply's helpers**, not a working
|
||||
budget for one of them. The distinction is the one `Limits.steps` already
|
||||
makes: a bound low enough to be reached by ordinary work stops the work
|
||||
halfway instead of catching a runaway.
|
||||
"""
|
||||
return {
|
||||
"enabled": False,
|
||||
# How many one reply may spawn in total. Small on purpose: fanning out
|
||||
# across four sub-questions is the use this exists for, and a reply that
|
||||
# wants twenty has misunderstood the tool rather than found a use for it.
|
||||
"max_per_reply": 4,
|
||||
# Running at once across the whole instance. A subagent is a whole
|
||||
# generation against the same endpoint the parent is waiting on.
|
||||
"max_concurrent": 6,
|
||||
# What one subagent may spend. Its own numbers rather than the chat's or
|
||||
# the agent settings', because a helper answering one question is not
|
||||
# the same shape of work as the reply that asked it: it should run out
|
||||
# of room long before the parent does.
|
||||
"max_rounds": 30,
|
||||
"wall_seconds": 600,
|
||||
"max_completion_tokens": 60_000,
|
||||
# Whether the helper's own chat is kept after its answer is handed back.
|
||||
# Off means it is deleted, which is what makes this cheap to use; on is
|
||||
# for working out why one came back with something odd. Kept chats are
|
||||
# temporary either way, so the day-old sweep still gets them.
|
||||
"keep_transcript": False,
|
||||
}
|
||||
|
||||
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
GENERAL: _general_defaults,
|
||||
AUDIO: _audio_defaults,
|
||||
@@ -343,6 +381,7 @@ _DEFAULTS: dict[str, Any] = {
|
||||
AGENTS: _agents_defaults,
|
||||
IMAGES: _images_defaults,
|
||||
SCHEDULES: _schedules_defaults,
|
||||
SUBAGENTS: _subagents_defaults,
|
||||
}
|
||||
|
||||
|
||||
@@ -506,6 +545,25 @@ def schedules(db: DBSession) -> dict[str, Any]:
|
||||
return values
|
||||
|
||||
|
||||
def subagents(db: DBSession) -> dict[str, Any]:
|
||||
"""Subagent settings, clamped on read for the reason `agents` gives.
|
||||
|
||||
Zero is meaningful for `max_completion_tokens` alone — no ceiling on what
|
||||
one helper writes — and is a floor of one everywhere else, because a
|
||||
`max_per_reply` of zero is the feature switched off wearing the switch's
|
||||
clothes, and that is a thing to answer in one place rather than two.
|
||||
"""
|
||||
values = get_group(db, SUBAGENTS)
|
||||
values["max_per_reply"] = min(max(int(values.get("max_per_reply") or 1), 1), 20)
|
||||
values["max_concurrent"] = min(max(int(values.get("max_concurrent") or 1), 1), 50)
|
||||
values["max_rounds"] = min(max(int(values.get("max_rounds") or 1), 1), 200)
|
||||
values["wall_seconds"] = min(max(int(values.get("wall_seconds") or 1), 30), 7200)
|
||||
values["max_completion_tokens"] = min(
|
||||
max(int(values.get("max_completion_tokens") or 0), 0), 5_000_000
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
def images_ready(db: DBSession) -> bool:
|
||||
"""Whether image generation can actually happen.
|
||||
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Handing a piece of a reply to a second model that runs on its own.
|
||||
|
||||
## What it is
|
||||
|
||||
`subagent_run` creates a hidden chat, puts one self-contained task into it, lets
|
||||
the ordinary generation loop answer it, and gives the answer back as the tool
|
||||
result. That is the whole mechanism. Nothing about streaming, rounds, budgets,
|
||||
metrics, steps or tools is re-implemented here, because a second implementation
|
||||
of any of those is a second thing to keep correct.
|
||||
|
||||
Two shapes were rejected on the way.
|
||||
|
||||
A **nested `Generation` in the parent's chat** would mean two replies writing one
|
||||
transcript, which `services/wake.py` exists to make impossible: a chat has one
|
||||
generation at a time, and the Stop button points at whichever bubble comes first
|
||||
in the document.
|
||||
|
||||
A **one-shot `complete()`** — the shape `generate_title` uses — has no tools and
|
||||
no rounds, which `schedule/runner.py` already records as useless for exactly this
|
||||
case. A helper that cannot search is not a helper.
|
||||
|
||||
So the pattern is `runner.fire`'s: `wake_chat`, then poll `running_for` until it
|
||||
stops. The child chat is `temporary`, so it is in no listing, and it is deleted
|
||||
when its answer has been handed over unless an administrator asked to keep it.
|
||||
|
||||
## What makes it safe with nobody watching
|
||||
|
||||
The rule this codebase holds is that restriction happens **at tool resolution,
|
||||
never in the prompt** — a model can be talked out of a system message by a page
|
||||
it just read, and a subagent's task text is written by a model that has been
|
||||
reading pages. So every restriction below is a property of the child's row,
|
||||
applied by `resolve_tools` after every gate:
|
||||
|
||||
- **Nothing may ask.** `Chat.unattended` withdraws `ask_user`, and
|
||||
`generation._authorise` answers an approval with a refusal instead of pausing.
|
||||
Without the second half a helper in Manual mode would sit on a card nobody can
|
||||
see until `approval_timeout`, which is fifteen minutes of doing nothing.
|
||||
- **No recursion.** The same flag withdraws the `subagent` family from the child.
|
||||
- **No writing, by default.** `scope_json["write"] = False` drops every tool
|
||||
whose declared risk is `RISK_WRITE` — notes, memories, reports, skills, the
|
||||
canvas, file writes, schedules, images. A *writing* helper is a per-call
|
||||
parameter and is refused outright in a chat whose own mode would have stopped
|
||||
to ask before writing, because a subagent that writes where its parent had to
|
||||
ask is the mode being laundered through a tool call.
|
||||
- **Commands from a fixed list only, in every mode including Auto.** The child
|
||||
runs in Plan or Edit mode, both of which resolve `RISK_EXECUTE` to ASK, and
|
||||
ASK in an unattended chat is a refusal. What runs is what matches
|
||||
`SAFE_COMMANDS` — and `policy.subject` refuses to match any line carrying a
|
||||
shell metacharacter, so `git log; curl … | sh` matches nothing. Auto is
|
||||
deliberately **not** inherited: the task text can have come from a page, and
|
||||
that is the injection path this list exists to close.
|
||||
- **The credential is copied**, never referenced, for the reason
|
||||
`agent/jobs.py:start_watch` gives: the parent's `spec` is cleared when its
|
||||
reply ends and a helper outlives nothing but is not entitled to assume so.
|
||||
|
||||
## What bounds it
|
||||
|
||||
`settings_store.subagents` — how many one reply may spawn, how many run at once
|
||||
across the instance, and what one of them may spend. The per-reply count lives on
|
||||
the parent's `Generation`, which is the only object that knows what "this reply"
|
||||
means; the instance count is a set here, because a counter that a restart clears
|
||||
is the correct shape for a thing that cannot survive a restart anyway.
|
||||
|
||||
The wall clock is the honest bound. When it runs out the child is **stopped**,
|
||||
not abandoned: `request_stop` keeps whatever it had written and marks the message
|
||||
`stopped`, so the parent gets a partial answer and a sentence saying it is one,
|
||||
rather than silence or a wait that outlives the reply that asked for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from lembas.db.models import KIND_AGENT, Chat, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import policy as agent_policy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from lembas.services.tools import ToolContext, ToolDef, ToolOutcome
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How often the parent looks to see whether its helper has finished. Coarser
|
||||
# than a stream and finer than the schedule runner's three seconds: a caller is
|
||||
# blocked on this, so a second of latency at the end is worth avoiding, and
|
||||
# anything finer is a poll per hundred milliseconds for a minute of work.
|
||||
POLL_SECONDS = 1.0
|
||||
|
||||
# How long to keep asking after `request_stop`, before giving up on the row and
|
||||
# reading whatever is there. The producer checks `cancel` between streamed
|
||||
# chunks, so a stop lands within one chunk unless the far side has stalled.
|
||||
STOP_GRACE = 20.0
|
||||
|
||||
# What a helper may run on a machine, whatever mode its parent is in.
|
||||
#
|
||||
# Every entry is a command that reads. There is no `find -delete`, no `git
|
||||
# checkout`, no package manager: the list is short because the argument for
|
||||
# adding to it is always "this one is fine", and the sum of those is a shell.
|
||||
# `policy.subject` normalises whitespace and refuses to match anything holding a
|
||||
# shell metacharacter, so none of these can be extended with a `;` or a pipe.
|
||||
#
|
||||
# `file_read` and `file_list` are here as tool names rather than commands, which
|
||||
# is what `subject` returns for anything that is not `shell_run` -- the same
|
||||
# entries `agents.allow_default` ships with.
|
||||
SAFE_COMMANDS: tuple[str, ...] = (
|
||||
"file_read",
|
||||
"file_list",
|
||||
"ls",
|
||||
"ls *",
|
||||
"pwd",
|
||||
"cat *",
|
||||
"head *",
|
||||
"tail *",
|
||||
"wc *",
|
||||
"file *",
|
||||
"stat *",
|
||||
"du *",
|
||||
"df *",
|
||||
"tree *",
|
||||
"find *",
|
||||
"grep *",
|
||||
"rg *",
|
||||
"git status",
|
||||
"git log*",
|
||||
"git show*",
|
||||
"git diff*",
|
||||
"git branch",
|
||||
"git remote -v",
|
||||
)
|
||||
|
||||
# Modes a helper may be given, and nothing else. Plan reads; Edit reads and
|
||||
# writes files. Neither allows a command outside the list above, because both
|
||||
# resolve RISK_EXECUTE to ASK and an unattended chat cannot ask.
|
||||
MODE_READING = agent_policy.MODE_PLAN
|
||||
MODE_WRITING = agent_policy.MODE_EDIT
|
||||
|
||||
# The parent modes from which a *writing* helper may be asked for. In Manual and
|
||||
# Plan the reader is stopped before anything is written, and a helper that wrote
|
||||
# on the model's own authority would be that rule going through a side door.
|
||||
WRITING_ALLOWED_FROM = (agent_policy.MODE_EDIT, agent_policy.MODE_AUTO)
|
||||
|
||||
# Helpers running right now, across the instance, by child chat id. In-process
|
||||
# and cleared by a restart, which is correct: a restart abandons replies in
|
||||
# flight, so there is nothing for a durable count to describe.
|
||||
_LIVE: set[str] = set()
|
||||
|
||||
|
||||
def live_count() -> int:
|
||||
return len(_LIVE)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""For tests. The set is the only state this module holds."""
|
||||
_LIVE.clear()
|
||||
|
||||
|
||||
# --- Building the child --------------------------------------------------------
|
||||
def _child_scope(parent: Chat, *, write: bool) -> dict[str, Any]:
|
||||
"""What the helper's chat is narrowed to.
|
||||
|
||||
`families` names the two withdrawals that are absolute; `write` is the
|
||||
risk-class narrowing; `allow` is the command list. Everything else the
|
||||
parent had, the child has -- searching, fetching, reading the library --
|
||||
because a helper that cannot look things up is a slower way of asking the
|
||||
same model the same question.
|
||||
|
||||
The parent's own narrowing is carried across whole. A chat with web search
|
||||
switched off must not be able to reach it by delegating.
|
||||
"""
|
||||
inherited = dict((parent.scope_json or {}).get("families") or {})
|
||||
inherited.update({"ask": False, "subagent": False})
|
||||
return {
|
||||
"families": inherited,
|
||||
"skills": dict((parent.scope_json or {}).get("skills") or {}),
|
||||
"write": bool(write),
|
||||
"allow": list(SAFE_COMMANDS),
|
||||
}
|
||||
|
||||
|
||||
def _create_child(db, parent: Chat, *, title: str, write: bool) -> Chat:
|
||||
"""The hidden chat one helper runs in.
|
||||
|
||||
It inherits the parent's model, connection, directory and reasoning effort,
|
||||
and nothing else. The effort has to be **seeded onto the row** rather than
|
||||
left to be inherited at request time: `chat_service.resolved_effort` reads
|
||||
the chat's own `params_json` and deliberately consults no fallback, so a
|
||||
helper of a high-effort reply would otherwise quietly run at none.
|
||||
"""
|
||||
from lembas.services import chat as chat_service
|
||||
|
||||
child = Chat(
|
||||
user_id=parent.user_id,
|
||||
kind=parent.kind,
|
||||
title=title[:200] or "Helper",
|
||||
model_id=parent.model_id,
|
||||
connection_id=parent.connection_id,
|
||||
# Never in a listing, and swept a day later even if it is kept.
|
||||
temporary=True,
|
||||
parent_chat_id=parent.id,
|
||||
unattended=True,
|
||||
scope_json=_child_scope(parent, write=write),
|
||||
)
|
||||
if parent.kind == KIND_AGENT:
|
||||
child.ssh_profile_id = parent.ssh_profile_id
|
||||
child.project_dir = parent.project_dir
|
||||
child.agent_mode = MODE_WRITING if write else MODE_READING
|
||||
effort = chat_service.resolved_effort(parent)
|
||||
if effort:
|
||||
child.params_json = {"reasoning_effort": effort}
|
||||
# The bases the parent is scoped to, or the helper searches everything its
|
||||
# owner can see and answers from documents the parent was not looking at.
|
||||
child.knowledge_bases = list(parent.knowledge_bases)
|
||||
db.add(child)
|
||||
db.commit()
|
||||
return child
|
||||
|
||||
|
||||
def _task_turn(task: str, context: str) -> str:
|
||||
"""The one turn a helper is given.
|
||||
|
||||
Named as a delegation in *words*, for the reason `wake.py` sets out: the
|
||||
role stays `user` because `build_messages` requires one there, so the
|
||||
framing cannot live in the role. `core.subagent` is the other half — this
|
||||
says what the job is, the fragment says what being a helper means.
|
||||
"""
|
||||
lines = [
|
||||
"You are answering a request from another model, not from a person. "
|
||||
"Nobody is reading this conversation; your reply is handed back whole "
|
||||
"as the result of one tool call.",
|
||||
"",
|
||||
"## The task",
|
||||
task.strip(),
|
||||
]
|
||||
if context.strip():
|
||||
lines += ["", "## What you have been told about it", context.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- Running one ---------------------------------------------------------------
|
||||
async def _await_reply(chat_id: str, message_id: str, deadline: float) -> bool:
|
||||
"""Wait for the helper's reply. True if it finished on its own.
|
||||
|
||||
Polled rather than awaited on the task, the same reasoning
|
||||
`schedule/runner._await_reply` writes down: `generation` owns its registry
|
||||
and reaching into it from here would couple this to internals whose whole
|
||||
job is to be replaceable.
|
||||
"""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
running = generation_service.running_for(chat_id)
|
||||
# The id check is what stops this waiting on some *later* reply in the
|
||||
# same chat -- there is nothing else to produce one here, but the same
|
||||
# loop in `runner` needed it and the cost of keeping it is nothing.
|
||||
if running is None or running.message_id != message_id:
|
||||
return True
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
return False
|
||||
|
||||
|
||||
async def _stop(chat_id: str, message_id: str) -> None:
|
||||
"""End a helper that has run out of clock, keeping what it wrote.
|
||||
|
||||
`request_stop` rather than cancelling the task: it sets the flag the
|
||||
producer checks between chunks, so the partial reply is persisted and marked
|
||||
`stopped` rather than `error`. An abandoned generation would go on spending
|
||||
the endpoint after the parent had stopped caring.
|
||||
"""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
generation_service.request_stop(message_id)
|
||||
deadline = time.monotonic() + STOP_GRACE
|
||||
while time.monotonic() < deadline:
|
||||
running = generation_service.running_for(chat_id)
|
||||
if running is None or running.message_id != message_id:
|
||||
return
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
log.warning("subagent %s did not stop within the grace period", chat_id)
|
||||
|
||||
|
||||
def _harvest(db, chat_id: str, message_id: str) -> tuple[str, str]:
|
||||
"""The helper's answer, and what went wrong if anything did.
|
||||
|
||||
Read from the row rather than from the `Generation`, because `_persist` is
|
||||
the single writer and the row is authoritative the moment `done` is set --
|
||||
the same order `_follow` depends on.
|
||||
"""
|
||||
from lembas.db.models import Message
|
||||
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
return "", "The helper's reply could not be found."
|
||||
text = (message.content or "").strip()
|
||||
if message.error:
|
||||
return text, str(message.error)
|
||||
if not text:
|
||||
return "", "The helper produced no answer."
|
||||
return text, ""
|
||||
|
||||
|
||||
def _tool_names(db, chat_id: str) -> list[str]:
|
||||
"""What the helper actually did, for the transcript.
|
||||
|
||||
The tool names off its messages, in order, deduplicated by run. It is what
|
||||
makes a collapsed block worth expanding: an answer alone cannot say whether
|
||||
it was researched or recalled.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Message
|
||||
|
||||
names: list[str] = []
|
||||
rows = db.scalars(
|
||||
select(Message).where(Message.chat_id == chat_id).order_by(Message.created_at)
|
||||
)
|
||||
for row in rows:
|
||||
for event in row.tool_calls_json or []:
|
||||
name = str((event or {}).get("name") or "")
|
||||
if name and (not names or names[-1] != name):
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _cleanup(chat_id: str, *, keep: bool) -> None:
|
||||
"""Delete the helper's chat unless an administrator asked to keep it.
|
||||
|
||||
Best-effort and outside every other session: a helper whose answer has been
|
||||
handed back has done its job, and failing to tidy up must not turn a good
|
||||
result into an error. Kept chats are `temporary`, so the day-old sweep gets
|
||||
them either way.
|
||||
"""
|
||||
if keep:
|
||||
return
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is not None:
|
||||
db.delete(chat)
|
||||
except Exception: # noqa: BLE001 - tidying up is not the result
|
||||
log.debug("could not remove subagent chat %s", chat_id, exc_info=True)
|
||||
|
||||
|
||||
# --- The tool ------------------------------------------------------------------
|
||||
def _outcome(text: str, event: dict[str, Any]) -> ToolOutcome:
|
||||
"""Imported inside the call: `services/tools.py` imports this module to build
|
||||
the definition, so a top-level import back is a cycle."""
|
||||
from lembas.services.tools import ToolOutcome
|
||||
|
||||
return ToolOutcome(text, event)
|
||||
|
||||
|
||||
def _error(message: str, *, task: str = "") -> ToolOutcome:
|
||||
return _outcome(
|
||||
message,
|
||||
{"name": "subagent_run", "status": "error", "query": task[:120], "error": message},
|
||||
)
|
||||
|
||||
|
||||
def _budget(generation, values: dict[str, Any]) -> str:
|
||||
"""Whether this reply may spawn another helper, and why not if it may not.
|
||||
|
||||
Counted on the parent's `Generation` because that is the only object that
|
||||
knows what "this reply" is: a chat-keyed counter would have to be reset by
|
||||
something, and every candidate for that something is a place to forget.
|
||||
Read and incremented with no `await` between, which is what makes it safe
|
||||
against the four calls a round runs together.
|
||||
|
||||
`values` is passed in rather than read here, so this can be called from
|
||||
inside the caller's session -- opening a second one underneath an open one
|
||||
is a shape this codebase does not have anywhere else and is not worth
|
||||
introducing for a settings lookup.
|
||||
"""
|
||||
if generation is None:
|
||||
# No generation means no reply to bound. It happens if a runner is ever
|
||||
# reached outside the loop; refusing is the answer that cannot be wrong.
|
||||
return "This reply cannot delegate."
|
||||
if generation.subagents >= int(values["max_per_reply"]):
|
||||
return (
|
||||
f"This reply has already used its {values['max_per_reply']} helpers. "
|
||||
"Do the rest yourself, or answer with what you have."
|
||||
)
|
||||
if len(_LIVE) >= int(values["max_concurrent"]):
|
||||
return (
|
||||
"Too many helpers are running on this instance right now. "
|
||||
"Do this part yourself rather than waiting."
|
||||
)
|
||||
generation.subagents += 1
|
||||
return ""
|
||||
|
||||
|
||||
async def _run_subagent(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import wake as wake_service
|
||||
|
||||
task = str(args.get("task") or "").strip()
|
||||
title = str(args.get("title") or "").strip() or task[:60]
|
||||
briefing = str(args.get("context") or "")
|
||||
want_write = bool(args.get("write"))
|
||||
|
||||
if not task:
|
||||
return _error(
|
||||
"A helper needs a task: what to find out or do, written out in full. "
|
||||
"It starts with none of this conversation, so say everything it needs."
|
||||
)
|
||||
|
||||
parent_id = context.chat_id
|
||||
if not parent_id:
|
||||
return _error("There is no conversation to delegate from.", task=task)
|
||||
|
||||
with session_scope() as db:
|
||||
parent = db.get(Chat, parent_id)
|
||||
if parent is None:
|
||||
return _error("That conversation no longer exists.", task=task)
|
||||
# Enforced here as well as by the withdrawn family, because this is the
|
||||
# cheaper half to get right and the two failures look different: the
|
||||
# family withdrawal means the model is never offered the tool, and this
|
||||
# means a call that arrived by some other path is refused rather than
|
||||
# opening a third level.
|
||||
if parent.parent_chat_id or parent.unattended:
|
||||
return _error("A helper cannot ask for a helper of its own.", task=task)
|
||||
|
||||
write = want_write
|
||||
if write and parent.kind == KIND_AGENT and parent.agent_mode not in WRITING_ALLOWED_FROM:
|
||||
return _error(
|
||||
"This chat is in "
|
||||
f"{agent_policy.MODE_LABELS.get(parent.agent_mode, parent.agent_mode)} mode, "
|
||||
"where you are stopped before anything is written — so a helper "
|
||||
"cannot write either, since nobody can be stopped to ask. Send a "
|
||||
"reading helper and make the changes yourself, or ask the reader "
|
||||
"to switch mode.",
|
||||
task=task,
|
||||
)
|
||||
owner = db.get(User, parent.user_id)
|
||||
if owner is None: # pragma: no cover - a chat outliving its owner
|
||||
return _error("That account no longer exists.", 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
|
||||
# only move for a call that is about to spend one.
|
||||
values = settings_store.subagents(db)
|
||||
refusal = _budget(generation_service.running_for(parent_id), values)
|
||||
if refusal:
|
||||
return _error(refusal, task=task)
|
||||
|
||||
child = _create_child(db, parent, title=title, write=write)
|
||||
child_id = child.id
|
||||
|
||||
_LIVE.add(child_id)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
message_id = await wake_service.wake_chat(child_id, _task_turn(task, briefing))
|
||||
if not message_id:
|
||||
_cleanup(child_id, keep=False)
|
||||
return _error("The helper could not be started.", task=task)
|
||||
|
||||
finished = await _await_reply(
|
||||
child_id, message_id, started + float(values["wall_seconds"])
|
||||
)
|
||||
if not finished:
|
||||
await _stop(child_id, message_id)
|
||||
|
||||
with session_scope() as db:
|
||||
answer, problem = _harvest(db, child_id, message_id)
|
||||
used = _tool_names(db, child_id)
|
||||
finally:
|
||||
_LIVE.discard(child_id)
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
keep = bool(values.get("keep_transcript"))
|
||||
_cleanup(child_id, keep=keep)
|
||||
|
||||
if not answer:
|
||||
return _error(problem or "The helper produced no answer.", task=task)
|
||||
|
||||
# The account of what it did goes in the *event*, where the transcript shows
|
||||
# it; the answer goes to the model. Putting the tool list in front of the
|
||||
# model as well would be spending its window on our own bookkeeping.
|
||||
note = "" if finished else "\n\n(It ran out of time; this is as far as it got.)"
|
||||
return _outcome(
|
||||
f"The helper answered:\n\n{answer}{note}\n\n"
|
||||
"This is another model's work, not yours and not the reader's. Check it "
|
||||
"against what you know before relying on it, and say what came from it.",
|
||||
{
|
||||
"name": "subagent_run",
|
||||
"status": "ok" if finished else "error" if not answer else "ok",
|
||||
"query": title,
|
||||
"detail": (
|
||||
f"{len(used)} tool call(s), {elapsed:.0f}s"
|
||||
+ ("" if finished else ", stopped at the time limit")
|
||||
),
|
||||
"text": answer,
|
||||
"why": ", ".join(used[:8]) if used else "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def tool_defs() -> list[ToolDef]:
|
||||
"""The one tool, built here so `services/tools.py` need not know the wording."""
|
||||
from lembas.services.tools import FAMILY_SUBAGENT, RISK_READ, ToolDef
|
||||
|
||||
return [
|
||||
ToolDef(
|
||||
name="subagent_run",
|
||||
family=FAMILY_SUBAGENT,
|
||||
description=(
|
||||
"Hand one self-contained piece of work to a helper — a second "
|
||||
"model with the same tools that works on its own and gives you "
|
||||
"its answer. Use it to cover several independent areas at once: "
|
||||
"call it several times in one turn and each runs in parallel. "
|
||||
"It cannot ask you or the reader anything, cannot delegate "
|
||||
"further, and starts knowing nothing about this conversation, "
|
||||
"so the task must say everything it needs. Do not use it for "
|
||||
"something you could do in one call yourself, or for anything "
|
||||
"needing a decision only the reader can make."
|
||||
),
|
||||
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."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
run=_run_subagent,
|
||||
# It reads, from the parent's side: what it changes, it changes
|
||||
# through tools that carry their own risk class inside the helper's
|
||||
# own chat, where the mode and the scope decide. Classing the spawn
|
||||
# itself as a write would put an approval card in front of every
|
||||
# research fan-out in Edit mode, which is the mode that permits
|
||||
# writing anyway.
|
||||
risk=RISK_READ,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODE_READING",
|
||||
"MODE_WRITING",
|
||||
"SAFE_COMMANDS",
|
||||
"WRITING_ALLOWED_FROM",
|
||||
"clear",
|
||||
"live_count",
|
||||
"tool_defs",
|
||||
]
|
||||
@@ -72,6 +72,8 @@ LABELS: dict[str, str] = {
|
||||
"schedule_list": "Schedules read",
|
||||
"schedule_update": "Schedule changed",
|
||||
"schedule_cancel": "Schedule stopped",
|
||||
# Work handed to a second model.
|
||||
"subagent_run": "Helper",
|
||||
"memory_add": "Memory saved",
|
||||
"memory_forget": "Memory removed",
|
||||
"skill_get": "Skill read",
|
||||
@@ -112,6 +114,7 @@ ICONS: dict[str, str] = {
|
||||
"schedule_list": "clock",
|
||||
"schedule_update": "clock",
|
||||
"schedule_cancel": "stop-circle",
|
||||
"subagent_run": "sparkle",
|
||||
"memory_add": "star",
|
||||
"memory_forget": "trash",
|
||||
"skill_get": "sparkle",
|
||||
@@ -156,6 +159,7 @@ ACTIONS: dict[str, str] = {
|
||||
"schedule_create": "Set up a schedule",
|
||||
"schedule_update": "Change a schedule",
|
||||
"schedule_cancel": "Stop a schedule",
|
||||
"subagent_run": "Send a helper",
|
||||
"memory_add": "Remember something",
|
||||
"memory_forget": "Forget something",
|
||||
"skill_get": "Read a skill",
|
||||
@@ -193,6 +197,10 @@ DETAIL_KEYS: dict[str, str] = {
|
||||
# timing is an object, and the tool answers with it in words afterwards,
|
||||
# which is where it is actually checkable.
|
||||
"schedule_create": "instruction",
|
||||
# The task, not the title. It is the thing a helper is actually sent, and
|
||||
# the one field worth correcting before it goes -- a task with a wrong path
|
||||
# in it comes back as a confident answer about the wrong thing.
|
||||
"subagent_run": "task",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,13 @@ FAMILY_REPORT = "report"
|
||||
# note, said it had scheduled something, and nothing anywhere disagreed.
|
||||
FAMILY_SCHEDULE = "schedule"
|
||||
|
||||
# Handing a self-contained piece of work to a second model that runs on its own
|
||||
# and reports back. Its own family because it is the one tool whose cost is
|
||||
# another whole reply -- an instance may reasonably offer everything else and
|
||||
# not this, and on a single local endpoint four helpers at once is four times
|
||||
# the queue rather than four times the speed.
|
||||
FAMILY_SUBAGENT = "subagent"
|
||||
|
||||
# The built-in families, in the order they are offered.
|
||||
FAMILIES = (
|
||||
FAMILY_SEARCH,
|
||||
@@ -150,6 +157,7 @@ FAMILIES = (
|
||||
FAMILY_IMAGE,
|
||||
FAMILY_REPORT,
|
||||
FAMILY_SCHEDULE,
|
||||
FAMILY_SUBAGENT,
|
||||
FAMILY_AGENT,
|
||||
)
|
||||
|
||||
@@ -209,6 +217,14 @@ class ToolContext:
|
||||
# something. Read from the instance settings while the session was open,
|
||||
# like everything else here.
|
||||
interaction_timeout: float = 900.0
|
||||
# Whether there is anybody who could answer. False for an ordinary chat;
|
||||
# true for a scheduled task's and a subagent's. `ask_user` is already
|
||||
# withdrawn when it is set, so what this reaches is `_authorise`, which
|
||||
# answers an approval with a refusal instead of pausing on a card nobody
|
||||
# can see. Without it the reply stalls for `interaction_timeout` and then
|
||||
# gives up having done nothing -- which is the failure the withdrawal was
|
||||
# added to prevent, arriving by the other door.
|
||||
unattended: bool = False
|
||||
# Set only for an agent chat: the machine to act on, the mode in force, and
|
||||
# the decrypted credential. None everywhere else, which is what every agent
|
||||
# runner checks first. `generation` clears it when the reply ends.
|
||||
@@ -1301,6 +1317,7 @@ def _family_allowed(
|
||||
allowed: dict,
|
||||
images: bool = False,
|
||||
schedules: bool = False,
|
||||
subagents: bool = False,
|
||||
) -> bool:
|
||||
"""Whether one family is on for this chat.
|
||||
|
||||
@@ -1342,6 +1359,14 @@ def _family_allowed(
|
||||
# offer this at all, or a model spends a round being told the tool it
|
||||
# was handed does not work.
|
||||
return bool(allowed.get("schedule.use") and schedules)
|
||||
if gate == FAMILY_SUBAGENT:
|
||||
# Its own permission and its own instance switch, for the reason the
|
||||
# image tool has both: what this costs is a second reply, which is not
|
||||
# a cost the tools around it have, and an instance whose endpoint is one
|
||||
# local card has a real reason to say no. `subagents` is passed in
|
||||
# rather than read here so that the whole gate is answered from the
|
||||
# snapshot `resolve_tools` already took.
|
||||
return bool(allowed.get("tools.subagent") and subagents)
|
||||
if gate in (
|
||||
FAMILY_CUSTOM,
|
||||
FAMILY_MCP,
|
||||
@@ -1412,6 +1437,13 @@ 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."""
|
||||
from lembas.services import subagent as subagent_service
|
||||
|
||||
return subagent_service.tool_defs()
|
||||
|
||||
|
||||
def _image_defs(db: DBSession, values: dict | None = None) -> list[ToolDef]:
|
||||
"""The image tool, whose schema carries this instance's own choices.
|
||||
|
||||
@@ -1454,7 +1486,6 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
|
||||
working on. The same omission cost custom tools their guidance once already.
|
||||
"""
|
||||
from lembas.services.agent import tools as agent_tools
|
||||
from lembas.services.schedule import tool as schedule_tool
|
||||
|
||||
return _book(
|
||||
[
|
||||
@@ -1465,7 +1496,8 @@ def registry(db: DBSession) -> dict[str, ToolDef]:
|
||||
# `schedule_create` back to a family and the guidance never
|
||||
# reaches the model. That omission has cost two features their
|
||||
# instructions already.
|
||||
*schedule_tool.tool_defs(),
|
||||
*_schedule_defs(),
|
||||
*_subagent_defs(),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1494,6 +1526,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
image_values = settings_store.images(db)
|
||||
images_ready = settings_store.images_ready(db)
|
||||
schedules_on = bool(settings_store.schedules(db).get("enabled"))
|
||||
subagents_on = bool(settings_store.subagents(db).get("enabled"))
|
||||
|
||||
# Resolved against what this reader may see, not against everything that
|
||||
# exists: a tool restricted to a group is not offered outside it. The image
|
||||
@@ -1506,6 +1539,7 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
*_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 []),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1526,8 +1560,25 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
# merely discouraged in `core.unattended`, because a rule living only in a
|
||||
# system message is one a page the model just read can argue with. The
|
||||
# fragment is the half that stops it *planning* around a tool it has not got.
|
||||
if chat is not None and chat.kind == KIND_TASK:
|
||||
off = off | {FAMILY_ASK}
|
||||
#
|
||||
# A subagent's chat is unattended for a different reason and arrives at the
|
||||
# same place, which is why the question asked is `unattended` and not the
|
||||
# kind: it is also where the *recursion* stops. A helper that could spawn a
|
||||
# helper is a fan-out with no bound anybody set.
|
||||
if unattended(chat):
|
||||
off = off | {FAMILY_ASK, FAMILY_SUBAGENT}
|
||||
|
||||
# Everything that changes something, withheld. Set by `services/subagent.py`
|
||||
# on the chat it creates and by nothing else, so absent means on exactly as
|
||||
# every other key here does. Keyed on the tool's declared **risk** rather
|
||||
# than on a list of names, because a list is a thing that goes out of date
|
||||
# silently: a tool added next year would default into a read-only helper's
|
||||
# set unless somebody remembered.
|
||||
#
|
||||
# `RISK_EXECUTE` is deliberately not included. In an agent chat it is
|
||||
# governed by the mode and the allow list instead, which is a finer
|
||||
# instrument -- `git log` is a read whatever its risk class says.
|
||||
writes_off = scoped_writes_off(chat)
|
||||
|
||||
return ToolSet(
|
||||
tuple(
|
||||
@@ -1540,8 +1591,10 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
allowed=allowed,
|
||||
images=images_ready,
|
||||
schedules=schedules_on,
|
||||
subagents=subagents_on,
|
||||
)
|
||||
and gate_of(tool.family) not in off
|
||||
and not (writes_off and tool.risk == RISK_WRITE)
|
||||
# Nothing to read and nothing to improve. Offering `skill_get` with
|
||||
# no skills is what makes a model spend a round looking one up and
|
||||
# being told it does not exist -- and `context.skills` already
|
||||
@@ -1557,6 +1610,38 @@ def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet:
|
||||
_NEEDS_A_SKILL = frozenset({"skill_get", "skill_edit"})
|
||||
|
||||
|
||||
def unattended(chat: Chat | None) -> bool:
|
||||
"""Whether there is anybody who could answer a question in this chat.
|
||||
|
||||
Two things make a chat unattended and they are not the same fact. A
|
||||
scheduled task's chat is one because of what starts it; a subagent's is one
|
||||
because of what it *is*. `Chat.unattended` is the column both now set, and
|
||||
the kind is still consulted beside it because the column was added to a
|
||||
table that already had task chats in it -- `sync_schema` backfills a new
|
||||
NOT NULL column with its type default, so every task chat written before
|
||||
this reads back as attended. Dropping the kind check would silently give
|
||||
every existing scheduled task a tool that stalls it for fifteen minutes.
|
||||
"""
|
||||
if chat is None:
|
||||
return False
|
||||
return bool(getattr(chat, "unattended", False)) or chat.kind == KIND_TASK
|
||||
|
||||
|
||||
def scoped_writes_off(chat: Chat | None) -> bool:
|
||||
"""Whether this chat has had everything that changes something withdrawn.
|
||||
|
||||
One key rather than a family list, because "may not write" is a property of
|
||||
the *conversation* and not of any one gate: a read-only helper must not
|
||||
write a note, file a report, save a memory or edit a file, and those are
|
||||
four gates it would otherwise have to name — and a fifth would arrive
|
||||
unnamed. Absent means writes are on, the same convention as everything else
|
||||
under `scope_json`.
|
||||
"""
|
||||
if chat is None:
|
||||
return False
|
||||
return (getattr(chat, "scope_json", None) or {}).get("write") is False
|
||||
|
||||
|
||||
def scoped_off(chat: Chat | None) -> frozenset[str]:
|
||||
"""Gates this chat has switched off. **Absent means on**, always.
|
||||
|
||||
@@ -1595,6 +1680,12 @@ def scoped_allow(chat: Chat | None) -> tuple[str, ...]:
|
||||
compared, and it refuses to produce anything at all for a command line
|
||||
carrying a shell metacharacter. "Always" can therefore only ever mean "this
|
||||
exact thing again".
|
||||
|
||||
There is a second server-side writer now: `services/subagent.py` puts its
|
||||
fixed safe list here when it creates a helper's chat. That does not weaken
|
||||
the property above -- the list is a constant in this codebase, the chat is
|
||||
created here and never by a request, and the model asking for the helper
|
||||
chooses none of it.
|
||||
"""
|
||||
if chat is None:
|
||||
return ()
|
||||
@@ -1637,6 +1728,7 @@ def context_for(
|
||||
skills_off=scoped_skills_off(chat),
|
||||
tools=tools.by_name if tools is not None else None,
|
||||
interaction_timeout=float(settings_store.agents(db)["approval_timeout"]),
|
||||
unattended=unattended(chat),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -433,4 +433,128 @@
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#
|
||||
A second form, and a second settings group. Subagents are not an agent-chat
|
||||
feature — an ordinary chat can delegate as well — but this is the page
|
||||
somebody comes to when they want to know what one reply may set going on its
|
||||
own, and a nav entry for a single card would be worse than the near-miss.
|
||||
|
||||
A form cannot nest inside another, so this sits *after* the one above rather
|
||||
than as a card inside it. Two forms means the browser posts only the one whose
|
||||
button was pressed, which is what keeps each group's save handler writing one
|
||||
key.
|
||||
#}
|
||||
<form method="post" action="/admin/agents/subagents" class="form-grid">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Helpers</h2>
|
||||
<p class="field__hint">
|
||||
A reply can hand a self-contained piece of work to a second model that
|
||||
runs on its own and reports back — several at once, which is what makes
|
||||
research fan out instead of queueing. This applies to ordinary chats as
|
||||
much as agent ones.
|
||||
</p>
|
||||
|
||||
<div class="alert">
|
||||
{{ icon("shield", "icon--sm") }}
|
||||
<span>
|
||||
A helper cannot ask anybody anything, so nothing in its chat can stop
|
||||
for approval. It therefore gets only what this chat could already do
|
||||
<em>without</em> asking: it reads, it searches, and on a machine it runs
|
||||
a short fixed list of read-only commands and nothing else, in every mode
|
||||
including <strong>Auto</strong>. It cannot send helpers of its own.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if subagents.enabled }}>
|
||||
<span>Let a model delegate</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
People also need the <strong>Delegate to a helper</strong> permission,
|
||||
and the model needs the <strong>Tools</strong> capability. Off by
|
||||
default: a reply that spawns helpers spends model time multiplicatively,
|
||||
and on one local endpoint four at once is four times the queue rather
|
||||
than four times the speed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_per_reply">Most helpers one reply may send</label>
|
||||
<input class="input" id="sub_max_per_reply" name="max_per_reply"
|
||||
type="number" min="1" max="20" step="1"
|
||||
value="{{ subagents.max_per_reply }}">
|
||||
<p class="field__hint">
|
||||
Fanning out across a handful of independent questions is what this is
|
||||
for. A reply that wants twenty has misread the tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_concurrent">Running at once, instance-wide</label>
|
||||
<input class="input" id="sub_max_concurrent" name="max_concurrent"
|
||||
type="number" min="1" max="50" step="1"
|
||||
value="{{ subagents.max_concurrent }}">
|
||||
<p class="field__hint">
|
||||
Each is a whole generation against the same endpoint the reply that
|
||||
asked for it is waiting on. Past this a model is told to do the work
|
||||
itself rather than made to wait.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_completion_tokens">Most a helper may write</label>
|
||||
<input class="input" id="sub_max_completion_tokens" name="max_completion_tokens"
|
||||
type="number" min="0" max="5000000" step="1000"
|
||||
value="{{ subagents.max_completion_tokens }}">
|
||||
<p class="field__hint">
|
||||
In tokens, across every round. A helper answers one question, so this
|
||||
should run out well before the reply that asked does. Zero means no
|
||||
ceiling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_wall_seconds">Longest a helper may take</label>
|
||||
<input class="input" id="sub_wall_seconds" name="wall_seconds"
|
||||
type="number" min="30" max="7200" step="30"
|
||||
value="{{ subagents.wall_seconds }}">
|
||||
<p class="field__hint">
|
||||
Seconds. Past it the helper is <em>stopped</em>, not abandoned: what it
|
||||
had written is kept and handed back with a note saying it is partial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sub_max_rounds">Most rounds of tool calls</label>
|
||||
<input class="input" id="sub_max_rounds" name="max_rounds"
|
||||
type="number" min="1" max="200" step="1"
|
||||
value="{{ subagents.max_rounds }}">
|
||||
<p class="field__hint">
|
||||
A backstop, as it is above. The clock and the token ceiling are what
|
||||
normally end one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="keep_transcript" value="true"
|
||||
{{ 'checked' if subagents.keep_transcript }}>
|
||||
<span>Keep a helper's own chat afterwards</span>
|
||||
</label>
|
||||
<p class="field__hint">
|
||||
Off means it is deleted once its answer has been handed over, which is
|
||||
what keeps this cheap to use. Turn it on to work out why one came back
|
||||
with something odd. Kept chats are temporary either way and are swept a
|
||||
day later, and neither appears in anybody's sidebar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user