From 345bdf6665e100faca63535b662ad93e20579e1d Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 16 Jul 2026 13:09:32 +0000 Subject: [PATCH] fix(core): limit v2 subagent nesting depth --- packages/opencode/src/config/config.ts | 6 ++ packages/opencode/src/tool/task.ts | 16 +++++ packages/opencode/test/tool/task.test.ts | 83 ++++++++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++ 4 files changed, 109 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6aa79e3090..0d39822f90 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1044,6 +1044,12 @@ export namespace Config { .array(z.string()) .optional() .describe("Tools that should only be available to primary agents."), + subagent_depth: z + .number() + .int() + .min(0) + .optional() + .describe("Maximum subagent nesting depth. Defaults to 1."), continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), mcp_timeout: z .number() diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ce99ab2992..35a338cc86 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -41,6 +41,22 @@ export const TaskTool = Tool.define( const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { const cfg = yield* config.get() + const parent = yield* sessions.get(ctx.sessionID) + let current = parent + let depth = 0 + while (current.parentID) { + depth++ + current = yield* sessions.get(current.parentID) + } + const limit = cfg.experimental?.subagent_depth ?? 1 + if (depth >= limit) { + return yield* Effect.fail( + new Error( + `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`, + ), + ) + } + if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ permission: id, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 436c46490b..3e963d67db 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -313,6 +313,89 @@ describe("tool.task", () => { ), ) + it.live("prevents subagents from launching subagents by default", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "child" }) + const nested = yield* sessions.updateMessage({ + ...assistant, + id: MessageID.ascending(), + parentID: MessageID.ascending(), + sessionID: child.id, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + let asked = false + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: child.id, + messageID: nested.id, + agent: "general", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.sync(() => (asked = true)), + }, + ) + .pipe(Effect.exit) + + expect(exit._tag).toBe("Failure") + expect(asked).toBe(false) + expect(yield* sessions.children(child.id)).toHaveLength(0) + }), + ), + ) + + it.live("allows nested subagents up to the configured depth", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "child" }) + const nested = yield* sessions.updateMessage({ + ...assistant, + id: MessageID.ascending(), + parentID: MessageID.ascending(), + sessionID: child.id, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: child.id, + messageID: nested.id, + agent: "general", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect((yield* sessions.get(result.metadata.sessionId)).parentID).toBe(child.id) + }), + { config: { experimental: { subagent_depth: 2 } } }, + ), + ) + it.live("execute shapes child permissions for task, todowrite, and primary tools", () => provideTmpdirInstance( () => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 83ec3d751a..18f37c2557 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1623,6 +1623,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Maximum subagent nesting depth. Defaults to 1. + */ + subagent_depth?: number /** * Continue the agent loop when a tool call is denied */