fix(core): limit v2 subagent nesting depth (#37291)
This commit is contained in:
parent
198ca749fd
commit
d01dfa57b7
7 changed files with 116 additions and 0 deletions
|
|
@ -18,6 +18,7 @@ import { ConfigAgent } from "./config/agent"
|
||||||
import { ConfigAttachments } from "./config/attachments"
|
import { ConfigAttachments } from "./config/attachments"
|
||||||
import { ConfigCompaction } from "./config/compaction"
|
import { ConfigCompaction } from "./config/compaction"
|
||||||
import { ConfigCommand } from "./config/command"
|
import { ConfigCommand } from "./config/command"
|
||||||
|
import { ConfigExperimental } from "./config/experimental"
|
||||||
import { ConfigFormatter } from "./config/formatter"
|
import { ConfigFormatter } from "./config/formatter"
|
||||||
import { ConfigLSP } from "./config/lsp"
|
import { ConfigLSP } from "./config/lsp"
|
||||||
import { ConfigMCP } from "./config/mcp"
|
import { ConfigMCP } from "./config/mcp"
|
||||||
|
|
@ -109,6 +110,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
description: "Ordered plugin enablement directives and external package declarations",
|
description: "Ordered plugin enablement directives and external package declarations",
|
||||||
}),
|
}),
|
||||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||||
|
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||||
|
|
|
||||||
10
packages/core/src/config/experimental.ts
Normal file
10
packages/core/src/config/experimental.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export * as ConfigExperimental from "./experimental"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { NonNegativeInt } from "../schema"
|
||||||
|
|
||||||
|
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||||
|
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||||
|
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||||
|
}),
|
||||||
|
}) {}
|
||||||
|
|
@ -4,6 +4,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Effect, Schema, Scope } from "effect"
|
import { Effect, Schema, Scope } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
|
import { Config } from "../config"
|
||||||
import { PluginRuntime } from "../plugin/runtime"
|
import { PluginRuntime } from "../plugin/runtime"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
|
|
@ -43,6 +44,7 @@ export const Plugin = {
|
||||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
|
@ -123,6 +125,23 @@ export const Plugin = {
|
||||||
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
|
(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)
|
const agent = yield* agents.resolve(input.agent)
|
||||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||||
if (agent.mode === "primary")
|
if (agent.mode === "primary")
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,9 @@ export const Info = Schema.Struct({
|
||||||
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||||
description: "Tools that should only be available to primary agents.",
|
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({
|
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
|
||||||
description: "Continue the agent loop when a tool call is denied",
|
description: "Continue the agent loop when a tool call is denied",
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||||
commands: commands(info.command),
|
commands: commands(info.command),
|
||||||
instructions: info.instructions,
|
instructions: info.instructions,
|
||||||
references: info.references ?? info.reference,
|
references: info.references ?? info.reference,
|
||||||
|
experimental:
|
||||||
|
info.experimental?.subagent_depth === undefined
|
||||||
|
? undefined
|
||||||
|
: { subagent_depth: info.experimental.subagent_depth },
|
||||||
plugins: info.plugin?.map((plugin) =>
|
plugins: info.plugin?.map((plugin) =>
|
||||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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", () =>
|
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const migrated = ConfigMigrateV1.migrate({
|
const migrated = ConfigMigrateV1.migrate({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
|
import path from "path"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
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", () =>
|
it.live("runs a foreground child session and returns the final assistant text", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue