2026 03 18 Jarvis P1 Sub Vitto Engine

Documentation for Plans

Jarvis Phase 1: Sub-Vitto Engine — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enable Vitto to deploy parallel sub-Vittos via a deploy_agents tool, running them through the same AgentLoopService.run() engine with scoped context, tier-based tool filtering, and prefixed SSE events.

Architecture: Refactor SubAgentService.executeTask() to delegate to AgentLoopService.run() instead of its own while loop (~160 lines removed). New SubVittoOrchestratorService handles parallel dispatch via Promise.allSettled() with multi-round synthesis. New orchestration.plugin.ts registers the deploy_agents tool. Frontend gets a SubVittoOrchestrationRenderer for the chat panel.

Tech Stack: NestJS (backend-vitto-api), Angular 20+ (libs-ai/vitto-chat), RxJS Subject (SSE), Mongoose (MongoDB)

Spec: docs/superpowers/specs/2026-03-18-vitto-jarvis-comprehensive-evolution-design.md (Section 1 + Section 8 chat renderer)


File Structure

New Files

File Responsibility
APIs/backend-vitto-api/src/app/engine/sub-vitto-orchestrator.service.ts Parallel dispatch, multi-round synthesis, parent budget tracking
APIs/backend-vitto-api/src/app/engine/sub-vitto-sse-proxy.ts Wraps parent SSE subject, re-emits events with subvitto.* prefix + subTaskId
APIs/backend-vitto-api/src/app/tools/plugins/orchestration.plugin.ts deploy_agents tool handler
APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-orchestrator.spec.ts Unit tests for orchestrator
APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-sse-proxy.spec.ts Unit tests for SSE proxy
APIs/backend-vitto-api/src/app/tools/plugins/__tests__/orchestration.plugin.spec.ts Unit tests for deploy_agents tool
libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/renderers/sub-vitto-orchestration-renderer/sub-vitto-orchestration-renderer.component.ts Chat renderer component
libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/renderers/sub-vitto-orchestration-renderer/sub-vitto-orchestration-renderer.component.html Chat renderer template
libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/renderers/sub-vitto-orchestration-renderer/sub-vitto-orchestration-renderer.component.scss Chat renderer styles

Modified Files

File Change
APIs/backend-vitto-api/src/app/engine/sub-agent.service.ts Replace executeTask() while loop with AgentLoopService.run() call
APIs/backend-vitto-api/src/app/tools/plugins/plugins.index.ts Register orchestrationPlugin
APIs/backend-vitto-api/src/app/tools/plugins/tool-plugin.types.ts Add orchestrator to ToolPluginContext
APIs/backend-vitto-api/src/app/engine/engine.module.ts Register SubVittoOrchestratorService, add to CHANNEL_ADAPTERS or exports
APIs/backend-vitto-api/src/app/tools/tool-registry.service.ts Pass orchestrator to plugin context
APIs/backend-vitto-api/src/app/types/vitto.types.ts Add SubVittoEventType SSE types
libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/tool-renderer-host/tool-renderer-host.component.ts Register orchestration renderer

Task 1: SubVitto SSE Proxy

Why first: Both the orchestrator and the SubAgentService refactoring depend on this. It's a pure utility with no dependencies — ideal starting point.

Files:

// sub-vitto-sse-proxy.spec.ts
import { Subject } from 'rxjs';
import { SubVittoSSEProxy } from '../sub-vitto-sse-proxy';

interface SSEEvent {
  type: string;
  taskId: string;
  data: Record<string, unknown>;
  timestamp: string;
}

describe('SubVittoSSEProxy', () => {
  let parentSubject: Subject<SSEEvent>;
  let proxy: SubVittoSSEProxy;
  const subTaskId = 'sub-task-123';
  const parentTaskId = 'parent-task-456';

  beforeEach(() => {
    parentSubject = new Subject<SSEEvent>();
    proxy = new SubVittoSSEProxy(parentSubject, subTaskId, parentTaskId);
  });

  it('should re-emit agent.iteration as subvitto.progress', () => {
    const events: SSEEvent[] = [];
    parentSubject.subscribe(e => events.push(e));

    proxy.subject.next({
      type: 'agent.iteration',
      taskId: subTaskId,
      data: { iteration: 1, maxIterations: 10 },
      timestamp: new Date().toISOString(),
    });

    expect(events).toHaveLength(1);
    expect(events[0].type).toBe('subvitto.progress');
    expect(events[0].data['subTaskId']).toBe(subTaskId);
    expect(events[0].data['parentTaskId']).toBe(parentTaskId);
    expect(events[0].data['iteration']).toBe(1);
  });

  it('should re-emit tool.called with subvitto prefix', () => {
    const events: SSEEvent[] = [];
    parentSubject.subscribe(e => events.push(e));

    proxy.subject.next({
      type: 'tool.called',
      taskId: subTaskId,
      data: { tool: 'recall_memory', success: true },
      timestamp: new Date().toISOString(),
    });

    expect(events).toHaveLength(1);
    expect(events[0].type).toBe('subvitto.tool');
    expect(events[0].data['subTaskId']).toBe(subTaskId);
  });

  it('should pass through task.stuck as subvitto.stuck', () => {
    const events: SSEEvent[] = [];
    parentSubject.subscribe(e => events.push(e));

    proxy.subject.next({
      type: 'task.stuck',
      taskId: subTaskId,
      data: { reason: 'loop detected' },
      timestamp: new Date().toISOString(),
    });

    expect(events).toHaveLength(1);
    expect(events[0].type).toBe('subvitto.stuck');
  });

  it('should unsubscribe on dispose', () => {
    proxy.dispose();
    const events: SSEEvent[] = [];
    parentSubject.subscribe(e => events.push(e));

    proxy.subject.next({
      type: 'agent.iteration',
      taskId: subTaskId,
      data: {},
      timestamp: new Date().toISOString(),
    });

    expect(events).toHaveLength(0);
  });
});

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-sse-proxy.spec.ts --no-cache Expected: FAIL — Cannot find module '../sub-vitto-sse-proxy'

