fix(core): limit subagent nesting depth (#37124)

Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
opencode-agent[bot]
2026-07-15 09:53:52 -05:00
committed by GitHub
co-authored by Dax
parent 656f299017
commit 285d315b4e
4 changed files with 99 additions and 1 deletions
+3
View File
@@ -81,6 +81,9 @@ export const Info = Schema.Struct({
description:
"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.",
}),
subagent_depth: Schema.optional(NonNegativeInt).annotate({
description: "Maximum subagent nesting depth. Defaults to 1, which prevents subagents from launching subagents.",
}),
username: Schema.optional(Schema.String).annotate({
description: "Custom username to display in conversations instead of system username",
}),
+15 -1
View File
@@ -101,6 +101,21 @@ export const TaskTool = Tool.define(
)
}
const parent = yield* sessions.get(ctx.sessionID)
let current = parent
let depth = 0
while (current.parentID) {
depth++
current = yield* sessions.get(current.parentID)
}
if (depth >= (cfg.subagent_depth ?? 1)) {
return yield* Effect.fail(
new Error(
`Subagent depth limit reached (${cfg.subagent_depth ?? 1}). Increase "subagent_depth" to allow nested subagents.`,
),
)
}
if (!ctx.extra?.bypassAgentCheck) {
yield* ctx.ask({
permission: id,
@@ -121,7 +136,6 @@ export const TaskTool = Tool.define(
const session = params.task_id
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
subagent: next,
+80
View File
@@ -388,6 +388,86 @@ describe("tool.task", () => {
}),
)
it.instance("prevents subagents from launching subagents by default", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
const nestedAssistant = 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: nestedAssistant.id,
agent: "general",
abort: new AbortController().signal,
extra: { promptOps: stubOps() },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.sync(() => (asked = true)),
},
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(asked).toBe(false)
expect(yield* sessions.children(child.id)).toHaveLength(0)
}),
)
it.instance(
"allows nested subagents up to the configured depth",
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
const nestedAssistant = 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: nestedAssistant.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: { subagent_depth: 2 } },
)
it.instance(
"execute shapes child permissions for task, todowrite, and primary tools",
() =>
+1
View File
@@ -1928,6 +1928,7 @@ export type Config = {
model?: string
small_model?: string
default_agent?: string
subagent_depth?: number
username?: string
mode?: {
build?: AgentConfig