Conversation
The parallel opt-in was answered per tool NAME, which works only while a tool's calls are all alike. A delegation tool breaks that: every call is named the same and differs only in which configured agent it names, and those agents differ in exactly the three properties the opt-in is about — whether the call writes, prompts, or opens a surface. One answer per name would have to lie in one direction, either serializing a read-only fan-out or letting a write-capable call into a batch. SupportsParallel now takes the call's arguments. parallelRun already held the whole ToolCall and was throwing everything but the name away, so the data was at the call site all along; the four read-only file tools ignore the parameter and are otherwise untouched. The contract widens from "this tool's calls never conflict" to "this call does not", and that needs a limit. The arguments must be LOOKED UP, never interpreted: a key into a table the user wrote resolves to a static fact they already authorized, a free-form string does not. That is the line bash stays on the wrong side of even now that the gate exists — a shell command's effects cannot be known from its declaration, and a model-supplied "this one only reads" is the constrained party signing its own certificate. The test dispatcher gains a compile-time interface assertion. ParallelReporter is optional, so a signature drifting out of step here would not fail to compile — it would silently stop being detected, serialize everything, and pass most of the file while proving nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
Granting a model the ability to search a codebase meant also granting it edit_file and write_file — the set was all or nothing. That made the two useful shapes unreachable at once: a reviewer that must not touch the tree, and (once delegation exists) a child that can search AND fan out, since the parallel opt-in requires that nothing in the set writes. `read_only: true` withholds the two writers and keeps the four tools that only look. Withholding beats forbidding: a tool the model cannot see is never attempted, never refused, and never argued with. It also needs no new notion of "harmless" — what remains already satisfies the parallel opt-in, so a read-only set answers yes to it for free. read_only together with auto_write is rejected rather than resolved: auto_write approves writes the set does not offer, so saying both is a mistake worth hearing about instead of one silently winning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
A long survey — every call site of a symbol, whether a hypothesis holds
across a tree — costs the conversation its own transcript to reach one
paragraph of answer. The delegate toolset runs that work as a child agent
and brings back the reply ALONE: no tool calls, no reasoning. Claude Code,
Codex and pi all settled on that same contract.
An agent IS a provider entry:
tools:
delegate:
agents:
search: fast-provider
review: {provider: careful-provider, description: reads a diff}
Nothing about the child is configured twice. Which model, which tools,
whether those tools ask for approval, the system prompt, temperature,
context window — the referenced entry already says all of it, and saying
it again is how the two copies would come to disagree. Only `description`
is new, because it is about the ROLE rather than the provider and is the
one thing the model has to go on when choosing.
The layering follows what each layer already knows. cmd resolves a name
to a provider and a toolset, which is what it does for the main session
anyway; chat runs the loop; tool decides whether a delegation is allowed
and what to call it. The seam between them is tool.Delegator, shaped like
Env.Interact — absent, and the set contributes no tools, so the model
never sees what it cannot use.
Parallelism falls out of the capability above: a delegation may overlap
another exactly when its agent grants nothing that changes state, which
is the same question as whether every tool in that agent's set may run in
parallel. Reusing that keeps one definition of harmless rather than two
that could disagree.
Two limits are deliberate. The child's toolset never includes delegate —
recursive delegation is unbounded in a way no per-run cap describes, and
pi's extension disables it for the same reason. And an agent whose
provider entry has no `model:` is a startup error: the main session
answers a missing model with a picker or by demanding -M, neither of
which exists down here, so the alternative is an empty model name
reaching the API as a 400 mid-conversation. (It did, once.)
executeWithTools gains an approval seam it always needed. It has no user
of its own, so it now either forwards the question to one who exists — a
child runs inside a parent that owns a terminal — or, with nobody to ask,
refuses and says how to enable the call, which is what it did before.
Binding a live parent's gate to it is left for its own change; until then
a child's writes are refused exactly as a -m run's are.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
The delegate seam was built with the gate unbound, so a child's writes met
the same refusal a -m run gets — the machinery existed and nothing was
plugged into it. A child has no terminal of its own, and giving it one
would mean two prompts with two memories in front of one person.
The prompt moves out of the tool loop into an approvalGate the loop and
the delegator share. A child's question arrives at the same place, labelled
with the agent that asked:
writer › write_file wants to modify files — allow?
Without that label a delegated request is indistinguishable from one the
conversation made itself, which is the difference between approving what
you asked for and approving what something else did.
"Allow for this session" is shared rather than tracked per agent. The
grant a user gives is "this session may edit files", and a child running
inside the session is part of it; the evidence is symmetric too, since the
prompt shows the tool and the file and never the diff, which settles only
after the call. Asking twice for one decision is the friction, not the
safeguard.
The approval callbacks take a context so the prompt can be cancelled with
the turn. Concurrency needs no more than the delegator's existing lock: a
delegation may only run in parallel when its agent grants no
state-changing tool, so two children can never be at this prompt at once.
Verified end to end in a real terminal — the child asked, the prompt
appeared labelled, the denial went back as the call's result, and the
child carried on and reported rather than the turn aborting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
A delegation's price was invisible. The parent's token counter moves only
by its own rounds — which is correct, and the point of delegating — but it
left no way to tell a cheap child from one that spent twenty rounds
reading files, and the cost of a fan-out was unknowable in the one place
it mattered.
The number goes to the user through the artifact side channel, which
already exists for exactly this: content meant for the reader and kept out
of the result text. Reporting a delegation's price by spending tokens on
the number would be its own small joke.
[delegate scout: every call site of parallelRun] · 4 rounds · 12.4k tokens · 8s
The slot is now injected for every call rather than only expanded ones, so
one channel serves both renderers — a diff for the expanded path, a
trailing detail for the event row. The batch path gives each concurrent
call its own slot; a shared one would be a race resolved by whoever
finished last.
finishCall takes the note as a variadic trailing argument. The sixteen
call sites with nothing to add stay as they are: appending "" to each
would be noise that says nothing about them.
The settled classic block keeps the note too. Without that a lone
delegation was the one case where the cost was visible while it ran and
gone the moment it finished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
Parallel execution was reachable only from the interactive loop, because the machinery took a transcript. A scripted run — CI, a pipeline, a parent treating this binary as a child — read four files one at a time for no reason other than where the code happened to live. The concurrency itself never needed a terminal, so it moves into runBatch and both loops call it: the interactive one wraps it in a widget and a cancel scope, the quiet one has neither and needs neither. Nothing else about the quiet loop changes — the approval gate cannot be reached from a batch, since a call may only opt into parallel execution if it needs no approval and opens no surface. Measured on a mock whose children sleep 1.5s each: two read-only delegations from `-m` finish in 2.28s where they took 3.09s, and the server-side timeline shows both starting at +0.00s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
Neither the code set's configuration nor delegation was described anywhere — auto_write had a paragraph, read_only had none, and delegate was absent entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
max_turns defaulted to 30 on the reasoning that a child has nobody watching it round by round. That is not true: ESC cancels the turn, and the context it cancels reaches the child's every round, so the brake works on a delegation exactly as it does on anything else. Which leaves a default that contradicted the two loops beside it — --max-turns is 0, and the interactive loop says in as many words that it has no round cap because the user is the brake. Unlimited is now the default here too; the key stays, because a cap the user chooses is a different thing from one they were given. It is still the wrong instrument for a wedged child. That failure is measured in wall-clock — bash caps at ten minutes, pi's delegate extension at fifteen — and thirty cheap rounds cost nothing like three expensive ones. A timeout is worth having, and worth having separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
Reviewed by gpt-5.6-sol and glm-5.3, run concurrently through the delegate toolset this branch adds. Their sixth finding — that the unlimited max_turns default is justified by a brake that does not exist in -m — is real and left for its own change. Every one of these is a promise made in a comment and not kept by the code beside it. The approval prompt named only the tool. For a delegated call nothing else on screen identified the operation: the widget above describes the DELEGATION, not what the child asked to do, so the user was approving "edit_file" with no idea which file. The detail now travels with the request, rendered by whichever side holds the dispatcher that owns the tool — a child's tools are its own, and the parent cannot describe them. toolCallDetail is the header's existing logic, split out so both readers share one implementation. An `agent: true` child never got load_skill. AgentMode injects the AGENTS.md/skills text; the tool comes from the agent SET, which the main session enables separately and this did not — so a child was told which skills exist and given no way to open one, while the docs claimed the provider entry decides a child's tools. A malformed toolset passed startup and then wrote ANSI to stderr from under bubbletea. The validation pass used a silent warnf and the per-delegation rebuild used a loud one; now one helper builds both, its complaints are the startup error this function already promised to produce, and the runtime build says nothing because there is nothing left to say. The parallel classification read the raw agent argument while execution read it trimmed, so a call could be admitted to a batch as a read-only agent and then run as a write-capable one. Both resolve it through one helper. A failed child's accounting was discarded — the early return on error came before the artifact — hiding the cost exactly where it is surprising, and contradicting the same principle stated one commit earlier for -m runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
--max-turns capped the parent's loop and nothing else, so a delegated child ran an uncapped loop of its own — in -m, on a background context, with no ESC and nobody watching. The flag exists for exactly that unattended case, and delegation moved the spending to the one place it could not see. The number the user wrote had no relationship to what the run could cost. It is now a budget for the run: one pool, published to the context, drawn on by the parent and by every child. That is what "limit this run" means, and it is the shape Claude Code settled on for the same problem — a budget owned by the run and decremented by every agent in it. Verified against a mock where both sides loop forever: --max-turns 6 spends 1 parent round and 5 child rounds, --max-turns 3 spends 1 and 2. Before, the parent stopped at the cap and the child never stopped at all. No number is invented. Without the flag there is no budget, because a cap nobody chose is either too low to be safe or too high to be a cap — which is why Claude Code ships maxBudgetUsd with no default and no timeout at all. Interactive runs stay uncapped for the same reason they always were: the user is the brake, and ESC reaches a child's every round. tools.delegate.max_turns stays as an optional per-child cap beside it. The two are different questions — what this run may spend, versus what this particular agent should ever need — and neither is a guard against a WEDGED child, which is measured in wall-clock and left for a change that has a reason to pick a number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
The JSON report did not account for what a run delegated. --max-turns
already charges the run for a child's rounds, so the two accountings
contradicted each other: the same rounds belonged to the run when it came
to bounding them and not when it came to reporting them. A caller running
this binary as a child — the consumer chat/output.go names first — was
billed for rounds the report never mentioned.
Children now report through a run-level ledger, travelling by context the
way the turn budget does, and land under `delegated`:
"usage": { "rounds-worth for this agent" },
"delegated": { "rounds": 4, "usage": { … } }
Kept beside the parent's own figures rather than folded into them. They
answer different questions, and merging them produces the puzzle of two
rounds costing four thousand tokens — which, measured on a mock, is exactly
the shape of a delegating run: 2 rounds and 20 tokens of its own beside 4
rounds and 4000 delegated.
A child's provider entry reached the wire unvalidated. The main session
rejects a bad effort, temperature or top_p at startup; a delegated one
accepted `effort: turbo` silently and failed with an API 400 partway
through a conversation — the same failure the model: check three lines
above exists to prevent, arriving by a different door.
Image settings are the one part of a provider entry a child does not adopt,
and that is now said out loud in the code and the README. It was neither
applied nor documented, and an image a child generated would land on disk
where the parent never learns of it.
And the call header was a third reader of the agent argument, still taking
it raw after the classifier and the executor were unified on the trimmed
one. Display-only, but that commit's own comment said all readers have to
share the rule, and there were three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169txuMy5yPuGc8ZovqGaGU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A long survey — every call site of a symbol, whether a hypothesis holds across a tree — costs the conversation its own transcript to reach one paragraph of answer. The
delegatetoolset runs that work as a child agent and brings back the reply alone: no tool calls, no reasoning.Configuration
An agent is a provider entry, so nothing about the child is configured twice:
Which model, which tools, whether those tools ask for approval, the system prompt, sampling — the referenced entry already says all of it. Only
descriptionis new, because it is about the role rather than the provider, and it is what the model chooses between agents on. Image settings are the one part a child does not adopt (a delegation returns text).What came with it
SupportsParallelnow takes the call's arguments, because a delegation tool's calls are all named the same and differ only in which agent they name. Two delegations overlap exactly when neither child can change state. The arguments are looked up, never interpreted — which is whybashstill stays out.code: {read_only: true}. Granting search used to grant writes. Withholding the two writers leaves a set that satisfies the parallel opt-in for free, which is what makes a searching-and-fanning-out agent configurable at all.review › edit_file src/x.go wants to modify files. "Allow for this session" is shared: the grant is this session may edit files, and a child is part of the session.delegatedin--output-format json, beside the parent's own usage rather than inside it.--max-turnsbounds the run. One pool, drawn on by the parent and every child. Verified:--max-turns 6spends 1 parent round and 5 child rounds.-mbatches concurrent-safe calls — the machinery took a transcript, so only the interactive loop could reach it.Deliberate limits
delegate. Unbounded in a way no per-run cap describes.maxBudgetUsdwith no default and no subagent timeout at all. Interactive runs are bounded by ESC, which reaches a child's every round.Review
Dogfooded:
gpt-5.6-solandglm-5.3reviewed this branch concurrently through this feature. Two rounds of review found nine defects; all nine were real and all nine are fixed here. Eight of them were promises made in a comment or the README that the code beside them did not keep.🤖 Generated with Claude Code