Repository navigation
feat(core,cli): default subagent tool to background: true - #60618
kitlangton wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
The new default works in the TUI, but it makes opencode run (and any client that waits only for the parent session to go idle) exit before subagents finish whenever the model leaves out background.
How I checked it, with the simulated model and a child that takes about 2.5s:
- TUI: on the base the call blocks and the parent reads the child's result in the same turn. With this PR it returns
runningimmediately, the parent continues, and it is woken with the result later, as described. opencode run "delegate it": on the base it prints the reply that uses the child's result. With this PR it prints the parent's first reply and exits with code 0 before the child finishes. The reply after the notification is never shown.- Tests and type checks:
test/tool-subagent.test.tspasses (12/12) andtypecheck:tscinpackages/coreis clean.
Public API: no SDK, HTTP or config changes, but the built-in subagent tool's contract changes: background now defaults to true instead of false. Anything that relied on getting the subagent's result inline without passing the flag must now pass background: false. The main example is scripted or CI opencode run; plugin hooks that inspect subagent input are also affected.
Blocking: runNonInteractivePrompt only awaits experimental.session.wait on the parent, which returns as soon as the parent's turn ends. Nothing waits for running background jobs or for the turn the completion notification triggers. This is the known gap in #48361, which so far only showed up when a model opted in to background: true. With this change every delegation hits it. Either make run wait for the session's background jobs and the resumed turn before exiting, or keep run on synchronous subagents, before flipping the default.
| )) | ||
|
|
||
| const background = input.background === true | ||
| const background = input.background !== false |
There was a problem hiding this comment.
With this default, opencode run no longer waits for subagents. The non-interactive runner only waits for the parent session to go idle (experimental.session.wait), so once the parent ends its turn after launching a subagent, the process exits. The child's result and the parent's reply after the notification are lost. I reproduced this with the simulated model: the base prints the reply that uses the child's result, while this branch exits 0 after the first reply. See #48361. This needs the runner (and other clients that wait for idle) to also wait for pending background jobs and the resumed turn, or a synchronous default for non-interactive runs, before the default flips.
72026d9 to
dcd4f80
Compare
There was a problem hiding this comment.
The earlier blocker is fixed: opencode run now waits for background subagents. I re-ran the same simulated-model repro against a server running this PR: run prints the parent's first reply, waits for the child, then prints the parent's reply after the completion notice and exits with code 0. At f3d91bf it exited after the first reply. noninteractive.test.ts (13/13) and tool-subagent.test.ts (12/12) pass, and typecheck:tsc is clean in packages/cli.
Public API: unchanged apart from the built-in subagent tool's background default, false → true. Callers that need the result inline must pass background: false. The run change adds no flags or output formats.
One remaining point, inline: the new wait loop can poll forever if a completion notice never arrives.
| ) | ||
| } | ||
| if (backgroundSubagents.size > 0 || pendingWakeIds.size > 0) { | ||
| await Promise.race([completed, new Promise((resolve) => setTimeout(resolve, 25))]) |
There was a problem hiding this comment.
Once the parent and the child are both idle, this loop only exits when the child's completion notice shows up as session.inbox.enqueued and is then delivered. If that notice never arrives (for example the lost-notification bug in #50751, or a notice enqueued while the event stream is reconnecting), both wait calls return immediately and opencode run polls every 25ms forever instead of exiting. A time limit on this loop, or treating an idle child with no pending job as settled, would make a missed notice fail safely instead of hanging CI.
dcd4f80 to
766e16d
Compare
There was a problem hiding this comment.
Both earlier points are addressed: opencode run now waits for background subagents, and a child whose completion notice never arrives no longer leaves run polling forever.
I ran the reproduction script from #48361 (two background subagents, each taking 15s, run --standalone --auto) from source. On the base, run exits with code 0 after about 4s and the final answer never prints. With this PR it waits about 19s, prints FINAL: got TOKEN_A and TOKEN_B and exits with code 0. In packages/cli, noninteractive.test.ts passes (14/14) and typecheck:tsc is clean.
Public API: unchanged apart from the built-in subagent tool's background default, false → true. Callers that need the result inline must pass background: false.


subagentnow runs in the background by default (background: true) while keepingbackground: falsefor synchronous waits, andopencode runstays alive until spawned background subagents finish and the parent completes its resumed reply (#48361).🔍 Origin: reported in #48361 and #48826
💡 Why: Subagent turns typically run for minutes, so foreground-by-default stalled parent turns whenever an agent omitted
background: true— while non-interactiveopencode runexited on the parent's first idle boundary before background subagents delivered their results (#48361). Across Claude Code SDK (v2.1.198+) and Codex (spawn_agent), background subagent launch is now the default, and in a 30-run multi-turn eval across Haiku 5.5, DeepSeek V4.1 Flash, MiniMax M3, Gemini 3.5 Flash Lite, and Sonnet 5.5 (sequential dependency + 3-subagent parallel fan-out), defaultingsubagenttobackground: truewhile preservingbackground: falsescored 30/30 (100%) with0polling loops and0premature writes.🆕 Now you can: Spawn subagents asynchronously by default in both interactive sessions and
opencode run, or passbackground: falseexplicitly when the caller must block synchronously in the same turn.📊 Proof
subagentcalled withoutbackgroundstatus: "running") and wakes parent on completionopencode runwith background subagent (#48361)subagentcalled withbackground: falsestatus: "completed")n = 30)28/3030/30(0polls,0premature writes)packages/core/test/tool-subagent.test.tsandpackages/cli/test/run/noninteractive.test.tspass.👀 Read:
packages/cli/src/run/noninteractive.tsaroundbackgroundSubagentsandwaitUntilSettled— keepsconsume()open across the Turn 1 idle boundary until every spawned background subagent delivers its<subagent>inbox notice and the resumed parent turn settles.Checks run
bun run script/test.ts test/tool-subagent.test.ts(packages/core): passbun test test/run/noninteractive.test.ts(packages/cli): passbun run typecheck && bun run typecheck:tsc(packages/core,packages/cli): 0 errorsAdded by the opencode bot (AI-generated).
Fixes #48361
Related: #48826, #50751
Public API: changes the built-in
subagenttool'sbackgrounddefault from false to true (behaviour change: callers needing an inline result must passbackground: false)