// sub-vitto-sse-proxy.ts
import { Subject, Subscription } from 'rxjs';

interface SSEEvent {
  type: string;
  taskId: string;
  data: Record<string, unknown>;
  timestamp: string;
}

/**
 * Wraps a parent SSE subject, intercepts child events, and re-emits them
 * with subvitto.* type prefix + subTaskId/parentTaskId metadata.
 *
 * This prevents sub-Vitto events (agent.iteration, tool.called, etc.)
 * from polluting the main event stream.
 */
export class SubVittoSSEProxy {
  readonly subject = new Subject<SSEEvent>();
  private subscription: Subscription;

  private static readonly TYPE_MAP: Record<string, string> = {
    'agent.iteration': 'subvitto.progress',
    'agent.text': 'subvitto.text',
    'tool.starting': 'subvitto.tool_starting',
    'tool.called': 'subvitto.tool',
    'task.stuck': 'subvitto.stuck',
    'phase.started': 'subvitto.phase_started',
    'phase.completed': 'subvitto.phase_completed',
  };

  constructor(
    private readonly parentSubject: Subject<SSEEvent>,
    private readonly subTaskId: string,
    private readonly parentTaskId: string,
  ) {
    this.subscription = this.subject.subscribe(event => {
      const mappedType = SubVittoSSEProxy.TYPE_MAP[event.type] || `subvitto.${event.type}`;
      this.parentSubject.next({
        type: mappedType,
        taskId: this.parentTaskId,
        data: {
          ...event.data,
          subTaskId: this.subTaskId,
          parentTaskId: this.parentTaskId,
        },
        timestamp: event.timestamp,
      });
    });
  }

  dispose(): void {
    this.subscription.unsubscribe();
    this.subject.complete();
  }
}

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-sse-proxy.spec.ts --no-cache Expected: PASS — all 4 tests green

git add APIs/backend-vitto-api/src/app/engine/sub-vitto-sse-proxy.ts APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-sse-proxy.spec.ts
git commit -m "[S234.2] feat: add SubVittoSSEProxy for scoped sub-agent event streaming"

Task 2: SSE Type Definitions

Why now: The orchestrator and renderer both need these types. Define them once in the shared types file.

Files:

Add after the existing OrchestrationEventType definition (around line 84):

// --- S234: Sub-Vitto Orchestration Events ---

export type SubVittoEventType =
  | 'subvitto.spawned'
  | 'subvitto.progress'
  | 'subvitto.tool'
  | 'subvitto.tool_starting'
  | 'subvitto.text'
  | 'subvitto.stuck'
  | 'subvitto.completed'
  | 'subvitto.failed'
  | 'orchestrator.round';

export type SubVittoTier = 'in-process' | 'vm' | 'browser';

export interface SubVittoTask {
  subTaskId: string;
  description: string;
  tier: SubVittoTier;
  status: 'pending' | 'running' | 'completed' | 'failed';
  result?: string;
  error?: string;
  iterations?: number;
  inputTokens?: number;
  outputTokens?: number;
  durationMs?: number;
}

export interface SubVittoOrchestratorResult {
  round: number;
  tasks: SubVittoTask[];
  synthesis: string;
  totalInputTokens: number;
  totalOutputTokens: number;
  totalDurationMs: number;
}

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -20 Expected: No new errors

git add APIs/backend-vitto-api/src/app/types/vitto.types.ts
git commit -m "[S234.2] feat: add SubVitto SSE event types and orchestration interfaces"

Task 3: SubVittoOrchestratorService

Why now: This is the core engine. It depends on Task 1 (SSE proxy) and Task 2 (types). Everything else depends on it.

Files:

// sub-vitto-orchestrator.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { SubVittoOrchestratorService } from '../sub-vitto-orchestrator.service';
import { AgentLoopService } from '../agent-loop.service';
import { VittoNarrativeService } from '../vitto-narrative.service';
import { ConfigService } from '@nestjs/config';
import { Subject } from 'rxjs';

