diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 21859186659..65e574581d5 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -188,6 +188,7 @@ export namespace Agent { item.topP = value.top_p ?? item.topP item.mode = value.mode ?? item.mode item.color = value.color ?? item.color + item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 130031f020e..178526bbcd6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -465,6 +465,10 @@ export namespace Config { disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), + hidden: z + .boolean() + .optional() + .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), options: z.record(z.string(), z.any()).optional(), color: z .string() @@ -490,6 +494,7 @@ export namespace Config { "temperature", "top_p", "mode", + "hidden", "color", "steps", "maxSteps", diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 9a0395fa1ec..f95aaf34525 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -232,6 +232,7 @@ export namespace PermissionNext { const result = new Set() for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission)) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0306c59eba8..09155c86e7d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -37,7 +37,7 @@ import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" -import { TaskTool } from "@/tool/task" +import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task" import { Tool } from "@/tool/tool" import { PermissionNext } from "@/permission/next" import { SessionStatus } from "./status" @@ -382,7 +382,8 @@ export namespace SessionPrompt { messageID: assistantMessage.id, sessionID: sessionID, abort, - extra: { bypassAgentCheck: true }, + callID: part.callID, + extra: { userInvokedAgents: [task.agent] }, async metadata(input) { await Session.updatePart({ ...part, @@ -543,12 +544,20 @@ export namespace SessionPrompt { model, abort, }) + + // Track agents explicitly invoked by user via @ autocomplete + const userInvokedAgents = msgs + .filter((m) => m.info.role === "user") + .flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[]) + .map((p) => p.name) + const tools = await resolveTools({ agent, session, model, tools: lastUser.tools, processor, + userInvokedAgents, }) if (step === 1) { @@ -637,6 +646,7 @@ export namespace SessionPrompt { session: Session.Info tools?: Record processor: SessionProcessor.Info + userInvokedAgents: string[] }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -646,7 +656,7 @@ export namespace SessionPrompt { abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, - extra: { model: input.model }, + extra: { model: input.model, userInvokedAgents: input.userInvokedAgents }, agent: input.agent.name, metadata: async (val: { title?: string; metadata?: any }) => { const match = input.processor.partFromToolCall(options.toolCallId) @@ -789,6 +799,29 @@ export namespace SessionPrompt { } tools[key] = item } + + // Regenerate task tool description with filtered subagents + if (tools.task) { + const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) + const filtered = filterSubagents(all, input.agent.permission) + + // If no subagents are permitted, remove the task tool entirely + if (filtered.length === 0) { + delete tools.task + } else { + const description = TASK_DESCRIPTION.replace( + "{agents}", + filtered + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n"), + ) + tools.task = { + ...tools.task, + description, + } + } + } + return tools } @@ -1098,6 +1131,9 @@ export namespace SessionPrompt { } if (part.type === "agent") { + // Check if this agent would be denied by task permission + const perm = PermissionNext.evaluate("task", part.name, agent.permission) + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { id: Identifier.ascending("part"), @@ -1111,9 +1147,12 @@ export namespace SessionPrompt { sessionID: input.sessionID, type: "text", synthetic: true, + // An extra space is added here. Otherwise the 'Use' gets appended + // to user's last word; making a combined word text: - "Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name, + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, }, ] } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 3489f3bf7fb..a30a5a67502 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,6 +10,13 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" +import { PermissionNext } from "@/permission/next" + +export { DESCRIPTION as TASK_DESCRIPTION } + +export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) { + return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny") +} export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -30,8 +37,10 @@ export const TaskTool = Tool.define("task", async () => { }), async execute(params, ctx) { const config = await Config.get() + + const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[] // Skip permission check when invoked from a command subtask (user already approved by invoking the command) - if (!ctx.extra?.bypassAgentCheck) { + if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) { await ctx.ask({ permission: "task", patterns: [params.subagent_type], diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts new file mode 100644 index 00000000000..21a039d12a6 --- /dev/null +++ b/packages/opencode/test/permission-task.test.ts @@ -0,0 +1,459 @@ +import { describe, test, expect } from "bun:test" +import type { Agent } from "../src/agent/agent" +import { filterSubagents } from "../src/tool/task" +import { PermissionNext } from "../src/permission/next" +import { Config } from "../src/config/config" +import { Instance } from "../src/project/instance" +import { tmpdir } from "./fixture/fixture" + +describe("filterSubagents - permission.task filtering", () => { + const createRuleset = (rules: Record): PermissionNext.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + const mockAgents = [ + { name: "general", mode: "subagent", permission: [], options: {} }, + { name: "code-reviewer", mode: "subagent", permission: [], options: {} }, + { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} }, + { name: "orchestrator-slow", mode: "subagent", permission: [], options: {} }, + ] as Agent.Info[] + + test("returns all agents when permissions config is empty", () => { + const result = filterSubagents(mockAgents, []) + expect(result).toHaveLength(4) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("excludes agents with explicit deny", () => { + const ruleset = createRuleset({ "code-reviewer": "deny" }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("includes agents with explicit allow", () => { + const ruleset = createRuleset({ + "code-reviewer": "allow", + general: "deny", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("includes agents with ask permission (user approval is runtime behavior)", () => { + const ruleset = createRuleset({ + "code-reviewer": "ask", + general: "deny", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("includes agents with undefined permission (default allow)", () => { + const ruleset = createRuleset({ + general: "deny", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("supports wildcard patterns with deny", () => { + const ruleset = createRuleset({ "orchestrator-*": "deny" }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }) + + test("supports wildcard patterns with allow", () => { + const ruleset = createRuleset({ + "*": "allow", + "orchestrator-fast": "deny", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"]) + }) + + test("supports wildcard patterns with ask", () => { + const ruleset = createRuleset({ + "orchestrator-*": "ask", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(4) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("longer pattern takes precedence over shorter pattern", () => { + const ruleset = createRuleset({ + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) + }) + + test("edge case: all agents denied", () => { + const ruleset = createRuleset({ "*": "deny" }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(0) + expect(result).toEqual([]) + }) + + test("edge case: mixed patterns with multiple wildcards", () => { + const ruleset = createRuleset({ + "*": "ask", + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + const result = filterSubagents(mockAgents, ruleset) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) + }) + + test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => { + const agents = [ + { name: "general", mode: "subagent", hidden: true, permission: [], options: {} }, + { name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} }, + { name: "orchestrator", mode: "subagent", permission: [], options: {} }, + ] as Agent.Info[] + + const result = filterSubagents(agents, []) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"]) + }) + + test("hidden: true agents can be filtered by permission.task deny", () => { + const agents = [ + { name: "general", mode: "subagent", hidden: true, permission: [], options: {} }, + { name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} }, + ] as Agent.Info[] + + const ruleset = createRuleset({ general: "deny" }) + const result = filterSubagents(agents, ruleset) + expect(result).toHaveLength(1) + expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"]) + }) +}) + +describe("PermissionNext.evaluate for permission.task", () => { + const createRuleset = (rules: Record): PermissionNext.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + test("returns ask when no match (default)", () => { + expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask") + }) + + test("returns deny for explicit deny", () => { + const ruleset = createRuleset({ "code-reviewer": "deny" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + }) + + test("returns allow for explicit allow", () => { + const ruleset = createRuleset({ "code-reviewer": "allow" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow") + }) + + test("returns ask for explicit ask", () => { + const ruleset = createRuleset({ "code-reviewer": "ask" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") + }) + + test("matches wildcard patterns with deny", () => { + const ruleset = createRuleset({ "orchestrator-*": "deny" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") + }) + + test("matches wildcard patterns with allow", () => { + const ruleset = createRuleset({ "orchestrator-*": "allow" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") + }) + + test("matches wildcard patterns with ask", () => { + const ruleset = createRuleset({ "orchestrator-*": "ask" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") + const globalRuleset = createRuleset({ "*": "ask" }) + expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") + }) + + test("later rules take precedence (last match wins)", () => { + const ruleset = createRuleset({ + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") + }) + + test("matches global wildcard", () => { + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask") + }) +}) + +describe("PermissionNext.disabled for task tool", () => { + // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. + // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. + // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. + const createRuleset = (rules: Record): PermissionNext.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + test("task tool is disabled when global deny pattern exists (even with specific allows)", () => { + // When "*": "deny" exists, the task tool is disabled because the disabled() function + // only checks for wildcard deny patterns - it doesn't consider that specific subagents might be allowed + const ruleset = createRuleset({ + "orchestrator-*": "allow", + "*": "deny", + }) + const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset) + // The task tool IS disabled because there's a pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is disabled when global deny pattern exists (even with ask overrides)", () => { + const ruleset = createRuleset({ + "orchestrator-*": "ask", + "*": "deny", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + // The task tool IS disabled because there's a pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is disabled when global deny pattern exists", () => { + const ruleset = createRuleset({ "*": "deny" }) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is NOT disabled when only specific patterns are denied (no wildcard)", () => { + // The disabled() function only disables tools when pattern: "*" && action: "deny" + // Specific subagent denies don't disable the task tool - those are handled at runtime + const ruleset = createRuleset({ + "orchestrator-*": "deny", + general: "deny", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is enabled when no task rules exist (default ask)", () => { + const disabled = PermissionNext.disabled(["task"], []) + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is NOT disabled when last wildcard pattern is allow", () => { + // Last matching rule wins - if wildcard allow comes after wildcard deny, tool is enabled + const ruleset = createRuleset({ + "*": "deny", + "orchestrator-coder": "allow", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + // The disabled() function uses findLast and checks if the last matching rule + // has pattern: "*" and action: "deny". In this case, the last rule matching + // "task" permission has pattern "orchestrator-coder", not "*", so not disabled + expect(disabled.has("task")).toBe(false) + }) +}) + +// Integration tests that load permissions from real config files +describe("permission.task with real config files", () => { + const mockAgents = [ + { name: "general", mode: "subagent", permission: [], options: {} }, + { name: "code-reviewer", mode: "subagent", permission: [], options: {} }, + { name: "orchestrator-fast", mode: "subagent", permission: [], options: {} }, + ] as Agent.Info[] + + test("loads task permissions from opencode.json config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + task: { + "*": "allow", + "code-reviewer": "deny", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const result = filterSubagents(mockAgents, ruleset) + expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"]) + }, + }) + }) + + test("loads task permissions with wildcard patterns from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + task: { + "*": "ask", + "orchestrator-*": "deny", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + const result = filterSubagents(mockAgents, ruleset) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }, + }) + }) + + test("evaluate respects task permission from config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + task: { + general: "allow", + "code-reviewer": "deny", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + // Unspecified agents default to "ask" + expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask") + }, + }) + }) + + test("mixed permission config with task and other tools", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + bash: "allow", + edit: "ask", + task: { + "*": "deny", + general: "allow", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + + // Verify task permissions + expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + + // Verify other tool permissions + expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow") + expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask") + + // Verify disabled tools + const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset) + expect(disabled.has("bash")).toBe(false) + expect(disabled.has("edit")).toBe(false) + // task is NOT disabled because disabled() uses findLast, and the last rule + // matching "task" permission is {pattern: "general", action: "allow"}, not pattern: "*" + expect(disabled.has("task")).toBe(false) + }, + }) + }) + + test("task tool disabled when global deny comes last in config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + task: { + general: "allow", + "code-reviewer": "allow", + "*": "deny", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + + // Last matching rule wins - "*" deny is last, so all agents are denied + expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny") + + // Since "*": "deny" is the last rule, disabled() finds it with findLast + // and sees pattern: "*" with action: "deny", so task is disabled + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }, + }) + }) + + test("task tool NOT disabled when specific allow comes last in config", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + permission: { + task: { + "*": "deny", + general: "allow", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const ruleset = PermissionNext.fromConfig(config.permission ?? {}) + + // Evaluate uses findLast - "general" allow comes after "*" deny + expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") + // Other agents still denied by the earlier "*" deny + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") + + // disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny" + // In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*" + // So the task tool is NOT disabled (even though most subagents are denied) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(false) + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fb3be8208ac..97a695162ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1259,6 +1259,10 @@ export type AgentConfig = { */ description?: string mode?: "subagent" | "primary" | "all" + /** + * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) + */ + hidden?: boolean options?: { [key: string]: unknown } diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index f3f0b52eb12..3dfd16e7d60 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -510,6 +510,62 @@ The `mode` option can be set to `primary`, `subagent`, or `all`. If no `mode` is --- +### Hidden + +Hide a subagent from the `@` autocomplete menu with `hidden: true`. Useful for internal subagents that should only be invoked programmatically by other agents via the Task tool. + +```json title="opencode.json" +{ + "agent": { + "internal-helper": { + "mode": "subagent", + "hidden": true + } + } +} +``` + +This only affects user visibility in the autocomplete menu. Hidden agents can still be invoked by the model via the Task tool if permissions allow. + +:::note +Only applies to `mode: subagent` agents. +::: + +--- + +### Task permissions + +Control which subagents an agent can invoke via the Task tool with `permission.task`. Uses glob patterns for flexible matching. + +```json title="opencode.json" +{ + "agent": { + "orchestrator": { + "mode": "primary", + "permission": { + "task": { + "*": "deny", + "orchestrator-*": "allow", + "code-reviewer": "ask" + } + } + } + } +} +``` + +When set to `deny`, the subagent is removed from the Task tool description entirely, so the model won't attempt to invoke it. + +:::tip +Rules are evaluated in order, and the **last matching rule wins**. In the example above, `orchestrator-planner` matches both `*` (deny) and `orchestrator-*` (allow), but since `orchestrator-*` comes after `*`, the result is `allow`. +::: + +:::tip +Users can always invoke any subagent directly via the `@` autocomplete menu, even if the agent's task permissions would deny it. +::: + +--- + ### 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.