From 765812c7682193cfdf0bcff915007268b73655c9 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 26 Nov 2025 07:47:21 +0000 Subject: [PATCH 01/18] feat: add configurable subagent visibility per agent - Add subagents config field to agent schema for wildcard-based filtering - Add filterSubagents helper and runtime validation in Task tool - Add per-agent subagent filtering in prompt tool resolution - Add comprehensive tests for subagent filtering patterns --- packages/opencode/src/agent/agent.ts | 25 ++++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/session/prompt.ts | 19 +++- packages/opencode/src/tool/task.ts | 10 ++ .../opencode/test/subagents-filter.test.ts | 93 +++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/subagents-filter.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 740f67b7e04..46b4568d0f4 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -32,6 +32,7 @@ export namespace Agent { .optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()), + subagents: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), }) .meta({ @@ -109,6 +110,7 @@ export namespace Agent { todowrite: false, ...defaultTools, }, + subagents: {}, options: {}, permission: agentPermission, mode: "subagent", @@ -117,6 +119,7 @@ export namespace Agent { build: { name: "build", tools: { ...defaultTools }, + subagents: {}, options: {}, permission: agentPermission, mode: "primary", @@ -129,6 +132,7 @@ export namespace Agent { tools: { ...defaultTools, }, + subagents: {}, mode: "primary", builtIn: true, }, @@ -146,9 +150,23 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, + subagents: {}, builtIn: false, } - const { name, model, prompt, tools, description, temperature, top_p, mode, permission, color, ...extra } = value + const { + name, + model, + prompt, + tools, + subagents, + description, + temperature, + top_p, + mode, + permission, + color, + ...extra + } = value item.options = { ...item.options, ...extra, @@ -164,6 +182,11 @@ export namespace Agent { ...defaultTools, ...item.tools, } + if (subagents) + item.subagents = { + ...item.subagents, + ...subagents, + } if (description) item.description = description if (temperature != undefined) item.temperature = temperature if (top_p != undefined) item.topP = top_p diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0ea0e8fa23b..d3fec6eb8ae 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -367,6 +367,7 @@ export namespace Config { top_p: z.number().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + subagents: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c6721202a8d..dfced78bfe6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -46,7 +46,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 { SessionStatus } from "./status" // @ts-ignore @@ -792,6 +792,23 @@ 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.subagents) + 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 } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 3bb7fb2bf39..a5645c5f8d9 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,13 @@ import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" +import { Wildcard } from "@/util/wildcard" + +export { DESCRIPTION as TASK_DESCRIPTION } + +export function filterSubagents(agents: Agent.Info[], subagents: Record) { + return agents.filter((a) => Wildcard.all(a.name, subagents) !== false) +} export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -29,6 +36,9 @@ export const TaskTool = Tool.define("task", async () => { async execute(params, ctx) { const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + const calling = await Agent.get(ctx.agent) + if (calling && Wildcard.all(params.subagent_type, calling.subagents) === false) + throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) diff --git a/packages/opencode/test/subagents-filter.test.ts b/packages/opencode/test/subagents-filter.test.ts new file mode 100644 index 00000000000..5cb0b7e0a08 --- /dev/null +++ b/packages/opencode/test/subagents-filter.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect } from "bun:test" +import type { Agent } from "../src/agent/agent" +import { filterSubagents } from "../src/tool/task" +import { Wildcard } from "../src/util/wildcard" + +describe("filterSubagents", () => { + const mockAgents = [ + { name: "general", mode: "subagent" }, + { name: "code-reviewer", mode: "subagent" }, + { name: "orchestrator-fast", mode: "subagent" }, + { name: "orchestrator-slow", mode: "subagent" }, + ] as Agent.Info[] + + test("returns all agents when subagents 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 false", () => { + const result = filterSubagents(mockAgents, { "code-reviewer": false }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("includes agents with explicit true", () => { + const result = filterSubagents(mockAgents, { + "code-reviewer": true, + general: false, + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("supports wildcard patterns to exclude", () => { + const result = filterSubagents(mockAgents, { "orchestrator-*": false }) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }) + + test("supports wildcard patterns to include with specific exclusion", () => { + const result = filterSubagents(mockAgents, { + "*": true, + "orchestrator-fast": false, + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"]) + }) + + test("longer pattern takes precedence", () => { + const result = filterSubagents(mockAgents, { + "orchestrator-*": false, + "orchestrator-fast": true, + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) + }) +}) + +describe("Wildcard.all for subagents", () => { + test("returns undefined when no match", () => { + expect(Wildcard.all("code-reviewer", {})).toBeUndefined() + }) + + test("returns false for explicit false", () => { + expect(Wildcard.all("code-reviewer", { "code-reviewer": false })).toBe(false) + }) + + test("returns true for explicit true", () => { + expect(Wildcard.all("code-reviewer", { "code-reviewer": true })).toBe(true) + }) + + test("matches wildcard patterns", () => { + expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": false })).toBe(false) + expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": false })).toBe(false) + expect(Wildcard.all("general", { "orchestrator-*": false })).toBeUndefined() + }) + + test("longer pattern takes precedence over shorter", () => { + expect( + Wildcard.all("orchestrator-fast", { + "orchestrator-*": false, + "orchestrator-fast": true, + }), + ).toBe(true) + expect( + Wildcard.all("orchestrator-slow", { + "orchestrator-*": false, + "orchestrator-fast": true, + }), + ).toBe(false) + }) +}) From 9de00688fa666c0bcc709e66e45108f25e33bf9b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 26 Nov 2025 08:05:31 +0000 Subject: [PATCH 02/18] docs: add subagents configuration documentation - Document subagents config option for filtering agent invocations - Add examples for exclusion patterns, wildcards, and precedence rules - Include Markdown agent configuration example --- packages/web/src/content/docs/agents.mdx | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index f63457cc026..d6cd7131a31 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -346,6 +346,76 @@ You can also use wildcards to control multiple tools at once. For example, to di --- +### Subagents + +Control which subagents this agent can invoke via the Task tool with the `subagents` config. + +By default, all subagents are available. Set a subagent to `false` to hide it from this agent. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "subagents": { + "code-reviewer": false + } + } + } +} +``` + +You can also use wildcards to control multiple subagents at once: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "subagents": { + "orchestrator-*": false + } + } + } +} +``` + +Longer patterns take precedence over shorter ones. This allows you to exclude a group while keeping specific exceptions: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "subagents": { + "orchestrator-*": false, + "orchestrator-fast": true + } + } + } +} +``` + +You can also configure subagents in Markdown agents: + +```markdown title="~/.config/opencode/agent/focused.md" +--- +description: Agent with limited subagent access +mode: primary +subagents: + general: true + orchestrator-*: false +--- + +You are a focused agent with limited subagent access. +``` + +:::note +Filtered subagents will not appear in the Task tool description and cannot be invoked. +::: + +--- + ### Permissions You can configure permissions to manage what actions an agent can take. Currently, the permissions for the `edit`, `bash`, and `webfetch` tools can be configured to: From b582b53c454fafde374e140140d71d16b0f6859b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 26 Nov 2025 08:33:11 +0000 Subject: [PATCH 03/18] fix: TUI autocomplete now respects agent subagents visibility config - Filter @ autocomplete suggestions based on current agent's subagents configuration - Fixes bug where all subagents were shown regardless of visibility settings --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 5780be4e987..8b5993a9490 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -5,10 +5,12 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" +import { useLocal } from "@tui/context/local" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { Locale } from "@/util/locale" +import { Wildcard } from "@/util/wildcard" import type { PromptInfo } from "./history" export type AutocompleteRef = { @@ -39,6 +41,7 @@ export function Autocomplete(props: { }) { const sdk = useSDK() const sync = useSync() + const local = useLocal() const command = useCommandDialog() const { theme } = useTheme() @@ -152,9 +155,11 @@ export function Autocomplete(props: { ) const agents = createMemo(() => { - const agents = sync.data.agent - return agents + const current = local.agent.current() as { subagents?: Record } + const subagents = current.subagents ?? {} + return sync.data.agent .filter((agent) => !agent.builtIn && agent.mode !== "primary") + .filter((agent) => Wildcard.all(agent.name, subagents) !== false) .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, From eab8fc8484f6ce069100d2c93b40ab5dec09caf2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 26 Nov 2025 19:30:24 +0000 Subject: [PATCH 04/18] chore: format code --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c60104e874f..90159f73bf2 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 0566b19d27c..af95a219375 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 1bbb7d01f9b51bf0dc726d511ea2ba7673a09ca4 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sat, 29 Nov 2025 18:15:48 +0000 Subject: [PATCH 05/18] Update Nix flake.lock and hashes --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 33aae38122b..f35c345f0ba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1764290847, - "narHash": "sha256-VwPgoDgnd628GdE3KyLqTyPF1WWh0VwT5UoKygoi8sg=", + "lastModified": 1764384123, + "narHash": "sha256-UoliURDJFaOolycBZYrjzd9Cc66zULEyHqGFH3QHEq0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cd5fedfc384cb98d9fd3827b55f4522f49efda42", + "rev": "59b6c96beacc898566c9be1052ae806f3835f87d", "type": "github" }, "original": { From a7924bda86020fd9a608d2d08e5816ca6711b8c2 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sun, 30 Nov 2025 23:35:27 +0000 Subject: [PATCH 06/18] Update Nix flake.lock and hashes --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 211be53aa99..ea6e3da96e7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1764445028, - "narHash": "sha256-ik6H/0Zl+qHYDKTXFPpzuVHSZE+uvVz2XQuQd1IVXzo=", + "lastModified": 1764506988, + "narHash": "sha256-clj4TsIVqiFfvyu+mfm3s94Y7iOP+eRa62wxzLUV49M=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a09378c0108815dbf3961a0e085936f4146ec415", + "rev": "4c7d90a136071eb8154d6b3fe63b0046de9d4712", "type": "github" }, "original": { From a52acd1e95aaf3b284168cf46d8fc1e4f0a85033 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 12 Dec 2025 16:32:37 +0000 Subject: [PATCH 07/18] feat(opencode): add visible field and task permission to agents design from @malhashemi --- packages/opencode/src/agent/agent.ts | 28 +- .../cmd/tui/component/prompt/autocomplete.tsx | 7 +- packages/opencode/src/config/config.ts | 6 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/task.ts | 30 ++- .../opencode/test/permission-task.test.ts | 252 ++++++++++++++++++ .../opencode/test/subagents-filter.test.ts | 93 ------- packages/sdk/js/src/v2/gen/types.gen.ts | 14 + packages/web/src/content/docs/agents.mdx | 114 +++----- 9 files changed, 359 insertions(+), 187 deletions(-) create mode 100644 packages/opencode/test/permission-task.test.ts delete mode 100644 packages/opencode/test/subagents-filter.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b0d3df0a76f..2ebf4aa043e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -17,12 +17,14 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), + visible: z.boolean().optional(), permission: z.object({ edit: Config.Permission, bash: z.record(z.string(), Config.Permission), webfetch: Config.Permission.optional(), doom_loop: Config.Permission.optional(), external_directory: Config.Permission.optional(), + task: z.record(z.string(), Config.Permission).optional(), }), model: z .object({ @@ -32,7 +34,6 @@ export namespace Agent { .optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()), - subagents: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), maxSteps: z.number().int().positive().optional(), }) @@ -110,7 +111,6 @@ export namespace Agent { todowrite: false, ...defaultTools, }, - subagents: {}, options: {}, permission: agentPermission, mode: "subagent", @@ -154,7 +154,6 @@ export namespace Agent { build: { name: "build", tools: { ...defaultTools }, - subagents: {}, options: {}, permission: agentPermission, mode: "primary", @@ -167,7 +166,6 @@ export namespace Agent { tools: { ...defaultTools, }, - subagents: {}, mode: "primary", builtIn: true, }, @@ -185,7 +183,6 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, - subagents: {}, builtIn: false, } const { @@ -193,7 +190,6 @@ export namespace Agent { model, prompt, tools, - subagents, description, temperature, top_p, @@ -201,6 +197,7 @@ export namespace Agent { permission, color, maxSteps, + visible, ...extra } = value item.options = { @@ -218,16 +215,12 @@ export namespace Agent { ...defaultTools, ...item.tools, } - if (subagents) - item.subagents = { - ...item.subagents, - ...subagents, - } if (description) item.description = description if (temperature != undefined) item.temperature = temperature if (top_p != undefined) item.topP = top_p if (mode) item.mode = mode if (color) item.color = color + if (visible != undefined) item.visible = visible // just here for consistency & to prevent it from being added as an option if (name) item.name = name if (maxSteps != undefined) item.maxSteps = maxSteps @@ -314,12 +307,25 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag } } + let mergedTask + if (merged.task) { + if (typeof merged.task === "object") { + mergedTask = mergeDeep( + { + "*": "allow", + }, + merged.task, + ) + } + } + const result: Agent.Info["permission"] = { edit: merged.edit ?? "allow", webfetch: merged.webfetch ?? "allow", bash: mergedBash ?? { "*": "allow" }, doom_loop: merged.doom_loop, external_directory: merged.external_directory, + task: mergedTask, } return result diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 88565577d79..f11a3d0db1f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -5,13 +5,11 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, For, Show import { createStore } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { useSync } from "@tui/context/sync" -import { useLocal } from "@tui/context/local" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" -import { Wildcard } from "@/util/wildcard" import type { PromptInfo } from "./history" export type AutocompleteRef = { @@ -42,7 +40,6 @@ export function Autocomplete(props: { }) { const sdk = useSDK() const sync = useSync() - const local = useLocal() const command = useCommandDialog() const { theme } = useTheme() const dimensions = useTerminalDimensions() @@ -185,11 +182,9 @@ export function Autocomplete(props: { ) const agents = createMemo(() => { - const current = local.agent.current() as { subagents?: Record } - const subagents = current.subagents ?? {} return sync.data.agent .filter((agent) => !agent.builtIn && agent.mode !== "primary") - .filter((agent) => Wildcard.all(agent.name, subagents) !== false) + .filter((agent) => agent.visible !== false) .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a0f05cc93f5..9a721deb962 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -389,10 +389,13 @@ export namespace Config { top_p: z.number().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), - subagents: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), + visible: z + .boolean() + .optional() + .describe("Whether this subagent appears in the agent menu (default: true, only applies to mode: subagent)"), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") @@ -411,6 +414,7 @@ export namespace Config { webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), + task: z.record(z.string(), Permission).optional(), }) .optional(), }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9eff8ffd144..ff3c0dffbba 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -845,7 +845,7 @@ export namespace SessionPrompt { // 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.subagents) + const filtered = filterSubagents(all, input.agent.permission.task ?? {}) const description = TASK_DESCRIPTION.replace( "{agents}", filtered diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f1e753fd555..bafec422e2c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -10,11 +10,15 @@ import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Wildcard } from "@/util/wildcard" +import { Permission } from "../permission" export { DESCRIPTION as TASK_DESCRIPTION } -export function filterSubagents(agents: Agent.Info[], subagents: Record) { - return agents.filter((a) => Wildcard.all(a.name, subagents) !== false) +export function filterSubagents(agents: Agent.Info[], permissions: Record) { + return agents.filter((a) => { + if (a.visible === false) return false + return Wildcard.all(a.name, permissions) !== "deny" + }) } import { Config } from "../config/config" @@ -38,8 +42,26 @@ export const TaskTool = Tool.define("task", async () => { const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const calling = await Agent.get(ctx.agent) - if (calling && Wildcard.all(params.subagent_type, calling.subagents) === false) - throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) + if (calling) { + const perm = Wildcard.all(params.subagent_type, calling.permission.task ?? {}) + if (perm === "deny") { + throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) + } + if (perm === "ask") { + await Permission.ask({ + type: "task", + title: `Invoke subagent: ${params.subagent_type}`, + pattern: params.subagent_type, + callID: ctx.callID, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + metadata: { + subagent: params.subagent_type, + description: params.description, + }, + }) + } + } const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts new file mode 100644 index 00000000000..e27366e0e38 --- /dev/null +++ b/packages/opencode/test/permission-task.test.ts @@ -0,0 +1,252 @@ +import { describe, test, expect } from "bun:test" +import type { Agent } from "../src/agent/agent" +import { filterSubagents } from "../src/tool/task" +import { Wildcard } from "../src/util/wildcard" + +describe("filterSubagents - permission.task filtering", () => { + const mockAgents = [ + { name: "general", mode: "subagent" }, + { name: "code-reviewer", mode: "subagent" }, + { name: "orchestrator-fast", mode: "subagent" }, + { name: "orchestrator-slow", mode: "subagent" }, + ] 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 result = filterSubagents(mockAgents, { "code-reviewer": "deny" }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("includes agents with explicit allow", () => { + const result = filterSubagents(mockAgents, { + "code-reviewer": "allow", + general: "deny", + }) + 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 result = filterSubagents(mockAgents, { + "code-reviewer": "ask", + general: "deny", + }) + 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 result = filterSubagents(mockAgents, { + general: "deny", + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) + }) + + test("supports wildcard patterns with deny", () => { + const result = filterSubagents(mockAgents, { "orchestrator-*": "deny" }) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }) + + test("supports wildcard patterns with allow", () => { + const result = filterSubagents(mockAgents, { + "*": "allow", + "orchestrator-fast": "deny", + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"]) + }) + + test("supports wildcard patterns with ask", () => { + const result = filterSubagents(mockAgents, { + "orchestrator-*": "ask", + }) + 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 result = filterSubagents(mockAgents, { + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) + }) + + test("edge case: all agents denied", () => { + const result = filterSubagents(mockAgents, { "*": "deny" }) + expect(result).toHaveLength(0) + expect(result).toEqual([]) + }) + + test("edge case: mixed patterns with multiple wildcards", () => { + const result = filterSubagents(mockAgents, { + "*": "ask", + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) + }) +}) + +describe("filterSubagents - visible property filtering", () => { + test("excludes agents with visible: false", () => { + const agents = [ + { name: "general", mode: "subagent", visible: false }, + { name: "code-reviewer", mode: "subagent", visible: true }, + { name: "orchestrator", mode: "subagent" }, + ] as Agent.Info[] + + const result = filterSubagents(agents, {}) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator"]) + }) + + test("includes agents with visible: true", () => { + const agents = [ + { name: "general", mode: "subagent", visible: true }, + { name: "code-reviewer", mode: "subagent", visible: true }, + ] as Agent.Info[] + + const result = filterSubagents(agents, {}) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }) + + test("includes agents with visible: undefined (default visible)", () => { + const agents = [ + { name: "general", mode: "subagent" }, + { name: "code-reviewer", mode: "subagent", visible: undefined }, + ] as Agent.Info[] + + const result = filterSubagents(agents, {}) + expect(result).toHaveLength(2) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) + }) + + test("visible: false takes precedence over permission allow", () => { + const agents = [ + { name: "general", mode: "subagent", visible: false }, + { name: "code-reviewer", mode: "subagent", visible: true }, + ] as Agent.Info[] + + const result = filterSubagents(agents, { general: "allow", "code-reviewer": "allow" }) + expect(result).toHaveLength(1) + expect(result.map((a) => a.name)).toEqual(["code-reviewer"]) + }) + + test("visible: false combined with permission deny excludes agent", () => { + const agents = [ + { name: "general", mode: "subagent", visible: false }, + { name: "code-reviewer", mode: "subagent", visible: true }, + ] as Agent.Info[] + + const result = filterSubagents(agents, { general: "deny", "code-reviewer": "deny" }) + expect(result).toHaveLength(0) + }) + + test("edge case: all agents have visible: false", () => { + const agents = [ + { name: "general", mode: "subagent", visible: false }, + { name: "code-reviewer", mode: "subagent", visible: false }, + ] as Agent.Info[] + + const result = filterSubagents(agents, {}) + expect(result).toHaveLength(0) + expect(result).toEqual([]) + }) + + test("mixed visible states with permissions", () => { + const agents = [ + { name: "general", mode: "subagent", visible: true }, + { name: "code-reviewer", mode: "subagent", visible: false }, + { name: "orchestrator-fast", mode: "subagent" }, + { name: "orchestrator-slow", mode: "subagent", visible: false }, + ] as Agent.Info[] + + const result = filterSubagents(agents, { + "orchestrator-fast": "deny", + }) + expect(result).toHaveLength(1) + expect(result.map((a) => a.name)).toEqual(["general"]) + }) +}) + +describe("Wildcard.all for permission.task", () => { + test("returns undefined when no match", () => { + expect(Wildcard.all("code-reviewer", {})).toBeUndefined() + }) + + test("returns deny for explicit deny", () => { + expect(Wildcard.all("code-reviewer", { "code-reviewer": "deny" })).toBe("deny") + }) + + test("returns allow for explicit allow", () => { + expect(Wildcard.all("code-reviewer", { "code-reviewer": "allow" })).toBe("allow") + }) + + test("returns ask for explicit ask", () => { + expect(Wildcard.all("code-reviewer", { "code-reviewer": "ask" })).toBe("ask") + }) + + test("matches wildcard patterns with deny", () => { + expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": "deny" })).toBe("deny") + expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": "deny" })).toBe("deny") + expect(Wildcard.all("general", { "orchestrator-*": "deny" })).toBeUndefined() + }) + + test("matches wildcard patterns with allow", () => { + expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": "allow" })).toBe("allow") + expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": "allow" })).toBe("allow") + }) + + test("matches wildcard patterns with ask", () => { + expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": "ask" })).toBe("ask") + expect(Wildcard.all("code-reviewer", { "*": "ask" })).toBe("ask") + }) + + test("longer pattern takes precedence over shorter with mixed permissions", () => { + expect( + Wildcard.all("orchestrator-fast", { + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }), + ).toBe("allow") + expect( + Wildcard.all("orchestrator-slow", { + "orchestrator-*": "deny", + "orchestrator-fast": "allow", + }), + ).toBe("deny") + }) + + test("longer pattern takes precedence with ask permission", () => { + expect( + Wildcard.all("orchestrator-fast", { + "orchestrator-*": "ask", + "orchestrator-fast": "deny", + }), + ).toBe("deny") + expect( + Wildcard.all("orchestrator-slow", { + "orchestrator-*": "ask", + "orchestrator-fast": "deny", + }), + ).toBe("ask") + }) + + test("matches global wildcard", () => { + expect(Wildcard.all("any-agent", { "*": "allow" })).toBe("allow") + expect(Wildcard.all("any-agent", { "*": "deny" })).toBe("deny") + expect(Wildcard.all("any-agent", { "*": "ask" })).toBe("ask") + }) +}) diff --git a/packages/opencode/test/subagents-filter.test.ts b/packages/opencode/test/subagents-filter.test.ts deleted file mode 100644 index 5cb0b7e0a08..00000000000 --- a/packages/opencode/test/subagents-filter.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, test, expect } from "bun:test" -import type { Agent } from "../src/agent/agent" -import { filterSubagents } from "../src/tool/task" -import { Wildcard } from "../src/util/wildcard" - -describe("filterSubagents", () => { - const mockAgents = [ - { name: "general", mode: "subagent" }, - { name: "code-reviewer", mode: "subagent" }, - { name: "orchestrator-fast", mode: "subagent" }, - { name: "orchestrator-slow", mode: "subagent" }, - ] as Agent.Info[] - - test("returns all agents when subagents 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 false", () => { - const result = filterSubagents(mockAgents, { "code-reviewer": false }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"]) - }) - - test("includes agents with explicit true", () => { - const result = filterSubagents(mockAgents, { - "code-reviewer": true, - general: false, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) - }) - - test("supports wildcard patterns to exclude", () => { - const result = filterSubagents(mockAgents, { "orchestrator-*": false }) - expect(result).toHaveLength(2) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) - }) - - test("supports wildcard patterns to include with specific exclusion", () => { - const result = filterSubagents(mockAgents, { - "*": true, - "orchestrator-fast": false, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"]) - }) - - test("longer pattern takes precedence", () => { - const result = filterSubagents(mockAgents, { - "orchestrator-*": false, - "orchestrator-fast": true, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) - }) -}) - -describe("Wildcard.all for subagents", () => { - test("returns undefined when no match", () => { - expect(Wildcard.all("code-reviewer", {})).toBeUndefined() - }) - - test("returns false for explicit false", () => { - expect(Wildcard.all("code-reviewer", { "code-reviewer": false })).toBe(false) - }) - - test("returns true for explicit true", () => { - expect(Wildcard.all("code-reviewer", { "code-reviewer": true })).toBe(true) - }) - - test("matches wildcard patterns", () => { - expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": false })).toBe(false) - expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": false })).toBe(false) - expect(Wildcard.all("general", { "orchestrator-*": false })).toBeUndefined() - }) - - test("longer pattern takes precedence over shorter", () => { - expect( - Wildcard.all("orchestrator-fast", { - "orchestrator-*": false, - "orchestrator-fast": true, - }), - ).toBe(true) - expect( - Wildcard.all("orchestrator-slow", { - "orchestrator-*": false, - "orchestrator-fast": true, - }), - ).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 9d0bbcc92cd..60eff229b89 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1116,6 +1116,10 @@ export type AgentConfig = { */ description?: string mode?: "subagent" | "primary" | "all" + /** + * Whether this subagent appears in the agent menu (default: true, only applies to mode: subagent) + */ + visible?: boolean /** * Hex color code for the agent (e.g., #FF5733) */ @@ -1136,6 +1140,9 @@ export type AgentConfig = { webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" external_directory?: "ask" | "allow" | "deny" + task?: { + [key: string]: "ask" | "allow" | "deny" + } } [key: string]: | unknown @@ -1162,6 +1169,9 @@ export type AgentConfig = { webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" external_directory?: "ask" | "allow" | "deny" + task?: { + [key: string]: "ask" | "allow" | "deny" + } } | undefined } @@ -1738,6 +1748,7 @@ export type Agent = { topP?: number temperature?: number color?: string + visible?: boolean permission: { edit: "ask" | "allow" | "deny" bash: { @@ -1746,6 +1757,9 @@ export type Agent = { webfetch?: "ask" | "allow" | "deny" doom_loop?: "ask" | "allow" | "deny" external_directory?: "ask" | "allow" | "deny" + task?: { + [key: string]: "ask" | "allow" | "deny" + } } model?: { modelID: string diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 0f727366b4b..d34eb0b2a5c 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -318,7 +318,7 @@ This path is relative to where the config file is located. So this works for bot Use the `model` config to override the model for this agent. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation. :::tip -If you don’t specify a model, primary agents use the [model globally configured](/docs/config#models) while subagents will use the model of the primary agent that invoked the subagent. +If you don't specify a model, primary agents use the [model globally configured](/docs/config#models) while subagents will use the model of the primary agent that invoked the subagent. ::: ```json title="opencode.json" @@ -380,76 +380,6 @@ You can also use wildcards to control multiple tools at once. For example, to di --- -### Subagents - -Control which subagents this agent can invoke via the Task tool with the `subagents` config. - -By default, all subagents are available. Set a subagent to `false` to hide it from this agent. - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "code-reviewer": false - } - } - } -} -``` - -You can also use wildcards to control multiple subagents at once: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "orchestrator-*": false - } - } - } -} -``` - -Longer patterns take precedence over shorter ones. This allows you to exclude a group while keeping specific exceptions: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "orchestrator-*": false, - "orchestrator-fast": true - } - } - } -} -``` - -You can also configure subagents in Markdown agents: - -```markdown title="~/.config/opencode/agent/focused.md" ---- -description: Agent with limited subagent access -mode: primary -subagents: - general: true - orchestrator-*: false ---- - -You are a focused agent with limited subagent access. -``` - -:::note -Filtered subagents will not appear in the Task tool description and cannot be invoked. -::: - ---- - ### Permissions You can configure permissions to manage what actions an agent can take. Currently, the permissions for the `edit`, `bash`, and `webfetch` tools can be configured to: @@ -556,6 +486,29 @@ Where the specific rule can override the `*` wildcard. } ``` +#### Task permissions + +Control which subagents a primary agent can invoke via the Task tool with `permission.task`. Uses the same glob pattern syntax as bash permissions. + +```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. + [Learn more about permissions](/docs/permissions). --- @@ -578,6 +531,25 @@ The `mode` option can be set to `primary`, `subagent`, or `all`. If no `mode` is --- +### Visible + +Hide a subagent from the `@` autocomplete with `visible: false`. Useful for internal subagents that should only be invoked by other agents via the Task tool. + +```json title="opencode.json" +{ + "agent": { + "internal-helper": { + "mode": "subagent", + "visible": false + } + } +} +``` + +Only applies to `mode: subagent` agents. + +--- + ### 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. From 58f8dce43081698e632daa12ffafb060401845f8 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 12 Dec 2025 17:49:15 +0000 Subject: [PATCH 08/18] fix: don't hide visible==false from system prompt. --- packages/opencode/src/tool/task.ts | 5 +- .../opencode/test/permission-task.test.ts | 74 ++----------------- 2 files changed, 8 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bafec422e2c..2de1421ea19 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -15,10 +15,7 @@ import { Permission } from "../permission" export { DESCRIPTION as TASK_DESCRIPTION } export function filterSubagents(agents: Agent.Info[], permissions: Record) { - return agents.filter((a) => { - if (a.visible === false) return false - return Wildcard.all(a.name, permissions) !== "deny" - }) + return agents.filter((a) => Wildcard.all(a.name, permissions) !== "deny") } import { Config } from "../config/config" diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index e27366e0e38..6f46413f70b 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -96,10 +96,8 @@ describe("filterSubagents - permission.task filtering", () => { expect(result).toHaveLength(3) expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) }) -}) -describe("filterSubagents - visible property filtering", () => { - test("excludes agents with visible: false", () => { + test("visible: false does not affect filtering (visible only affects autocomplete)", () => { const agents = [ { name: "general", mode: "subagent", visible: false }, { name: "code-reviewer", mode: "subagent", visible: true }, @@ -107,77 +105,19 @@ describe("filterSubagents - visible property filtering", () => { ] as Agent.Info[] const result = filterSubagents(agents, {}) - expect(result).toHaveLength(2) - expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator"]) - }) - - test("includes agents with visible: true", () => { - const agents = [ - { name: "general", mode: "subagent", visible: true }, - { name: "code-reviewer", mode: "subagent", visible: true }, - ] as Agent.Info[] - - const result = filterSubagents(agents, {}) - expect(result).toHaveLength(2) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) - }) - - test("includes agents with visible: undefined (default visible)", () => { - const agents = [ - { name: "general", mode: "subagent" }, - { name: "code-reviewer", mode: "subagent", visible: undefined }, - ] as Agent.Info[] - - const result = filterSubagents(agents, {}) - expect(result).toHaveLength(2) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) - }) - - test("visible: false takes precedence over permission allow", () => { - const agents = [ - { name: "general", mode: "subagent", visible: false }, - { name: "code-reviewer", mode: "subagent", visible: true }, - ] as Agent.Info[] - - const result = filterSubagents(agents, { general: "allow", "code-reviewer": "allow" }) - expect(result).toHaveLength(1) - expect(result.map((a) => a.name)).toEqual(["code-reviewer"]) - }) - - test("visible: false combined with permission deny excludes agent", () => { - const agents = [ - { name: "general", mode: "subagent", visible: false }, - { name: "code-reviewer", mode: "subagent", visible: true }, - ] as Agent.Info[] - - const result = filterSubagents(agents, { general: "deny", "code-reviewer": "deny" }) - expect(result).toHaveLength(0) + expect(result).toHaveLength(3) + expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"]) }) - test("edge case: all agents have visible: false", () => { + test("visible: false agents can be filtered by permission.task deny", () => { const agents = [ { name: "general", mode: "subagent", visible: false }, - { name: "code-reviewer", mode: "subagent", visible: false }, + { name: "orchestrator-coder", mode: "subagent", visible: false }, ] as Agent.Info[] - const result = filterSubagents(agents, {}) - expect(result).toHaveLength(0) - expect(result).toEqual([]) - }) - - test("mixed visible states with permissions", () => { - const agents = [ - { name: "general", mode: "subagent", visible: true }, - { name: "code-reviewer", mode: "subagent", visible: false }, - { name: "orchestrator-fast", mode: "subagent" }, - { name: "orchestrator-slow", mode: "subagent", visible: false }, - ] as Agent.Info[] - - const result = filterSubagents(agents, { - "orchestrator-fast": "deny", - }) + const result = filterSubagents(agents, { general: "deny" }) expect(result).toHaveLength(1) - expect(result.map((a) => a.name)).toEqual(["general"]) + expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"]) }) }) From f353f0854e8d9a4a5f5b9f9259781f4bd5948208 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 12 Dec 2025 18:13:39 +0000 Subject: [PATCH 09/18] improve: allow user to invoke agents even if they are denied --- packages/opencode/src/session/prompt.ts | 8 +++++- packages/opencode/src/tool/task.ts | 38 ++++++++++++++----------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ff3c0dffbba..700cd130944 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -483,12 +483,17 @@ export namespace SessionPrompt { system: lastUser.system, isLastStep, }) + 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, sessionID, model, tools: lastUser.tools, processor, + userInvokedAgents, }) const provider = await Provider.getProvider(model.providerID) const params = await Plugin.trigger( @@ -705,6 +710,7 @@ export namespace SessionPrompt { sessionID: string tools?: Record processor: SessionProcessor.Info + userInvokedAgents: string[] }) { const tools: Record = {} const enabledTools = pipe( @@ -736,7 +742,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) => { const match = input.processor.partFromToolCall(options.toolCallId) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2de1421ea19..a1ecb67c47d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -40,23 +40,27 @@ export const TaskTool = Tool.define("task", async () => { if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) const calling = await Agent.get(ctx.agent) if (calling) { - const perm = Wildcard.all(params.subagent_type, calling.permission.task ?? {}) - if (perm === "deny") { - throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) - } - if (perm === "ask") { - await Permission.ask({ - type: "task", - title: `Invoke subagent: ${params.subagent_type}`, - pattern: params.subagent_type, - callID: ctx.callID, - sessionID: ctx.sessionID, - messageID: ctx.messageID, - metadata: { - subagent: params.subagent_type, - description: params.description, - }, - }) + const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[] + // Skip permission check if user explicitly invoked this agent via @ autocomplete + if (!userInvokedAgents.includes(params.subagent_type)) { + const perm = Wildcard.all(params.subagent_type, calling.permission.task ?? {}) + if (perm === "deny") { + throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) + } + if (perm === "ask") { + await Permission.ask({ + type: "task", + title: `Invoke subagent: ${params.subagent_type}`, + pattern: params.subagent_type, + callID: ctx.callID, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + metadata: { + subagent: params.subagent_type, + description: params.description, + }, + }) + } } } const session = await iife(async () => { From c42bd93a3c14575d30ac597d72415fc49a387003 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 12 Dec 2025 18:28:23 +0000 Subject: [PATCH 10/18] improve: added quick note signifying that the subagent call is user invoked. --- packages/opencode/src/session/prompt.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 700cd130944..04ac4e619e1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1098,6 +1098,8 @@ export namespace SessionPrompt { } if (part.type === "agent") { + const perm = Wildcard.all(part.name, agent.permission.task ?? {}) + const hint = perm === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { id: Identifier.ascending("part"), @@ -1112,8 +1114,11 @@ export namespace SessionPrompt { type: "text", synthetic: true, text: - "Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name, + // An extra space is added here. Otherwise the 'Use' gets appended + // to user's last word; making a combined word + " Use the above message and context to generate a prompt and call the task tool with subagent: " + + part.name + + hint, }, ] } From 3d290b8e3403319ecec26ef80a079ce621a08f8b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 16 Dec 2025 11:06:43 +0000 Subject: [PATCH 11/18] refactor: rename visible to hidden for subagent config Align with dev branch naming convention. The semantic change is: - visible: false -> hidden: true - Default behavior unchanged (agents are not hidden by default) --- packages/opencode/src/agent/agent.ts | 6 +++--- .../cli/cmd/tui/component/prompt/autocomplete.tsx | 2 +- packages/opencode/src/config/config.ts | 4 ++-- packages/opencode/test/permission-task.test.ts | 12 ++++++------ packages/sdk/js/src/v2/gen/types.gen.ts | 6 +++--- packages/web/src/content/docs/agents.mdx | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2ebf4aa043e..4728b738a61 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -17,7 +17,7 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - visible: z.boolean().optional(), + hidden: z.boolean().optional(), permission: z.object({ edit: Config.Permission, bash: z.record(z.string(), Config.Permission), @@ -197,7 +197,7 @@ export namespace Agent { permission, color, maxSteps, - visible, + hidden, ...extra } = value item.options = { @@ -220,7 +220,7 @@ export namespace Agent { if (top_p != undefined) item.topP = top_p if (mode) item.mode = mode if (color) item.color = color - if (visible != undefined) item.visible = visible + if (hidden != undefined) item.hidden = hidden // just here for consistency & to prevent it from being added as an option if (name) item.name = name if (maxSteps != undefined) item.maxSteps = maxSteps diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index f11a3d0db1f..4f09d857f09 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -184,7 +184,7 @@ export function Autocomplete(props: { const agents = createMemo(() => { return sync.data.agent .filter((agent) => !agent.builtIn && agent.mode !== "primary") - .filter((agent) => agent.visible !== false) + .filter((agent) => !agent.hidden) .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9a721deb962..f36cf6e25bf 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -392,10 +392,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(), - visible: z + hidden: z .boolean() .optional() - .describe("Whether this subagent appears in the agent menu (default: true, only applies to mode: subagent)"), + .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 6f46413f70b..06453190edb 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -97,10 +97,10 @@ describe("filterSubagents - permission.task filtering", () => { expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) }) - test("visible: false does not affect filtering (visible only affects autocomplete)", () => { + test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => { const agents = [ - { name: "general", mode: "subagent", visible: false }, - { name: "code-reviewer", mode: "subagent", visible: true }, + { name: "general", mode: "subagent", hidden: true }, + { name: "code-reviewer", mode: "subagent", hidden: false }, { name: "orchestrator", mode: "subagent" }, ] as Agent.Info[] @@ -109,10 +109,10 @@ describe("filterSubagents - permission.task filtering", () => { expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"]) }) - test("visible: false agents can be filtered by permission.task deny", () => { + test("hidden: true agents can be filtered by permission.task deny", () => { const agents = [ - { name: "general", mode: "subagent", visible: false }, - { name: "orchestrator-coder", mode: "subagent", visible: false }, + { name: "general", mode: "subagent", hidden: true }, + { name: "orchestrator-coder", mode: "subagent", hidden: true }, ] as Agent.Info[] const result = filterSubagents(agents, { general: "deny" }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 60eff229b89..b71a0f8102b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1117,9 +1117,9 @@ export type AgentConfig = { description?: string mode?: "subagent" | "primary" | "all" /** - * Whether this subagent appears in the agent menu (default: true, only applies to mode: subagent) + * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) */ - visible?: boolean + hidden?: boolean /** * Hex color code for the agent (e.g., #FF5733) */ @@ -1748,7 +1748,7 @@ export type Agent = { topP?: number temperature?: number color?: string - visible?: boolean + hidden?: boolean permission: { edit: "ask" | "allow" | "deny" bash: { diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index d34eb0b2a5c..c09cf8a7d73 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -531,16 +531,16 @@ The `mode` option can be set to `primary`, `subagent`, or `all`. If no `mode` is --- -### Visible +### Hidden -Hide a subagent from the `@` autocomplete with `visible: false`. Useful for internal subagents that should only be invoked by other agents via the Task tool. +Hide a subagent from the `@` autocomplete with `hidden: true`. Useful for internal subagents that should only be invoked by other agents via the Task tool. ```json title="opencode.json" { "agent": { "internal-helper": { "mode": "subagent", - "visible": false + "hidden": true } } } From 3eb358fe08dcd9cdfff9dd567118ca26e0c17266 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 2 Jan 2026 20:53:32 +0000 Subject: [PATCH 12/18] feat: allow hiding subagents and controlling task invocation permissions --- packages/opencode/src/agent/agent.ts | 1 + packages/opencode/src/config/config.ts | 5 + packages/opencode/src/session/prompt.ts | 40 +++- packages/opencode/src/tool/task.ts | 30 ++- .../opencode/test/permission-task.test.ts | 203 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 + packages/web/src/content/docs/agents.mdx | 52 +++++ 7 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/permission-task.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index db49b0f4fc5..03b7c8484d1 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -181,6 +181,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.options?.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 5d95814d7b0..11366d98a3f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -437,6 +437,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() @@ -461,6 +465,7 @@ export namespace Config { "temperature", "top_p", "mode", + "hidden", "color", "steps", "maxSteps", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d4fef6f7a1c..8fa8a9709b4 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" @@ -540,12 +540,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) { @@ -615,6 +623,7 @@ export namespace SessionPrompt { session: Session.Info tools?: Record processor: SessionProcessor.Info + userInvokedAgents: string[] }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -624,7 +633,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) @@ -767,6 +776,23 @@ 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) + 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 } @@ -1004,6 +1030,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 === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { id: Identifier.ascending("part"), @@ -1017,9 +1046,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 112edc3dc88..b875a5720e0 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) !== "deny") +} export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) @@ -30,15 +37,20 @@ export const TaskTool = Tool.define("task", async () => { }), async execute(params, ctx) { const config = await Config.get() - await ctx.ask({ - permission: "task", - patterns: [params.subagent_type], - always: ["*"], - metadata: { - description: params.description, - subagent_type: params.subagent_type, - }, - }) + const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[] + + // Skip permission check if user explicitly invoked this agent via @ autocomplete + if (!userInvokedAgents.includes(params.subagent_type)) { + await ctx.ask({ + permission: "task", + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) + } const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent 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..c188502f849 --- /dev/null +++ b/packages/opencode/test/permission-task.test.ts @@ -0,0 +1,203 @@ +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" + +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", [])).toBe("ask") + }) + + test("returns deny for explicit deny", () => { + const ruleset = createRuleset({ "code-reviewer": "deny" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset)).toBe("deny") + }) + + test("returns allow for explicit allow", () => { + const ruleset = createRuleset({ "code-reviewer": "allow" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset)).toBe("allow") + }) + + test("returns ask for explicit ask", () => { + const ruleset = createRuleset({ "code-reviewer": "ask" }) + expect(PermissionNext.evaluate("task", "code-reviewer", ruleset)).toBe("ask") + }) + + test("matches wildcard patterns with deny", () => { + const ruleset = createRuleset({ "orchestrator-*": "deny" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset)).toBe("deny") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("deny") + expect(PermissionNext.evaluate("task", "general", ruleset)).toBe("ask") + }) + + test("matches wildcard patterns with allow", () => { + const ruleset = createRuleset({ "orchestrator-*": "allow" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset)).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("allow") + }) + + test("matches wildcard patterns with ask", () => { + const ruleset = createRuleset({ "orchestrator-*": "ask" }) + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset)).toBe("ask") + const globalRuleset = createRuleset({ "*": "ask" }) + expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset)).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)).toBe("allow") + expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("deny") + }) + + test("matches global wildcard", () => { + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" }))).toBe("allow") + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" }))).toBe("deny") + expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" }))).toBe("ask") + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 10764bebee8..4c3736e6ba2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1239,6 +1239,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 1922fece77f..ddc3d9ef73e 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -510,6 +510,58 @@ 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 +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. From 2798b2fdacbaf46f5a33e1c4d9fb24504f32ff33 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 2 Jan 2026 21:25:10 +0000 Subject: [PATCH 13/18] fix: treat slash command subagents as user-invoked to bypass permission checks --- packages/opencode/src/session/prompt.ts | 2 ++ packages/opencode/src/tool/task.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8fa8a9709b4..11ec11cb2a2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -380,6 +380,8 @@ export namespace SessionPrompt { messageID: assistantMessage.id, sessionID: sessionID, abort, + callID: part.callID, + extra: { userInvokedAgents: [task.agent] }, async metadata(input) { await Session.updatePart({ ...part, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b875a5720e0..ffad669927a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -39,7 +39,7 @@ export const TaskTool = Tool.define("task", async () => { const config = await Config.get() const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[] - // Skip permission check if user explicitly invoked this agent via @ autocomplete + // Skip permission check if user explicitly invoked this agent via @ autocomplete or slash command if (!userInvokedAgents.includes(params.subagent_type)) { await ctx.ask({ permission: "task", From 932b4576813f6ebedf7bcd1d24cbc6c136eed482 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 04:38:15 +0000 Subject: [PATCH 14/18] fix: include task tool when subagent-specific patterns are allowed The task tool was incorrectly disabled when using permission rules like: task: "orchestrator-*": "allow" "*": "deny" The disabled() function was checking evaluate("task", "*", ruleset) which matched the global deny rule, causing the tool to be removed even though specific subagent patterns were allowed. Changes: - PermissionNext.disabled() now special-cases task tool to check if ANY rule is non-deny before disabling - prompt.ts removes task tool only if no subagents pass the filter - Fixed pre-existing bugs where evaluate() result (Rule object) was compared to string instead of checking .action property --- packages/opencode/src/permission/next.ts | 15 ++++ packages/opencode/src/session/prompt.ts | 26 +++--- packages/opencode/src/tool/task.ts | 2 +- .../opencode/test/permission-task.test.ts | 88 +++++++++++++++---- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 6d18caefb38..b3661d27f10 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -232,6 +232,21 @@ export namespace PermissionNext { const result = new Set() for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + + // Special case: task tool - only disable if ALL task rules are deny + // If any rule is non-deny (allow/ask), some subagents may be usable + if (permission === "task") { + const taskRules = ruleset.filter((r) => Wildcard.match("task", r.permission)) + // If no task rules, default is "ask" - keep tool enabled + // If any rule is non-deny, some subagents may be usable - keep tool enabled + if (taskRules.length === 0 || taskRules.some((r) => r.action !== "deny")) { + continue + } + // All task rules are deny - disable the tool + result.add(tool) + continue + } + if (evaluate(permission, "*", ruleset).action === "deny") { result.add(tool) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9aa573c4385..2a3d9786db7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -805,15 +805,21 @@ export namespace SessionPrompt { if (tools.task) { const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) const filtered = filterSubagents(all, input.agent.permission) - 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, + + // 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, + } } } @@ -1128,7 +1134,7 @@ 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 === "deny" ? " . Invoked by user; guaranteed to exist." : "" + const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" return [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ffad669927a..75278ea6889 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -15,7 +15,7 @@ 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) !== "deny") + return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny") } export const TaskTool = Tool.define("task", async () => { diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index c188502f849..c53b82115ea 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -148,42 +148,42 @@ describe("PermissionNext.evaluate for permission.task", () => { })) test("returns ask when no match (default)", () => { - expect(PermissionNext.evaluate("task", "code-reviewer", [])).toBe("ask") + 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)).toBe("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)).toBe("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)).toBe("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)).toBe("deny") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("deny") - expect(PermissionNext.evaluate("task", "general", ruleset)).toBe("ask") + 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)).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("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)).toBe("ask") + expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask") const globalRuleset = createRuleset({ "*": "ask" }) - expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset)).toBe("ask") + expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask") }) test("later rules take precedence (last match wins)", () => { @@ -191,13 +191,69 @@ describe("PermissionNext.evaluate for permission.task", () => { "orchestrator-*": "deny", "orchestrator-fast": "allow", }) - expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset)).toBe("allow") - expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset)).toBe("deny") + 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" }))).toBe("allow") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" }))).toBe("deny") - expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" }))).toBe("ask") + 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", () => { + const createRuleset = (rules: Record): PermissionNext.Ruleset => + Object.entries(rules).map(([pattern, action]) => ({ + permission: "task", + pattern, + action, + })) + + test("task tool is enabled when specific subagent patterns are allowed with global deny", () => { + const ruleset = createRuleset({ + "orchestrator-*": "allow", + "*": "deny", + }) + const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset) + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is enabled when specific subagent patterns have ask permission", () => { + const ruleset = createRuleset({ + "orchestrator-*": "ask", + "*": "deny", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(false) + }) + + test("task tool is disabled when all rules are deny", () => { + const ruleset = createRuleset({ "*": "deny" }) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }) + + test("task tool is disabled when all explicit rules are deny", () => { + const ruleset = createRuleset({ + "orchestrator-*": "deny", + general: "deny", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(true) + }) + + 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 enabled with mixed allow and deny rules", () => { + const ruleset = createRuleset({ + "*": "deny", + "orchestrator-coder": "allow", + }) + const disabled = PermissionNext.disabled(["task"], ruleset) + expect(disabled.has("task")).toBe(false) }) }) From 1063b13db4db59f811396e11c154c36ae7ef57a0 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 04:50:13 +0000 Subject: [PATCH 15/18] docs: clarify that last matching rule wins for task permissions --- packages/web/src/content/docs/agents.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 914047c591e..3ad3fa5e040 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -556,6 +556,10 @@ Control which subagents an agent can invoke via the Task tool with `permission.t 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. ::: From dff1c6d9b5b716b62c37ec46b88f75f024ed01b0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 00:04:05 -0600 Subject: [PATCH 16/18] rm todo --- packages/opencode/src/permission/next.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 692a99dffc0..f95aaf34525 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -233,24 +233,6 @@ export namespace PermissionNext { for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - // TODO: figure out how to blend this correctly - // Special case: task tool - only disable if ALL task rules are deny - // If any rule is non-deny (allow/ask), some subagents may be usable - // if (permission === "task") { - // const taskRules = ruleset.filter((r) => Wildcard.match("task", r.permission)) - // // If no task rules, default is "ask" - keep tool enabled - // // If any rule is non-deny, some subagents may be usable - keep tool enabled - // if (taskRules.length === 0 || taskRules.some((r) => r.action !== "deny")) { - // continue - // } - // // All task rules are deny - disable the tool - // result.add(tool) - // continue - // } - - // if (evaluate(permission, "*", ruleset).action === "deny") { - // result.add(tool) - // } const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission)) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) From 7a46f448294f59e348c011ab0962a65a930173ff Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 22:05:24 -0600 Subject: [PATCH 17/18] clean --- packages/opencode/src/session/prompt.ts | 5 ++--- packages/opencode/src/tool/task.ts | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f78320bbbbb..09155c86e7d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -382,9 +382,8 @@ export namespace SessionPrompt { messageID: assistantMessage.id, sessionID: sessionID, abort, -// callID: part.callID, -// extra: { userInvokedAgents: [task.agent] }, - extra: { bypassAgentCheck: true }, + callID: part.callID, + extra: { userInvokedAgents: [task.agent] }, async metadata(input) { await Session.updatePart({ ...part, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 52284994864..a30a5a67502 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -37,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], From 2c670a67a52c6754ec879e4d8d4350598e40ecd8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 22:23:26 -0600 Subject: [PATCH 18/18] test: fix --- .../opencode/test/permission-task.test.ts | 216 +++++++++++++++++- 1 file changed, 208 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index c53b82115ea..21a039d12a6 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,9 @@ 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 => @@ -203,6 +206,9 @@ describe("PermissionNext.evaluate for permission.task", () => { }) 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", @@ -210,37 +216,44 @@ describe("PermissionNext.disabled for task tool", () => { action, })) - test("task tool is enabled when specific subagent patterns are allowed with global deny", () => { + 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) - expect(disabled.has("task")).toBe(false) + // The task tool IS disabled because there's a pattern: "*" with action: "deny" + expect(disabled.has("task")).toBe(true) }) - test("task tool is enabled when specific subagent patterns have ask permission", () => { + 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) - expect(disabled.has("task")).toBe(false) + // 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 all rules are deny", () => { + 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 disabled when all explicit rules are deny", () => { + 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) - expect(disabled.has("task")).toBe(true) + // 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)", () => { @@ -248,12 +261,199 @@ describe("PermissionNext.disabled for task tool", () => { expect(disabled.has("task")).toBe(false) }) - test("task tool is enabled with mixed allow and deny rules", () => { + 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) + }, + }) + }) +})