describe('SubVittoOrchestratorService', () => {
  let service: SubVittoOrchestratorService;
  let agentLoop: jest.Mocked<AgentLoopService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        SubVittoOrchestratorService,
        {
          provide: AgentLoopService,
          useValue: {
            run: jest.fn().mockResolvedValue({
              output: 'Task completed successfully',
              toolCalls: [],
              iterations: 3,
              inputTokens: 500,
              outputTokens: 200,
              stopReason: 'end_turn',
              completedViaTaskComplete: true,
            }),
          },
        },
        {
          provide: VittoNarrativeService,
          useValue: {
            emitConscience: jest.fn(),
            emitIntrospection: jest.fn(),
          },
        },
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn((key: string, def: unknown) => def),
          },
        },
      ],
    }).compile();

    service = module.get(SubVittoOrchestratorService);
    agentLoop = module.get(AgentLoopService);
  });

  it('should dispatch multiple tasks in parallel', async () => {
    const result = await service.orchestrate({
      parentTaskId: 'parent-1',
      userId: 'user-1',
      tasks: [
        { description: 'Search for security patterns', tier: 'in-process' },
        { description: 'Analyze code structure', tier: 'in-process' },
      ],
      sseSubject: new Subject(),
    });

    expect(agentLoop.run).toHaveBeenCalledTimes(2);
    expect(result.tasks).toHaveLength(2);
    expect(result.tasks[0].status).toBe('completed');
    expect(result.tasks[1].status).toBe('completed');
    expect(result.totalInputTokens).toBe(1000); // 500 * 2
  });

  it('should handle partial failures gracefully', async () => {
    agentLoop.run
      .mockResolvedValueOnce({
        output: 'Success', toolCalls: [], iterations: 2,
        inputTokens: 300, outputTokens: 100, stopReason: 'end_turn',
        completedViaTaskComplete: true,
      })
      .mockRejectedValueOnce(new Error('LLM timeout'));

    const result = await service.orchestrate({
      parentTaskId: 'parent-2',
      userId: 'user-2',
      tasks: [
        { description: 'Task A', tier: 'in-process' },
        { description: 'Task B', tier: 'in-process' },
      ],
      sseSubject: new Subject(),
    });

    expect(result.tasks[0].status).toBe('completed');
    expect(result.tasks[1].status).toBe('failed');
    expect(result.tasks[1].error).toContain('LLM timeout');
  });

  it('should respect max parallel limit', async () => {
    const callOrder: number[] = [];
    let callCount = 0;
    agentLoop.run.mockImplementation(async () => {
      const myIndex = callCount++;
      callOrder.push(myIndex);
      await new Promise(r => setTimeout(r, 10));
      return {
        output: `Result ${myIndex}`, toolCalls: [], iterations: 1,
        inputTokens: 100, outputTokens: 50, stopReason: 'end_turn',
        completedViaTaskComplete: true,
      };
    });

    const tasks = Array.from({ length: 7 }, (_, i) => ({
      description: `Task ${i}`, tier: 'in-process' as const,
    }));

    const result = await service.orchestrate({
      parentTaskId: 'parent-3',
      userId: 'user-3',
      tasks,
      sseSubject: new Subject(),
      maxParallel: 3,
    });

    expect(result.tasks).toHaveLength(7);
    // All should complete even with concurrency limit
    expect(result.tasks.filter(t => t.status === 'completed')).toHaveLength(7);
  });

  it('should block recursive orchestration (depth > 1)', async () => {
    const result = await service.orchestrate({
      parentTaskId: 'parent-4',
      userId: 'user-4',
      tasks: [{ description: 'Recursive task', tier: 'in-process' }],
      sseSubject: new Subject(),
      depth: 2,
    });

    expect(agentLoop.run).not.toHaveBeenCalled();
    expect(result.tasks[0].status).toBe('failed');
    expect(result.tasks[0].error).toContain('depth');
  });
});

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-orchestrator.spec.ts --no-cache Expected: FAIL — Cannot find module '../sub-vitto-orchestrator.service'

// sub-vitto-orchestrator.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Subject } from 'rxjs';
import { AgentLoopService, AgentLoopOptions } from './agent-loop.service';
import { VittoNarrativeService } from './vitto-narrative.service';
import { SubVittoSSEProxy } from './sub-vitto-sse-proxy';
import {
  SubVittoTask,
  SubVittoTier,
  SubVittoOrchestratorResult,
} from '../types/vitto.types';

/** Tool names allowed per tier (in-process only for P1; VM/browser deferred) */
const TIER_TOOL_FILTERS: Record<SubVittoTier, string[]> = {
  'in-process': [
    'recall_memory', 'search_decisions', 'query_docs',
    'web_search', 'fetch_webpage', 'http_request',
    'task_complete',
  ],
  // VM and browser tiers will be enabled in Phase 2
  'vm': [
    'recall_memory', 'search_decisions', 'query_docs',
    'web_search', 'fetch_webpage', 'http_request',
    'run_command', 'write_file', 'read_file', 'search_code',
    'task_complete',
  ],
  'browser': [
    'recall_memory', 'search_decisions', 'query_docs',
    'web_search', 'fetch_webpage', 'http_request',
    'browser_navigate', 'browser_interact', 'browser_screenshot',
    'task_complete',
  ],
};

export interface OrchestrateOptions {
  parentTaskId: string;
  userId: string;
  tasks: Array<{ description: string; tier: SubVittoTier }>;
  sseSubject: Subject<unknown>;
  maxParallel?: number;
  maxIterationsPerTask?: number;
  maxTokensPerTask?: number;
  /** Recursion depth — blocked if > 1 */
  depth?: number;
}

interface SSEEvent {
  type: string;
  taskId: string;
  data: Record<string, unknown>;
  timestamp: string;
}

const MAX_DEPTH = 1;

@Injectable()
export class SubVittoOrchestratorService {
  private readonly logger = new Logger('SubVittoOrchestrator');

  private readonly maxParallel: number;
  private readonly defaultIterations: number;
  private readonly defaultMaxTokens: number;

