Skip to content

feat(core): add subagent concurrency limit - #55983

Open
gianniacquisto wants to merge 4 commits into
anomalyco:devfrom
gianniacquisto:subagent-concurrency
Open

gianniacquisto wants to merge 4 commits into
anomalyco:devfrom
gianniacquisto:subagent-concurrency

Conversation

@gianniacquisto

@gianniacquisto gianniacquisto commented Oct 16, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #27110

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds experimental.subagent_concurrency, a cap on how many subagents execute at the same time; unset keeps today's no limit.

This is a port of the v1 attempt (Jieiku/opencode@101be29). v1 wrapped the task tool's inline run effect; v2 subagents do not run inline. The child executes from prompt admission to job settlement, so a call that starts a run takes the permit before admitting the child's prompt, and a forked fiber releases it at settlement. The permit therefore spans foreground and background calls alike: a background subagent holds it past the tool's return, and a foreground call that gets backgrounded keeps it until the child settles. A catchCause gives it back when admission fails before the job exists, so a failed call cannot erode the limit.

Two calls take no permit, and the reason is the same in both: a permit marks a run, and neither call starts one.

  • A nested call runs under its ancestor's permit. The ancestor holds its own until it settles, and it cannot settle while the tool call it is waiting on is this one, so a second permit is a deadlock at any limit rather than a slow path.
  • Continuing a running child joins its job. The steer reaches a child that is already executing, so that child counts once instead of twice.

The limit therefore counts top-level subagents; packages/web/src/content/docs/config.mdx says so where it describes the option.

How did you verify your code works?

  • packages/core/test/tool-subagent.test.ts, against the gated fake execution: two background calls keep peak execution at one and the second prompt out of the inbox until the first settles; a nested call from a running child gets through at subagent_depth: 2 with subagent_concurrency: 1; a steer reaches a running child while that child still holds its permit. The nested and steer cases both hang (30s test timeout) on the previous head and pass on this one.
  • Config tests cover a valid value and a rejected zero.
  • bun run check passes (lint + typecheck), and typecheck:tsc passes in packages/schema, packages/core and packages/client on the merged head.

The branch is dev merged in and the four conflicts resolved: two keys landing at the same spot in experimental.ts, config/normalize.ts and the generated promise ConfigEntry, plus the test file's import block, which upstream moved to @opencode/ai/effect. packages/client is regenerated, not hand-edited.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit works for sibling subagents: the new test and a reproduction with a gated execution confirm that a second subagent waits until the first settles. Tests and type checks pass. One bug and one behaviour question need addressing first.

I checked it with the pull request's tests plus a temporary test of my own for the nested case.

Public API: adds the optional config key experimental.subagent_concurrency (a positive integer) to the config schema, the generated client ConfigEntry type and the docs. It is backwards compatible: when the key is unset, nothing changes.

Concerns:

  • Nested subagents deadlock (blocking). With subagent_depth >= 2, a running child holds its permit while its own tool call waits for one. With a limit of 1 (or N nested levels), this never resolves. I reproduced it: the nested call never gets a permit. See the inline comment.
  • Steering a running child takes a second permit. Continuing an existing sessionID that is still running takes a new permit even though no new run starts. At limit 1, the steer waits until the child finishes instead of steering it.
  • Background calls block the parent's turn. background: true now waits for a permit before returning, so a parent that fans out several background subagents is stuck on the tool call instead of continuing. If that is intended, the docs should say so; otherwise the wait could happen inside the background path.

Related: #54935, #50347, #50563

yield* subagents.start(recovery)
// A child executes from prompt admission until its job settles. Settlement outlives
// background calls, so the permit releases on settlement rather than the tool call.
const semaphore = semaphoreFor(experimental?.subagent_concurrency)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When existing is a child that is still running, sessions.prompt just steers it and subagents.start joins the same job. This still takes a second permit and starts a second waiter on the same job. At limit 1, the steer waits until the child finishes instead of steering it, and at higher limits one child counts twice. A permit should only be taken when this call actually starts a new run.

This deadlocks nested subagents. When subagent_depth >= 2, a child that calls the subagent tool still holds its own permit, because its job is waiting on this very tool call. With subagent_concurrency: 1, the nested call waits on take(1) forever. I reproduced this with the gated execution from your test: a foreground call from a running child's session never admits its prompt. Without the limit, it does. One fix is to take a permit only for top-level subagents. Another is to let a nested call skip the wait when its parent session already holds a permit.

@gianniacquisto gianniacquisto Oct 27, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in 78a07d5, and the rule is the same for both: a permit marks a run, and neither of these calls starts one.

  • The steer takes no permit. A call takes one only when it starts a run: holding = concurrency !== undefined && (existing === undefined || (yield* jobs.get(existing.id))?.status !== "running"). Continuing a running child joins the job it already runs, so the child counts once. steers a running child without taking a second permit asserts the steer reaches the child while its permit is still held, peak execution at one.
  • Nesting runs under the parent's permit. depth === 0 gates, the same depth the tool already walks for subagent_depth. Waiting is not repairable by ordering: the ancestor holds its permit until it settles, and it cannot settle while this call is outstanding. runs a nested subagent under the permit of the child it descends from covers subagent_depth: 2 with subagent_concurrency: 1.

