Browse Source

feat: restrict subagent delegation based on agent type (#117)

* feat: restrict subagent delegation based on agent type (#116)

Add SUBAGENT_DELEGATION_RULES to control which agents can spawn subagents:
- orchestrator: can spawn all subagents (full delegation)
- fixer/designer: can spawn explorer only (for research)
- explorer/librarian/oracle: cannot spawn any subagents (leaf nodes)

Update BackgroundTaskManager to:
- Track agent type per session via agentBySessionId map
- Calculate tool permissions based on parent agent's delegation rules
- Apply appropriate background_task/task tool permissions when spawning

Add comprehensive tests for all delegation restrictions.

Fixes #116

* fix: enforce agent-type validation in background_task tool delegation

The background_task tool accepted any agent string without checking
SUBAGENT_DELEGATION_RULES, allowing agents like fixer to delegate to
any agent despite being restricted to explorer only. Add isAgentAllowed()
and getAllowedSubagents() methods to BackgroundTaskManager and validate
the requested agent in the tool before launching.

* fix: treat untracked sessions as root orchestrator in delegation checks

The root orchestrator session is created by OpenCode, not by
BackgroundTaskManager, so it is never registered in agentBySessionId.
This caused isAgentAllowed(), getAllowedSubagents(), and
calculateToolPermissions() to reject all delegation from the root
session — completely blocking the primary agent from launching tasks.

Default untracked sessions to 'orchestrator' instead of rejecting them.

* feat: default unknown agent types to explorer-only delegation

New background agent types no longer need explicit entries in
SUBAGENT_DELEGATION_RULES. Agents not listed default to ['explorer']
access via a centralized getSubagentRules() helper.

* fix: base tool permissions on spawned agent's own delegation rules, not parent's

* test: add multi-layered delegation chain tests for orchestrator→fixer→explorer paths

---------

Co-authored-by: vllm-user <vllm-user@example.com>
Kassie Povinelli 2 months ago
parent
commit
404d8c0c20

+ 801 - 0
src/background/background-manager.test.ts

@@ -703,4 +703,805 @@ describe('BackgroundTaskManager', () => {
       expect(ctx.client.session.prompt).toHaveBeenCalled();
     });
   });
