Compare commits

...

1 Commits

Author SHA1 Message Date
Dax 345bdf6665 fix(core): limit v2 subagent nesting depth 2026-07-16 13:09:32 +00:00
4 changed files with 109 additions and 0 deletions
+6
View File
@@ -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()
+16
View File
@@ -41,6 +41,22 @@ export const TaskTool = Tool.define(
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, 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,
+83
View File
@@ -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(
() =>
+4
View File
@@ -1623,6 +1623,10 @@ export type Config = {
* Tools that should only be available to primary agents.
*/
primary_tools?: Array<string>
/**
* Maximum subagent nesting depth. Defaults to 1.
*/
subagent_depth?: number
/**
* Continue the agent loop when a tool call is denied
*/