Skip to content

Commit 375cab5

Browse files
committed
feat(task): add level_limit to prevent infinite delegation depth
Adds global level_limit configuration to cap subagent session tree depth. Complements existing task_budget (horizontal limit) with vertical depth limit for complete loop prevention. - Add level_limit to experimental config schema (default: 5) - Add getSessionDepth() helper to calculate session tree depth - Add depth check before task delegation (Check 3) - Add 3 unit tests for level_limit configuration - Regenerate SDK types with level_limit field Related to PR #7756 (subagent delegation)
1 parent 5724469 commit 375cab5

4 files changed

Lines changed: 117 additions & 37 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,15 @@ export namespace Config {
11641164
.positive()
11651165
.optional()
11661166
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
1167+
level_limit: z
1168+
.number()
1169+
.int()
1170+
.nonnegative()
1171+
.optional()
1172+
.describe(
1173+
"Maximum depth for subagent session trees. Prevents infinite delegation loops. " +
1174+
"Default: 5. Set to 0 to disable (not recommended)."
1175+
),
11671176
})
11681177
.optional(),
11691178
})

packages/opencode/src/tool/task.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ function incrementCallCount(sessionID: string): number {
3030
return newCount
3131
}
3232

33+
/**
34+
* Calculate session depth by walking up the parentID chain.
35+
* Root session = depth 0, first child = depth 1, etc.
36+
*/
37+
async function getSessionDepth(sessionID: string): Promise<number> {
38+
let depth = 0
39+
let currentID: string | undefined = sessionID
40+
while (currentID) {
41+
const session: Awaited<ReturnType<typeof Session.get>> | undefined =
42+
await Session.get(currentID).catch(() => undefined)
43+
if (!session?.parentID) break
44+
currentID = session.parentID
45+
depth++
46+
}
47+
return depth
48+
}
49+
3350
const parameters = z.object({
3451
description: z.string().describe("A short (3-5 words) description of the task"),
3552
prompt: z.string().describe("The task for the agent to perform"),
@@ -126,6 +143,18 @@ export const TaskTool = Tool.define("task", async (ctx) => {
126143
)
127144
}
128145

146+
// Check 3: Level limit not exceeded
147+
const levelLimit = config.experimental?.level_limit ?? 5 // Default: 5
148+
if (levelLimit > 0) {
149+
const currentDepth = await getSessionDepth(ctx.sessionID)
150+
if (currentDepth >= levelLimit) {
151+
throw new Error(
152+
`Level limit reached (depth ${currentDepth}/${levelLimit}). ` +
153+
`Cannot create deeper subagent sessions. Return control to caller.`
154+
)
155+
}
156+
}
157+
129158
// Increment count after passing all checks (including ownership above)
130159
incrementCallCount(ctx.sessionID)
131160
}

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,55 @@ describe("backwards compatibility", () => {
190190
})
191191
})
192192
})
193+
194+
describe("level_limit configuration", () => {
195+
test("level_limit is preserved from config", async () => {
196+
await using tmp = await tmpdir({
197+
git: true,
198+
config: {
199+
experimental: {
200+
level_limit: 8,
201+
},
202+
},
203+
})
204+
await Instance.provide({
205+
directory: tmp.path,
206+
fn: async () => {
207+
const config = await Config.get()
208+
expect(config.experimental?.level_limit).toBe(8)
209+
},
210+
})
211+
})
212+
213+
test("level_limit defaults to undefined when not set (implementation defaults to 5)", async () => {
214+
await using tmp = await tmpdir({
215+
git: true,
216+
config: {},
217+
})
218+
await Instance.provide({
219+
directory: tmp.path,
220+
fn: async () => {
221+
const config = await Config.get()
222+
expect(config.experimental?.level_limit).toBeUndefined()
223+
},
224+
})
225+
})
226+
227+
test("level_limit of 0 is preserved (disabled)", async () => {
228+
await using tmp = await tmpdir({
229+
git: true,
230+
config: {
231+
experimental: {
232+
level_limit: 0,
233+
},
234+
},
235+
})
236+
await Instance.provide({
237+
directory: tmp.path,
238+
fn: async () => {
239+
const config = await Config.get()
240+
expect(config.experimental?.level_limit).toBe(0)
241+
},
242+
})
243+
})
244+
})

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,6 @@ export type EventProjectUpdated = {
4747
properties: Project
4848
}
4949

