Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit 34c9d10

Browse files
refactor: simplify task tool subagent filtering (anomalyco#7165)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent fe57d7b commit 34c9d10

3 files changed

Lines changed: 36 additions & 198 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { SessionSummary } from "./summary"
3737
import { NamedError } from "@opencode-ai/util/error"
3838
import { fn } from "@/util/fn"
3939
import { SessionProcessor } from "./processor"
40-
import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task"
40+
import { TaskTool } from "@/tool/task"
4141
import { Tool } from "@/tool/tool"
4242
import { PermissionNext } from "@/permission/next"
4343
import { SessionStatus } from "./status"
@@ -383,7 +383,7 @@ export namespace SessionPrompt {
383383
sessionID: sessionID,
384384
abort,
385385
callID: part.callID,
386-
extra: { userInvokedAgents: [task.agent] },
386+
extra: { bypassAgentCheck: true },
387387
async metadata(input) {
388388
await Session.updatePart({
389389
...part,
@@ -545,19 +545,17 @@ export namespace SessionPrompt {
545545
abort,
546546
})
547547

548-
// Track agents explicitly invoked by user via @ autocomplete
549-
const userInvokedAgents = msgs
550-
.filter((m) => m.info.role === "user")
551-
.flatMap((m) => m.parts.filter((p) => p.type === "agent") as MessageV2.AgentPart[])
552-
.map((p) => p.name)
548+
// Check if user explicitly invoked an agent via @ in this turn
549+
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
550+
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
553551

554552
const tools = await resolveTools({
555553
agent,
556554
session,
557555
model,
558556
tools: lastUser.tools,
559557
processor,
560-
userInvokedAgents,
558+
bypassAgentCheck,
561559
})
562560

563561
if (step === 1) {
@@ -646,7 +644,7 @@ export namespace SessionPrompt {
646644
session: Session.Info
647645
tools?: Record<string, boolean>
648646
processor: SessionProcessor.Info
649-
userInvokedAgents: string[]
647+
bypassAgentCheck: boolean
650648
}) {
651649
using _ = log.time("resolveTools")
652650
const tools: Record<string, AITool> = {}
@@ -656,7 +654,7 @@ export namespace SessionPrompt {
656654
abort: options.abortSignal!,
657655
messageID: input.processor.message.id,
658656
callID: options.toolCallId,
659-
extra: { model: input.model, userInvokedAgents: input.userInvokedAgents },
657+
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
660658
agent: input.agent.name,
661659
metadata: async (val: { title?: string; metadata?: any }) => {
662660
const match = input.processor.partFromToolCall(options.toolCallId)
@@ -800,28 +798,6 @@ export namespace SessionPrompt {
800798
tools[key] = item
801799
}
802800

803-
// Regenerate task tool description with filtered subagents
804-
if (tools.task) {
805-
const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
806-
const filtered = filterSubagents(all, input.agent.permission)
807-
808-
// If no subagents are permitted, remove the task tool entirely
809-
if (filtered.length === 0) {
810-
delete tools.task
811-
} else {
812-
const description = TASK_DESCRIPTION.replace(
813-
"{agents}",
814-
filtered
815-
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
816-
.join("\n"),
817-
)
818-
tools.task = {
819-
...tools.task,
820-
description,
821-
}
822-
}
823-
}
824-
825801
return tools
826802
}
827803

packages/opencode/src/tool/task.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,35 +12,37 @@ import { defer } from "@/util/defer"
1212
import { Config } from "../config/config"
1313
import { PermissionNext } from "@/permission/next"
1414

15-
export { DESCRIPTION as TASK_DESCRIPTION }
16-
17-
export function filterSubagents(agents: Agent.Info[], ruleset: PermissionNext.Ruleset) {
18-
return agents.filter((a) => PermissionNext.evaluate("task", a.name, ruleset).action !== "deny")
19-
}
15+
const parameters = z.object({
16+
description: z.string().describe("A short (3-5 words) description of the task"),
17+
prompt: z.string().describe("The task for the agent to perform"),
18+
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
19+
session_id: z.string().describe("Existing Task session to continue").optional(),
20+
command: z.string().describe("The command that triggered this task").optional(),
21+
})
2022

21-
export const TaskTool = Tool.define("task", async () => {
23+
export const TaskTool = Tool.define("task", async (ctx) => {
2224
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
25+
26+
// Filter agents by permissions if agent provided
27+
const caller = ctx?.agent
28+
const accessibleAgents = caller
29+
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
30+
: agents
31+
2332
const description = DESCRIPTION.replace(
2433
"{agents}",
25-
agents
34+
accessibleAgents
2635
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
2736
.join("\n"),
2837
)
2938
return {
3039
description,
31-
parameters: z.object({
32-
description: z.string().describe("A short (3-5 words) description of the task"),
33-
prompt: z.string().describe("The task for the agent to perform"),
34-
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
35-
session_id: z.string().describe("Existing Task session to continue").optional(),
36-
command: z.string().describe("The command that triggered this task").optional(),
37-
}),
38-
async execute(params, ctx) {
40+
parameters,
41+
async execute(params: z.infer<typeof parameters>, ctx) {
3942
const config = await Config.get()
4043

41-
const userInvokedAgents = (ctx.extra?.userInvokedAgents ?? []) as string[]
42-
// Skip permission check when invoked from a command subtask (user already approved by invoking the command)
43-
if (!ctx.extra?.bypassAgentCheck && !userInvokedAgents.includes(params.subagent_type)) {
44+
// Skip permission check when user explicitly invoked via @ or command subtask
45+
if (!ctx.extra?.bypassAgentCheck) {
4446
await ctx.ask({
4547
permission: "task",
4648
patterns: [params.subagent_type],

packages/opencode/test/permission-task.test.ts

Lines changed: 8 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,9 @@
11
import { describe, test, expect } from "bun:test"
2-
import type { Agent } from "../src/agent/agent"
3-
import { filterSubagents } from "../src/tool/task"
42
import { PermissionNext } from "../src/permission/next"
53
import { Config } from "../src/config/config"
64
import { Instance } from "../src/project/instance"
75
import { tmpdir } from "./fixture/fixture"
86

9-
describe("filterSubagents - permission.task filtering", () => {
10-
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
11-
Object.entries(rules).map(([pattern, action]) => ({
12-
permission: "task",
13-
pattern,
14-
action,
15-
}))
16-
17-
const mockAgents = [
18-
{ name: "general", mode: "subagent", permission: [], options: {} },
19-
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
20-
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
21-
{ name: "orchestrator-slow", mode: "subagent", permission: [], options: {} },
22-
] as Agent.Info[]
23-
24-
test("returns all agents when permissions config is empty", () => {
25-
const result = filterSubagents(mockAgents, [])
26-
expect(result).toHaveLength(4)
27-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
28-
})
29-
30-
test("excludes agents with explicit deny", () => {
31-
const ruleset = createRuleset({ "code-reviewer": "deny" })
32-
const result = filterSubagents(mockAgents, ruleset)
33-
expect(result).toHaveLength(3)
34-
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"])
35-
})
36-
37-
test("includes agents with explicit allow", () => {
38-
const ruleset = createRuleset({
39-
"code-reviewer": "allow",
40-
general: "deny",
41-
})
42-
const result = filterSubagents(mockAgents, ruleset)
43-
expect(result).toHaveLength(3)
44-
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
45-
})
46-
47-
test("includes agents with ask permission (user approval is runtime behavior)", () => {
48-
const ruleset = createRuleset({
49-
"code-reviewer": "ask",
50-
general: "deny",
51-
})
52-
const result = filterSubagents(mockAgents, ruleset)
53-
expect(result).toHaveLength(3)
54-
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
55-
})
56-
57-
test("includes agents with undefined permission (default allow)", () => {
58-
const ruleset = createRuleset({
59-
general: "deny",
60-
})
61-
const result = filterSubagents(mockAgents, ruleset)
62-
expect(result).toHaveLength(3)
63-
expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"])
64-
})
65-
66-
test("supports wildcard patterns with deny", () => {
67-
const ruleset = createRuleset({ "orchestrator-*": "deny" })
68-
const result = filterSubagents(mockAgents, ruleset)
69-
expect(result).toHaveLength(2)
70-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
71-
})
72-
73-
test("supports wildcard patterns with allow", () => {
74-
const ruleset = createRuleset({
75-
"*": "allow",
76-
"orchestrator-fast": "deny",
77-
})
78-
const result = filterSubagents(mockAgents, ruleset)
79-
expect(result).toHaveLength(3)
80-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"])
81-
})
82-
83-
test("supports wildcard patterns with ask", () => {
84-
const ruleset = createRuleset({
85-
"orchestrator-*": "ask",
86-
})
87-
const result = filterSubagents(mockAgents, ruleset)
88-
expect(result).toHaveLength(4)
89-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"])
90-
})
91-
92-
test("longer pattern takes precedence over shorter pattern", () => {
93-
const ruleset = createRuleset({
94-
"orchestrator-*": "deny",
95-
"orchestrator-fast": "allow",
96-
})
97-
const result = filterSubagents(mockAgents, ruleset)
98-
expect(result).toHaveLength(3)
99-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
100-
})
101-
102-
test("edge case: all agents denied", () => {
103-
const ruleset = createRuleset({ "*": "deny" })
104-
const result = filterSubagents(mockAgents, ruleset)
105-
expect(result).toHaveLength(0)
106-
expect(result).toEqual([])
107-
})
108-
109-
test("edge case: mixed patterns with multiple wildcards", () => {
110-
const ruleset = createRuleset({
111-
"*": "ask",
112-
"orchestrator-*": "deny",
113-
"orchestrator-fast": "allow",
114-
})
115-
const result = filterSubagents(mockAgents, ruleset)
116-
expect(result).toHaveLength(3)
117-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"])
118-
})
119-
120-
test("hidden: true does not affect filtering (hidden only affects autocomplete)", () => {
121-
const agents = [
122-
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
123-
{ name: "code-reviewer", mode: "subagent", hidden: false, permission: [], options: {} },
124-
{ name: "orchestrator", mode: "subagent", permission: [], options: {} },
125-
] as Agent.Info[]
126-
127-
const result = filterSubagents(agents, [])
128-
expect(result).toHaveLength(3)
129-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator"])
130-
})
131-
132-
test("hidden: true agents can be filtered by permission.task deny", () => {
133-
const agents = [
134-
{ name: "general", mode: "subagent", hidden: true, permission: [], options: {} },
135-
{ name: "orchestrator-coder", mode: "subagent", hidden: true, permission: [], options: {} },
136-
] as Agent.Info[]
137-
138-
const ruleset = createRuleset({ general: "deny" })
139-
const result = filterSubagents(agents, ruleset)
140-
expect(result).toHaveLength(1)
141-
expect(result.map((a) => a.name)).toEqual(["orchestrator-coder"])
142-
})
143-
})
144-
1457
describe("PermissionNext.evaluate for permission.task", () => {
1468
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
1479
Object.entries(rules).map(([pattern, action]) => ({
@@ -277,12 +139,6 @@ describe("PermissionNext.disabled for task tool", () => {
277139

278140
// Integration tests that load permissions from real config files
279141
describe("permission.task with real config files", () => {
280-
const mockAgents = [
281-
{ name: "general", mode: "subagent", permission: [], options: {} },
282-
{ name: "code-reviewer", mode: "subagent", permission: [], options: {} },
283-
{ name: "orchestrator-fast", mode: "subagent", permission: [], options: {} },
284-
] as Agent.Info[]
285-
286142
test("loads task permissions from opencode.json config", async () => {
287143
await using tmp = await tmpdir({
288144
git: true,
@@ -300,8 +156,10 @@ describe("permission.task with real config files", () => {
300156
fn: async () => {
301157
const config = await Config.get()
302158
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
303-
const result = filterSubagents(mockAgents, ruleset)
304-
expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast"])
159+
// general and orchestrator-fast should be allowed, code-reviewer denied
160+
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
161+
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
162+
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
305163
},
306164
})
307165
})
@@ -323,8 +181,10 @@ describe("permission.task with real config files", () => {
323181
fn: async () => {
324182
const config = await Config.get()
325183
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
326-
const result = filterSubagents(mockAgents, ruleset)
327-
expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"])
184+
// general and code-reviewer should be ask, orchestrator-* denied
185+
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
186+
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
187+
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
328188
},
329189
})
330190
})

0 commit comments

Comments
 (0)