Skip to content

Commit 76dffde

Browse files
Claude Agentclaude
andcommitted
feat(opencode): add subagent limits module and session lineage helpers
Track T1 (foundation) of nested subagent spawning (design-final.md §2/§4.1): - new session/subagent-limits.ts: depth/tree/lineage constants (DEFAULT_MAX_TASK_DEPTH=5, HARD_MAX_DEPTH=10, SUBAGENT_TREE_LIMIT=200, LINEAGE_ITERATION_CAP=32), maxDepth(cfg) clamp helper, depthHint, typed errors SubagentDepthError/SubagentTreeLimitError/SubagentResumeError/ SubagentBudgetError/SubagentLineageError with pinned model-facing texts, and the tree-limit test seam - Session.lineage(sessionID): parent-chain walk self → root, iteration cap 32 fails with SubagentLineageError on cyclic data, vanished parents end the chain (orphans count as roots) - Session.descendants(sessionID): iterative cycle-safe BFS over children, capped at 10 000 nodes - Session.createNext: config-free HARD_MAX_DEPTH invariant for child sessions; violations surface as defects carrying the typed error so the public create() contract stays Effect<Info> Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 4fcd4b3 commit 76dffde

4 files changed

Lines changed: 563 additions & 0 deletions

File tree

‎packages/opencode/src/session/session.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { Snapshot } from "@/snapshot"
3636
import { ProjectV2 } from "@opencode-ai/core/project"
3737
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
3838
import { SessionID, MessageID, PartID } from "./schema"
39+
import { SubagentLimits } from "./subagent-limits"
3940

4041
import type { Provider } from "@/provider/provider"
4142
import { Permission } from "@/permission"
@@ -498,6 +499,23 @@ export interface Interface {
498499
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
499500
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<SessionV1.WithParts[], NotFound>
500501
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
502+
/**
503+
* The parent chain of a session, ordered self → root: `depth = chain.length`,
504+
* `rootID = chain.at(-1)!.id`. A parent row that vanished mid-chain ends the
505+
* walk there (orphans count as roots — the safest reading). Fails with
506+
* SubagentLineageError once the walk exceeds LINEAGE_ITERATION_CAP (corrupt
507+
* or cyclic parent data); callers treat that like "depth ≥ limit" instead of
508+
* hanging.
509+
*/
510+
readonly lineage: (
511+
sessionID: SessionID,
512+
) => Effect.Effect<Info[], SubagentLimits.SubagentLineageError | NotFound>
513+
/**
514+
* Every transitive child of a session (the session itself excluded),
515+
* collected by iterative BFS over `children`. Cycle-safe via a seen-set and
516+
* truncated at a 10 000-node safety cap.
517+
*/
518+
readonly descendants: (sessionID: SessionID) => Effect.Effect<Info[]>
501519
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
502520
readonly updateMessage: <T extends SessionV1.Info>(msg: T) => Effect.Effect<T>
503521
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
@@ -559,6 +577,23 @@ export const layer: Layer.Layer<
559577
metadata?: typeof Metadata.Type
560578
permission?: PermissionV1.Ruleset
561579
}) {
580+
// Config-free hard cap on session nesting (design-final §4.1, line 4):
581+
// no code path — today's or a future one — may create a session deeper
582+
// than HARD_MAX_DEPTH. Every guarded spawn path fails earlier with a
583+
// typed error at the (clamped, ≤10) configured limit, so tripping this
584+
// is an invariant violation and surfaces as a defect carrying the typed
585+
// error; the public create() contract stays Effect<Info>.
586+
if (input.parentID !== undefined) {
587+
const chain = yield* lineage(input.parentID).pipe(
588+
// An unknown parent is an orphan and orphans count as roots.
589+
Effect.catchTag("NotFoundError", () => Effect.succeed<Info[]>([])),
590+
Effect.catchTag("SubagentLineageError", (error) => Effect.die(error)),
591+
)
592+
if (chain.length + 1 > SubagentLimits.HARD_MAX_DEPTH)
593+
yield* Effect.die(
594+
SubagentLimits.depthError({ depth: chain.length + 1, limit: SubagentLimits.HARD_MAX_DEPTH }),
595+
)
596+
}
562597
const ctx = yield* InstanceState.context
563598
const result: Info = {
564599
id: SessionID.descending(input.id),
@@ -654,6 +689,40 @@ export const layer: Layer.Layer<
654689
return rows.map(fromRow)
655690
})
656691

