diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index adf6e9912bf..c195a56b712 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,6 +18,7 @@ import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" +import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" @@ -109,6 +110,7 @@ export class Info extends Schema.Class("Config.Info")({ description: "Ordered plugin enablement directives and external package declarations", }), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), + experimental: ConfigExperimental.Info.pipe(Schema.optional), }) {} export class Document extends Schema.Class("Config.Document")({ diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 00000000000..a89fbfe97f9 --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -0,0 +1,10 @@ +export * as ConfigExperimental from "./experimental" + +import { Schema } from "effect" +import { NonNegativeInt } from "../schema" + +export class Info extends Schema.Class("ConfigExperimental.Info")({ + subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({ + description: "Maximum subagent nesting depth. Defaults to 1.", + }), +}) {} diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 3043ab7ddcf..1e459a674e6 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -4,6 +4,7 @@ import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" +import { Config } from "../config" import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" @@ -43,6 +44,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const config = yield* Config.Service const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope @@ -123,6 +125,23 @@ export const Plugin = { (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), ), ) + let current = parent + let depth = 0 + while (current.parentID) { + depth++ + current = yield* runtime.session + .get(current.parentID) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }), + ), + ) + } + const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1 + if (depth >= limit) + return yield* new ToolFailure({ + message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`, + }) 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/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 6e6cee5c5ef..e3ecf7324f7 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -175,6 +175,9 @@ export const Info = Schema.Struct({ primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", }), + subagent_depth: Schema.optional(NonNegativeInt).annotate({ + description: "Maximum subagent nesting depth. Defaults to 1.", + }), continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ description: "Continue the agent loop when a tool call is denied", }), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 15b464f7761..ed9e3455083 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -77,6 +77,10 @@ export function migrate(info: typeof ConfigV1.Info.Type) { commands: commands(info.command), instructions: info.instructions, references: info.references ?? info.reference, + experimental: + info.experimental?.subagent_depth === undefined + ? undefined + : { subagent_depth: info.experimental.subagent_depth }, plugins: info.plugin?.map((plugin) => typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }, ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 65267ec41bc..e0ddb13d166 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -283,6 +283,12 @@ describe("Config", () => { }), ) + it.effect("migrates the v1 experimental subagent depth", () => + Effect.sync(() => { + expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2) + }), + ) + it.effect("migrates v1 provider setup options into AISDK settings", () => Effect.sync(() => { const migrated = ConfigMigrateV1.migrate({ diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index e9b4a5c56e0..fc257a7d402 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" +import path from "path" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -164,6 +165,77 @@ describe("SubagentTool", () => { ), ) + it.live("prevents subagents from launching subagents by default", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const sessions = yield* SessionV2.Service + const root = yield* sessions.create({ location }) + const parent = yield* sessions.create({ parentID: root.id, title: "parent" }) + yield* withSubagent(parent.location) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) + + expect( + yield* executeTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-nested-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "nested", prompt: "should fail" }, + }, + }), + ).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") }) + expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0) + }), + ), + ), + ) + + it.live("allows nested subagents up to the configured depth", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_depth: 2 } })), + ) + const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const sessions = yield* SessionV2.Service + const root = yield* sessions.create({ location }) + const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel }) + yield* withSubagent(parent.location) + const locations = yield* LocationServiceMap.Service + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* waitForTool(registry, SubagentTool.name) + + const settled = yield* settleTool(registry, { + sessionID: parent.id, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-configured-nested-subagent", + name: SubagentTool.name, + input: { agent: "reviewer", description: "nested", prompt: "should run" }, + }, + }) + + expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) + }), + ), + ), + ) + it.live("runs a foreground child session and returns the final assistant text", () => Effect.acquireRelease( Effect.promise(() => tmpdir()),