Skip to content

Commit c156f3b

Browse files
Claude Agentclaude
andcommitted
feat(opencode): make subagent tree limit configurable (nested-agents Issue 2)
Promote the SUBAGENT_TREE_LIMIT=200 lifetime cap to a configurable experimental key (experimental.subagent_tree_limit, PositiveInt). Add a single-source-of-truth clamp helper treeLimit(cfg) in subagent-limits.ts, exactly analogous to maxDepth(cfg): the __testHooks.treeLimit seam keeps top precedence, then the config key (clamped to 1..HARD_MAX_TREE_LIMIT=10000, truncated), else the 200 default. task.ts consumes the helper exclusively instead of the bare constant/seam. The in-memory per-process counter is unchanged (safety ceiling, no DB priming). Clamp bounds: min 1 (at least one spawn must remain possible), max 10000 (keeps the ceiling a sane order of magnitude, guards against typos/overflow making it effectively unlimited). Tests: config-parse round-trip + non-positive rejection; treeLimit clamp (0/negative/too-large/undefined->default, NaN/Infinity->default, fractional truncation, seam override). Docs updated in config.mdx and agents.mdx. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 81ea40c commit c156f3b

7 files changed

Lines changed: 122 additions & 8 deletions

File tree

‎packages/core/src/v1/config/config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ export const Info = Schema.Struct({
218218
description:
219219
"Maximum subagent nesting depth (the root session is depth 1; clamped to 1..10). Default 5. Set 2 to restore the previous spawn behavior (the root spawns, subagents do not) — additionally the workflow tool is removed at the limit (security fix) and custom agents that previously nested without limit are bounded; 1 is the kill switch that removes the task and workflow tools entirely.",
220220
}),
221+
subagent_tree_limit: Schema.optional(PositiveInt).annotate({
222+
description:
223+
"In-memory lifetime cap on subagents started per session tree, a per-process safety ceiling against runaway delegation (clamped to 1..10000). Default 200. Resumes and workflow dispatches do not count against it; the counter is not persisted, so it resets each process run.",
224+
}),
221225
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
222226
description: "Policy statements applied to supported resources, such as provider access",
223227
}),

‎packages/opencode/src/session/subagent-limits.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,23 @@ export const DEFAULT_MAX_TASK_DEPTH = 5
2121
export const HARD_MAX_DEPTH = 10
2222

2323
/**
24-
* In-memory lifetime cap on subagents started per session tree (keyed by root
25-
* session). A safety ceiling against runaway delegation within one process
26-
* run, not an accounting counter; resumes do not count, workflow dispatches
27-
* keep their own `DEFAULT_AGENT_LIMIT`.
24+
* Default in-memory lifetime cap on subagents started per session tree (keyed
25+
* by root session). A safety ceiling against runaway delegation within one
26+
* process run, not an accounting counter; resumes do not count, workflow
27+
* dispatches keep their own `DEFAULT_AGENT_LIMIT`. Overridable via
28+
* `experimental.subagent_tree_limit` — see `treeLimit(cfg)`.
2829
*/
2930
export const SUBAGENT_TREE_LIMIT = 200
3031