  constructor(
    private readonly agentLoop: AgentLoopService,
    private readonly narrative: VittoNarrativeService,
    private readonly config: ConfigService,
  ) {
    this.maxParallel = this.config.get<number>('VITTO_SUBVITTO_MAX_PARALLEL', 5);
    this.defaultIterations = this.config.get<number>('VITTO_SUBVITTO_DEFAULT_ITERATIONS', 10);
    this.defaultMaxTokens = this.config.get<number>('VITTO_SUBVITTO_DEFAULT_MAX_TOKENS', 100000);
  }

  async orchestrate(options: OrchestrateOptions): Promise<SubVittoOrchestratorResult> {
    const startTime = Date.now();
    const depth = options.depth || 1;
    const maxParallel = options.maxParallel || this.maxParallel;
    const sseSubject = options.sseSubject as Subject<SSEEvent>;

    // Block recursive orchestration
    if (depth > MAX_DEPTH) {
      this.logger.warn(`Blocked recursive orchestration at depth ${depth} for task ${options.parentTaskId}`);
      return {
        round: 0,
        tasks: options.tasks.map((t, i) => ({
          subTaskId: `${options.parentTaskId}_sub_${i}`,
          description: t.description,
          tier: t.tier,
          status: 'failed' as const,
          error: `Recursive orchestration blocked: max depth is ${MAX_DEPTH}`,
        })),
        synthesis: 'Orchestration blocked: recursive sub-Vitto deployment is not allowed.',
        totalInputTokens: 0,
        totalOutputTokens: 0,
        totalDurationMs: 0,
      };
    }

    // Only in-process tier supported in P1
    const validTasks = options.tasks.map((t, i) => {
      if (t.tier !== 'in-process') {
        this.logger.warn(`Tier '${t.tier}' not yet supported, falling back to in-process for: ${t.description}`);
      }
      return {
        ...t,
        tier: 'in-process' as SubVittoTier,
        subTaskId: `${options.parentTaskId}_sub_${i}_${Date.now().toString(36)}`,
      };
    });

    // Emit spawned events
    for (const task of validTasks) {
      sseSubject.next({
        type: 'subvitto.spawned',
        taskId: options.parentTaskId,
        data: {
          subTaskId: task.subTaskId,
          tier: task.tier,
          description: task.description,
        },
        timestamp: new Date().toISOString(),
      });
    }

    // Execute with concurrency limit
    const results = await this.executeWithConcurrency(
      validTasks, options, sseSubject, maxParallel,
    );

    const totalDurationMs = Date.now() - startTime;

    const orchestratorResult: SubVittoOrchestratorResult = {
      round: 1,
      tasks: results,
      synthesis: this.buildSynthesis(results),
      totalInputTokens: results.reduce((sum, t) => sum + (t.inputTokens || 0), 0),
      totalOutputTokens: results.reduce((sum, t) => sum + (t.outputTokens || 0), 0),
      totalDurationMs,
    };

    // Emit round complete
    sseSubject.next({
      type: 'orchestrator.round',
      taskId: options.parentTaskId,
      data: {
        roundNumber: 1,
        subTaskIds: results.map(t => t.subTaskId),
        completedCount: results.filter(t => t.status === 'completed').length,
        failedCount: results.filter(t => t.status === 'failed').length,
        totalDurationMs,
      },
      timestamp: new Date().toISOString(),
    });

    return orchestratorResult;
  }

  private async executeWithConcurrency(
    tasks: Array<{ subTaskId: string; description: string; tier: SubVittoTier }>,
    options: OrchestrateOptions,
    sseSubject: Subject<SSEEvent>,
    maxParallel: number,
  ): Promise<SubVittoTask[]> {
    const results: SubVittoTask[] = [];
    const chunks = this.chunk(tasks, maxParallel);

    for (const chunk of chunks) {
      const chunkResults = await Promise.allSettled(
        chunk.map(task => this.executeSubTask(task, options, sseSubject)),
      );

      for (let i = 0; i < chunkResults.length; i++) {
        const settled = chunkResults[i];
        const task = chunk[i];

        if (settled.status === 'fulfilled') {
          results.push(settled.value);
        } else {
          results.push({
            subTaskId: task.subTaskId,
            description: task.description,
            tier: task.tier,
            status: 'failed',
            error: settled.reason?.message || String(settled.reason),
          });

          sseSubject.next({
            type: 'subvitto.failed',
            taskId: options.parentTaskId,
            data: { subTaskId: task.subTaskId, error: settled.reason?.message },
            timestamp: new Date().toISOString(),
          });
        }
      }
    }

    return results;
  }