+
+  describe('subagent delegation restrictions', () => {
+    test('spawned explorer gets tools disabled (leaf node)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // First, simulate orchestrator starting (parent session with no parent)
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Verify orchestrator's session is tracked
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Launch explorer from orchestrator - explorer is a leaf node so tools disabled
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: orchestratorSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Explorer cannot delegate, so delegation tools are hidden
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('spawned fixer gets tools enabled (can delegate to explorer)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // First, launch an explorer task
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Launch fixer from explorer - fixer can delegate to explorer, so tools enabled
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: explorerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Fixer can delegate (to explorer), so delegation tools are enabled
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: true,
+        task: true,
+      });
+    });
+
+    test('spawned explorer from fixer gets tools disabled (leaf node)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch a fixer task
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Launch explorer from fixer - explorer is a leaf node so tools disabled
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: fixerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('spawned explorer from designer gets tools disabled (leaf node)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch a designer task
+      const designerTask = manager.launch({
+        agent: 'designer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Launch explorer from designer - explorer is a leaf node so tools disabled
+      const designerSessionId = designerTask.sessionId;
+      if (!designerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: designerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('librarian cannot delegate to any subagents', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch a librarian task
+      const librarianTask = manager.launch({
+        agent: 'librarian',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Launch subagent from librarian - should have tools disabled
+      const librarianSessionId = librarianTask.sessionId;
+      if (!librarianSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: librarianSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('oracle cannot delegate to any subagents', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch an oracle task
+      const oracleTask = manager.launch({
+        agent: 'oracle',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Launch subagent from oracle - should have tools disabled
+      const oracleSessionId = oracleTask.sessionId;
+      if (!oracleSessionId) throw new Error('Expected sessionId to be defined');
+
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: oracleSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('spawned explorer from unknown parent gets tools disabled (leaf node)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch explorer from unknown parent session (root orchestrator)
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'unknown-session-id',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      // Explorer is a leaf agent — tools disabled regardless of parent
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('isAgentAllowed returns true for valid delegations', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Orchestrator can delegate to all subagents
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'explorer')).toBe(
+        true,
+      );
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'fixer')).toBe(true);
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'designer')).toBe(
+        true,
+      );
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'librarian')).toBe(
+        true,
+      );
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'oracle')).toBe(
+        true,
+      );
+    });
+
+    test('isAgentAllowed returns false for invalid delegations', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Fixer can only delegate to explorer
+      expect(manager.isAgentAllowed(fixerSessionId, 'explorer')).toBe(true);
+      expect(manager.isAgentAllowed(fixerSessionId, 'oracle')).toBe(false);
+      expect(manager.isAgentAllowed(fixerSessionId, 'designer')).toBe(false);
+      expect(manager.isAgentAllowed(fixerSessionId, 'librarian')).toBe(false);
+    });
+
+    test('isAgentAllowed returns false for leaf agents', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Explorer is a leaf agent
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      expect(manager.isAgentAllowed(explorerSessionId, 'fixer')).toBe(false);
+
+      // Librarian is also a leaf agent
+      const librarianTask = manager.launch({
+        agent: 'librarian',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const librarianSessionId = librarianTask.sessionId;
+      if (!librarianSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      expect(manager.isAgentAllowed(librarianSessionId, 'explorer')).toBe(
+        false,
+      );
+    });
+
+    test('isAgentAllowed treats unknown session as root orchestrator', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Unknown sessions default to orchestrator, which can delegate to all subagents
+      expect(manager.isAgentAllowed('unknown-session', 'explorer')).toBe(true);
+      expect(manager.isAgentAllowed('unknown-session', 'fixer')).toBe(true);
+      expect(manager.isAgentAllowed('unknown-session', 'designer')).toBe(true);
+      expect(manager.isAgentAllowed('unknown-session', 'librarian')).toBe(true);
+      expect(manager.isAgentAllowed('unknown-session', 'oracle')).toBe(true);
+    });
+
+    test('unknown agent type defaults to explorer-only delegation', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch a task with an agent type not in SUBAGENT_DELEGATION_RULES
+      const customTask = manager.launch({
+        agent: 'custom-agent',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const customSessionId = customTask.sessionId;
+      if (!customSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Unknown agent types should default to explorer-only
+      expect(manager.getAllowedSubagents(customSessionId)).toEqual([
+        'explorer',
+      ]);
+      expect(manager.isAgentAllowed(customSessionId, 'explorer')).toBe(true);
+      expect(manager.isAgentAllowed(customSessionId, 'fixer')).toBe(false);
+      expect(manager.isAgentAllowed(customSessionId, 'oracle')).toBe(false);
+    });
+
+    test('spawned explorer from custom agent gets tools disabled (leaf node)', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch a custom agent first to get a tracked session
+      const parentTask = manager.launch({
+        agent: 'custom-agent',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const parentSessionId = parentTask.sessionId;
+      if (!parentSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Launch explorer from custom agent - explorer is leaf, tools disabled
+      manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: parentSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Explorer is a leaf agent — tools disabled regardless of parent
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const lastCall = promptCalls[promptCalls.length - 1];
+      expect(lastCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+    });
+
+    test('full chain: orchestrator → fixer → explorer', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Level 1: Launch orchestrator from root
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'coordinate work',
+        description: 'orchestrator',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Orchestrator can delegate to fixer
+      expect(manager.isAgentAllowed(orchestratorSessionId, 'fixer')).toBe(true);
+
+      // Level 2: Launch fixer from orchestrator
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'implement changes',
+        description: 'fixer',
+        parentSessionId: orchestratorSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Fixer gets tools ENABLED (can delegate to explorer)
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const fixerPromptCall = promptCalls[1]; // Second prompt call is fixer
+      expect(fixerPromptCall[0].body.tools).toEqual({
+        background_task: true,
+        task: true,
+      });
+
+      // Fixer can delegate to explorer but NOT oracle
+      expect(manager.isAgentAllowed(fixerSessionId, 'explorer')).toBe(true);
+      expect(manager.isAgentAllowed(fixerSessionId, 'oracle')).toBe(false);
+
+      // Level 3: Launch explorer from fixer
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'search codebase',
+        description: 'explorer',
+        parentSessionId: fixerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Explorer gets tools DISABLED (leaf node)
+      const explorerPromptCall = promptCalls[2]; // Third prompt call is explorer
+      expect(explorerPromptCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+
+      // Explorer cannot delegate to anything
+      expect(manager.isAgentAllowed(explorerSessionId, 'explorer')).toBe(false);
+      expect(manager.isAgentAllowed(explorerSessionId, 'fixer')).toBe(false);
+      expect(manager.isAgentAllowed(explorerSessionId, 'oracle')).toBe(false);
+      expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
+    });
+
+    test('full chain: orchestrator → designer → explorer', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Level 1: Launch orchestrator
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'coordinate work',
+        description: 'orchestrator',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Level 2: Launch designer from orchestrator
+      const designerTask = manager.launch({
+        agent: 'designer',
+        prompt: 'design UI',
+        description: 'designer',
+        parentSessionId: orchestratorSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const designerSessionId = designerTask.sessionId;
+      if (!designerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Designer gets tools ENABLED (can delegate to explorer)
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const designerPromptCall = promptCalls[1];
+      expect(designerPromptCall[0].body.tools).toEqual({
+        background_task: true,
+        task: true,
+      });
+
+      // Designer can only spawn explorer
+      expect(manager.isAgentAllowed(designerSessionId, 'explorer')).toBe(true);
+      expect(manager.isAgentAllowed(designerSessionId, 'fixer')).toBe(false);
+      expect(manager.isAgentAllowed(designerSessionId, 'oracle')).toBe(false);
+
+      // Level 3: Launch explorer from designer
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'find patterns',
+        description: 'explorer',
+        parentSessionId: designerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Explorer gets tools DISABLED
+      const explorerPromptCall = promptCalls[2];
+      expect(explorerPromptCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+
+      // Explorer is a dead end
+      expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
+    });
+
+    test('chain enforcement: fixer cannot spawn unauthorized agents mid-chain', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Orchestrator spawns fixer
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: orchestratorSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Fixer should be blocked from spawning these agents
+      expect(manager.isAgentAllowed(fixerSessionId, 'oracle')).toBe(false);
+      expect(manager.isAgentAllowed(fixerSessionId, 'designer')).toBe(false);
+      expect(manager.isAgentAllowed(fixerSessionId, 'librarian')).toBe(false);
+      expect(manager.isAgentAllowed(fixerSessionId, 'fixer')).toBe(false);
+
+      // Only explorer is allowed
+      expect(manager.isAgentAllowed(fixerSessionId, 'explorer')).toBe(true);
+      expect(manager.getAllowedSubagents(fixerSessionId)).toEqual(['explorer']);
+    });
+
+    test('chain: completed parent does not affect child permissions', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'done' }],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Launch fixer
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      // Launch explorer from fixer BEFORE fixer completes
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: fixerSessionId,
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      // Explorer has its own tracking — tools disabled
+      const promptCalls = ctx.client.session.prompt.mock.calls as Array<
+        [{ body: { tools?: Record<string, boolean> } }]
+      >;
+      const explorerPromptCall = promptCalls[1];
+      expect(explorerPromptCall[0].body.tools).toEqual({
+        background_task: false,
+        task: false,
+      });
+
+      // Now complete the fixer (cleans up fixer's agentBySessionId entry)
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: fixerSessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(fixerTask.status).toBe('completed');
+
+      // Explorer's own session tracking is independent — still works
+      expect(manager.isAgentAllowed(explorerSessionId, 'fixer')).toBe(false);
+      expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
+    });
+
+    test('getAllowedSubagents returns correct lists', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Orchestrator -> all 5 subagent names
+      const orchestratorTask = manager.launch({
+        agent: 'orchestrator',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const orchestratorSessionId = orchestratorTask.sessionId;
+      if (!orchestratorSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      expect(manager.getAllowedSubagents(orchestratorSessionId)).toEqual([
+        'explorer',
+        'librarian',
+        'oracle',
+        'designer',
+        'fixer',
+      ]);
+
+      // Fixer -> only explorer
+      const fixerTask = manager.launch({
+        agent: 'fixer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const fixerSessionId = fixerTask.sessionId;
+      if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
+
+      expect(manager.getAllowedSubagents(fixerSessionId)).toEqual(['explorer']);
+
+      // Designer -> only explorer
+      const designerTask = manager.launch({
+        agent: 'designer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const designerSessionId = designerTask.sessionId;
+      if (!designerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      expect(manager.getAllowedSubagents(designerSessionId)).toEqual([
+        'explorer',
+      ]);
+
+      // Explorer -> empty (leaf)
+      const explorerTask = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'root-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const explorerSessionId = explorerTask.sessionId;
+      if (!explorerSessionId)
+        throw new Error('Expected sessionId to be defined');
+
+      expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
+
+      // Unknown session -> orchestrator (all subagents)
+      expect(manager.getAllowedSubagents('unknown-session')).toEqual([
+        'explorer',
+        'librarian',
+        'oracle',
+        'designer',
+        'fixer',
+      ]);
+    });
+  });
 });

+ 84 - 3
src/background/background-manager.ts

@@ -15,7 +15,10 @@
 
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { BackgroundTaskConfig, PluginConfig } from '../config';
-import { FALLBACK_FAILOVER_TIMEOUT_MS } from '../config';
+import {
+  FALLBACK_FAILOVER_TIMEOUT_MS,
+  SUBAGENT_DELEGATION_RULES,
+} from '../config';
 import type { TmuxConfig } from '../config/schema';
 import { applyAgentVariant, resolveAgentVariant } from '../utils';
 import { log } from '../utils/logger';
@@ -90,6 +93,8 @@ function generateTaskId(): string {
 export class BackgroundTaskManager {
   private tasks = new Map<string, BackgroundTask>();
   private tasksBySessionId = new Map<string, string>();
+  // Track which agent type owns each session for delegation permission checks
+  private agentBySessionId = new Map<string, string>();
   private client: OpencodeClient;
   private directory: string;
   private tmuxEnabled: boolean;
@@ -123,6 +128,50 @@ export class BackgroundTaskManager {
   }
 
   /**
+   * Look up the delegation rules for an agent type.
+   * Unknown agent types default to explorer-only access, making it easy
+   * to add new background agent types without updating SUBAGENT_DELEGATION_RULES.
+   */
+  private getSubagentRules(agentName: string): readonly string[] {
+    return (
+      SUBAGENT_DELEGATION_RULES[
+        agentName as keyof typeof SUBAGENT_DELEGATION_RULES
+      ] ?? ['explorer']
+    );
+  }
+
+  /**
+   * Check if a parent session is allowed to delegate to a specific agent type.
+   * @param parentSessionId - The session ID of the parent
+   * @param requestedAgent - The agent type being requested
+   * @returns true if allowed, false if not
+   */
+  isAgentAllowed(parentSessionId: string, requestedAgent: string): boolean {
+    // Untracked sessions are the root orchestrator (created by OpenCode, not by us)
+    const parentAgentName =
+      this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
+
+    const allowedSubagents = this.getSubagentRules(parentAgentName);
+
+    if (allowedSubagents.length === 0) return false;
+
+    return allowedSubagents.includes(requestedAgent);
+  }
+
+  /**
+   * Get the list of allowed subagents for a parent session.
+   * @param parentSessionId - The session ID of the parent
+   * @returns Array of allowed agent names, empty if none
+   */
+  getAllowedSubagents(parentSessionId: string): readonly string[] {
+    // Untracked sessions are the root orchestrator (created by OpenCode, not by us)
+    const parentAgentName =
+      this.agentBySessionId.get(parentSessionId) ?? 'orchestrator';
+
+    return this.getSubagentRules(parentAgentName);
+  }
+
+  /**
    * Launch a new background task (fire-and-forget).
    *
    * Phase A (sync): Creates task record and returns immediately.
@@ -216,6 +265,31 @@ export class BackgroundTaskManager {
   }
 
   /**
+   * Calculate tool permissions for a spawned agent based on its own delegation rules.
+   * Agents that cannot delegate (leaf nodes) get delegation tools disabled entirely,
+   * preventing models from even seeing tools they can never use.
+   *
+   * @param agentName - The agent type being spawned
+   * @returns Tool permissions object with background_task and task enabled/disabled
+   */
+  private calculateToolPermissions(agentName: string): {
+    background_task: boolean;
+    task: boolean;
+  } {
+    const allowedSubagents = this.getSubagentRules(agentName);
+
+    // Leaf agents (no delegation rules) get tools hidden entirely
+    if (allowedSubagents.length === 0) {
+      return { background_task: false, task: false };
+    }
+
+    // Agent can delegate - enable the delegation tools
+    // The restriction of WHICH specific subagents are allowed is enforced
+    // by the background_task tool via isAgentAllowed()
+    return { background_task: true, task: true };
+  }
+
+  /**
    * Start a task in the background (Phase B).
    */
   private async startTask(task: BackgroundTask): Promise<void> {
@@ -245,6 +319,8 @@ export class BackgroundTaskManager {
 
       task.sessionId = session.data.id;
       this.tasksBySessionId.set(session.data.id, task.id);
+      // Track the agent type for this session for delegation checks
+      this.agentBySessionId.set(session.data.id, task.agent);
       task.status = 'running';
 
       // Give TmuxSessionManager time to spawn the pane
@@ -252,12 +328,15 @@ export class BackgroundTaskManager {
         await new Promise((r) => setTimeout(r, 500));
       }
 
+      // Calculate tool permissions based on the spawned agent's own delegation rules
+      const toolPermissions = this.calculateToolPermissions(task.agent);
+
       // Send prompt
       const promptQuery: Record<string, string> = { directory: this.directory };
       const resolvedVariant = resolveAgentVariant(this.config, task.agent);
       const basePromptBody = applyAgentVariant(resolvedVariant, {
         agent: task.agent,
-        tools: { background_task: false, task: false },
+        tools: toolPermissions,
         parts: [{ type: 'text' as const, text: task.prompt }],
       } as PromptBody) as unknown as PromptBody;
 
@@ -420,9 +499,10 @@ export class BackgroundTaskManager {
       task.error = resultOrError;
     }
 
-    // Clean up tasksBySessionId map to prevent memory leak
+    // Clean up session tracking maps to prevent memory leak
     if (task.sessionId) {
       this.tasksBySessionId.delete(task.sessionId);
+      this.agentBySessionId.delete(task.sessionId);
     }
 
     // Send notification to parent session
@@ -587,5 +667,6 @@ export class BackgroundTaskManager {
     this.completionResolvers.clear();
     this.tasks.clear();
     this.tasksBySessionId.clear();
+    this.agentBySessionId.clear();
   }
 }

+ 15 - 0
src/config/constants.ts

@@ -19,6 +19,21 @@ export const ALL_AGENT_NAMES = [ORCHESTRATOR_NAME, ...SUBAGENT_NAMES] as const;
 // Agent name type (for use in DEFAULT_MODELS)
 export type AgentName = (typeof ALL_AGENT_NAMES)[number];
 
+// Subagent delegation rules: which agents can spawn which subagents
+// orchestrator: can spawn all subagents (full delegation)
+// fixer: can spawn explorer (for research during implementation)
+// designer: can spawn explorer (for research during design)
+// explorer/librarian/oracle: cannot spawn any subagents (leaf nodes)
+// Unknown agent types not listed here default to explorer-only access
+export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
+  orchestrator: SUBAGENT_NAMES,
+  fixer: ['explorer'],
+  designer: ['explorer'],
+  explorer: [],
+  librarian: [],
+  oracle: [],
+};
+
 // Default models for each agent
 export const DEFAULT_MODELS: Record<AgentName, string> = {
   orchestrator: 'kimi-for-coding/k2p5',

+ 8 - 1
src/tools/background.ts

@@ -56,13 +56,20 @@ Key behaviors:
       const agent = String(args.agent);
       const prompt = String(args.prompt);
       const description = String(args.description);
+      const parentSessionId = (toolContext as { sessionID: string }).sessionID;
+
+      // Validate agent against delegation rules
+      if (!manager.isAgentAllowed(parentSessionId, agent)) {
+        const allowed = manager.getAllowedSubagents(parentSessionId);
+        return `Agent '${agent}' is not allowed. Allowed agents: ${allowed.join(', ')}`;
+      }
 
       // Fire-and-forget launch
       const task = manager.launch({
         agent,
         prompt,
         description,
-        parentSessionId: (toolContext as { sessionID: string }).sessionID,
+        parentSessionId,
       });
 
       return `Background task launched.