Skip to content

Commit 101be29

Browse files
committed
feat(opencode): add configurable subagent concurrency limit
Adds a subagent_concurrency config option to limit concurrent task execution. Includes SDK schema updates and coverage for semaphore-based execution limits. Closes anomalyco#27110
1 parent 696f41b commit 101be29

5 files changed

Lines changed: 114 additions & 15 deletions

File tree

‎packages/core/src/v1/config/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export const Info = Schema.Struct({
8484
subagent_depth: Schema.optional(NonNegativeInt).annotate({
8585
description: "Maximum subagent nesting depth. Defaults to 1, which prevents subagents from launching subagents.",
8686
}),
87+
subagent_concurrency: Schema.optional(PositiveInt).annotate({
88+
description: "Maximum number of subagents that may execute concurrently. Unset allows unlimited concurrency.",
89+
}),
8790
username: Schema.optional(Schema.String).annotate({
8891
description: "Custom username to display in conversations instead of system username",
8992
}),

‎packages/opencode/src/tool/task.ts‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Agent } from "../agent/agent"
1010
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
1111
import type { SessionPrompt } from "../session/prompt"
1212
import { Config } from "@/config/config"
13-
import { Effect, Exit, Schema, Scope } from "effect"
13+
import { Effect, Exit, Schema, Scope, Semaphore } from "effect"
1414
import { EffectBridge } from "@/effect/bridge"
1515
import { RuntimeFlags } from "@/effect/runtime-flags"
1616
import { Database } from "@opencode-ai/core/database/database"
@@ -21,6 +21,19 @@ export interface TaskPromptOps {
2121
prompt(input: SessionPrompt.PromptInput): Effect.Effect<SessionV1.WithParts>
2222
}
2323

24+
function isTaskPromptOps(value: unknown): value is TaskPromptOps {
25+
return (
26+
typeof value === "object" &&
27+
value !== null &&
28+
"cancel" in value &&
29+
typeof value.cancel === "function" &&
30+
"resolvePromptParts" in value &&
31+
typeof value.resolvePromptParts === "function" &&
32+
"prompt" in value &&
33+
typeof value.prompt === "function"
34+
)
35+
}
36+
2437
const id = "task"
2538
const BACKGROUND_DESCRIPTION = [
2639
"Background mode: background=true launches the subagent asynchronously and returns immediately.",
@@ -88,6 +101,16 @@ export const TaskTool = Tool.define(
88101
const scope = yield* Scope.Scope
89102
const flags = yield* RuntimeFlags.Service
90103
const database = yield* Database.Service
104+
const limits = new Map<number, Semaphore.Semaphore>()
105+
106+
function limit(concurrency: number | undefined) {
107+
if (concurrency === undefined) return undefined
108+
const current = limits.get(concurrency)
109+
if (current) return current
110+
const created = Semaphore.makeUnsafe(concurrency)
111+
limits.set(concurrency, created)
112+
return created
113+
}
91114

92115
const run = Effect.fn("TaskTool.execute")(function* (
93116
params: Schema.Schema.Type<typeof Parameters>,
@@ -194,12 +217,14 @@ export const TaskTool = Tool.define(
194217
metadata,
195218
})
196219

197-
const ops = ctx.extra?.promptOps as TaskPromptOps
198-
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
220+
const promptOps = ctx.extra?.promptOps
221+
if (!isTaskPromptOps(promptOps)) {
222+
return yield* Effect.fail(new Error("TaskTool requires valid promptOps in ctx.extra"))
223+
}
199224

200-
const runTask = Effect.fn("TaskTool.runTask")(function* () {
201-
const parts = yield* ops.resolvePromptParts(params.prompt)
202-
const result = yield* ops.prompt({
225+
const executeTask = Effect.fn("TaskTool.runTask")(function* () {
226+
const parts = yield* promptOps.resolvePromptParts(params.prompt)
227+
const result = yield* promptOps.prompt({
203228
messageID: MessageID.ascending(),
204229
sessionID: nextSession.id,
205230
model: {
@@ -223,13 +248,15 @@ export const TaskTool = Tool.define(
223248
}
224249
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
225250
})
251+
const semaphore = limit(cfg.subagent_concurrency)
252+
const runTask = () => (semaphore ? semaphore.withPermit(executeTask()) : executeTask())
226253

227254
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (
228255
state: "completed" | "error",
229256
text: string,
230257
) {
231258
const currentParent = yield* sessions.get(ctx.sessionID)
232-
yield* ops
259+
yield* promptOps
233260
.prompt({
234261
sessionID: ctx.sessionID,
235262
agent: currentParent.agent ?? ctx.agent,
@@ -286,14 +313,14 @@ export const TaskTool = Tool.define(
286313
type: id,
287314
title: params.description,
288315
metadata,
289-
onPromote: Effect.all([
290-
ctx.metadata({
316+
onPromote: Effect.gen(function* () {
317+
yield* ctx.metadata({
291318
title: params.description,
292319
metadata: { ...metadata, background: true, jobId: nextSession.id },
293-
}),
294-
notify(nextSession.id),
295-
]),
296-
run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
320+
})
321+
yield* notify(nextSession.id)
322+
}),
323+
run: runTask().pipe(Effect.onInterrupt(() => promptOps.cancel(nextSession.id))),
297324
})
298325

299326
function backgroundResult() {
@@ -319,7 +346,7 @@ export const TaskTool = Tool.define(
319346
}
320347

321348
const runCancel = yield* EffectBridge.make()
322-
const cancel = ops.cancel(nextSession.id)
349+
const cancel = promptOps.cancel(nextSession.id)
323350

324351
function onAbort() {
325352
runCancel.fork(cancel)

‎packages/opencode/test/tool/task.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
33
import { Database } from "@opencode-ai/core/database/database"
44
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
55
import { SessionProjector } from "@opencode-ai/core/session/projector"
6-
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
6+
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
77
import { Agent } from "../../src/agent/agent"
88
import { BackgroundJob } from "@/background/job"
99
import { EventV2Bridge } from "@/event-v2-bridge"
@@ -545,6 +545,70 @@ describe("tool.task", () => {
545545
}),
546546
)
547547

548+
it.instance(
549+
"limits concurrent subagent execution when configured",
550+
() =>
551+
Effect.gen(function* () {
552+
const { chat, assistant } = yield* seed()
553+
const tool = yield* TaskTool
554+
const def = yield* tool.init()
555+
const firstStarted = yield* Deferred.make<void>()
556+
const release = yield* Deferred.make<void>()
557+
let calls = 0
558+
let active = 0
559+
let peak = 0
560+
const promptOps: TaskPromptOps = {
561+
cancel: () => Effect.void,
562+
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
563+
prompt: (input) =>
564+
Effect.gen(function* () {
565+
calls++
566+
active++
567+
peak = Math.max(peak, active)
568+
if (calls === 1) yield* Deferred.succeed(firstStarted, undefined)
569+
yield* Deferred.await(release)
570+
active--
571+
return reply(input, "done")
572+
}),
573+
}
574+
const execute = () =>
575+
def.execute(
576+
{
577+
description: "inspect bug",
578+
prompt: "look into the cache key path",
579+
subagent_type: "general",
580+
},
581+
{
582+
sessionID: chat.id,
583+
messageID: assistant.id,
584+
agent: "build",
585+
abort: new AbortController().signal,
586+
extra: { promptOps },
587+
messages: [],
588+
metadata: () => Effect.void,
589+
ask: () => Effect.void,
590+
},
591+
)
592+
593+
const first = yield* execute().pipe(Effect.forkChild)
594+
yield* Deferred.await(firstStarted)
595+
const second = yield* execute().pipe(Effect.forkChild)
596+
yield* Effect.yieldNow
597+
yield* Effect.yieldNow
598+
599+
expect(calls).toBe(1)
600+
expect(peak).toBe(1)
601+
602+
yield* Deferred.succeed(release, undefined)
603+
yield* Fiber.join(first)
604+
yield* Fiber.join(second)
605+
606+
expect(calls).toBe(2)
607+
expect(peak).toBe(1)
608+
}),
609+
{ config: { subagent_concurrency: 1 } },
610+
)
611+
548612
it.instance(
549613
"allows nested subagents up to the configured depth",
550614
() =>

‎packages/sdk/js/src/v2/gen/types.gen.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,7 @@ export type Config = {
19361936
small_model?: string
19371937
default_agent?: string
19381938
subagent_depth?: number
1939+
subagent_concurrency?: number
19391940
username?: string
19401941
mode?: {
19411942
build?: AgentConfig

‎packages/sdk/openapi.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21290,6 +21290,10 @@
2129021290
"type": "integer",
2129121291
"minimum": 0
2129221292
},
21293+
"subagent_concurrency": {
21294+
"type": "integer",
21295+
"minimum": 1
21296+
},
2129321297
"username": {
2129421298
"type": "string"
2129521299
},

0 commit comments

Comments
 (0)