692+
const lineage: Interface["lineage"] = Effect.fn("Session.lineage")(function* (sessionID: SessionID) {
693+
const chain = [yield* get(sessionID)]
694+
while (chain.at(-1)!.parentID !== undefined) {
695+
if (chain.length >= SubagentLimits.LINEAGE_ITERATION_CAP)
696+
return yield* Effect.fail(SubagentLimits.lineageError({ sessionID }))
697+
const parent = yield* get(chain.at(-1)!.parentID!).pipe(Effect.option)
698+
// A vanished parent ends the chain: orphans count as roots.
699+
if (Option.isNone(parent)) return chain
700+
chain.push(parent.value)
701+
}
702+
return chain
703+
})
704+
705+
// Safety cap for the descendants traversal; legitimate trees stay far
706+
// below it (HARD_MAX_DEPTH levels × the per-tree spawn cap).
707+
const DESCENDANTS_CAP = 10_000
708+
709+
const descendants: Interface["descendants"] = Effect.fn("Session.descendants")(function* (sessionID: SessionID) {
710+
const seen = new Set<SessionID>([sessionID])
711+
const result: Info[] = []
712+
const queue = [sessionID]
713+
while (queue.length > 0) {
714+
const kids = yield* children(queue.shift()!)
715+
for (const kid of kids) {
716+
if (seen.has(kid.id)) continue
717+
seen.add(kid.id)
718+
result.push(kid)
719+
if (result.length >= DESCENDANTS_CAP) return result
720+
queue.push(kid.id)
721+
}
722+
}
723+
return result
724+
})
725+
657726
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
658727
const session = yield* get(sessionID)
659728
try {
@@ -961,6 +1030,8 @@ export const layer: Layer.Layer<
9611030
diff,
9621031
messages,
9631032
children,
1033+
lineage,
1034+
descendants,
9641035
remove,
9651036
updateMessage,
9661037
removeMessage,
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Schema } from "effect"
2+
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
3+
import { SessionID } from "./schema"
4+
5+
/**
6+
* Shared constants and typed errors for nested subagent spawning
7+
* (design-final.md §2). Depth is a purely derived value: the root ("CEO")
8+
* session is depth 1 and every task/subtask/workflow dispatch adds one level.
9+
* This is a plain module on purpose — NOT an Effect service — so the session
10+
* layer, tools and the workflow dispatcher can consume it without wiring.
11+
*/
12+
13+
/** Default maximum nesting depth (root session = 1; levels 1..4 may spawn). */
14+
export const DEFAULT_MAX_TASK_DEPTH = 5
15+
16+
/**
17+
* Config-free upper bound enforced as an invariant in `Session.createNext`:
18+
* no code path — including plugins or future dispatchers — can create a
19+
* session deeper than this. Also the clamp ceiling for `maxDepth`.
20+
*/
21+
export const HARD_MAX_DEPTH = 10
22+
23+
/**
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`.
28+
*/
29+
export const SUBAGENT_TREE_LIMIT = 200
30+
31+
/**
32+
* Iteration cap for the `Session.lineage` parent-chain walk. Legitimate chains
33+
* are at most HARD_MAX_DEPTH long; exceeding this means corrupt or cyclic
34+
* parent data and fails with SubagentLineageError instead of hanging.
35+
*/
36+
export const LINEAGE_ITERATION_CAP = 32
37+
38+
/**
39+
* Resolves the effective maximum nesting depth from config — the single source
40+
* of truth for `experimental.subagent_max_depth` (clamped to 1..HARD_MAX_DEPTH,
41+
* default DEFAULT_MAX_TASK_DEPTH). Semantics (design-final §2.5): `5` is the
42+
* target behavior, `2` reproduces the pre-nesting behavior (root spawns,
43+
* subagents do not) and `1` is the kill switch (even the root loses the task
44+
* tool). NEVER document `1` as the legacy behavior — that is an off-by-one.
45+
*/
46+
export function maxDepth(cfg: ConfigV1.Info): number {
47+
// The config key ships with the default-opening track; the cast keeps this
48+
// helper typed against ConfigV1.Info before and after the schema gains it.
49+
const raw = (cfg.experimental as { subagent_max_depth?: number } | undefined)?.subagent_max_depth
50+
if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_MAX_TASK_DEPTH
51+
return Math.min(Math.max(Math.trunc(raw), 1), HARD_MAX_DEPTH)
52+
}
53+
54+
/**
55+
* Appended to the task tool description on levels 2..max−1 so subagents know
56+
* their remaining delegation budget instead of discovering it by failed calls.
57+
*/
58+
export function depthHint(depth: number, max: number): string {
59+
return `You are a sub-agent at delegation depth ${depth} of ${max}. You may delegate to deeper sub-agents; prefer doing small tasks yourself.`
60+
}
61+
62+
/**
63+
* Raised when a session at the maximum nesting depth attempts to spawn another
64+
* subagent (or, as the config-free hard cap in `Session.createNext`, when a
65+
* child would exceed HARD_MAX_DEPTH). Tests match on the tag; the message is
66+
* model-directed and pinned once in subagent-limits.test.ts.
67+
*/
68+
export class SubagentDepthError extends Schema.TaggedErrorClass<SubagentDepthError>()("SubagentDepthError", {
69+
message: Schema.String,
70+
depth: Schema.Finite,
71+
limit: Schema.Finite,
72+
}) {}
73+
74+
export const depthError = (input: { depth: number; limit: number }) =>
75+
new SubagentDepthError({
76+
...input,
77+
message: `Subagent nesting limit reached: this session is already at the maximum nesting depth (${input.depth} of ${input.limit}; the root session is depth 1). Do the remaining work yourself in this session and report the results in your final message instead of delegating.`,
78+
})
79+
80+
/**
81+
* Raised when a session tree has started its lifetime cap of subagents
82+
* (SUBAGENT_TREE_LIMIT, overridable via `__testHooks.treeLimit`).
83+
*/
84+
export class SubagentTreeLimitError extends Schema.TaggedErrorClass<SubagentTreeLimitError>()(
85+
"SubagentTreeLimitError",
86+
{
87+
message: Schema.String,
88+
started: Schema.Finite,
89+
limit: Schema.Finite,
90+
},
91+
) {}
92+
93+
export const treeLimitError = (input: { started: number; limit: number }) =>
94+
new SubagentTreeLimitError({
95+
...input,
96+
message: `Subagent limit reached: this session tree has already started ${input.started} of ${input.limit} subagents (the cap guards against runaway delegation). Finish the remaining work directly in this session.`,
97+
})
98+
99+
/**
100+
* Raised when `task_id` names an existing session that is NOT a direct child
101+
* of the caller — resuming ancestors or foreign sessions is refused instead of
102+
* silently adopted (closes the verified resume deadlock).
103+
*/
104+
export class SubagentResumeError extends Schema.TaggedErrorClass<SubagentResumeError>()("SubagentResumeError", {
105+
message: Schema.String,
106+
taskID: Schema.String,
107+
}) {}
108+
109+
export const resumeError = (input: { taskID: string }) =>
110+
new SubagentResumeError({
111+
...input,
112+
message: `Cannot resume task ${input.taskID}: it is not a subagent of this session. task_id can only resume tasks this session started itself.`,
113+
})
114+
115+
/**
116+
* Raised by the spawn gate when the shared turn-budget pool is exhausted —
117+
* running subagents may finish, new ones are refused (soft cap).
118+
*/
119+
export class SubagentBudgetError extends Schema.TaggedErrorClass<SubagentBudgetError>()("SubagentBudgetError", {
120+
message: Schema.String,
121+
}) {}
122+
123+
export const budgetError = () =>
124+
new SubagentBudgetError({
125+
message:
126+
"Turn budget exhausted: cannot start another subagent. Finish the remaining work directly within this session.",
127+
})
128+
129+
/**
130+
* Raised when the parent-chain walk exceeds LINEAGE_ITERATION_CAP (corrupt or
131+
* cyclic session parents). Callers treat this like "depth ≥ limit": spawning
132+
* is refused and the task/workflow tools are filtered.
133+
*/
134+
export class SubagentLineageError extends Schema.TaggedErrorClass<SubagentLineageError>()("SubagentLineageError", {
135+
message: Schema.String,
136+
sessionID: SessionID,
137+
}) {}
138+
139+
export const lineageError = (input: { sessionID: SessionID }) =>
140+
new SubagentLineageError({
141+
...input,
142+
message:
143+
"Session ancestry could not be resolved (possible cycle in session parents); refusing to spawn subagents from this session.",
144+
})
145+
146+
/**
147+
* 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.
150+
*/
151+
export const __testHooks = {
152+
treeLimit: undefined as number | undefined,
153+
}
154+
155+
export * as SubagentLimits from "./subagent-limits"

0 commit comments

Comments
 (0)