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:
@@ -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.
|
||||
Reference in New Issue
Block a user