Skip to content

Commit ff5ab1f

Browse files
NamedIdentityclaude
andcommitted
feat(task): add subagent-to-subagent delegation with persistent sessions
Adds support for subagents to delegate tasks to other subagents with configurable call budgets and session persistence. This enables: 1. Subagent-to-subagent task delegation via Task tool 2. Session persistence across multiple task calls 3. Per-session budget tracking to prevent infinite loops 4. Permission-based delegation control Key Changes: - config.ts: Add task_budget field to agent schema - agent.ts: Add task_budget to Agent.Info type and config mapping - task.ts: Implement budget tracking, session ownership checks, and bypassAgentCheck fix for subagent-to-subagent delegation - Add task-delegation.test.ts with 6 unit tests (all passing) Configuration: - Set task_budget > 0 on agents that should delegate - Use permission.task rules for granular delegation control - Remove tools: { task: true } from agent frontmatter Security: - Default behavior unchanged (delegation disabled) - Budget system provides hard stop for runaway delegation - Permission checks always enforced for subagent-to-subagent calls Tests: 6/6 passing Typecheck: Clean Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 71cd599 commit ff5ab1f

File tree

4 files changed

+294
-28
lines changed

4 files changed

+294
-28
lines changed

packages/opencode/src/agent/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export namespace Agent {
3939
prompt: z.string().optional(),
4040
options: z.record(z.string(), z.any()),
4141
steps: z.number().int().positive().optional(),
42+
task_budget: z.number().int().nonnegative().optional(),
4243
})
4344
.meta({
4445
ref: "Agent",
@@ -220,6 +221,7 @@ export namespace Agent {
220221
item.hidden = value.hidden ?? item.hidden
221222
item.name = value.name ?? item.name
222223
item.steps = value.steps ?? item.steps
224+
item.task_budget = value.task_budget ?? item.task_budget
223225
item.options = mergeDeep(item.options, value.options ?? {})
224226
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
225227
}

packages/opencode/src/config/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,14 @@ export namespace Config {
573573
.boolean()
574574
.optional()
575575
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
576+
task_budget: z
577+
.number()
578+
.int()
579+
.nonnegative()
580+
.optional()
581+
.describe(
582+
"Maximum task calls this agent can make per session when delegating to other subagents. Set to 0 to explicitly disable, omit to use default (disabled).",
583+
),
576584
options: z.record(z.string(), z.any()).optional(),
577585
color: z
578586
.string()
@@ -599,6 +607,7 @@ export namespace Config {
599607
"top_p",
600608
"mode",
601609
"hidden",
610+
"task_budget",
602611
"color",
603612
"steps",
604613
"maxSteps",

packages/opencode/src/tool/task.ts

Lines changed: 91 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ import { iife } from "@/util/iife"
1111
import { defer } from "@/util/defer"
1212
import { Config } from "../config/config"
1313
import { PermissionNext } from "@/permission/next"
14+
import { Instance } from "../project/instance"
15+
16+
// Track task calls per session: Map<sessionID, count>
17+
// Budget is per-session (all calls within the delegated work count toward the limit)
18+
// Note: State grows with sessions but entries are small. Future optimization:
19+
// clean up completed sessions via Session lifecycle hooks if memory becomes a concern.
20+
const taskCallState = Instance.state(() => new Map<string, number>())
21+
22+
function getCallCount(sessionID: string): number {
23+
return taskCallState().get(sessionID) ?? 0
24+
}
25+
26+
function incrementCallCount(sessionID: string): number {
27+
const state = taskCallState()
28+
const newCount = (state.get(sessionID) ?? 0) + 1
29+
state.set(sessionID, newCount)
30+
return newCount
31+
}
1432

1533
const parameters = z.object({
1634
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -41,8 +59,13 @@ export const TaskTool = Tool.define("task", async (ctx) => {
4159
async execute(params: z.infer<typeof parameters>, ctx) {
4260
const config = await Config.get()
4361

62+
// Get caller's session to check if this is a subagent calling
63+
const callerSession = await Session.get(ctx.sessionID)
64+
const isSubagent = callerSession.parentID !== undefined
65+
4466
// Skip permission check when user explicitly invoked via @ or command subtask
45-
if (!ctx.extra?.bypassAgentCheck) {
67+
// BUT: always check permissions for subagent-to-subagent delegation
68+
if (!ctx.extra?.bypassAgentCheck || isSubagent) {
4669
await ctx.ask({
4770
permission: "task",
4871
patterns: [params.subagent_type],
@@ -54,40 +77,79 @@ export const TaskTool = Tool.define("task", async (ctx) => {
5477
})
5578
}
5679

57-
const agent = await Agent.get(params.subagent_type)
58-
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
80+
const targetAgent = await Agent.get(params.subagent_type)
81+
if (!targetAgent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
82+
83+
// Get caller agent info for budget check (ctx.agent is just the name)
84+
const callerAgentInfo = ctx.agent ? await Agent.get(ctx.agent) : undefined
85+
86+
// Get config values:
87+
// - task_budget on CALLER: how many calls the caller can make per session
88+
const callerTaskBudget = callerAgentInfo?.task_budget ?? 0
89+
90+
// Get target's task_budget once (used for session permissions and tool availability)
91+
const targetTaskBudget = targetAgent.task_budget ?? 0
92+
93+
// Check session ownership BEFORE incrementing budget (if session_id provided)
94+
// This prevents "wasting" budget on invalid session resume attempts
95+
if (isSubagent && params.session_id) {
96+
const existingSession = await Session.get(params.session_id).catch(() => undefined)
97+
if (existingSession && existingSession.parentID !== ctx.sessionID) {
98+
throw new Error(
99+
`Cannot resume session: not a child of caller session. ` +
100+
`Session "${params.session_id}" is not owned by this caller.`,
101+
)
102+
}
103+
}
104+
105+
// Enforce nested delegation controls only for subagent-to-subagent calls
106+
if (isSubagent) {
107+
// Check 1: Caller must have task_budget configured
108+
if (callerTaskBudget <= 0) {
109+
throw new Error(
110+
`Caller has no task budget configured. ` +
111+
`Set task_budget > 0 on the calling agent to enable nested delegation.`,
112+
)
113+
}
59114

60-
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
115+
// Check 2: Budget not exhausted for this session
116+
const currentCount = getCallCount(ctx.sessionID)
117+
if (currentCount >= callerTaskBudget) {
118+
throw new Error(
119+
`Task budget exhausted (${currentCount}/${callerTaskBudget} calls). ` +
120+
`Return control to caller to continue.`,
121+
)
122+
}
123+
124+
// Increment count after passing all checks (including ownership above)
125+
incrementCallCount(ctx.sessionID)
126+
}
61127

62128
const session = await iife(async () => {
63129
if (params.session_id) {
64130
const found = await Session.get(params.session_id).catch(() => {})
65-
if (found) return found
131+
if (found) {
132+
// Ownership already verified above for subagents
133+
return found
134+
}
135+
}
136+
137+
// Build session permissions
138+
const sessionPermissions: PermissionNext.Rule[] = [
139+
{ permission: "todowrite", pattern: "*", action: "deny" },
140+
{ permission: "todoread", pattern: "*", action: "deny" },
141+
]
142+
143+
// Only deny task if target agent has no task_budget (cannot delegate further)
144+
if (targetTaskBudget <= 0) {
145+
sessionPermissions.push({ permission: "task", pattern: "*", action: "deny" })
66146
}
67147

68148
return await Session.create({
69149
parentID: ctx.sessionID,
70-
title: params.description + ` (@${agent.name} subagent)`,
150+
title: params.description + ` (@${targetAgent.name} subagent)`,
71151
permission: [
72-
{
73-
permission: "todowrite",
74-
pattern: "*",
75-
action: "deny",
76-
},
77-
{
78-
permission: "todoread",
79-
pattern: "*",
80-
action: "deny",
81-
},
82-
...(hasTaskPermission
83-
? []
84-
: [
85-
{
86-
permission: "task" as const,
87-
pattern: "*" as const,
88-
action: "deny" as const,
89-
},
90-
]),
152+
...sessionPermissions,
91153
...(config.experimental?.primary_tools?.map((t) => ({
92154
pattern: "*",
93155
action: "allow" as const,
@@ -130,7 +192,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
130192
})
131193
})
132194

133-
const model = agent.model ?? {
195+
const model = targetAgent.model ?? {
134196
modelID: msg.info.modelID,
135197
providerID: msg.info.providerID,
136198
}
@@ -149,11 +211,12 @@ export const TaskTool = Tool.define("task", async (ctx) => {
149211
modelID: model.modelID,
150212
providerID: model.providerID,
151213
},
152-
agent: agent.name,
214+
agent: targetAgent.name,
153215
tools: {
154216
todowrite: false,
155217
todoread: false,
156-
...(hasTaskPermission ? {} : { task: false }),
218+
// Only disable task if target agent has no task_budget (cannot delegate further)
219+
...(targetTaskBudget <= 0 ? { task: false } : {}),
157220
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
158221
},
159222
parts: promptParts,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { Config } from "../src/config/config"
3+
import { Instance } from "../src/project/instance"
4+
import { Agent } from "../src/agent/agent"
5+
import { PermissionNext } from "../src/permission/next"
6+
import { tmpdir } from "./fixture/fixture"
7+
8+
describe("task_budget configuration (caller)", () => {
9+
test("task_budget is preserved from config", async () => {
10+
await using tmp = await tmpdir({
11+
git: true,
12+
config: {
13+
agent: {
14+
orchestrator: {
15+
description: "Agent with high task budget",
16+
mode: "subagent",
17+
task_budget: 20,
18+
},
19+
},
20+
},
21+
})
22+
await Instance.provide({
23+
directory: tmp.path,
24+
fn: async () => {
25+
const config = await Config.get()
26+
const agentConfig = config.agent?.["orchestrator"]
27+
expect(agentConfig?.task_budget).toBe(20)
28+
},
29+
})
30+
})
31+
32+
test("task_budget of 0 is preserved (disabled)", async () => {
33+
await using tmp = await tmpdir({
34+
git: true,
35+
config: {
36+
agent: {
37+
"disabled-agent": {
38+
description: "Agent with explicitly disabled budget",
39+
mode: "subagent",
40+
task_budget: 0,
41+
},
42+
},
43+
},
44+
})
45+
await Instance.provide({
46+
directory: tmp.path,
47+
fn: async () => {
48+
const config = await Config.get()
49+
const agentConfig = config.agent?.["disabled-agent"]
50+
expect(agentConfig?.task_budget).toBe(0)
51+
},
52+
})
53+
})
54+
55+
test("missing task_budget defaults to undefined (disabled)", async () => {
56+
await using tmp = await tmpdir({
57+
git: true,
58+
config: {
59+
agent: {
60+
"default-agent": {
61+
description: "Agent without task_budget",
62+
mode: "subagent",
63+
},
64+
},
65+
},
66+
})
67+
await Instance.provide({
68+
directory: tmp.path,
69+
fn: async () => {
70+
const config = await Config.get()
71+
const agentConfig = config.agent?.["default-agent"]
72+
expect(agentConfig?.task_budget).toBeUndefined()
73+
},
74+
})
75+
})
76+
})
77+
78+
describe("task_budget with permissions config", () => {
79+
test("task_budget with permission rules for selective delegation", async () => {
80+
await using tmp = await tmpdir({
81+
git: true,
82+
config: {
83+
agent: {
84+
orchestrator: {
85+
description: "Coordinates other subagents",
86+
mode: "subagent",
87+
task_budget: 20,
88+
permission: {
89+
task: {
90+
"*": "deny",
91+
"worker-a": "allow",
92+
"worker-b": "allow",
93+
},
94+
},
95+
},
96+
"worker-a": {
97+
description: "Worker with medium budget",
98+
mode: "subagent",
99+
task_budget: 3,
100+
permission: {
101+
task: {
102+
"*": "deny",
103+
"worker-b": "allow",
104+
},
105+
},
106+
},
107+
"worker-b": {
108+
description: "Worker with minimal budget",
109+
mode: "subagent",
110+
task_budget: 1,
111+
permission: {
112+
task: {
113+
"*": "deny",
114+
"worker-a": "allow",
115+
},
116+
},
117+
},
118+
},
119+
},
120+
})
121+
await Instance.provide({
122+
directory: tmp.path,
123+
fn: async () => {
124+
const config = await Config.get()
125+
126+
// Orchestrator: high budget
127+
const orchestratorConfig = config.agent?.["orchestrator"]
128+
expect(orchestratorConfig?.task_budget).toBe(20)
129+
130+
// Verify permission rules
131+
const orchestratorRuleset = PermissionNext.fromConfig(orchestratorConfig?.permission ?? {})
132+
expect(PermissionNext.evaluate("task", "worker-a", orchestratorRuleset).action).toBe("allow")
133+
expect(PermissionNext.evaluate("task", "worker-b", orchestratorRuleset).action).toBe("allow")
134+
expect(PermissionNext.evaluate("task", "orchestrator", orchestratorRuleset).action).toBe("deny")
135+
136+
// Worker-A: medium budget
137+
const workerAConfig = config.agent?.["worker-a"]
138+
expect(workerAConfig?.task_budget).toBe(3)
139+
140+
// Worker-B: minimal budget
141+
const workerBConfig = config.agent?.["worker-b"]
142+
expect(workerBConfig?.task_budget).toBe(1)
143+
},
144+
})
145+
})
146+
})
147+
148+
describe("backwards compatibility", () => {
149+
test("agent without delegation config has defaults (disabled)", async () => {
150+
await using tmp = await tmpdir({
151+
git: true,
152+
config: {
153+
agent: {
154+
"legacy-agent": {
155+
description: "Agent without delegation config",
156+
mode: "subagent",
157+
},
158+
},
159+
},
160+
})
161+
await Instance.provide({
162+
directory: tmp.path,
163+
fn: async () => {
164+
const config = await Config.get()
165+
const agentConfig = config.agent?.["legacy-agent"]
166+
167+
// Should be undefined/falsy = delegation disabled
168+
const taskBudget = (agentConfig?.task_budget as number) ?? 0
169+
170+
expect(taskBudget).toBe(0)
171+
},
172+
})
173+
})
174+
175+
test("built-in agents should not have delegation config by default", async () => {
176+
await using tmp = await tmpdir({
177+
git: true,
178+
})
179+
await Instance.provide({
180+
directory: tmp.path,
181+
fn: async () => {
182+
// Get the built-in general agent
183+
const generalAgent = await Agent.get("general")
184+
185+
// Built-in agents should not have delegation configured
186+
const taskBudget = generalAgent?.task_budget ?? 0
187+
188+
expect(taskBudget).toBe(0)
189+
},
190+
})
191+
})
192+
})

0 commit comments

Comments
 (0)