diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index c23743095987..1d9ad6a5bf42 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -13,6 +13,7 @@ import { Agent } from "../../agent.js" import { Config, reloadsSections } from "../../config.js" import { Location } from "../../location.js" import { Session } from "../../session.js" +import { SubagentDepth } from "../../session/subagent-depth.js" import { SubagentJob } from "../../session/subagent-job.js" import { ShellSelect } from "../../shell/select.js" import { FSUtil } from "@opencode/util/fs-util" @@ -97,6 +98,7 @@ export const Plugin = define({ }) if (subagent ?? commandAgent?.mode === "subagent") { const parent = yield* sessions.get(input.sessionID) + yield* SubagentDepth.assertWithinLimit(sessions, config, parent) const selected = yield* agents.select(agent ?? parent.agent) const child = yield* sessions.create({ parentID: parent.id, diff --git a/packages/core/src/session/subagent-depth.ts b/packages/core/src/session/subagent-depth.ts new file mode 100644 index 000000000000..f39e98fff161 --- /dev/null +++ b/packages/core/src/session/subagent-depth.ts @@ -0,0 +1,51 @@ +export * as SubagentDepth from "./subagent-depth.js" + +import { Effect, Schema } from "effect" +import { Config } from "../config.js" +import type { Session } from "../session.js" +import { SessionSchema } from "./schema.js" + +export class LimitError extends Schema.TaggedError()("SubagentDepth.LimitError", { + message: Schema.String, +}) {} + +/** + * Returns the depth of `parent` below its top-level session (0 for a top-level + * session), failing when launching a subagent from it would exceed + * `experimental.subagent_depth`. Shared by the subagent tool and by commands + * that run as subagents, so both enforce the same limit the same way. + * + * Parent links can form a cycle (corrupt or imported session data), so the + * walk visits each session once, and the limit is checked inside the walk: + * either condition fails promptly instead of looping on the store forever. + * A limit of 0 disables subagents entirely, so the check also runs for a + * top-level session, whose depth is 0 and which has no parent to walk to. + */ +export const assertWithinLimit = Effect.fn("SubagentDepth.assertWithinLimit")(function* ( + sessions: Pick, + config: Pick, + parent: SessionSchema.Info, +) { + const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 5 + let current = parent + let depth = 0 + const seen = new Set([parent.id]) + if (depth >= limit) + return yield* new LimitError({ + message: `Subagent depth limit reached (${limit}). Do this work in the current session; "experimental.subagent_depth" sets the limit.`, + }) + while (current.parentID) { + if (seen.has(current.parentID)) + return yield* new LimitError({ message: `Session parent cycle: ${current.parentID}` }) + seen.add(current.parentID) + depth++ + current = yield* sessions + .get(current.parentID) + .pipe(Effect.mapError(() => new LimitError({ message: `Parent session not found: ${current.parentID}` }))) + if (depth >= limit) + return yield* new LimitError({ + message: `Subagent depth limit reached (${limit}). Do this work in the current session; "experimental.subagent_depth" sets the limit.`, + }) + } + return depth +}) diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index d8466914c0f8..133e8ea7a6c8 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -12,6 +12,7 @@ import { Permission } from "../../permission.js" import { Session } from "../../session.js" import { SessionSchema } from "../../session/schema.js" import { SubagentCompletion } from "../../session/subagent-completion.js" +import { SubagentDepth } from "../../session/subagent-depth.js" import { SubagentJob } from "../../session/subagent-job.js" import { McpxSubagent } from "./mcpx-subagent.js" @@ -116,36 +117,9 @@ export const Plugin = { (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) - const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 5 - let current = parent - let depth = 0 - // Parent links can form a cycle (corrupt or imported session data), so each walk - // visits a session once, and the depth limit is checked inside the walk: either - // condition fails the call promptly instead of looping on the store forever. - const seen = new Set([parent.id]) - // A limit of 0 disables subagents entirely, so the depth check must also run - // for a top-level session, whose depth is 0 and which has no parent to walk to. - if (depth >= limit) - return yield* new ToolFailure({ - message: `Subagent depth limit reached (${limit}). Do this work in the current session; "experimental.subagent_depth" sets the limit.`, - }) - while (current.parentID) { - if (seen.has(current.parentID)) - return yield* new ToolFailure({ message: `Session parent cycle: ${current.parentID}` }) - seen.add(current.parentID) - depth++ - current = yield* sessions - .get(current.parentID) - .pipe( - Effect.mapError( - (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }), - ), - ) - if (depth >= limit) - return yield* new ToolFailure({ - message: `Subagent depth limit reached (${limit}). Do this work in the current session; "experimental.subagent_depth" sets the limit.`, - }) - } + const depth = yield* SubagentDepth.assertWithinLimit(sessions, config, parent).pipe( + Effect.mapError((error) => new ToolFailure({ message: error.message })), + ) const agent = yield* agents.resolve(input.agent) if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") diff --git a/packages/core/test/config/command-subagent.test.ts b/packages/core/test/config/command-subagent.test.ts index a5351e0a1dd3..65cd6c9a4a32 100644 --- a/packages/core/test/config/command-subagent.test.ts +++ b/packages/core/test/config/command-subagent.test.ts @@ -106,6 +106,49 @@ describe("command subagents", () => { ) } + it.live("refuses a subtask command when subagent_depth is 0", () => + Effect.gen(function* () { + const parent = yield* project({ subagent: true, agent: "build" }, "json", 0) + const sessions = yield* Session.Service + const error = yield* sessions + .command({ sessionID: parent.id, command: "review", text: "changes" }) + .pipe(Effect.flip) + expect(error.message).toContain("Subagent depth limit reached (0)") + expect(error.message).toContain("experimental.subagent_depth") + expect(error).toMatchObject({ _tag: "Command.ExecutionError" }) + expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([]) + }), + ) + + it.live("refuses a subtask command in a session already at the depth limit", () => + Effect.gen(function* () { + const root = yield* project({ subagent: true, agent: "build" }, "json", 1) + const sessions = yield* Session.Service + const parent = yield* sessions.create({ parentID: root.id, title: "Level one", model: parentModel }) + const error = yield* sessions + .command({ sessionID: parent.id, command: "review", text: "changes" }) + .pipe(Effect.flip) + expect(error.message).toContain("Subagent depth limit reached (1)") + expect(error).toMatchObject({ _tag: "Command.ExecutionError" }) + expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([]) + }), + ) + + it.live("runs a subtask command below the depth limit", () => + Effect.gen(function* () { + const parent = yield* project({ subagent: true, agent: "build" }, "json", 1) + const sessions = yield* Session.Service + const llm = yield* TestLLM.Test + const gate = yield* llm.gate() + yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" }) + yield* gate.started + expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1) + yield* gate.release + yield* llm.wait(2) + yield* sessions.wait(parent.id) + }), + ) + it.live("subagent: false overrides subagent mode and the legacy alias", () => Effect.gen(function* () { const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json") @@ -127,6 +170,7 @@ describe("command subagents", () => { function project( command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean }, format: "json" | "markdown", + subagentDepth?: number, ) { return Effect.gen(function* () { const tmp = yield* tmpdirScoped() @@ -136,6 +180,7 @@ function project( path.join(tmp.path, "opencode.json"), JSON.stringify({ agents: { reviewer: { mode: "subagent", model: "test/child" } }, + ...(subagentDepth === undefined ? {} : { experimental: { subagent_depth: subagentDepth } }), ...(format === "markdown" ? {} : { commands: { review: definition } }), }), ),