Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
765812c
feat: add configurable subagent visibility per agent
Sewer56 Nov 26, 2025
9de0068
docs: add subagents configuration documentation
Sewer56 Nov 26, 2025
b582b53
fix: TUI autocomplete now respects agent subagents visibility config
Sewer56 Nov 26, 2025
515e859
Merge remote-tracking branch 'origin/dev' into add-hide-subagents
Sewer56 Nov 26, 2025
3c7b3e5
Merge tag 'v1.0.115' into add-hide-subagents
Sewer56 Nov 26, 2025
eab8fc8
chore: format code
actions-user Nov 26, 2025
0d2a06c
Merge branch 'dev' into add-hide-subagents
Sewer56 Nov 29, 2025
1bbb7d0
Update Nix flake.lock and hashes
actions-user Nov 29, 2025
29cdde3
Merge branch 'dev' into add-hide-subagents
Sewer56 Nov 30, 2025
a7924bd
Update Nix flake.lock and hashes
actions-user Nov 30, 2025
1e5c116
Merge branch 'dev' into add-hide-subagents
Sewer56 Dec 3, 2025
d51e467
Merge branch 'dev' into add-hide-subagents
Sewer56 Dec 4, 2025
aaea27e
Merge branch 'dev' into add-hide-subagents
Sewer56 Dec 6, 2025
f3b40d7
Merge remote-tracking branch 'origin/dev' into add-hide-subagents
Sewer56 Dec 12, 2025
a52acd1
feat(opencode): add visible field and task permission to agents
Sewer56 Dec 12, 2025
58f8dce
fix: don't hide visible==false from system prompt.
Sewer56 Dec 12, 2025
cbf549f
Merge remote-tracking branch 'origin/dev' into add-hide-subagents
Sewer56 Dec 12, 2025
f353f08
improve: allow user to invoke agents even if they are denied
Sewer56 Dec 12, 2025
c42bd93
improve: added quick note signifying that the subagent call is user i…
Sewer56 Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -195,6 +197,7 @@ export namespace Agent {
permission,
color,
maxSteps,
visible,
...extra
} = value
item.options = {
Expand All @@ -217,6 +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
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (maxSteps != undefined) item.maxSteps = maxSteps
Expand Down Expand Up @@ -303,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ export function Autocomplete(props: {
)

const agents = createMemo(() => {
const agents = sync.data.agent
return agents
return sync.data.agent
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
.filter((agent) => agent.visible !== false)
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +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
.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")
Expand All @@ -410,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(),
})
Expand Down
36 changes: 32 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { Config } from "../config/config"
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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -705,6 +710,7 @@ export namespace SessionPrompt {
sessionID: string
tools?: Record<string, boolean>
processor: SessionProcessor.Info
userInvokedAgents: string[]
}) {
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -841,6 +847,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.task ?? {})
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
}

Expand Down Expand Up @@ -1075,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"),
Expand All @@ -1089,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,
},
]
}
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ 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"
import { Permission } from "../permission"

export { DESCRIPTION as TASK_DESCRIPTION }

export function filterSubagents(agents: Agent.Info[], permissions: Record<string, Config.Permission>) {
return agents.filter((a) => Wildcard.all(a.name, permissions) !== "deny")
}
import { Config } from "../config/config"

export const TaskTool = Tool.define("task", async () => {
Expand All @@ -30,6 +38,31 @@ 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) {
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 () => {
if (params.session_id) {
const found = await Session.get(params.session_id).catch(() => {})
Expand Down
Loading