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:
Jaroslav Beneš
2026-08-06 15:05:30 +02:00
parent 6fcb9c9892
commit e16bede85b
20 changed files with 2035 additions and 30 deletions
+151
View File
@@ -0,0 +1,151 @@
# 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 | `unattended``resolve_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 helper** — `agent/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.