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_toolswithdrawsaskandsubagentfrom the offered set. A question nobody can answer holds the reply untilapproval_timeout; a helper that could send helpers is a fan-out with no bound anybody set.generation._authoriseanswers 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 ofask_userwas 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_forbranches onparent_chat_idfor an agent helper;generation._runreads the same number in place ofchat_roundsfor 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_variablessets that variable fromchat.parent_chat_id, one column read and no query. It is a flag wearing a variable's clothes, becauserequiresis 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.