Skip to content

feat(core): bound subagent concurrency - #1

Merged
darkmatter2222 merged 3 commits into
devfrom
subagent-concurrency
Nov 11, 2026
Merged

darkmatter2222 merged 3 commits into
devfrom
subagent-concurrency

Conversation

@darkmatter2222

Copy link
Copy Markdown
Owner

Summary

  • experimental.subagent_concurrency caps how many of one session's subagents run at once. Omit it or set -1 for unlimited (today's behavior), 0 for none, or any positive count for a real cap.
  • experimental.subagent_over_limit decides what an over-limit call does: "fail" (default) returns a tool error right away, "wait" queues it until a slot frees.
  • OPENCODE_SUBAGENT_CONCURRENCY sets the same cap when OpenCode starts and outranks every config document, the way OPENCODE_FLAGS outranks experimental.<flag> in Flag.resolve.
  • At a cap of 0 the subagent tool leaves the advertised tool list, so the model is never offered a call that can only fail.

How it works

  • The cap is per parent session, which is the unit a fan-out is planned in. The slot registry lives in the existing global SubagentJob service as one Map<parentID, {children, waiting}>; limit < 0 short-circuits with no bookkeeping, so the default path stays inert.
  • SubagentJob.limits() reads both keys with a per-key findLast scan instead of one Config.latest("experimental") block, so a global value survives a project's partial experimental block. subagent_depth keeps its whole-object read. Same choice as all_flags in Flag.resolve.
  • A slot is taken before sessions.create, keyed by a pre-generated SessionSchema.ID.create(), so a rejected call leaves no orphan session. Release is idempotent and owned either by the call fiber (Effect.ensuring with a handoff flag) or, for backgrounded work, by the completion observer.
  • A follow-up on a running child (sessionID passed back in) keeps its existing slot instead of budgeting a second one.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
  <rect x="6" y="26" width="56" height="20" rx="6" fill="none" stroke="currentColor" stroke-opacity=".45"/>
  <text x="34" y="40" font-family="ui-monospace,monospace" font-size="9" text-anchor="middle" fill="currentColor" fill-opacity=".7">parent</text>
  <path d="M62 36h14" stroke="currentColor" stroke-opacity=".4"/>
  <g fill="none" stroke="currentColor" stroke-opacity=".45">
    <rect x="80" y="18" width="44" height="36" rx="7"/>
    <rect x="130" y="18" width="44" height="36" rx="7"/>
    <rect x="180" y="18" width="44" height="36" rx="7" stroke-dasharray="3 3" stroke-opacity=".3"/>
  </g>
  <g fill="currentColor">
    <circle cx="102" cy="30" r="5" fill-opacity=".8"/>
    <circle cx="152" cy="30" r="5" fill-opacity=".8">
      <animate attributeName="fill-opacity" values=".8;.8;.2;.2;.8" keyTimes="0;.45;.5;.9;1" dur="2.4s" repeatCount="indefinite"/>
    </circle>
    <circle cx="202" cy="30" r="5" fill-opacity=".18">
      <animate attributeName="fill-opacity" values=".18;.18;.8;.8;.18" keyTimes="0;.5;.55;.95;1" dur="2.4s" repeatCount="indefinite"/>
    </circle>
  </g>
  <text x="102" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">1</text>
  <text x="152" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">2</text>
  <text x="202" y="66" font-family="ui-monospace,monospace" font-size="8" text-anchor="middle" fill="currentColor" fill-opacity=".55">cap 2</text>
</svg>

Two slots run, the third stays dashed until one frees.

Feature flag

None. The elected default (-1, unlimited) reproduces today's behavior exactly, so nothing user-visible changes until a config sets a key. Both keys already sit in the experimental block, and OPENCODE_SUBAGENT_CONCURRENCY is itself the per-process switch.

Tests

  • packages/schema/test/config.test.ts — round-trips both keys, rejects -2, 1.5, "2" and the "queue" spelling, and omits an unset key.
  • packages/core/test/config/normalization.test.ts — a rejected leaf drops only itself, so its sibling still lands.
  • packages/core/test/session-subagent-job.test.ts — pure limits() precedence (no setting, per-key from separate documents, a later partial block does not shadow an earlier cap, the override outranks documents and reports a rejected value) plus slot semantics: unlimited stays unbudgeted, per-parent allowance, idempotent acquire and release, cap of zero in both modes, a queued wait call woken by a release, and a cancelled wait.
  • packages/core/test/tool-subagent.test.ts — six new cases: cap of 1 fails a second call and frees the slot when the child settles, a rejected call creates no session, a follow-up needs no second slot, the tool stays listed at cap 2, no tool at cap 0, wait mode runs the queued call, unlimited fan-out, and the startup override outranks the config file.

bun run script/test.ts for the four files: 82 pass, 0 fail. bun run check green except @opencode/app#typecheck, which fails only on this Windows checkout because core.symlinks=false turns packages/app/src/custom-elements.d.ts (git mode 120000) into a text file. bun run check:generated passes in both packages/protocol and packages/client.

Caps how many of one session subagents run at once with experimental.subagent_concurrency (-1 unlimited, 0 none, N a limit) and chooses over-limit behavior with experimental.subagent_over_limit (fail or wait). OPENCODE_SUBAGENT_CONCURRENCY outranks every config document, and a cap of 0 hides the subagent tool. Slots live in SubagentJob per parent session, keyed by child session ID, so a rejected call leaves no orphan session.
@darkmatter2222
darkmatter2222 merged commit c09222b into dev Nov 11, 2026
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.

1 participant