  private async executeSubTask(
    task: { subTaskId: string; description: string; tier: SubVittoTier },
    options: OrchestrateOptions,
    parentSSE: Subject<SSEEvent>,
  ): Promise<SubVittoTask> {
    const startTime = Date.now();
    const proxy = new SubVittoSSEProxy(parentSSE, task.subTaskId, options.parentTaskId);

    try {
      const loopOptions: AgentLoopOptions = {
        systemPrompt: this.buildSubVittoSystemPrompt(task.description),
        userMessage: task.description,
        layer: 'developer',
        taskId: task.subTaskId,
        toolFilter: TIER_TOOL_FILTERS[task.tier],
        maxIterations: options.maxIterationsPerTask || this.defaultIterations,
        maxTokens: options.maxTokensPerTask || this.defaultMaxTokens,
        userId: options.userId,
        temperature: 0.3,
      };

      const result = await this.agentLoop.run(loopOptions, proxy.subject as Subject<unknown>);

      const subTask: SubVittoTask = {
        subTaskId: task.subTaskId,
        description: task.description,
        tier: task.tier,
        status: 'completed',
        result: result.output,
        iterations: result.iterations,
        inputTokens: result.inputTokens,
        outputTokens: result.outputTokens,
        durationMs: Date.now() - startTime,
      };

      parentSSE.next({
        type: 'subvitto.completed',
        taskId: options.parentTaskId,
        data: {
          subTaskId: task.subTaskId,
          result: result.output.substring(0, 500),
          iterations: result.iterations,
          durationMs: subTask.durationMs,
        },
        timestamp: new Date().toISOString(),
      });

      return subTask;
    } catch (err) {
      const error = err instanceof Error ? err.message : String(err);
      return {
        subTaskId: task.subTaskId,
        description: task.description,
        tier: task.tier,
        status: 'failed',
        error,
        durationMs: Date.now() - startTime,
      };
    } finally {
      proxy.dispose();
    }
  }

  private buildSubVittoSystemPrompt(taskDescription: string): string {
    return [
      'You are a sub-Vitto — a focused specialist deployed by the main Vitto agent.',
      'Your job is to complete ONE specific task thoroughly and report results.',
      '',
      'Guidelines:',
      '- Stay focused on the assigned task. Do not expand scope.',
      '- Use available tools to gather information needed.',
      '- Call task_complete when done, with a clear summary of findings.',
      '- Be concise but thorough in your analysis.',
      '',
      `Task: ${taskDescription}`,
    ].join('\n');
  }

  private buildSynthesis(tasks: SubVittoTask[]): string {
    const completed = tasks.filter(t => t.status === 'completed');
    const failed = tasks.filter(t => t.status === 'failed');

    const parts: string[] = [];
    if (completed.length > 0) {
      parts.push(`${completed.length} task(s) completed successfully.`);
      for (const t of completed) {
        parts.push(`- ${t.description}: ${(t.result || '').substring(0, 200)}`);
      }
    }
    if (failed.length > 0) {
      parts.push(`${failed.length} task(s) failed.`);
      for (const t of failed) {
        parts.push(`- ${t.description}: ${t.error}`);
      }
    }
    return parts.join('\n');
  }

  private chunk<T>(arr: T[], size: number): T[][] {
    const result: T[][] = [];
    for (let i = 0; i < arr.length; i += size) {
      result.push(arr.slice(i, i + size));
    }
    return result;
  }
}

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-orchestrator.spec.ts --no-cache Expected: PASS — all 4 tests green

git add APIs/backend-vitto-api/src/app/engine/sub-vitto-orchestrator.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/sub-vitto-orchestrator.spec.ts
git commit -m "[S234.2] feat: add SubVittoOrchestratorService with parallel dispatch and budget control"

Task 4: Orchestration Tool Plugin (deploy_agents)

Files:

// orchestration.plugin.spec.ts
import { orchestrationPlugin } from '../orchestration.plugin';
import { ToolPluginContext } from '../tool-plugin.types';

describe('orchestrationPlugin', () => {
  it('should return a deploy_agents tool handler', () => {
    const mockOrchestrator = { orchestrate: jest.fn() };
    const ctx = { orchestrator: mockOrchestrator } as unknown as ToolPluginContext;
    const handlers = orchestrationPlugin(ctx);

    expect(handlers).toHaveLength(1);
    expect(handlers[0].name).toBe('deploy_agents');
    expect(handlers[0].layers).toContain('developer');
    expect(handlers[0].layers).toContain('platform_builder');
  });

  it('should return error when orchestrator not available', async () => {
    const ctx = { orchestrator: undefined } as unknown as ToolPluginContext;
    const handlers = orchestrationPlugin(ctx);
    const result = await handlers[0].execute({ tasks: [] });

    expect(result).toContain('not available');
  });

  it('should call orchestrator.orchestrate with correct params', async () => {
    const mockOrchestrator = {
      orchestrate: jest.fn().mockResolvedValue({
        round: 1,
        tasks: [{ subTaskId: 'sub-1', status: 'completed', result: 'Done' }],
        synthesis: '1 task completed.',
        totalInputTokens: 500,
        totalOutputTokens: 200,
        totalDurationMs: 1500,
      }),
    };
    const ctx = { orchestrator: mockOrchestrator } as unknown as ToolPluginContext;
    const handlers = orchestrationPlugin(ctx);

    const result = await handlers[0].execute(
      {
        tasks: [
          { description: 'Analyze auth module', tier: 'in-process' },
        ],
      },
      { userId: 'user-1', taskId: 'task-1', sseSubject: {} },
    );

    expect(mockOrchestrator.orchestrate).toHaveBeenCalledTimes(1);
    const callArgs = mockOrchestrator.orchestrate.mock.calls[0][0];
    expect(callArgs.parentTaskId).toBe('task-1');
    expect(callArgs.userId).toBe('user-1');
    expect(callArgs.tasks).toHaveLength(1);

    const parsed = JSON.parse(result);
    expect(parsed.synthesis).toBe('1 task completed.');
  });
});

Run: npx jest APIs/backend-vitto-api/src/app/tools/plugins/__tests__/orchestration.plugin.spec.ts --no-cache Expected: FAIL — Cannot find module '../orchestration.plugin'