Both hang on the previous head (30s test timeout) and pass here. The limit counts top-level subagents, which the config docs now say.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

Adds experimental.subagent_concurrency, which caps how many subagents
execute at the same time. Subagent calls take a permit before admitting
the child's prompt and release it when the child's job settles, so
foreground and background calls share the same accounting.

Closes anomalyco#27110

@balabommablock-cpu balabommablock-cpu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested at 9b0aa59. The 12 tests in tool-subagent.test.ts pass. I found one case that can hang, with nested subagents.

With subagent_concurrency: 1 and subagent_depth: 2, I started a background subagent (it takes the only permit). Then I made that running child call the subagent tool itself, in the foreground. The inner call waits for a permit that its own parent holds. In my test it was still waiting after 2 s. In a real run the parent can't finish until the inner call returns, so neither ever does. It's the same with any limit when every running child spawns one more level.

(Scratch test: a copy of "limits parallel subagents…" where the second call uses sessionID: child and Effect.timeoutOption("2 seconds"). It returns None. Happy to share it.)

Two possible fixes:

  • only count top-level subagents (depth 1) against the limit, or
  • let a child that already holds a permit run its own subagents without taking another one.

A small UX point too: while it waits, the inner call already reports status: "running". Showing "queued" until it gets a permit would make it clear the slowdown is the limit, not a stuck model.

# Conflicts:
#	packages/client/src/promise/generated/types.ts
#	packages/core/src/config/normalize.ts
#	packages/core/test/tool-subagent.test.ts
#	packages/schema/src/config/experimental.ts
@gianniacquisto

gianniacquisto commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

Head is 78a07d5.

The merge. origin is my fork and its dev was stale, so dev came from upstream: the branch was 34 commits behind and is level with it now. Four conflicts, all of them two keys landing at the same spot: experimental.ts, config/normalize.ts, the generated promise ConfigEntry, and the test file's import block, which upstream moved to @opencode/ai/effect. Both sides kept; packages/client regenerated with bun run generate, not hand-edited. typecheck:tsc passes in packages/schema, packages/core and packages/client, and tool-subagent.test.ts with config/normalization.test.ts is 56 passing.

The deadlock. Nested calls take no permit and run under the permit of the subagent they descend from. A second permit is a deadlock rather than a wait: the ancestor holds its permit until it settles, and it cannot settle while the tool call it waits on is this one. runs a nested subagent under the permit of the child it descends from, at subagent_depth: 2 and subagent_concurrency: 1.

The double count. Continuing a running child joins its job, so it takes no permit and the child counts once. steers a running child without taking a second permit asserts the steer reaches the child while that child's permit is still held, peak execution at one.

Both hang on the previous head and pass on this one.

Background calls still wait before they return, which is intended. Admission is what starts the child, so the permit is held before sessions.prompt; moving the wait inside the background path only moves it into another fiber, and the call returns "running" for a child that has not started. packages/web/src/content/docs/config.mdx now says that a background: true call waits for a slot and that the limit counts top-level subagents.

The queued status is not here. packages/tui/src/mini/stream-v2.subagent.ts:187 accepts only running and completed from the tool metadata, so queued drops the child out of the footer instead of labelling it. Not doing it in this PR: the TUI change comes first.

@balabommablock-cpu balabommablock-cpu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-tested at 78a07d5. Both fixes hold for me: the 14 tests in tool-subagent.test.ts pass, including the nested and steer cases.

I also tried two ways the permit could leak (scratch tests, not pushed):

  1. Failing child. With subagent_concurrency: 1, three calls in a row whose child fails ("No model is available"). All three return the error; none waits. So the permit comes back on failure.
  2. Cancel while queued. First background child holds the permit. I interrupted the second call while it waited, then started a third. When I released the first, the third started, and the peak stayed at 1. No leak.

One small thing from case 2, not blocking: the cancelled call has already created its child session, so an empty, never-prompted child is left in the parent's list. With a limit, a call can wait a long time, so "cancel while queued" becomes a normal thing to do. Creating the child after getting the permit, or cleaning it up on interrupt, would avoid the empty entry. Happy for that to be a follow-up.

@gianniacquisto

Copy link
Copy Markdown
Author

I've actually been able to reliably solve this via prompting so not sure it warrants a PR and config change. I'm happy to close at this stage unless people think this can be genuinely useful.

@PiRomant

PiRomant commented Nov 7, 2026

Copy link
Copy Markdown

I've noticed that the agent becomes very obsessed with subagents where they are not needed, due to the fact that I ask not to use more than one subagent at once. So I would prefer to change how tools are handled, rather than changing the way of thinking.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Setting to limit max number of parallel subagents

3 participants