Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/config/plugin/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/session/subagent-depth.ts
Original file line number Diff line number Diff line change
@@ -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<LimitError>()("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<Session.Interface, "get">,
config: Pick<Config.Interface, "entries">,
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
})
34 changes: 4 additions & 30 deletions packages/core/src/tool/plugin/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/config/command-subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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 } }),
}),
),
Expand Down
Loading