// orchestration.plugin.ts
/**
 * Orchestration Plugin — deploy_agents tool
 * S234: Enables Vitto to spawn parallel sub-Vittos for complex tasks.
 */
import { ToolHandler, ToolPluginContext, ToolExecutionContext } from './tool-plugin.types';
import { SubVittoTier } from '../../types/vitto.types';
import { Subject } from 'rxjs';

export function orchestrationPlugin(ctx: ToolPluginContext): ToolHandler[] {
  return [
    {
      name: 'deploy_agents',
      description: [
        'Deploy multiple sub-Vitto agents to work on subtasks in parallel.',
        'Use this when a task can be broken into independent pieces that benefit from parallel execution.',
        'Each sub-agent runs with its own context and reports results back.',
        'Available tiers: "in-process" (search, analyze, recall — instant startup).',
        'Call this tool when you need to: search multiple topics simultaneously,',
        'analyze different aspects of a problem in parallel, or gather diverse context.',
      ].join(' '),
      parameters: {
        type: 'object',
        properties: {
          tasks: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                description: {
                  type: 'string',
                  description: 'Clear description of what this sub-agent should accomplish',
                },
                tier: {
                  type: 'string',
                  enum: ['in-process'],
                  description: 'Execution tier. Currently only "in-process" is available.',
                },
              },
              required: ['description'],
            },
            description: 'Array of sub-tasks to execute in parallel (max 5)',
          },
        },
        required: ['tasks'],
      },
      layers: ['developer', 'platform_builder'],
      execute: async (
        input: Record<string, unknown>,
        context?: ToolExecutionContext,
      ): Promise<string> => {
        if (!ctx.orchestrator) {
          return JSON.stringify({ error: 'Sub-Vitto orchestrator not available' });
        }

        const rawTasks = input.tasks as Array<{ description: string; tier?: string }>;
        if (!rawTasks?.length) {
          return JSON.stringify({ error: 'No tasks provided' });
        }

        // Cap at 5 tasks
        const tasks = rawTasks.slice(0, 5).map(t => ({
          description: t.description,
          tier: (t.tier || 'in-process') as SubVittoTier,
        }));

        try {
          const result = await ctx.orchestrator.orchestrate({
            parentTaskId: context?.taskId || `orch_${Date.now().toString(36)}`,
            userId: context?.userId || 'system',
            tasks,
            sseSubject: (context?.sseSubject || new Subject()) as Subject<unknown>,
          });

          return JSON.stringify({
            round: result.round,
            tasks: result.tasks.map((t: Record<string, unknown>) => ({
              subTaskId: t.subTaskId,
              description: t.description,
              status: t.status,
              result: typeof t.result === 'string' ? t.result.substring(0, 1000) : t.result,
              error: t.error,
              iterations: t.iterations,
              durationMs: t.durationMs,
            })),
            synthesis: result.synthesis,
            totalDurationMs: result.totalDurationMs,
          });
        } catch (err) {
          const message = err instanceof Error ? err.message : String(err);
          return JSON.stringify({ error: `Orchestration failed: ${message}` });
        }
      },
    },
  ];
}

Run: npx jest APIs/backend-vitto-api/src/app/tools/plugins/__tests__/orchestration.plugin.spec.ts --no-cache Expected: PASS — all 3 tests green

git add APIs/backend-vitto-api/src/app/tools/plugins/orchestration.plugin.ts APIs/backend-vitto-api/src/app/tools/plugins/__tests__/orchestration.plugin.spec.ts
git commit -m "[S234.2] feat: add deploy_agents tool plugin for sub-Vitto orchestration"

Task 5: Wire Orchestrator into Plugin System

Files:

In tool-plugin.types.ts, add to ToolPluginContext interface (after the whistleOrchestrator field, around line 105):

  /** S234: Sub-Vitto orchestrator — optional, for deploy_agents tool */
  orchestrator?: {
    orchestrate: (options: {
      parentTaskId: string;
      userId: string;
      tasks: Array<{ description: string; tier: string }>;
      sseSubject: import('rxjs').Subject<unknown>;
      maxParallel?: number;
      maxIterationsPerTask?: number;
      maxTokensPerTask?: number;
      depth?: number;
    }) => Promise<{
      round: number;
      tasks: Array<Record<string, unknown>>;
      synthesis: string;
      totalInputTokens: number;
      totalOutputTokens: number;
      totalDurationMs: number;
    }>;
  };

Add import and registration:

import { orchestrationPlugin } from './orchestration.plugin';

Add orchestrationPlugin to the array returned by getAllToolPlugins():

export function getAllToolPlugins(): ToolPluginFn[] {
  return [
    // ... existing plugins ...
    whistlePlugin,
    orchestrationPlugin,
  ];
}

Add import:

import { SubVittoOrchestratorService } from './sub-vitto-orchestrator.service';

Add to providers array (after WhistleOrchestratorService):

// S234: Sub-Vitto parallel orchestration
SubVittoOrchestratorService,

Add to exports array:

SubVittoOrchestratorService,

Find the buildPluginContext() or equivalent method in tool-registry.service.ts that builds ToolPluginContext. Add orchestrator injection.

First, read tool-registry.service.ts to find the exact location (~lines 299-326 per exploration).

Add SubVittoOrchestratorService to constructor injection and pass it in the context:

