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 0fa05c88b2
commit 46066150d9
17 changed files with 1850 additions and 13 deletions
+75
View File
@@ -228,6 +228,13 @@ class Generation:
# and been told to carry on. Reset the moment it calls a tool again, so the
# count is of consecutive stops rather than of stops in total.
nudges: int = 0
# How many helpers this reply has spawned. Here rather than keyed on the
# chat because this object is the only one 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. See services/subagent.py:_budget.
subagents: int = 0
# A tool this reply must call, set by `/image` and by nothing else. It goes
# into the *first* request only -- `_run` rebuilds the payload's messages
# per round but keeps this body, and `tool_choice` left in place would make
@@ -514,6 +521,14 @@ async def _run(generation: Generation) -> None:
# rejects the whole request.
vision = chat_service.model_supports(db, chat, "vision")
chat_rounds = settings_store.chat_rounds(db)
# A helper's chat is bounded by its own number, not the instance's.
# Only reached in an *ordinary* helper chat -- an agent one is sized
# by `Limits` below, which `agent/session.py` already narrows the
# same way. Without this the round ceiling on a helper is whatever
# an ordinary chat has, which by default is none at all, and the
# only thing left holding it is the parent's wall clock.
if chat.parent_chat_id:
chat_rounds = int(settings_store.subagents(db)["max_rounds"])
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
limits = tool_context.agent.limits if tool_context.agent else None
@@ -1562,6 +1577,20 @@ async def _authorise(
if not items:
return {}, set(), set()
# Nobody can answer, so nothing waits. A scheduled task and a subagent both
# run with no reader, and a card built for one of them is a reply doing
# nothing for fifteen minutes and then giving up -- indistinguishable, from
# every screen, from the feature not working. Answered immediately instead,
# in the same shape a refusal takes, so the model reads a sentence it can
# act on and the round carries on with everything else in it.
#
# This is what makes the modes usable here at all: a helper runs in Plan or
# Edit, both of which resolve a command to ASK, and ASK arriving here means
# "not in this chat" rather than "hold everything". See
# services/subagent.py.
if getattr(context, "unattended", False):
return {item.index: _unanswerable(item) for item in items}, set(), set()
timeout = float(context.interaction_timeout or 900)
pause = interaction.build(uuid.uuid4().hex, items, timeout=timeout)
generation.status = interaction.summarise(pause.items)
@@ -1646,6 +1675,52 @@ def _apply_edit(
return edited
def _unanswerable(item: interaction.Item) -> ToolOutcome:
"""What a call gets back in a chat where nobody can be asked.
Deliberately not worded as a refusal by a person: nobody refused, and a
model told "they declined" reasons about a reader who is not there. It says
the thing that is actually true and the thing that follows from it -- this
cannot happen here, so do the rest without it -- because the alternative a
model reaches for otherwise is to ask again in different words.
Two shapes arrive here. An approval, which is a command or a write outside
what this chat allows; and a question, which should not exist at all because
`ask_user` is withdrawn from an unattended chat -- it is answered anyway, so
that a call arriving by some path that skipped `resolve_tools` is refused
rather than left to hang.
"""
if item.kind == interaction.KIND_APPROVAL:
return ToolOutcome(
"That is not something you may do here: this conversation runs with "
"nobody present, so there is no one to approve it. Do what you can "
"without it and say plainly in your answer what you could not do.",
{
"name": item.tool_name,
"kind": "agent",
"label": item.title,
"query": item.detail,
"results": [],
"status": "error",
"error": "Not permitted here — nobody is present to approve it.",
},
)
return ToolOutcome(
"There is nobody to ask: this conversation runs on its own. Choose the "
"most reasonable reading, carry on, and say in your answer what you "
"assumed.",
{
"name": item.tool_name,
"kind": "ask",
"label": "Nobody to ask",
"query": item.title,
"results": [],
"status": "error",
"error": "Nobody is present to answer.",
},
)
def _not_allowed(item: interaction.Item, reply: interaction.Reply) -> ToolOutcome:
"""What the model is told when a person declined, or never answered.