diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5d558ea14ca..2c83d692470 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -35,6 +35,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + subagents: z.array(z.string()).optional(), }) .meta({ ref: "Agent", @@ -198,6 +199,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.subagents = value.subagents ?? item.subagents item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6f04ecc6854..45144f2f55f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -502,6 +502,12 @@ export namespace Config { .describe("Maximum number of agentic iterations before forcing text-only response"), maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), permission: Permission.optional(), + subagents: z + .array(z.string()) + .optional() + .describe( + "List of subagent names this agent can spawn via the Task tool. When set, only these subagents will appear in the task tool description. Supports glob patterns (e.g., 'explore', 'code-*'). If not set, all subagents are available.", + ), }) .catchall(z.any()) .transform((agent, ctx) => { @@ -521,6 +527,7 @@ export namespace Config { "permission", "disable", "tools", + "subagents", ]) // Extract unknown properties into options diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 53b501ba91a..9ea30f27a4f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,7 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { PermissionNext } from "@/permission/next" +import { Wildcard } from "@/util/wildcard" const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -23,11 +24,11 @@ const parameters = z.object({ export const TaskTool = Tool.define("task", async (ctx) => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - // Filter agents by permissions if agent provided + // Filter agents by permissions and subagents list if agent provided const caller = ctx?.agent - const accessibleAgents = caller - ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") - : agents + const accessibleAgents = agents + .filter((a) => !caller || PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") + .filter((a) => !caller?.subagents?.length || caller.subagents.some((pattern) => Wildcard.match(a.name, pattern))) const description = DESCRIPTION.replace( "{agents}", diff --git a/packages/opencode/test/tool/task-subagents.test.ts b/packages/opencode/test/tool/task-subagents.test.ts new file mode 100644 index 00000000000..432a0bc40f1 --- /dev/null +++ b/packages/opencode/test/tool/task-subagents.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from "bun:test" +import { Wildcard } from "../../src/util/wildcard" + +describe("Task tool subagents filtering", () => { + // These tests verify the filtering logic used in task.ts + // The actual filtering is: agents.filter(a => !caller?.subagents?.length || caller.subagents.some(pattern => Wildcard.match(a.name, pattern))) + + const mockAgents = [ + { name: "general", description: "General purpose agent" }, + { name: "explore", description: "Codebase exploration" }, + { name: "code-reviewer", description: "Code review agent" }, + { name: "code-formatter", description: "Code formatting agent" }, + { name: "test-runner", description: "Test execution agent" }, + { name: "docs-generator", description: "Documentation generator" }, + ] + + const filterAgents = (agents: typeof mockAgents, subagents?: string[]) => + agents.filter((a) => !subagents?.length || subagents.some((pattern) => Wildcard.match(a.name, pattern))) + + test("returns all agents when subagents is undefined", () => { + const result = filterAgents(mockAgents, undefined) + expect(result).toHaveLength(6) + }) + + test("returns all agents when subagents is empty array", () => { + const result = filterAgents(mockAgents, []) + expect(result).toHaveLength(6) + }) + + test("filters to exact matches", () => { + const result = filterAgents(mockAgents, ["general", "explore"]) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "explore"]) + }) + + test("filters using wildcard patterns", () => { + const result = filterAgents(mockAgents, ["code-*"]) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "code-formatter"]) + }) + + test("filters using mixed exact and wildcard patterns", () => { + const result = filterAgents(mockAgents, ["general", "code-*"]) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "code-formatter"]) + }) + + test("filters using global wildcard allows all", () => { + const result = filterAgents(mockAgents, ["*"]) + expect(result).toHaveLength(6) + }) + + test("filters using suffix wildcard", () => { + const result = filterAgents(mockAgents, ["*-runner", "*-generator"]) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["test-runner", "docs-generator"]) + }) + + test("returns empty when no patterns match", () => { + const result = filterAgents(mockAgents, ["nonexistent", "also-nonexistent"]) + expect(result).toHaveLength(0) + }) + + test("handles single character wildcard", () => { + // ? matches single character + const result = filterAgents(mockAgents, ["code-?eviewer"]) + expect(result).toHaveLength(1) + expect(result[0].name).toBe("code-reviewer") + }) +}) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 3dfd16e7d60..c0258c8d34d 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -566,6 +566,37 @@ Users can always invoke any subagent directly via the `@` autocomplete menu, eve --- +### Subagents + +Control which subagents appear in the Task tool description with the `subagents` option. This is useful for reducing token overhead when you have many subagents configured but only want specific ones available to a particular agent. + +```json title="opencode.json" +{ + "agent": { + "build": { + "mode": "primary", + "subagents": ["explore", "general", "code-*"] + } + } +} +``` + +When `subagents` is set, only the listed subagents will appear in the Task tool description. This reduces the token cost of the system prompt, which can be significant when you have many subagents configured. + +:::tip +The `subagents` option supports glob patterns. Use `code-*` to match all subagents starting with `code-`, or `*-reviewer` to match all subagents ending with `-reviewer`. +::: + +:::note +If `subagents` is not set or is an empty array, all subagents are available (the default behavior). +::: + +The `subagents` option works alongside `permission.task`: +- `subagents` controls which subagents appear in the tool description (affects token usage) +- `permission.task` controls runtime access (ask/allow/deny) + +--- + ### Additional Any other options you specify in your agent configuration will be **passed through directly** to the provider as model options. This allows you to use provider-specific features and parameters.