// In constructor:
@Optional() private readonly subVittoOrchestrator?: SubVittoOrchestratorService,

// In the plugin context builder:
orchestrator: this.subVittoOrchestrator ? {
  orchestrate: (opts) => this.subVittoOrchestrator.orchestrate(opts as any),
} : undefined,

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -30 Expected: No new errors (warnings OK)

git add APIs/backend-vitto-api/src/app/tools/plugins/tool-plugin.types.ts \
       APIs/backend-vitto-api/src/app/tools/plugins/plugins.index.ts \
       APIs/backend-vitto-api/src/app/engine/engine.module.ts \
       APIs/backend-vitto-api/src/app/tools/tool-registry.service.ts
git commit -m "[S234.2] feat: wire SubVittoOrchestratorService into plugin system and module"

Task 6: Refactor SubAgentService.executeTask() to use AgentLoopService

Why now: With the orchestrator wired, refactor the duplicated while loop in SubAgentService to use the shared engine. This is the spec's core refactoring.

Files:

Read lines 460-690 of sub-agent.service.ts to understand all post-loop logic (validation, performance tracking, etc.) that must be preserved.

Replace the while loop (lines 550-614) with a call to AgentLoopService.run(). Preserve:

The refactored method body (replacing lines 516-614):

    // Use AgentLoopService.run() instead of manual while loop
    // This gives sub-agents: stuck detection, parallel tool calls, checkpoints, SSE
    const loopOptions: AgentLoopOptions = {
      systemPrompt,
      userMessage: userPrompt,
      layer: 'developer',
      taskId: parentTaskId,
      provider: def.preferredProvider as ProviderName | undefined,
      model: def.preferredModel || undefined,
      maxTokens: options?.maxTokens || def.maxTokens || 4096,
      temperature: 0.3,
      toolFilter: SubAgentService.SUB_AGENT_TOOL_NAMES,
      maxIterations: Math.min(
        options?.maxIterations || limits.maxIterations,
        def.maxIterations || 15,
      ),
      userId: undefined, // Sub-agents don't have user-scoped tools
    };

    let finalResult = '';
    let taskSuccess = false;
    let iterations = 0;
    let inputTokens = 0;
    let outputTokens = 0;

    try {
      const result = await this.agentLoop.run(loopOptions);
      finalResult = result.output;
      taskSuccess = result.completedViaTaskComplete || !!result.output;
      iterations = result.iterations;
      inputTokens = result.inputTokens;
      outputTokens = result.outputTokens;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.logger.error(`[${agent.agentId}] AgentLoop error: ${message}`);
      finalResult = `[ERROR: ${message}]`;
      taskSuccess = false;
    }

Also add a static SUB_AGENT_TOOL_NAMES field (string array of allowed tool names) alongside the existing SUB_AGENT_TOOLS definitions array:

static readonly SUB_AGENT_TOOL_NAMES = [
  'recall_memory', 'search_decisions', 'query_docs', 'task_complete',
];

And inject AgentLoopService in the constructor.

Find the constructor and add:

@Optional() private readonly agentLoop?: AgentLoopService,

Add the import at the top:

import { AgentLoopService, AgentLoopOptions } from './agent-loop.service';

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/ --testPathPattern="sub-agent|delegation" --no-cache 2>&1 | tail -20 Expected: Existing tests still pass (some may need mock updates for AgentLoopService)

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | head -30 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/sub-agent.service.ts
git commit -m "[S234.2] refactor: SubAgentService.executeTask() uses AgentLoopService.run() instead of manual while loop"

Task 7: Frontend — SubVittoOrchestrationRenderer

Files:

// sub-vitto-orchestration-renderer.component.ts
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import { ToolCallInfo } from '../../../../types/chat.types';

interface SubVittoTaskDisplay {
  subTaskId: string;
  description: string;
  status: 'pending' | 'running' | 'completed' | 'failed';
  result?: string;
  error?: string;
  iterations?: number;
  durationMs?: number;
}

