Files
LLeMbas/docs/notes/subagents.md
T
Jaroslav Beneš e16bede85b 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>
2026-08-06 15:05:30 +02:00

8.0 KiB

Subagents

Read this before changing services/subagent.py, Chat.unattended, Chat.parent_chat_id, or the unattended branch in generation._authorise.

subagent_run hands one self-contained piece of work to a second model that runs on its own and reports back. The mechanism is small on purpose; almost everything below is about what the helper is not given.

The shape, and the two that were rejected

A helper is a hidden Chat, one turn put into it by wake_chat, and a poll until the reply stops. Nothing about streaming, rounds, budgets, metrics, steps or tools is re-implemented, because a second implementation of any of them is a second thing to keep correct.

Not a nested Generation in the parent's chat. services/wake.py exists to make that impossible: a chat has one generation at a time, and two writing one transcript is a Stop button pointing at whichever bubble comes first in the document.

Not a one-shot complete() — the shape generate_title uses. schedule/runner.py already records why: it has no tools and no rounds, which is useless for the case the feature exists for. A helper that cannot search is not a helper.

So the pattern is runner.fire's, and runner._await_reply's poll is copied rather than shared, for the reason that one gives: generation owns its registry and its tasks, and reaching into either couples this to internals whose whole job is to be replaceable.

Nobody is watching, and that is a column

Chat.unattended is the question, and not the kind. A scheduled task's chat is unattended because of what started it; a helper's because of what it is; a third thing will be unattended for a third reason. tools.unattended(chat) reads the column and kind == KIND_TASK beside it, because the column was added to a table that already held task chats and sync_schema backfills a new NOT NULL column with its type default — so every task chat written before this reads back as attended. schedules.create sets the column now, so the kind check is a backfill and not a permanent second rule.

Two things follow from it, and both halves are needed:

  • resolve_tools withdraws ask and subagent from the offered set. A question nobody can answer holds the reply until approval_timeout; a helper that could send helpers is a fan-out with no bound anybody set.
  • generation._authorise answers an approval with a refusal instead of building a card. Without this half, a helper in Plan mode meets an ASK on its first command and parks for fifteen minutes — which from every screen is indistinguishable from the feature not working, and is the exact failure the withdrawal of ask_user was added to prevent, arriving by the other door.

_unanswerable is deliberately not worded as a refusal by a person. Nobody refused; a model told "they declined" reasons about a reader who is not there.

What a helper may do

Restriction happens at tool resolution, never in the prompt — the standing rule, and it matters more here than anywhere: a helper's task text is written by a model that has been reading web pages. Everything is a property of the child's row:

what how
no questions, no recursion unattendedresolve_tools drops ask, subagent
nothing that writes scope_json["write"] = False → every RISK_WRITE tool dropped
reads only what the parent could the parent's scope_json["families"] is copied whole
commands from a fixed list MODE_PLAN/MODE_EDIT + scope_json["allow"] = SAFE_COMMANDS

The write narrowing is keyed on the declared risk, not on a list of names, because a list 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 excluded from it — in an agent chat the mode and the allow list are a finer instrument, and git log is a read whatever its risk class says.

Auto is never inherited. Both modes a helper may be given resolve RISK_EXECUTE to ASK, and ASK here is a refusal, so what runs is what matches SAFE_COMMANDS and nothing else — in every mode, including Auto. That is the one place this is deliberately stricter than the parent, and the reason is the injection path: the task text can have come from a page.

policy.subject is what makes the list safe rather than decorative. It returns None for any line carrying a shell metacharacter, so git log being on the list does not put git log; curl … | sh on it.

A writing helper is a per-call parameter and is refused from Manual and Plan. Otherwise the mode is laundered: a reply that must be stopped before writing gets a helper to write on its behalf with nobody stopped. In Edit and Auto the parent could have written already, so the helper may too — and it gets MODE_EDIT, which buys files and still not a shell.

Bounds

settings_store.subagents, on the Helpers card of /admin/agents. It lives there rather than on a nav entry of its own because that is the page somebody comes to when they want to know what one reply may set going — even though subagents are not an agent-chat feature and an ordinary chat can delegate too. Its own form and its own route: one form writing two settings groups means one handler deciding which key each field belongs to, and that mapping goes wrong silently.

  • Per reply — counted on the parent's Generation.subagents, which is the only object 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.
  • Instance-wide — a module-level set, cleared by a restart, which is correct: a restart abandons replies in flight, so there is nothing for a durable count to describe.
  • Per helperagent/session._limits_for branches on parent_chat_id for an agent helper; generation._run reads the same number in place of chat_rounds for an ordinary one. Without the second, a helper in an ordinary chat has whatever ceiling an ordinary chat has, which by default is none.

The order in _run_subagent is the design: the refusals first, then the budget, then the child. A call that could never have worked is told why rather than told it has run out of helpers, and the counter only moves for a call that is about to spend one.

Running out of time

The helper is stopped, not abandoned. request_stop sets the flag the producer checks between chunks, so the partial reply is persisted and marked stopped rather than error, and the parent gets what there is plus a sentence saying it is partial. An abandoned generation would go on spending the endpoint after the parent had stopped caring.

The wording

Three fragments, and they say different things on purpose.

  • tool.subagent (families=("subagent",)) — when to delegate and when not to. A model gets this wrong in both directions: it answers four independent questions one after another, and then sends a helper to do a single search.
  • tool.subagent_agent (requires=("agent_target",)) — the agent-chat half. What it has to say is what a helper cannot do on a machine, because the failure otherwise is a model planning a phase around a helper that will refuse every step of it.
  • core.subagent (requires=("subagent",)) — read inside the helper's own chat. harness.context_variables sets that variable from chat.parent_chat_id, one column read and no query. 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.

The chat afterwards

Deleted once the answer is handed over, unless keep_transcript is on. Either way it is temporary, so it is in no listing and the day-old sweep gets it. Tidying up is best-effort and outside every other session: a helper whose answer has been handed back has done its job, and failing to delete a row must not turn a good result into an error.