Skip to content
Open
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/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 ?? {}))
}
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -521,6 +527,7 @@ export namespace Config {
"permission",
"disable",
"tools",
"subagents",
])

// Extract unknown properties into options
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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}",
Expand Down
70 changes: 70 additions & 0 deletions packages/opencode/test/tool/task-subagents.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
31 changes: 31 additions & 0 deletions packages/web/src/content/docs/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down