diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 0420a9bea..ee2b5e367 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3678,6 +3678,16 @@ "minimum": 0 } }, + "maxDepth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxDescendants": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, "staleTimeoutMs": { "type": "number", "minimum": 60000 diff --git a/src/config/schema/background-task.test.ts b/src/config/schema/background-task.test.ts index 2ca225864..9bd6c74de 100644 --- a/src/config/schema/background-task.test.ts +++ b/src/config/schema/background-task.test.ts @@ -3,6 +3,54 @@ import { ZodError } from "zod/v4" import { BackgroundTaskConfigSchema } from "./background-task" describe("BackgroundTaskConfigSchema", () => { + describe("maxDepth", () => { + describe("#given valid maxDepth (3)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ maxDepth: 3 }) + + expect(result.maxDepth).toBe(3) + }) + }) + + describe("#given maxDepth below minimum (0)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ maxDepth: 0 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) + + describe("maxDescendants", () => { + describe("#given valid maxDescendants (50)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 }) + + expect(result.maxDescendants).toBe(50) + }) + }) + + describe("#given maxDescendants below minimum (0)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ maxDescendants: 0 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) + describe("syncPollTimeoutMs", () => { describe("#given valid syncPollTimeoutMs (120000)", () => { test("#when parsed #then returns correct value", () => { diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts index b955de6b5..f53a67f6c 100644 --- a/src/config/schema/background-task.ts +++ b/src/config/schema/background-task.ts @@ -4,6 +4,8 @@ export const BackgroundTaskConfigSchema = z.object({ defaultConcurrency: z.number().min(1).optional(), providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), modelConcurrency: z.record(z.string(), z.number().min(0)).optional(), + maxDepth: z.number().int().min(1).optional(), + maxDescendants: z.number().int().min(1).optional(), /** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */ staleTimeoutMs: z.number().min(60000).optional(), /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */ diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index b3663916e..e1c107663 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1731,6 +1731,25 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { } } + function createMockClientWithSessionChain( + sessions: Record + ) { + return { + session: { + create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }), + get: async ({ path }: { path: { id: string } }) => ({ + data: sessions[path.id] ?? { directory: "/test/dir" }, + }), + prompt: async () => ({}), + promptAsync: async () => ({}), + messages: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async () => ({}), + }, + } + } + beforeEach(() => { // given mockClient = createMockClient() @@ -1925,6 +1944,98 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime()) } }) + + test("should track rootSessionID and spawnDepth from the parent chain", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" }, + "session-depth-1": { directory: "/test/dir", parentID: "session-root" }, + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDepth: 3 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-depth-2", + parentMessageID: "parent-message", + } + + // when + const task = await manager.launch(input) + + // then + expect(task.rootSessionID).toBe("session-root") + expect(task.spawnDepth).toBe(3) + }) + + test("should block launches that exceed maxDepth", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" }, + "session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" }, + "session-depth-1": { directory: "/test/dir", parentID: "session-root" }, + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDepth: 3 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-depth-3", + parentMessageID: "parent-message", + } + + // when + const result = manager.launch(input) + + // then + await expect(result).rejects.toThrow("background_task.maxDepth=3") + }) + + test("should block launches when maxDescendants is reached", async () => { + // given + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 1 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + await manager.launch(input) + + // when + const result = manager.launch(input) + + // then + await expect(result).rejects.toThrow("background_task.maxDescendants=1") + }) }) describe("pending task can be cancelled", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 6cd60273e..64068d866 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -51,6 +51,14 @@ import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" import { removeTaskToastTracking } from "./remove-task-toast-tracking" +import { + createSubagentDepthLimitError, + createSubagentDescendantLimitError, + getMaxRootDescendants, + getMaxSubagentDepth, + resolveSubagentSpawnContext, + type SubagentSpawnContext, +} from "./subagent-spawn-limits" type OpencodeClient = PluginInput["client"] @@ -115,6 +123,7 @@ export class BackgroundManager { private completionTimers: Map> = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() + private rootDescendantCounts: Map private enableParentSessionNotifications: boolean readonly taskHistory = new TaskHistory() @@ -139,10 +148,42 @@ export class BackgroundManager { this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.onShutdown = options?.onShutdown + this.rootDescendantCounts = new Map() this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true this.registerProcessCleanup() } + async assertCanSpawn(parentSessionID: string): Promise { + const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID) + const maxDepth = getMaxSubagentDepth(this.config) + if (spawnContext.childDepth > maxDepth) { + throw createSubagentDepthLimitError({ + childDepth: spawnContext.childDepth, + maxDepth, + parentSessionID, + rootSessionID: spawnContext.rootSessionID, + }) + } + + const maxDescendants = getMaxRootDescendants(this.config) + const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0 + if (descendantCount >= maxDescendants) { + throw createSubagentDescendantLimitError({ + rootSessionID: spawnContext.rootSessionID, + descendantCount, + maxDescendants, + }) + } + + return spawnContext + } + + private registerRootDescendant(rootSessionID: string): number { + const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1 + this.rootDescendantCounts.set(rootSessionID, nextCount) + return nextCount + } + async launch(input: LaunchInput): Promise { log("[background-agent] launch() called with:", { agent: input.agent, @@ -155,16 +196,28 @@ export class BackgroundManager { throw new Error("Agent parameter is required") } + const spawnContext = await this.assertCanSpawn(input.parentSessionID) + const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID) + + log("[background-agent] spawn guard passed", { + parentSessionID: input.parentSessionID, + rootSessionID: spawnContext.rootSessionID, + childDepth: spawnContext.childDepth, + descendantCount, + }) + // Create task immediately with status="pending" const task: BackgroundTask = { id: `bg_${crypto.randomUUID().slice(0, 8)}`, status: "pending", queuedAt: new Date(), + rootSessionID: spawnContext.rootSessionID, // Do NOT set startedAt - will be set when running // Do NOT set sessionID - will be set when running description: input.description, prompt: input.prompt, agent: input.agent, + spawnDepth: spawnContext.childDepth, parentSessionID: input.parentSessionID, parentMessageID: input.parentMessageID, parentModel: input.parentModel, @@ -209,7 +262,7 @@ export class BackgroundManager { // Trigger processing (fire-and-forget) this.processKey(key) - return task + return { ...task } } private async processKey(key: string): Promise { @@ -885,6 +938,7 @@ export class BackgroundManager { } } + this.rootDescendantCounts.delete(sessionID) SessionCategoryRegistry.remove(sessionID) } @@ -1625,6 +1679,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.pendingNotifications.clear() this.pendingByParent.clear() this.notificationQueueByParent.clear() + this.rootDescendantCounts.clear() this.queuesByKey.clear() this.processingKeys.clear() this.unregisterProcessCleanup() diff --git a/src/features/background-agent/subagent-spawn-limits.ts b/src/features/background-agent/subagent-spawn-limits.ts new file mode 100644 index 000000000..2b0068102 --- /dev/null +++ b/src/features/background-agent/subagent-spawn-limits.ts @@ -0,0 +1,79 @@ +import type { BackgroundTaskConfig } from "../../config/schema" +import type { OpencodeClient } from "./constants" + +export const DEFAULT_MAX_SUBAGENT_DEPTH = 3 +export const DEFAULT_MAX_ROOT_DESCENDANTS = 50 + +export interface SubagentSpawnContext { + rootSessionID: string + parentDepth: number + childDepth: number +} + +export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number { + return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH +} + +export function getMaxRootDescendants(config?: BackgroundTaskConfig): number { + return config?.maxDescendants ?? DEFAULT_MAX_ROOT_DESCENDANTS +} + +export async function resolveSubagentSpawnContext( + client: OpencodeClient, + parentSessionID: string +): Promise { + const visitedSessionIDs = new Set() + let rootSessionID = parentSessionID + let currentSessionID = parentSessionID + let parentDepth = 0 + + while (true) { + if (visitedSessionIDs.has(currentSessionID)) { + throw new Error(`Detected a session parent cycle while resolving ${parentSessionID}`) + } + + visitedSessionIDs.add(currentSessionID) + + const session = await client.session.get({ + path: { id: currentSessionID }, + }).catch(() => null) + + const nextParentSessionID = session?.data?.parentID + if (!nextParentSessionID) { + rootSessionID = currentSessionID + break + } + + currentSessionID = nextParentSessionID + parentDepth += 1 + } + + return { + rootSessionID, + parentDepth, + childDepth: parentDepth + 1, + } +} + +export function createSubagentDepthLimitError(input: { + childDepth: number + maxDepth: number + parentSessionID: string + rootSessionID: string +}): Error { + const { childDepth, maxDepth, parentSessionID, rootSessionID } = input + return new Error( + `Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.` + ) +} + +export function createSubagentDescendantLimitError(input: { + rootSessionID: string + descendantCount: number + maxDescendants: number +}): Error { + const { rootSessionID, descendantCount, maxDescendants } = input + return new Error( + `Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.` + ) +} diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index d1af31f43..73ae8a000 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -20,11 +20,13 @@ export interface TaskProgress { export interface BackgroundTask { id: string sessionID?: string + rootSessionID?: string parentSessionID: string parentMessageID: string description: string prompt: string agent: string + spawnDepth?: number status: BackgroundTaskStatus queuedAt?: Date startedAt?: Date diff --git a/src/tools/call-omo-agent/constants.ts b/src/tools/call-omo-agent/constants.ts index a17eea6dd..028b0d602 100644 --- a/src/tools/call-omo-agent/constants.ts +++ b/src/tools/call-omo-agent/constants.ts @@ -12,4 +12,4 @@ export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in Available: {agents} -Pass \`session_id=\` to continue previous agent with full context. Prompts MUST be in English. Use \`background_output\` for async results.` +Pass \`session_id=\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.` diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index 4efbe9657..40ceef67b 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -1,15 +1,18 @@ import { describe, test, expect, mock } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import type { FallbackEntry } from "../../shared/model-requirements" import { createCallOmoAgent } from "./tools" describe("createCallOmoAgent", () => { + const assertCanSpawnMock = mock(() => Promise.resolve(undefined)) const mockCtx = { client: {}, directory: "/test", } as unknown as PluginInput const mockBackgroundManager = { + assertCanSpawn: assertCanSpawnMock, launch: mock(() => Promise.resolve({ id: "test-task-id", sessionID: null, @@ -102,7 +105,7 @@ describe("createCallOmoAgent", () => { test("uses agent override fallback_models when launching background subagent", async () => { //#given - const launch = mock(() => Promise.resolve({ + const launch = mock((_input: { fallbackChain?: FallbackEntry[] }) => Promise.resolve({ id: "task-fallback", sessionID: "sub-session", description: "Test task", @@ -137,10 +140,36 @@ describe("createCallOmoAgent", () => { ) //#then - const launchArgs = launch.mock.calls[0]?.[0] + const firstLaunchCall = launch.mock.calls[0] + if (firstLaunchCall === undefined) { + throw new Error("Expected launch to be called") + } + + const [launchArgs] = firstLaunchCall expect(launchArgs.fallbackChain).toEqual([ { providers: ["quotio"], model: "kimi-k2.5", variant: undefined }, { providers: ["openai"], model: "gpt-5.2", variant: "high" }, ]) }) + + test("should return a tool error when sync spawn depth validation fails", async () => { + //#given + assertCanSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3.")) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function + + //#when + const result = await executeFunc( + { + description: "Test", + prompt: "Test prompt", + subagent_type: "explore", + run_in_background: false, + }, + { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }, + ) + + //#then + expect(result).toContain("background_task.maxDepth=3") + }) }) diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 1338282f1..219954f4a 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -95,6 +95,14 @@ export function createCallOmoAgent( return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain) } + if (!args.session_id) { + try { + await backgroundManager.assertCanSpawn(toolCtx.sessionID) + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}` + } + } + return await executeSync(args, toolCtx, ctx, undefined, fallbackChain) }, }) diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 115d2c57d..400ce502b 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -23,12 +23,20 @@ export async function executeSyncTask( fallbackChain?: import("../../shared/model-requirements").FallbackEntry[], deps: SyncTaskDeps = syncTaskDeps ): Promise { - const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx + const { manager, client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() let taskId: string | undefined let syncSessionID: string | undefined try { + const spawnContext = typeof manager?.assertCanSpawn === "function" + ? await manager.assertCanSpawn(parentContext.sessionID) + : { + rootSessionID: parentContext.sessionID, + parentDepth: 0, + childDepth: 1, + } + const createSessionResult = await deps.createSyncSession(client, { parentSessionID: parentContext.sessionID, agentToUse, @@ -90,6 +98,7 @@ export async function executeSyncTask( run_in_background: args.run_in_background, sessionId: sessionID, sync: true, + spawnDepth: spawnContext.childDepth, command: args.command, model: categoryModel ? { providerID: categoryModel.providerID, modelID: categoryModel.modelID } : undefined, },