@Component({
  selector: 'lib-sub-vitto-orchestration-renderer',
  standalone: true,
  templateUrl: './sub-vitto-orchestration-renderer.component.html',
  styleUrls: ['./sub-vitto-orchestration-renderer.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubVittoOrchestrationRendererComponent {
  readonly toolCall = input.required<ToolCallInfo>();

  readonly parsedOutput = computed(() => {
    try {
      const raw = this.toolCall().output;
      return raw ? JSON.parse(raw) : null;
    } catch {
      return null;
    }
  });

  readonly tasks = computed<SubVittoTaskDisplay[]>(() => {
    const output = this.parsedOutput();
    return output?.tasks || [];
  });

  readonly synthesis = computed(() => {
    const output = this.parsedOutput();
    return output?.synthesis || '';
  });

  readonly totalDuration = computed(() => {
    const output = this.parsedOutput();
    const ms = output?.totalDurationMs || 0;
    return ms > 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
  });

  readonly isRunning = computed(() => {
    return !this.toolCall().output;
  });

  getStatusIcon(status: string): string {
    switch (status) {
      case 'completed': return '✓';
      case 'failed': return '✗';
      case 'running': return '⏳';
      default: return '○';
    }
  }
}
<!-- sub-vitto-orchestration-renderer.component.html -->
<div class="sub-vitto-orchestration">
  <div class="header">
    <span class="title">
      @if (isRunning()) {
        Deploying {{ tasks().length || '...' }} agents
      } @else {
        Deployed {{ tasks().length }} agents
      }
    </span>
    @if (!isRunning()) {
      <span class="duration">{{ totalDuration() }}</span>
    }
  </div>

  <div class="task-list">
    @for (task of tasks(); track task.subTaskId) {
      <div class="task-row" [class]="'status-' + task.status">
        <span class="status-icon">{{ getStatusIcon(task.status) }}</span>
        <span class="description">{{ task.description }}</span>
        @if (task.iterations) {
          <span class="meta">{{ task.iterations }} iter</span>
        }
        @if (task.durationMs) {
          <span class="meta">{{ task.durationMs > 1000 ? (task.durationMs / 1000).toFixed(1) + 's' : task.durationMs + 'ms' }}</span>
        }
      </div>
      @if (task.error) {
        <div class="task-error">{{ task.error }}</div>
      }
    }
  </div>

  @if (synthesis() && !isRunning()) {
    <div class="synthesis">
      <span class="synthesis-label">Synthesis</span>
      <span class="synthesis-text">{{ synthesis() }}</span>
    </div>
  }

  @if (isRunning()) {
    <div class="running-indicator">Running...</div>
  }
</div>
// sub-vitto-orchestration-renderer.component.scss
:host {
  display: block;
}

.sub-vitto-orchestration {
  border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
  border-radius: 8px;
  padding: 12px;
  font-size: 13px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;

  .title {
    font-weight: 600;
  }

  .duration {
    opacity: 0.6;
    font-size: 12px;
  }
}

.task-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.task-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 8px;
  border-radius: 4px;

  &.status-completed {
    .status-icon { color: var(--success-color, #4caf50); }
  }

  &.status-failed {
    .status-icon { color: var(--error-color, #f44336); }
  }

  &.status-running {
    opacity: 0.8;
  }

  .description {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .meta {
    opacity: 0.5;
    font-size: 11px;
    white-space: nowrap;
  }
}

.task-error {
  margin-left: 24px;
  padding: 4px 8px;
  font-size: 12px;
  color: var(--error-color, #f44336);
  opacity: 0.8;
}

.synthesis {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
  font-size: 12px;

  .synthesis-label {
    font-weight: 600;
    margin-right: 6px;
  }

  .synthesis-text {
    opacity: 0.8;
  }
}

.running-indicator {
  margin-top: 8px;
  opacity: 0.6;
  font-style: italic;
}

Add import:

import { SubVittoOrchestrationRendererComponent } from '../renderers/sub-vitto-orchestration-renderer/sub-vitto-orchestration-renderer.component';

Add to imports array:

SubVittoOrchestrationRendererComponent,

Add to the rendererType computed (before the return 'default'):

if (name === 'deploy_agents') return 'sub-vitto-orchestration';

Read the template file and add the new case to the ngSwitch:

@case ('sub-vitto-orchestration') {
  <lib-sub-vitto-orchestration-renderer [toolCall]="toolCall()" />
}

Run: npx nx build libs-ai-vitto-chat --skip-nx-cache 2>&1 | tail -20 Expected: Build succeeds (or at least no new errors from our changes)

git add libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/renderers/sub-vitto-orchestration-renderer/ \
       libs/libs-ai/vitto-chat/src/lib/components/tool-renderers/tool-renderer-host/tool-renderer-host.component.ts
git commit -m "[S234.2] feat: add SubVittoOrchestrationRenderer for parallel agent execution display"

Task 8: Integration Smoke Test

Files:

// Test that deploy_agents tool is registered and callable
import { Test, TestingModule } from '@nestjs/testing';
import { ToolRegistryService } from '../../tools/tool-registry.service';

describe('Sub-Vitto Integration', () => {
  it('deploy_agents tool should be registered', async () => {
    // This test may need the full module setup — if too heavy,
    // verify by checking getAllToolPlugins() includes orchestrationPlugin
    const { getAllToolPlugins } = await import('../../tools/plugins/plugins.index');
    const plugins = getAllToolPlugins();

    // orchestrationPlugin should be in the list
    expect(plugins.length).toBeGreaterThanOrEqual(18); // 17 existing + 1 new
  });
});

Run: npx jest APIs/backend-vitto-api/src/app/engine/__tests__/ --no-cache 2>&1 | tail -30 Expected: All existing tests pass, no regressions

Run: npx tsc --noEmit --project APIs/backend-vitto-api/tsconfig.lib.json 2>&1 | tail -20 Expected: Clean compilation

git add APIs/backend-vitto-api/src/app/engine/__tests__/
git commit -m "[S234.2] test: integration smoke test for Sub-Vitto engine pipeline"

Post-Implementation Notes

What This Plan Does NOT Cover (Deferred to Phase 2+)

  1. VM tier execution — SandboxedToolProxy, warm pool (spec Section 2)
  2. Browser tier execution — Reuse ComputerUseSupervisorService
  3. Multi-round orchestration — Round 2+ based on Round 1 results (spec mentions it, but single-round is the MVP)
  4. Parent-level budget aggregation — Flagged in critical analysis. Add parentTaskBudget in Phase 2.
  5. OAuth infrastructure — Spec Section 4, separate plan
  6. Expert sub-agent UX — Chat-based creation, marketplace (spec Section 3)
  7. Vitto's Soul — Personality drift, visual evolution (spec Section 6)

Verification Checklist