50-
export type EventServerInstanceDisposed = {
51-
type: "server.instance.disposed"
52-
properties: {
53-
directory: string
54-
}
55-
}
56-
5750
export type EventServerConnected = {
5851
type: "server.connected"
5952
properties: {
@@ -68,6 +61,13 @@ export type EventGlobalDisposed = {
6861
}
6962
}
7063

64+
export type EventServerInstanceDisposed = {
65+
type: "server.instance.disposed"
66+
properties: {
67+
directory: string
68+
}
69+
}
70+
7171
export type EventLspClientDiagnostics = {
7272
type: "lsp.client.diagnostics"
7373
properties: {
@@ -888,9 +888,9 @@ export type Event =
888888
| EventInstallationUpdated
889889
| EventInstallationUpdateAvailable
890890
| EventProjectUpdated
891-
| EventServerInstanceDisposed
892891
| EventServerConnected
893892
| EventGlobalDisposed
893+
| EventServerInstanceDisposed
894894
| EventLspClientDiagnostics
895895
| EventLspUpdated
896896
| EventFileEdited
@@ -993,22 +993,6 @@ export type KeybindsConfig = {
993993
* Rename session
994994
*/
995995
session_rename?: string
996-
/**
997-
* Delete session
998-
*/
999-
session_delete?: string
1000-
/**
1001-
* Delete stash entry
1002-
*/
1003-
stash_delete?: string
1004-
/**
1005-
* Open provider list from model dialog
1006-
*/
1007-
model_provider_list?: string
1008-
/**
1009-
* Toggle model favorite status
1010-
*/
1011-
model_favorite_toggle?: string
1012996
/**
1013997
* Share current session
1014998
*/
@@ -1033,14 +1017,6 @@ export type KeybindsConfig = {
10331017
* Scroll messages down by one page
10341018
*/
10351019
messages_page_down?: string
1036-
/**
1037-
* Scroll messages up by one line
1038-
*/
1039-
messages_line_up?: string
1040-
/**
1041-
* Scroll messages down by one line
1042-
*/
1043-
messages_line_down?: string
10441020
/**
10451021
* Scroll messages up by half page
10461022
*/
@@ -1286,11 +1262,11 @@ export type KeybindsConfig = {
12861262
*/
12871263
history_next?: string
12881264
/**
1289-
* Next child session
1265+
* Next sibling session
12901266
*/
12911267
session_child_cycle?: string
12921268
/**
1293-
* Previous child session
1269+
* Previous sibling session
12941270
*/
12951271
session_child_cycle_reverse?: string
12961272
/**
@@ -1305,6 +1281,10 @@ export type KeybindsConfig = {
13051281
* Go to root session
13061282
*/
13071283
session_root?: string
1284+
/**
1285+
* Open session tree dialog
1286+
*/
1287+
session_child_list?: string
13081288
/**
13091289
* Suspend terminal
13101290
*/
@@ -1411,6 +1391,10 @@ export type AgentConfig = {
14111391
* Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)
14121392
*/
14131393
hidden?: boolean
1394+
/**
1395+
* 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).
1396+
*/
1397+
task_budget?: number
14141398
options?: {
14151399
[key: string]: unknown
14161400
}
@@ -1438,6 +1422,7 @@ export type AgentConfig = {
14381422
| "subagent"
14391423
| "primary"
14401424
| "all"
1425+
| number
14411426
| {
14421427
[key: string]: unknown
14431428
}
@@ -1562,7 +1547,7 @@ export type McpLocalConfig = {
15621547
*/
15631548
enabled?: boolean
15641549
/**
1565-
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
1550+
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
15661551
*/
15671552
timeout?: number
15681553
}
@@ -1606,7 +1591,7 @@ export type McpRemoteConfig = {
16061591
*/
16071592
oauth?: McpOAuthConfig | false
16081593
/**
1609-
* Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.
1594+
* Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.
16101595
*/
16111596
timeout?: number
16121597
}
@@ -1721,7 +1706,7 @@ export type Config = {
17211706
[key: string]: AgentConfig | undefined
17221707
}
17231708
/**
1724-
* Agent configuration, see https://opencode.ai/docs/agents
1709+
* Agent configuration, see https://opencode.ai/docs/agent
17251710
*/
17261711
agent?: {
17271712
plan?: AgentConfig
@@ -1828,6 +1813,10 @@ export type Config = {
18281813
* Timeout in milliseconds for model context protocol (MCP) requests
18291814
*/
18301815
mcp_timeout?: number
1816+
/**
1817+
* Maximum depth for subagent session trees. Prevents infinite delegation loops. Default: 5. Set to 0 to disable (not recommended).
1818+
*/
1819+
level_limit?: number
18311820
}
18321821
}
18331822

@@ -2170,6 +2159,7 @@ export type Agent = {
21702159
[key: string]: unknown
21712160
}
21722161
steps?: number
2162+
task_budget?: number
21732163
}
21742164

21752165
export type LspStatus = {

0 commit comments

Comments
 (0)