32+
/**
33+
* Config-free upper bound for `experimental.subagent_tree_limit`. The cap stays
34+
* a per-process safety ceiling, so even a power user's override is clamped to a
35+
* sane order of magnitude (guards against typos/overflow turning the ceiling
36+
* into "effectively unlimited"). Well above the 200 default; the lower clamp
37+
* bound is 1 (at least one spawn must remain possible).
38+
*/
39+
export const HARD_MAX_TREE_LIMIT = 10_000
40+
3141
/**
3242
* Iteration cap for the `Session.lineage` parent-chain walk. Legitimate chains
3343
* are at most HARD_MAX_DEPTH long; exceeding this means corrupt or cyclic
@@ -51,6 +61,23 @@ export function maxDepth(cfg: ConfigV1.Info): number {
5161
return Math.min(Math.max(Math.trunc(raw), 1), HARD_MAX_DEPTH)
5262
}
5363

64+
/**
65+
* Resolves the effective subagent tree lifetime cap — the single source of
66+
* truth for the spawn gate (design-final §2.5, Phase-2 Issue 2). Precedence:
67+
* the `__testHooks.treeLimit` seam wins (mirrors `__testHooks.agentLimit` in
68+
* workflow.ts), then `experimental.subagent_tree_limit` (clamped to
69+
* 1..HARD_MAX_TREE_LIMIT, truncated), else the SUBAGENT_TREE_LIMIT default.
70+
* The in-memory per-process counter is unchanged — this only sets the ceiling.
71+
*/
72+
export function treeLimit(cfg: ConfigV1.Info): number {
73+
if (typeof __testHooks.treeLimit === "number") return __testHooks.treeLimit
74+
// The config key ships with the default-opening track; the cast keeps this
75+
// helper typed against ConfigV1.Info before and after the schema gains it.
76+
const raw = (cfg.experimental as { subagent_tree_limit?: number } | undefined)?.subagent_tree_limit
77+
if (typeof raw !== "number" || !Number.isFinite(raw)) return SUBAGENT_TREE_LIMIT
78+
return Math.min(Math.max(Math.trunc(raw), 1), HARD_MAX_TREE_LIMIT)
79+
}
80+
5481
/**
5582
* Appended to the task tool description on levels 2..max−1 so subagents know
5683
* their remaining delegation budget instead of discovering it by failed calls.
@@ -145,8 +172,8 @@ export const lineageError = (input: { sessionID: SessionID }) =>
145172

146173
/**
147174
* Test seam for the tree lifetime cap (mirrors `__testHooks.agentLimit` in
148-
* workflow.ts): when set, the task tool gates on this value instead of
149-
* SUBAGENT_TREE_LIMIT. Inert in production.
175+
* workflow.ts): when set, `treeLimit(cfg)` returns this value instead of the
176+
* config-derived/default ceiling. Inert in production.
150177
*/
151178
export const __testHooks = {
152179
treeLimit: undefined as number | undefined,

‎packages/opencode/src/tool/task.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export const TaskTool = Tool.define(
189189
// Tree lifetime cap (design-final §2.2): gates NEW spawns only —
190190
// the resume/extend path never reaches this branch. Counted against
191191
// the tree's root so every level shares one ceiling.
192-
const treeLimit = SubagentLimits.__testHooks.treeLimit ?? SubagentLimits.SUBAGENT_TREE_LIMIT
192+
const treeLimit = SubagentLimits.treeLimit(cfg)
193193
const started = treeSpawnCounts.get(rootID) ?? 0
194194
if (started >= treeLimit) {
195195
return yield* Effect.fail(SubagentLimits.treeLimitError({ started, limit: treeLimit }))

‎packages/opencode/test/config/config.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,20 @@ test("config parser accepts experimental.subagent_max_depth and rejects non-posi
13291329
expect(() => ConfigParse.schema(ConfigV1.Info, { experimental: { subagent_max_depth: 0 } }, "test")).toThrow()
13301330
})
13311331

1332+
// Phase-2 Issue 2: the tree lifetime cap is now configurable. Same contract as
1333+
// subagent_max_depth — the value must round-trip the parser and non-positive
1334+
// values are rejected by PositiveInt (clamping to 1..10000 happens at read time
1335+
// via SubagentLimits.treeLimit).
1336+
test("config parser accepts experimental.subagent_tree_limit and rejects non-positive values", () => {
1337+
const config = ConfigParse.schema(ConfigV1.Info, { experimental: { subagent_tree_limit: 200 } }, "test")
1338+
expect(config.experimental?.subagent_tree_limit).toBe(200)
1339+
1340+
const custom = ConfigParse.schema(ConfigV1.Info, { experimental: { subagent_tree_limit: 50 } }, "test")
1341+
expect(custom.experimental?.subagent_tree_limit).toBe(50)
1342+
1343+
expect(() => ConfigParse.schema(ConfigV1.Info, { experimental: { subagent_tree_limit: 0 } }, "test")).toThrow()
1344+
})
1345+
13321346
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
13331347
const config = ConfigParse.schema(
13341348
ConfigV1.Info,

‎packages/opencode/test/session/subagent-limits.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,52 @@ import { SubagentLimits } from "@/session/subagent-limits"
88
const cfg = (value?: number) =>
99
({ experimental: value === undefined ? {} : { subagent_max_depth: value } }) as unknown as ConfigV1.Info
1010

11+
// Same cast for the Phase-2 `subagent_tree_limit` knob (Issue 2).
12+
const treeCfg = (value?: number) =>
13+
({ experimental: value === undefined ? {} : { subagent_tree_limit: value } }) as unknown as ConfigV1.Info
14+
1115
describe("constants", () => {
1216
test("limits match the design", () => {
1317
expect(SubagentLimits.DEFAULT_MAX_TASK_DEPTH).toBe(5)
1418
expect(SubagentLimits.HARD_MAX_DEPTH).toBe(10)
1519
expect(SubagentLimits.SUBAGENT_TREE_LIMIT).toBe(200)
20+
expect(SubagentLimits.HARD_MAX_TREE_LIMIT).toBe(10_000)
1621
expect(SubagentLimits.LINEAGE_ITERATION_CAP).toBe(32)
1722
})
1823
})
1924

25+
describe("treeLimit", () => {
26+
test("defaults to 200 when unset", () => {
27+
expect(SubagentLimits.treeLimit(treeCfg())).toBe(200)
28+
expect(SubagentLimits.treeLimit({} as ConfigV1.Info)).toBe(200)
29+
})
30+
31+
test("clamps to [1, HARD_MAX_TREE_LIMIT]", () => {
32+
expect(SubagentLimits.treeLimit(treeCfg(0))).toBe(1)
33+
expect(SubagentLimits.treeLimit(treeCfg(-5))).toBe(1)
34+
expect(SubagentLimits.treeLimit(treeCfg(1))).toBe(1)
35+
expect(SubagentLimits.treeLimit(treeCfg(50))).toBe(50)
36+
expect(SubagentLimits.treeLimit(treeCfg(200))).toBe(200)
37+
expect(SubagentLimits.treeLimit(treeCfg(999_999))).toBe(10_000)
38+
})
39+
40+
test("truncates fractional values", () => {
41+
expect(SubagentLimits.treeLimit(treeCfg(50.9))).toBe(50)
42+
})
43+
44+
test("ignores non-finite values (falls back to default)", () => {
45+
expect(SubagentLimits.treeLimit(treeCfg(Number.NaN))).toBe(200)
46+
expect(SubagentLimits.treeLimit(treeCfg(Number.POSITIVE_INFINITY))).toBe(200)
47+
})
48+
49+
test("__testHooks.treeLimit overrides config when set", () => {
50+
SubagentLimits.__testHooks.treeLimit = 3
51+
expect(SubagentLimits.treeLimit(treeCfg(200))).toBe(3)
52+
SubagentLimits.__testHooks.treeLimit = undefined
53+
expect(SubagentLimits.treeLimit(treeCfg(200))).toBe(200)
54+
})
55+
})
56+
2057
describe("maxDepth", () => {
2158
test("defaults to 5 when unset", () => {
2259
expect(SubagentLimits.maxDepth(cfg())).toBe(5)

‎packages/web/src/content/docs/agents.mdx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ By default, nesting is allowed up to **depth 5**. Sessions at depth 1 through 4
152152
| Limit | Default | On violation |
153153
| -------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
154154
| Maximum nesting depth | `5` (root session = depth 1), configurable via `experimental.subagent_max_depth`, clamped to 1–10 | The `task` and `workflow` tools are removed at the last level; any remaining spawn attempt fails with a typed `SubagentDepthError` |
155-
| Subagents per session tree | `200` started subagents per tree, counted **per OpenCode process** (the counter resets on restart) | Further spawns fail with `SubagentTreeLimitError` |
155+
| Subagents per session tree | `200` started subagents per tree, configurable via `experimental.subagent_tree_limit` (clamped to 1–10000), counted **per OpenCode process** (the counter resets on restart) | Further spawns fail with `SubagentTreeLimitError` |
156156
| `task_id` resume | Only tasks the session started itself (direct children) | Other session IDs fail with `SubagentResumeError`; unknown IDs still start a fresh session |
157157
| Permission prompts | Raised anywhere in the tree, answered on the **root session** of the tree | — see [permissions in nested trees](#permissions-in-nested-trees) |
158158

@@ -179,6 +179,23 @@ Values are clamped to the range 1–10.
179179

180180
---
181181

182+
### Configure the tree limit
183+
184+
Set the per-tree lifetime cap with `experimental.subagent_tree_limit`:
185+
186+
```json title="opencode.json"
187+
{
188+
"$schema": "https://opencode.ai/config.json",
189+
"experimental": {
190+
"subagent_tree_limit": 200
191+
}
192+
}
193+
```
194+
195+
The default is `200`. This is an in-memory safety ceiling counted per OpenCode process: resumed subagents and workflow dispatches do not count against it, and the counter resets on restart. Values are clamped to the range 1–10000; reaching the cap fails further spawns with `SubagentTreeLimitError`.
196+
197+
---
198+
182199
### Permissions in nested trees
183200

184201
- Whether an agent below the depth limit can spawn is governed by its regular [task permissions](#task-permissions). The default wildcard `"*": "allow"` is enough — an exact `task` rule is no longer required.

‎packages/web/src/content/docs/config.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,21 @@ Use `experimental.subagent_max_depth` to control how deep subagents can [nest](/
880880

881881
The default is `5`. Setting it to `2` restores the previous spawn behavior (the main session spawns subagents, subagents do not); additionally, the `workflow` tool is removed at the limit (a security fix), and custom agents that previously nested without limit are now bounded. Setting it to `1` is the kill switch: the `task` and `workflow` tools are removed everywhere, including from the main session.
882882

883+
#### Subagent tree limit
884+
885+
Use `experimental.subagent_tree_limit` to control how many subagents a single session tree may start over its lifetime. The cap is an in-memory, per-process safety ceiling against runaway delegation; values are clamped to 1–10000.
886+
887+
```json title="opencode.json"
888+
{
889+
"$schema": "https://opencode.ai/config.json",
890+
"experimental": {
891+
"subagent_tree_limit": 200
892+
}
893+
}
894+
```
895+
896+
The default is `200`. Resumed subagents and workflow dispatches do not count against it, and the counter is not persisted — it resets on each OpenCode process restart. Reaching the limit fails further spawns with a typed `SubagentTreeLimitError`.
897+
883898
---
884899

885900
## Variables

0 commit comments

Comments
 (0)