Repository navigation
Orchestration: parallel foreground subagents with a cap, per-child budget and cost, child cards (#32) - #98
Merged
Merged
Conversation
…hild Fan-out (#32) is the model calling the SDK's `Agent` tool (formerly `Task`) several times in one message; the CLI runs them as a parallel batch. Nothing bounded how many ran, what they cost or how deep the tree went, and canUseTool cannot: the Agent tool answers its own permission check with "allow" in the modes we run. The engine's PreToolUse hook fires for every call and is binding, so admission lives there. $lib/engine/delegation-gate (pure) decides each delegation: - rewrites run_in_background to false. A background child in a one-shot query is held back and killed at the print-mode ceiling and reports only a token total; a foreground child runs in parallel with its siblings and returns a typed AgentOutput; - strips isolation and mode, and model when the parent is on the gateway; - refuses a child past MAX_CONCURRENT_SUBAGENTS (4), with "wait for the running children to finish, then delegate the rest". Refused, not queued: a hook that waited would time out and let the call through. The slot is reserved before the budget check awaits; - refuses a call made inside a child (agent_id on the hook input); - runs an injected per-child budget check, bounded at 10s; - fails closed on any surprise. Agent, Task and Workflow join every definition's disallowedTools, and Workflow (the CLI's scripted fan-out, which would bypass the gate) joins DISALLOWED_BUILTIN_TOOLS. The roster now also maps each offered key to its agents row, for the budget check and the ledger. The child's card opens at the parent's Agent call ($lib/engine/ subagent-block), so a refused child still has one, and the delegation gets no tool block or tool_call frame beside it. The child's messages, routed by parent_tool_use_id, build an ordered, capped transcript ($lib/engine/subagent-transcript). The delegation's typed result closes the card with SubagentDetails (tool-result-details now distils Agent and Task); a launch placeholder does not; a card still open when the turn ends is closed as stopped. New block fields are optional jsonb: no migration. readTurnUsage now says whether its figure already includes the children, for the ledger. Stop needs nothing new: a foreground child runs on the parent turn's own abort controller (bundled CLI 2.1.278), so interrupt() ends it with the turn, and perTaskStopAffordance stays undeclared. Chat hooks read the delegation off its card's frames, so before_tool / after_tool still fire for it (as `Agent`, with the child's report). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat stream now hands every turn a delegation gate ($lib/chat/stream-delegation.server). Its budget check is the same enforceBudgetGuard a chat turn passes, scoped to the child's own agent, so a fan-out cannot walk through a limit set on the agents it delegates to; a blocked child records the same alert and review item, and the model is told not to retry. A child with no agents row of ours (the SDK's built-in agents) is checked as the parent's agent. Each child that completes gets one llm_usage row: source 'subagent', runId = the parent run, agentId = the child's agent (else the parent's), model = the model it ended on, cost 0 on the subscription and priced from the catalogue on the gateway ($lib/costs/subagent-ledger). The SDK's per-child usage (AgentOutput.usage) is the child's LAST model call, not its total (read in the bundled CLI 2.1.278), while the turn's modelUsage already includes every child. So a child row is carved out of the parent's row rather than added: tokens field by field, cost once the child row is priced. The turn's total is unchanged and nothing is counted twice; the rest of a child's spend stays on the parent, where all of it was before. Where the turn figure is main-loop only (a resumed session with no baseline) the child rows are additions. The message and conversation totals still show the whole turn, and each child's cost is stamped on its card before the blocks persist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nscript SubagentBlockCard now starts collapsed, live and reloaded alike, because a fan-out opens several at once. Collapsed it shows the child agent, how it ended (working, done, failed, refused, stopped), its task, and its tokens, cost, duration and tool count, leaving out what the child did not report ($lib/chat/subagent-card, pure). Expanded it shows the child's own transcript in order: what it said, and each tool it called with a short hint at what it touched and whether it worked. A refusal or failure shows its reason. The live page builds the same transcript from the subagent_* frames (streaming-blocks), subagent_done carries the typed result and how the child ended, subagent_start is idempotent for a resumed stream, and a partial save on Stop keeps the card and marks a running child stopped. Blocks saved before this still render from their text and tool names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents.md gains the delegation rules and the fan-out flow in plain English; agents/spec.md replaces the category-routing section, which was never built, with how delegation works and what was verified in the installed SDK. cost/spec.md explains the subagent rows and the carve-out; chat/spec.md the child cards and Stop; hooks.md how a delegation is reported. The parity table's orchestration row moves to even, and the re-audit's #32 section is marked shipped, correcting its canUseTool, background and worktree claims. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot (#32) Review follow-ups for delegation. - Stop: the CLI answers every delegation still running with an error result of its own ("[Request interrupted by user for tool use]", or a synthetic cancellation) before the turn's result, which closed each card as failed. The engine now records that it sent the interrupt, and an error result after that, or one carrying the CLI's interrupt text, closes the card as stopped. - The slot is freed when the child's card closes, not when its call is answered. A trusted project's agent definition with background: true is backgrounded by the CLI whatever the call says; its launch placeholder no longer frees the slot, and its task_notification closes the card and hands the slot back, so the cap holds. - Each child's spend is added up over its own model calls (forwarded assistant messages and message_start/message_delta events, one count per message id, largest report wins), with the typed result folded in as the last call. The total rides on the card's done frame and on the persisted block as `usage`. - onSubagentDone: the caller is told as each card closes, before the loop reads on. - SendMessage is off for parents and children: it can resume a finished agent outside any Agent call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) - A child's ledger row now carries the sum over its model calls (usageBasis 'model_calls'), falling back to the SDK's last-call figure only when none of its calls was seen ('final_call'). A child that failed or was stopped partway gets a row for what it spent. - The row is written the moment the child's card closes, through the engine's onSubagentDone, instead of after the turn. The budget check the next child passes reads the ledger, so a turn that fans out in waves is checked against the waves that already finished. createChatDelegation hands the route the gate and the ledger together; settle() books any child not booked yet and returns the parent's share. - The live spec no longer leaves the blocked child's review item in the shared database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card's "N tokens" was the SDK's totalTokens, the child's context at the end plus its final answer. It is now the child's spend added up over its model calls, the same figure as its ledger row, live and after a reload. A card saved before falls back to the old figure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #14, #9, #22, #24, #4 and #35 (live background-shell output) under the orchestration work (#32). Resolutions: - stream.server.ts: kept #32's delegation routing (PreToolUse admission gate, child cards, per-child spend, stopped children) beside #4's AskUserQuestion answered in canUseTool, #35's background shells and #24's transcript tail. Imports combined (UsageCoverage kept). On the result message, background shells end first, then readTurnUsage keeps includesSubagents and unfinished children are closed; the no-result path does both as well. - chat/[id]/stream/+server.ts: the delegation (gate + ledger) is created once per turn and passed into the #24 runWithResumeFallback attempt (a fallback only retries an attempt that produced nothing, so no child ran in it). The parent's gateway cost is priced by #9's priceGatewayTurn over #32's carved parentUsage, logged with ledgerCostOverride and the backend/costBasis metadata; messageCost still adds the children's rows back. - tool-result-details.ts: SubagentDetails (#32) and AskUserQuestionDetails (#4) both in ToolResultDetails; SHELL_TOOLS is #35's Bash-only set, SUBAGENT_TOOLS kept; both distillers reachable in toolResultDetails. - streaming-blocks.ts: imports combined (#4 ask-user, #35 shell tail and status, #32 SubagentDetails/SubagentSpend/transcript helpers). - agent-definitions.ts: SUBAGENT_DISALLOWED_TOOLS refuses AskUserQuestion (#4) plus Agent/Task/Workflow/SendMessage (#32). - options.server.ts: #32's perTaskStopAffordance note kept above #4's toolConfig/settings for AskUserQuestion. - runs.schema.ts: AskQuestion import (#4) beside the subagent transcript, spend and SubagentRunStatus (#32). - console-state.svelte.ts: took #14's trimmed store; the streaming-block types #32 widened with 'stopped' are gone and unused. - claude-desktop-parity.md: #35's terminal row, #32's git-worktrees row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Summary
I finished #32 with option A from the triage: delegated children run in the foreground and in parallel, and no migration was needed. I picked up the interrupted attempt's uncommitted work rather than redoing it.
The new module src/lib/engine/delegation-gate.ts runs from the engine's PreToolUse hook. It forces run_in_background:false and removes
isolationandmodefrom every call; on a gateway parent it also removesmodel. It refuses a fifth live child with a "wait for the running children, then delegate the rest" message (MAX_CONCURRENT_SUBAGENTS = 4). It also refuses a delegation made inside a child. Before a child starts, it runs the same budget check a chat turn uses, scoped to the child's own agent (src/lib/chat/stream-delegation.server.ts). Any error inside the gate refuses the call. Workflow is disabled, and Agent, Task and Workflow are blocked inside children, so nothing can bypass the gate.Each delegation gets one collapsed SubagentBlockCard, opened at the parent's Agent call. It shows the agent, status, tokens, cost and duration, and expands to the child's ordered transcript built from the subagent_* frames. The delegation no longer also appears as a separate tool card.
Each completed child writes one llm_usage row: source 'subagent', runId = the parent run, agentId = the child's agent. In the installed SDK the child's reported usage covers only its last model call, while the turn total already includes the children. So each child row is subtracted from the parent's row rather than added to it, and the turn total stays the same.
Pressing Stop on the parent turn stops the children too. I confirmed in the bundled CLI that foreground children share the parent turn's abort controller, and perTaskStopAffordance is never set.
I also fixed a regression the previous attempt left: chat hooks now fire before_tool/after_tool for a delegation as
Agent.check (0 errors) and build pass. On both projects, 590+94 tests passed. The 4 failures were the two known ECONNREFUSED 4173 cost.linkage cases, plus hooks.chat-run, which I then fixed and which now passes. The port patch was reverted.
After review: This round fixed all five should_fix items and the one claimed-but-not-fixed item from the #32 review. The branch is at 8 commits on origin/main (0bb97b6), the worktree is clean and there is no migration.
Stop now shows
stopped, notfailed. The engine records that it sent Stop to the CLI. An error result for a delegated child that arrives after that, or that carries the CLI's "[Request interrupted by user" text, closes the child's card asstopped.A child only gives back its slot when it has really finished. A child the CLI still runs in the background (because a trusted project's agent definition says
background: true) now keeps its slot. Its card stays open until itstask_notification, which closes the card with how the child ended and frees the slot. So the limit of 4 holds.A child's cost is now everything its model calls used. The new
src/lib/engine/subagent-usage.tsadds up each call once (one count per message id, larger report wins). The SDK's typed result is folded in as the last call. This total goes on the persisted block, the done frame, the ledger row and the card's token count. Failed and stopped children now get a ledger row for what they spent.Each child's ledger row is written the moment the child finishes, not at the end of the turn. So the budget check for the next child sees earlier children from the same turn.
Spec cleanup. The live spec now waits for and deletes the review item that a blocked child creates.
I also switched off
SendMessage, which can restart a finished agent without passing the gate. I confirmed this in the bundled CLI: it can say "Resuming agent …".Checks:
bun run checkhas 0 errors, including on the two middle commits.bun run buildpasses. The changed spec files also type-check withtsc. 15 relevant spec files passed on both desktop and mobile (366 passed).Closes #32
What was addressed
Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.
payload->>'conversationId' = ids.conversationId.stoppedis now true for the stream the real CLI sends.Verification
bun run check: 0 errorsbun run build: passesDocs updated
Follow-ups / residual risk
Why I did not refuse unknown agent types (should_fix-2's 'better still' suggestion). A trusted project's own
.claude/agents/is a deliberate feature (Per-project instructions and knowledge #23 and setting-sources.ts). A project file can also reuse a built-in agent's name, so a name allowlist would not be airtight. Holding the slot until the child really ends is what keeps the limit enforced whatever a definition asks for. A definition's own isolation, and a trusted project's own PreToolUse hooks, are that project's configuration and can already run arbitrary commands. This is documented in docs/agents/spec.md.Output tokens need a live check. I confirmed in the bundled CLI that each split assistant message is emitted when its content block ends, and that the call's final usage is written onto those same objects only when message_delta arrives. I did not confirm whether the copy sent out to our stream carries the final output count. If it does not, and child stream events are not forwarded either, output tokens for a child's earlier calls are undercounted; they are a floor. Input and cache tokens are exact, and so is the last call (the typed result is folded in). Either way the carve-out keeps the turn's total exact. Worth checking live once model credentials are available: look at
metadata.modelCallsandtokens_outon a 'subagent' row.Budget blind spots that remain. The check does not yet count siblings still running beside the child being checked, or the parent's own model calls this turn (its row is written at the end). So one wave can go past a limit by what that wave spends. On the Claude subscription every row costs $0, so a dollar limit never blocks there. All of this is stated in docs/cost/spec.md.
Unchanged from last round, both noted by the reviewer as not blocking. A delegation that needs approval still reserves its slot and runs the budget check before the user approves. A limit refusal for an 'ask' delegation still leaves an approval card that only the later tool result closes.
The Stop behaviour has not been tried live. In a real stopped run, the child cards should read 'stopped' and carry a
usagefigure.🤖 Generated with Claude Code