|
| 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