Browse Source

Remove redundant subagent depth limiting

OpenCode now enforces subagent depth natively via the subagent_depth config (default 1). The plugin's own SubagentDepthTracker was redundant defense-in-depth: councillors are already denied both task (TaskTool) and council_session (the council tool), so the council spawn path is a recursion dead-end.

Removes src/utils/subagent-depth.ts (+ test), the depth checks in council-manager and index.ts, and the DEFAULT_MAX_SUBAGENT_DEPTH constant. Docs updated; a comment in src/agents/index.ts notes council_session: 'deny' is now the recursion guard.
adikpb 2 weeks ago
parent
commit
fd50fa46d1

+ 2 - 2
codemap.md

@@ -56,7 +56,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/tools/` | Tool and runtime-command export surface for AST-grep, smartfetch, council orchestration, and `/preset` switching. | [View Map](src/tools/codemap.md) |
 | `src/tools/ast-grep/` | AST-grep binary management and AST-aware search/replace tool flow. | [View Map](src/tools/ast-grep/codemap.md) |
 | `src/tools/smartfetch/` | Fetch/extract/cache pipeline for web content and secondary-model summarization. | [View Map](src/tools/smartfetch/codemap.md) |
-| `src/utils/` | Cross-cutting helpers for logging, session metadata, resumable task aliases, system-message normalization, subagent depth tracking, environment, and runtime operations. | [View Map](src/utils/codemap.md) |
+| `src/utils/` | Cross-cutting helpers for logging, session metadata, resumable task aliases, system-message normalization, environment, and runtime operations. | [View Map](src/utils/codemap.md) |
 | `scripts/` | Build/release validation and generated-artifact maintenance scripts. | [View Map](scripts/codemap.md) |
 
 ## Runtime Control Flow
@@ -91,7 +91,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - `src/index.ts` is the central composition root for nearly every runtime subsystem.
 - `src/config/` feeds `src/agents/`, session/delegation utilities, and MCP registration.
 - `src/cli/skills.ts` and `src/cli/custom-skills.ts` bridge install-time skill packaging with runtime permission policy.
-- Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/` for depth tracking, result extraction, task output parsing, and alias state.
+- Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/` for result extraction, task output parsing, and alias state.
 - cmux-specific readiness, retry, orphan, and cleanup state lives under
   `src/multiplexer/cmux/`; the generic manager delegates cmux events so other
   multiplexer behavior remains on the upstream path.

+ 2 - 2
docs/council.md

@@ -468,6 +468,6 @@ Try:
 
 Also verify the configured model IDs exist in your OpenCode environment.
 
-### Subagent depth exceeded
+### Council recursion prevented
 
-Council is meant to be a leaf agent. Avoid recursive council chains.
+Council is meant to be a leaf agent. The councillor agent type has `council_session: 'deny'` permission, so councillors cannot invoke the council tool. OpenCode's native `subagent_depth` limit also prevents runaway delegation. Avoid recursive council chains.

+ 4 - 4
docs/loop-engineering-research.md

@@ -126,13 +126,13 @@ Osmani's framework identifies five primitives that compose a loop, plus durable
 | Agent spawning | Yes | Yes | Yes (9 agents) |
 | Background execution | Yes | Yes | Yes (background jobs) |
 | Maker/checker split | Yes | Yes | Yes (orchestrator dispatches, specialists execute) |
-| Depth tracking | Yes | Yes | Yes (max 3 levels) |
+| Depth tracking | Yes | Yes | Native (OpenCode's subagent_depth) |
 | Session reuse | Yes | Yes | Yes (BackgroundJobBoard) |
 | Job tracking | Limited | Limited | Yes (Background Job Board with aliases) |
 | Cancellation | Yes | Yes | Yes (cancel_task tool) |
 | Parallel dispatch | Yes | Yes | Yes (explicit in orchestrator prompt) |
 
-**Verdict:** All three have sub-agent support. OpenCode's is the most structured with 9 specialized agents, a formal Background Job Board, session reuse, depth tracking, and a dedicated orchestrator that never implements directly.
+**Verdict:** All three have sub-agent support. OpenCode's is the most structured with 9 specialized agents, a formal Background Job Board, session reuse, and a dedicated orchestrator that never implements directly.
 
 ### Loop Engine (Iteration Primitive)
 
@@ -215,7 +215,7 @@ In its purest form, Ralph is a Bash loop. That's it.
 |----------------|--------|----------------|
 | **Skills** | Mature | 7 bundled skills, per-agent permissions, auto-sync |
 | **Connectors/MCP** | Mature | 3 built-in MCPs, per-agent permission system |
-| **Sub-agents** | Mature | 9 agents, Background Job Board, depth tracking, session reuse |
+| **Sub-agents** | Mature | 9 agents, Background Job Board, session reuse |
 | **Worktrees** | Skill-only | Orchestrator skill, apply-patch hook support |
 | **Automations** | Not implemented | Deferred to Phase 4 |
 | **Loop engine** | Spec only | Fully designed, not implemented |
@@ -234,7 +234,7 @@ The planned loop engineering runtime includes:
 
 ### The Gap
 
-OpenCode has 3 of 5 building blocks fully wired (skills, connectors, sub-agents), worktrees as a skill, and automations + loop engine as unimplemented specs. The sub-agent infrastructure is the strongest piece - the Background Job Board, session reuse, and depth tracking are more structured than what Claude Code or Codex expose.
+OpenCode has 3 of 5 building blocks fully wired (skills, connectors, sub-agents), worktrees as a skill, and automations + loop engine as unimplemented specs. The sub-agent infrastructure is the strongest piece - the Background Job Board, session reuse, and native depth tracking are more structured than what Claude Code or Codex expose.
 
 The missing piece is the outer loop itself: the scheduler that runs on a timer, spawns work, and keeps going without human intervention. Once that lands (Phase 1-2 of the spec), OpenCode will have a complete loop engineering stack.
 

+ 4 - 0
src/agents/index.ts

@@ -296,6 +296,10 @@ function applyDefaultPermissions(
 
   // Respect explicit deny on question (councillor)
   const questionPerm = existing.question === 'deny' ? 'deny' : 'allow';
+  // Councillors are denied council_session so they cannot spawn nested
+  // councils — this permission denial is now the recursion guard (the
+  // plugin's SubagentDepthTracker was removed; OpenCode's native
+  // subagent_depth covers TaskTool-based recursion for other subagents).
   const councilSessionPerm = COUNCIL_TOOL_ALLOWED_AGENTS.has(agent.name)
     ? (existing.council_session ?? 'allow')
     : 'deny';

+ 3 - 3
src/codemap.md

@@ -85,9 +85,9 @@ OpenCode Core → Plugin Initialization (index.ts)
 Key event flows:
 
 1. **Session Lifecycle**:
-   - `session.created` → register child session in `SubagentDepthTracker`
+   - `session.created` → register child session
    - `session.status` → multiplexer session management and companion updates
-   - `session.deleted` → cleanup depth tracker, session agent map, and companion state
+   - `session.deleted` → cleanup session agent map and companion state
 
 2. **Message Updates**:
    - `message.updated` → record agent/model usage in TUI state
@@ -123,7 +123,7 @@ Key event flows:
 - **Multiplexer** (`src/multiplexer/`): Tmux/Zellij session management for child sessions
 - **Council** (`src/council/`): Multi-LLM council orchestration
 - **Companion** (`src/companion/`): Companion version management
-- **Utils** (`src/utils/`): Logger, environment checks, subagent depth tracking
+- **Utils** (`src/utils/`): Logger, environment checks
 
 ### Cross-Directory Flow
 

+ 0 - 3
src/config/constants.ts

@@ -47,9 +47,6 @@ export const POLL_INTERVAL_BACKGROUND_MS = 2000;
 // Timeouts
 export const MAX_POLL_TIME_MS = 5 * 60 * 1000; // 5 minutes
 
-// Subagent depth limits
-export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
-
 // Workflow reminders
 export const PHASE_REMINDER_TEXT = `!IMPORTANT! Scheduler workflow: First choose the lightest workflow that fits the work. If direct execution is justified, complete it and verify proportionately. Otherwise: plan lanes/dependencies → dispatch background specialists → track task IDs → wait for hook-driven completion → reconcile terminal results → verify. Do not poll running jobs, consume running-job output, or advance dependent work. !END!`;
 

+ 2 - 14
src/council/codemap.md

@@ -9,7 +9,6 @@ Orchestrates multi-LLM council sessions by spawning parallel councillor agents,
 - **Singleton**: One instance per plugin session manages the entire council lifecycle
 - **Strategy Pattern**: Configurable execution modes (`parallel` vs `serial`) for councillor orchestration
 - **Retry Pattern**: Automatic retry on empty responses with configurable limits
-- **Observer Pattern**: Tracks subagent depth to prevent infinite recursion
 
 ### Key Components
 
@@ -57,8 +56,7 @@ Orchestrates multi-LLM council sessions by spawning parallel councillor agents,
 │                   runCouncillors()                     │
 │  - For each councillor config:                         │
 │    - Spawn child session (session.create)               │
-│    - Apply depth tracking (if enabled)                 │
-│    - Send prompt with restricted tools                 │
+ │    - Send prompt with restricted tools                 │
 │    - Extract result (extractSessionResult)              │
 │    - Cleanup session (session.abort)                  │
 └─────────────────────────────────────────────────────────────┘
@@ -67,8 +65,7 @@ Orchestrates multi-LLM council sessions by spawning parallel councillor agents,
 ┌─────────────────────────────────────────────────────────────┐
 │                 runAgentSession()                      │
 │  - Create session with parentID                        │
-│  - Register child in depth tracker                     │
-│  - Send prompt (promptWithTimeout)                     │
+ │  - Send prompt (promptWithTimeout)                     │
 │  - Extract result with reasoning disabled               │
 │  - Abort session on completion/cleanup                 │
 └─────────────────────────────────────────────────────────────┘
@@ -88,7 +85,6 @@ Orchestrates multi-LLM council sessions by spawning parallel councillor agents,
 1. **Empty responses**: Retry up to `maxRetries` times (provider rate-limiting)
 2. **Timeouts**: Immediate failure, no retry
 3. **Session failures**: Mark as failed, continue with other councillors
-4. **Depth violations**: Block spawn immediately, return error
 
 ## Integration
 
@@ -97,7 +93,6 @@ Orchestrates multi-LLM council sessions by spawning parallel councillor agents,
 - **Agents**: `formatCouncillorPrompt()`, `formatCouncillorResults()` from `../agents/council`
 - **Session**: `extractSessionResult()`, `promptWithTimeout()` from `../utils/session`
 - **Logger**: `log()` from `../utils/logger`
-- **Depth Tracker**: `SubagentDepthTracker` from `../utils/subagent-depth` (optional)
 - **Client**: `OpencodeClient` from `@opencode-ai/plugin` (session management)
 
 ### Consumers
@@ -139,11 +134,6 @@ Councillors operate with **advisory-only** tool access:
 
 This ensures councillors provide guidance without side effects.
 
-### Depth Tracking
-- Prevents infinite recursion by tracking subagent depth
-- Configurable maximum depth (default: 3 levels)
-- Logs violations and blocks spawn attempts
-
 ### Notifications
 - Sends immediate feedback to parent session on council start
 - Message format: `⎔ Council starting - ${count} councillors launching - ctrl+x ↓ to watch`
@@ -164,7 +154,6 @@ This ensures councillors provide guidance without side effects.
 | Empty preset | Return error about no councillors | User adds councillors to preset |
 | All councillors fail | Return error with all failures | Investigate model availability or prompts |
 | Timeout | Mark timed_out status | Increase timeout or reduce council size |
-| Depth exceeded | Block spawn, return error | Increase maxDepth or simplify task |
 | Provider rate-limiting | Retry up to maxRetries | Automatic recovery |
 
 ## Testing Points
@@ -172,7 +161,6 @@ This ensures councillors provide guidance without side effects.
 - Preset resolution (default vs named)
 - Parallel vs serial execution modes
 - Retry logic for empty responses
-- Depth tracking and blocking
 - Tool restrictions enforcement
 - Session lifecycle (create → prompt → extract → abort)
 - Timeout behavior

+ 0 - 24
src/council/council-manager.test.ts

@@ -1,7 +1,6 @@
 import { describe, expect, mock, test } from 'bun:test';
 import type { PluginConfig } from '../config';
 import { CouncilConfigSchema } from '../config/council-schema';
-import { SubagentDepthTracker } from '../utils/subagent-depth';
 import { CouncilManager } from './council-manager';
 
 function createMockContext(overrides?: {
@@ -528,29 +527,6 @@ describe('CouncilManager', () => {
       expect(result.councillorResults).toHaveLength(0);
     });
 
-    test('returns error when depth exceeded', async () => {
-      const ctx = createMockContext();
-      const config = createTestCouncilConfig();
-      const tracker = new SubagentDepthTracker(3);
-
-      // Simulate depth: root (0) → child1 (1) → child2 (2) → child3 (3)
-      tracker.registerChild('root', 'child1'); // depth 1
-      tracker.registerChild('child1', 'child2'); // depth 2
-      tracker.registerChild('child2', 'child3'); // depth 3 (max)
-
-      const manager = new CouncilManager(ctx, config, tracker);
-
-      const result = await manager.runCouncil(
-        'test prompt',
-        undefined,
-        'child3', // parent at max depth, next spawn would exceed limit
-      );
-
-      expect(result.success).toBe(false);
-      expect(result.error).toBe('Subagent depth exceeded');
-      expect(result.councillorResults).toHaveLength(0);
-    });
-
     test('passes agent field in councillor prompt body', async () => {
       const ctx = createMockContext({
         sessionMessagesResult: {

+ 1 - 39
src/council/council-manager.ts

@@ -25,7 +25,6 @@ import {
   promptWithTimeout,
   shortModelLabel,
 } from '../utils/session';
-import type { SubagentDepthTracker } from '../utils/subagent-depth';
 
 type OpencodeClient = PluginInput['client'];
 
@@ -37,23 +36,16 @@ export class CouncilManager {
   private client: OpencodeClient;
   private directory: string;
   private config?: PluginConfig;
-  private depthTracker?: SubagentDepthTracker;
   private tmuxEnabled: boolean;
   private deprecatedFields?: string[];
   private legacyMasterModel?: string;
 
-  constructor(
-    ctx: PluginInput,
-    config?: PluginConfig,
-    depthTracker?: SubagentDepthTracker,
-    tmuxEnabled = false,
-  ) {
+  constructor(ctx: PluginInput, config?: PluginConfig, tmuxEnabled = false) {
     this.client = ctx.client;
     this.directory = ctx.directory;
     this.config = config;
     this.deprecatedFields = config?.council?._deprecated;
     this.legacyMasterModel = config?.council?._legacyMasterModel;
-    this.depthTracker = depthTracker;
     this.tmuxEnabled = tmuxEnabled;
   }
 
@@ -80,23 +72,6 @@ export class CouncilManager {
     presetName: string | undefined,
     parentSessionId: string,
   ): Promise<CouncilResult> {
-    // Check depth limit before starting councillors
-    if (this.depthTracker) {
-      const parentDepth = this.depthTracker.getDepth(parentSessionId);
-      if (parentDepth + 1 > this.depthTracker.maxDepth) {
-        log('[council-manager] spawn blocked: max depth exceeded', {
-          parentSessionId,
-          parentDepth,
-          maxDepth: this.depthTracker.maxDepth,
-        });
-        return {
-          success: false,
-          error: 'Subagent depth exceeded',
-          councillorResults: [],
-        };
-      }
-    }
-
     const councilConfig = this.config?.council;
     if (!councilConfig) {
       log('[council-manager] Council configuration not found');
@@ -255,16 +230,6 @@ export class CouncilManager {
 
       sessionId = session.data.id;
 
-      if (this.depthTracker) {
-        const registered = this.depthTracker.registerChild(
-          options.parentSessionId,
-          sessionId,
-        );
-        if (!registered) {
-          throw new Error('Subagent depth exceeded');
-        }
-      }
-
       if (this.tmuxEnabled) {
         await new Promise((r) => setTimeout(r, TMUX_SPAWN_DELAY_MS));
       }
@@ -315,9 +280,6 @@ export class CouncilManager {
     } finally {
       if (sessionId) {
         this.client.session.abort({ path: { id: sessionId } }).catch(() => {});
-        if (this.depthTracker) {
-          this.depthTracker.cleanup(sessionId);
-        }
       }
     }
   }

+ 1 - 13
src/index.ts

@@ -67,7 +67,6 @@ import {
 } from './utils';
 import { isPluginDisabledByEnv } from './utils/env';
 import { initLogger, log } from './utils/logger';
-import { SubagentDepthTracker } from './utils/subagent-depth';
 import { collapseSystemInPlace } from './utils/system-collapse';
 
 /**
@@ -143,7 +142,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let runtimeChains: Record<string, string[]>;
   let multiplexerConfig: MultiplexerConfig;
   let multiplexerEnabled: boolean;
-  let depthTracker: SubagentDepthTracker;
   let multiplexerSessionManager: MultiplexerSessionManager;
   let autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook>;
   let sessionAgentMap: Map<string, string>;
@@ -252,13 +250,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       startAvailabilityCheck(multiplexerConfig);
     }
 
-    depthTracker = new SubagentDepthTracker();
-
     // Initialize council tools (only when council is configured)
     councilTools = config.council
       ? createCouncilTool(
           ctx,
-          new CouncilManager(ctx, config, depthTracker, multiplexerEnabled),
+          new CouncilManager(ctx, config, multiplexerEnabled),
         )
       : {};
 
@@ -927,11 +923,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       if (event.type === 'session.created') {
-        const childSessionId = event.properties?.info?.id;
-        const parentSessionId = event.properties?.info?.parentID;
-        if (depthTracker && childSessionId && parentSessionId) {
-          depthTracker.registerChild(parentSessionId, childSessionId);
-        }
         const createdSessionId = event.properties?.info?.id;
         const createdSessionDir = event.properties?.info?.directory;
         if (createdSessionId && createdSessionDir) {
@@ -1011,9 +1002,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           sessionLifecycle.dispatchSessionDeleted(sessionID);
         }
         companionManager.onSessionDeleted(sessionID);
-        if (depthTracker && sessionID) {
-          depthTracker.cleanup(sessionID);
-        }
         if (sessionID) {
           sessionAgentMap.delete(sessionID);
           sessionDirectories.delete(sessionID);

+ 0 - 170
src/utils/subagent-depth.test.ts

@@ -1,170 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { SubagentDepthTracker } from './subagent-depth';
-
-describe('SubagentDepthTracker', () => {
-  describe('constructor', () => {
-    test('uses DEFAULT_MAX_SUBAGENT_DEPTH (3) by default', () => {
-      const tracker = new SubagentDepthTracker();
-      expect(tracker).toBeDefined();
-    });
-
-    test('accepts custom max depth', () => {
-      const tracker = new SubagentDepthTracker(5);
-      expect(tracker).toBeDefined();
-    });
-  });
-
-  describe('getDepth', () => {
-    test('returns 0 for untracked sessions (root sessions)', () => {
-      const tracker = new SubagentDepthTracker();
-      expect(tracker.getDepth('root-session')).toBe(0);
-      expect(tracker.getDepth('untracked-session')).toBe(0);
-    });
-
-    test('returns tracked depth for registered sessions', () => {
-      const tracker = new SubagentDepthTracker();
-      tracker.registerChild('root-session', 'child-session');
-      expect(tracker.getDepth('child-session')).toBe(1);
-    });
-  });
-
-  describe('registerChild', () => {
-    test('tracks depth correctly (parent=0, child=1, grandchild=2)', () => {
-      const tracker = new SubagentDepthTracker();
-
-      expect(tracker.getDepth('root')).toBe(0);
-
-      const allowed1 = tracker.registerChild('root', 'child1');
-      expect(allowed1).toBe(true);
-      expect(tracker.getDepth('child1')).toBe(1);
-
-      const allowed2 = tracker.registerChild('child1', 'grandchild');
-      expect(allowed2).toBe(true);
-      expect(tracker.getDepth('grandchild')).toBe(2);
-    });
-
-    test('returns false when max depth exceeded (depth 4 > max 3)', () => {
-      const tracker = new SubagentDepthTracker(3);
-
-      const root = 'root';
-      const child1 = 'child1';
-      const child2 = 'child2';
-      const child3 = 'child3';
-      const child4 = 'child4';
-
-      expect(tracker.registerChild(root, child1)).toBe(true);
-      expect(tracker.registerChild(child1, child2)).toBe(true);
-      expect(tracker.registerChild(child2, child3)).toBe(true);
-      expect(tracker.registerChild(child3, child4)).toBe(false);
-    });
-
-    test('tracks across multiple branches independently', () => {
-      const tracker = new SubagentDepthTracker();
-
-      const root = 'root';
-      const branch1Child = 'branch1-child';
-      const branch2Child = 'branch2-child';
-      const branch1Grandchild = 'branch1-grandchild';
-      const branch2Grandchild = 'branch2-grandchild';
-
-      tracker.registerChild(root, branch1Child);
-      tracker.registerChild(branch1Child, branch1Grandchild);
-
-      tracker.registerChild(root, branch2Child);
-      tracker.registerChild(branch2Child, branch2Grandchild);
-
-      expect(tracker.getDepth(branch1Child)).toBe(1);
-      expect(tracker.getDepth(branch2Child)).toBe(1);
-      expect(tracker.getDepth(branch1Grandchild)).toBe(2);
-      expect(tracker.getDepth(branch2Grandchild)).toBe(2);
-    });
-
-    test('does not re-register existing session', () => {
-      const tracker = new SubagentDepthTracker();
-
-      const root = 'root';
-      const child = 'child';
-
-      tracker.registerChild(root, child);
-      expect(tracker.getDepth(child)).toBe(1);
-
-      tracker.registerChild(root, child);
-      expect(tracker.getDepth(child)).toBe(1);
-    });
-
-    test('updates depth if child is re-registered from different parent', () => {
-      const tracker = new SubagentDepthTracker();
-
-      const root = 'root';
-      const child1 = 'child1';
-      const child2 = 'child2';
-      const grandchild = 'grandchild';
-
-      tracker.registerChild(root, child1);
-      tracker.registerChild(child1, grandchild);
-      expect(tracker.getDepth(grandchild)).toBe(2);
-
-      tracker.registerChild(root, child2);
-      tracker.registerChild(child2, grandchild);
-      expect(tracker.getDepth(grandchild)).toBe(2);
-    });
-  });
-
-  describe('cleanup', () => {
-    test('removes a specific session', () => {
-      const tracker = new SubagentDepthTracker();
-
-      const root = 'root';
-      const child1 = 'child1';
-      const child2 = 'child2';
-
-      tracker.registerChild(root, child1);
-      tracker.registerChild(root, child2);
-
-      expect(tracker.getDepth(child1)).toBe(1);
-      expect(tracker.getDepth(child2)).toBe(1);
-
-      tracker.cleanup(child1);
-
-      expect(tracker.getDepth(child1)).toBe(0);
-      expect(tracker.getDepth(child2)).toBe(1);
-    });
-
-    test('does not throw when cleaning up untracked session', () => {
-      const tracker = new SubagentDepthTracker();
-
-      expect(() => tracker.cleanup('untracked')).not.toThrow();
-    });
-  });
-
-  describe('cleanupAll', () => {
-    test('removes all sessions', () => {
-      const tracker = new SubagentDepthTracker();
-
-      const root = 'root';
-      const child1 = 'child1';
-      const child2 = 'child2';
-      const grandchild = 'grandchild';
-
-      tracker.registerChild(root, child1);
-      tracker.registerChild(root, child2);
-      tracker.registerChild(child1, grandchild);
-
-      expect(tracker.getDepth(child1)).toBe(1);
-      expect(tracker.getDepth(child2)).toBe(1);
-      expect(tracker.getDepth(grandchild)).toBe(2);
-
-      tracker.cleanupAll();
-
-      expect(tracker.getDepth(child1)).toBe(0);
-      expect(tracker.getDepth(child2)).toBe(0);
-      expect(tracker.getDepth(grandchild)).toBe(0);
-    });
-
-    test('does not throw when called on empty tracker', () => {
-      const tracker = new SubagentDepthTracker();
-
-      expect(() => tracker.cleanupAll()).not.toThrow();
-    });
-  });
-});

+ 0 - 75
src/utils/subagent-depth.ts

@@ -1,75 +0,0 @@
-import { DEFAULT_MAX_SUBAGENT_DEPTH } from '../config';
-import { log } from './logger';
-
-/**
- * Tracks subagent spawn depth to prevent excessive nesting.
- *
- * Depth 0 = root session (user's main conversation)
- * Depth 1 = agent spawned by root (e.g., explorer, council)
- * Depth 2 = agent spawned by depth-1 agent (e.g., councillor spawned by council)
- * Depth 3 = agent spawned by depth-2 agent (max depth by default)
- *
- * When max depth is exceeded, the spawn is blocked.
- */
-export class SubagentDepthTracker {
-  private depthBySession = new Map<string, number>();
-  private readonly _maxDepth: number;
-
-  constructor(maxDepth: number = DEFAULT_MAX_SUBAGENT_DEPTH) {
-    this._maxDepth = maxDepth;
-  }
-
-  /** Maximum allowed depth. */
-  get maxDepth(): number {
-    return this._maxDepth;
-  }
-
-  /**
-   * Get the current depth of a session.
-   * Root sessions (not tracked) have depth 0.
-   */
-  getDepth(sessionId: string): number {
-    return this.depthBySession.get(sessionId) ?? 0;
-  }
-
-  /**
-   * Register a child session and check if the spawn is allowed.
-   * @returns true if allowed, false if max depth exceeded
-   */
-  registerChild(parentSessionId: string, childSessionId: string): boolean {
-    const parentDepth = this.getDepth(parentSessionId);
-    const childDepth = parentDepth + 1;
-
-    if (childDepth > this.maxDepth) {
-      log('[subagent-depth] spawn blocked: max depth exceeded', {
-        parentSessionId,
-        parentDepth,
-        childDepth,
-        maxDepth: this.maxDepth,
-      });
-      return false;
-    }
-
-    this.depthBySession.set(childSessionId, childDepth);
-    log('[subagent-depth] child registered', {
-      parentSessionId,
-      childSessionId,
-      childDepth,
-    });
-    return true;
-  }
-
-  /**
-   * Clean up session tracking when a session is deleted.
-   */
-  cleanup(sessionId: string): void {
-    this.depthBySession.delete(sessionId);
-  }
-
-  /**
-   * Clean up all tracking data.
-   */
-  cleanupAll(): void {
-    this.depthBySession.clear();
-  }
-}