Skip to content

Commit 285d315

Browse files
fix(core): limit subagent nesting depth (anomalyco#37124)
Co-authored-by: Dax <mail@thdxr.com>
1 parent 656f299 commit 285d315

4 files changed

Lines changed: 99 additions & 1 deletion

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export const Info = Schema.Struct({
8181
description:
8282
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
8383
}),
84+
subagent_depth: Schema.optional(NonNegativeInt).annotate({
85+
description: "Maximum subagent nesting depth. Defaults to 1, which prevents subagents from launching subagents.",
86+
}),
8487
username: Schema.optional(Schema.String).annotate({
8588
description: "Custom username to display in conversations instead of system username",
8689
}),

packages/opencode/src/tool/task.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ export const TaskTool = Tool.define(
101101
)
102102
}
103103

104+
const parent = yield* sessions.get(ctx.sessionID)
105+
let current = parent
106+
let depth = 0
107+
while (current.parentID) {
108+
depth++
109+
current = yield* sessions.get(current.parentID)
110+
}
111+
if (depth >= (cfg.subagent_depth ?? 1)) {
112+
return yield* Effect.fail(
113+
new Error(
114+
`Subagent depth limit reached (${cfg.subagent_depth ?? 1}). Increase "subagent_depth" to allow nested subagents.`,
115+
),
116+
)
117+
}
118+
104119
if (!ctx.extra?.bypassAgentCheck) {
105120
yield* ctx.ask({
106121
permission: id,
@@ -121,7 +136,6 @@ export const TaskTool = Tool.define(
121136
const session = params.task_id
122137
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
123138
: undefined
124-
const parent = yield* sessions.get(ctx.sessionID)
125139
const childPermission = deriveSubagentSessionPermission({
126140
parentSessionPermission: parent.permission ?? [],
127141
subagent: next,

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,86 @@ describe("tool.task", () => {
388388
}),
389389
)
390390

391+
it.instance("prevents subagents from launching subagents by default", () =>
392+
Effect.gen(function* () {
393+
const sessions = yield* Session.Service
394+
const { chat, assistant } = yield* seed()
395+
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
396+
const nestedAssistant = yield* sessions.updateMessage({
397+
...assistant,
398+
id: MessageID.ascending(),
399+
parentID: MessageID.ascending(),
400+
sessionID: child.id,
401+
})
402+
const tool = yield* TaskTool
403+
const def = yield* tool.init()
404+
let asked = false
405+
406+
const exit = yield* def
407+
.execute(
408+
{
409+
description: "inspect bug",
410+
prompt: "look into the cache key path",
411+
subagent_type: "general",
412+
},
413+
{
414+
sessionID: child.id,
415+
messageID: nestedAssistant.id,
416+
agent: "general",
417+
abort: new AbortController().signal,
418+
extra: { promptOps: stubOps() },
419+
messages: [],
420+
metadata: () => Effect.void,
421+
ask: () => Effect.sync(() => (asked = true)),
422+
},
423+
)
424+
.pipe(Effect.exit)
425+
426+
expect(Exit.isFailure(exit)).toBe(true)
427+
expect(asked).toBe(false)
428+
expect(yield* sessions.children(child.id)).toHaveLength(0)
429+
}),
430+
)
431+
432+
it.instance(
433+
"allows nested subagents up to the configured depth",
434+
() =>
435+
Effect.gen(function* () {
436+
const sessions = yield* Session.Service
437+
const { chat, assistant } = yield* seed()
438+
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
439+
const nestedAssistant = yield* sessions.updateMessage({
440+
...assistant,
441+
id: MessageID.ascending(),
442+
parentID: MessageID.ascending(),
443+
sessionID: child.id,
444+
})
445+
const tool = yield* TaskTool
446+
const def = yield* tool.init()
447+
448+
const result = yield* def.execute(
449+
{
450+
description: "inspect bug",
451+
prompt: "look into the cache key path",
452+
subagent_type: "general",
453+
},
454+
{
455+
sessionID: child.id,
456+
messageID: nestedAssistant.id,
457+
agent: "general",
458+
abort: new AbortController().signal,
459+
extra: { promptOps: stubOps() },
460+
messages: [],
461+
metadata: () => Effect.void,
462+
ask: () => Effect.void,
463+
},
464+
)
465+
466+
expect((yield* sessions.get(result.metadata.sessionId)).parentID).toBe(child.id)
467+
}),
468+
{ config: { subagent_depth: 2 } },
469+
)
470+
391471
it.instance(
392472
"execute shapes child permissions for task, todowrite, and primary tools",
393473
() =>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1928,6 +1928,7 @@ export type Config = {
19281928
model?: string
19291929
small_model?: string
19301930
default_agent?: string
1931+
subagent_depth?: number
19311932
username?: string
19321933
mode?: {
19331934
build?: AgentConfig

0 commit comments

Comments
 (0)