# 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.