2026 03 19 Vitto Unified Autonomy Engine Design

Documentation for Plans

Vitto Unified Autonomy Engine — VM Execution, Adaptive Trust & Proactive Intelligence

Session: S240 Date: 2026-03-19 Status: Design Approved Owner: backend-vitto-api (primary), backend-ai-api (sandbox proxy), sandbox-orchestrator (VM lifecycle) Predecessor: S-future-vm-agent-tier-handoff.md, S237 Jarvis gap analysis, S239 Memory v2 spec Concurrent: S239 Memory System v2 (compatible — see Section 12)


Executive Summary

Vitto's current architecture has all the building blocks for a 24/7 autonomous super-agent — 40+ tools, parallel sub-Vittos, Firecracker VMs, tiered memory, proactive dispatch, autonomy gates — but they're disconnected. The VM tier isn't wired to sub-Vittos. Trust levels are static. Proactive behavior is hardcoded. Tasks can't recover from crashes.

This spec introduces the Unified Autonomy Engine — a single architectural layer that fuses VM execution, adaptive trust, and proactive intelligence into one coherent system. Instead of wiring them separately, they share a read-only AutonomyContext assembled by an AutonomyGate that simultaneously:

  1. Picks the right execution tier (in-process → VM → browser) based on what the task needs
  2. Checks/updates trust based on the user's outcome history with Vitto
  3. Decides whether to act, suggest, or wait — adapting in real-time
  4. Checkpoints progress so long-running tasks survive failures

Key design decisions:


Table of Contents

  1. Architecture Overview
  2. Autonomy Gate & Context
  3. VM Execution Layer
  4. Adaptive Trust Engine
  5. Dynamic Tier Escalation
  6. Proactive Intelligence
  7. Checkpoint & Recovery
  8. Cross-Agent Learning
  9. Agent Nesting
  10. Chat Visibility & UX
  11. Security Considerations
  12. S239 Memory v2 Compatibility
  13. Key Files Reference
  14. Migration & Phasing
  15. Risks & Mitigations
  16. Dependencies & Configuration

1. Architecture Overview

User message / Proactive trigger / Sub-Vitto spawn
  │
  ▼
┌─────────────────────────────────────────────────────────┐
│                    AUTONOMY GATE                         │
│                                                          │
│  TrustScoringService ──► trustScore, trustLevel          │
│  TierResolverService ──► availableTier, vmId             │
│  ActionDecider (existing AutonomyService) ──► proceed?   │
│                                                          │
│  Output: AutonomyContext (read-only snapshot)             │
└────────────────────────┬────────────────────────────────┘
                         │
           ┌─────────────┼─────────────┐
           │             │             │
           ▼             ▼             ▼
   ┌──────────────┐ ┌──────────┐ ┌──────────────────┐
   │ In-Process   │ │ VM Tier  │ │ Browser Tier     │
   │ (existing)   │ │ (NEW)    │ │ (future, S241+)  │
   │              │ │          │ │                   │
   │ ProcessSandbox│ │ Sandboxed│ │ Computer-Use     │
   │ Service      │ │ ToolProxy│ │ Supervisor        │
   │              │ │ Service  │ │ (existing)        │
   └──────────────┘ └──────────┘ └──────────────────┘
           │             │             │
           └─────────────┼─────────────┘
                         │
                         ▼
                ┌─────────────────┐
                │  Outcome Report  │
                │  → Trust Update  │
                │  → Trigger Check │
                │  → Learning Sync │
                └─────────────────┘

Three independent services, one gate:

The gate is the only place they meet. Each service can evolve independently.


2. Autonomy Gate & Context

AutonomyContext (Read-Only Snapshot)

// NEW: APIs/backend-vitto-api/src/app/engine/autonomy/autonomy-context.ts

interface AutonomyContext {
  // WHO
  userId: string;
  spaceId?: string;
  universeId?: string;
  role: SystemRole;

  // TRUST (adaptive)
  trustScore: number;           // 0-100, computed from outcome history
  trustLevel: AutonomyLevel;    // OBSERVE | SUGGEST | DIRECT | AUTONOMOUS
  trustTrajectory: 'rising' | 'stable' | 'declining';

  // EXECUTION (resolved)
  resolvedTier: SubVittoTier;   // 'in-process' | 'vm' | 'browser'
  activeVmId?: string;          // if a VM was claimed
  vmRestored: boolean;          // true if restored from snapshot (fast path)

  // TASK (analyzed)
  taskComplexity: 'simple' | 'moderate' | 'complex';
  estimatedDuration: 'short' | 'medium' | 'long';  // <1m | 1-10m | 10m+
  requiresIsolation: boolean;

  // PROACTIVE
  matchedTriggers: TriggerPattern[];  // patterns that matched this action
  suppressedUntil?: Date;

  // METADATA
  assembledAt: Date;
  cachedUntil: Date;            // context valid for this task's duration
}

AutonomyGateService

// NEW: APIs/backend-vitto-api/src/app/engine/autonomy/autonomy-gate.service.ts

@Injectable()
class AutonomyGateService {
  constructor(
    private trustScoring: TrustScoringService,
    private tierResolver: TierResolverService,
    private autonomy: AutonomyService,        // existing, extended
    private triggerEngine: TriggerEngineService,
  ) {}

  /**
   * Assemble a complete AutonomyContext for a task.
   * Called ONCE per task (not per tool call).
   * ~50ms total (trust lookup + tier resolution + trigger check).
   */
  async evaluate(task: TaskEvaluation): Promise<AutonomyContext> {
    const [trust, tier, triggers] = await Promise.all([
      this.trustScoring.getScore(task.userId, task.actionDomain),
      this.tierResolver.resolve(task),
      this.triggerEngine.checkTriggers(task.userId, task),
    ]);

    const trustLevel = this.trustScoring.scoreToLevel(trust.score, task.userId);
    const action = await this.autonomy.shouldProceed(
      task.taskId, task.phase, trustLevel, task.reasoning,
    );

    return {
      userId: task.userId,
      spaceId: task.spaceId,
      universeId: task.universeId,
      role: task.role,
      trustScore: trust.score,
      trustLevel,
      trustTrajectory: trust.trajectory,
      resolvedTier: tier.tier,
      activeVmId: tier.vmId,
      vmRestored: tier.restored,
      taskComplexity: task.complexity,
      estimatedDuration: task.estimatedDuration,
      requiresIsolation: tier.requiresIsolation,
      matchedTriggers: triggers,
      suppressedUntil: trust.suppressedUntil,
      assembledAt: new Date(),
      // Cache duration = min(estimated task duration, 5 minutes).
      // Critical trust changes (score drop >= 15) invalidate cached contexts immediately.
      cachedUntil: new Date(Date.now() + Math.min(this.estimateDurationMs(task), 300_000)),
    };
  }
}

interface TaskEvaluation {
  taskId: string;
  userId: string;
  spaceId?: string;
  universeId?: string;
  role: SystemRole;
  phase: string;
  reasoning: string;
  actionDomain: ActionDomain;
  complexity: 'simple' | 'moderate' | 'complex';
  estimatedDuration: 'short' | 'medium' | 'long';
  requestedTier?: SubVittoTier;
  tools?: string[];
}

Key design choice: Context is assembled once per task, cached for the duration. Sub-Vittos executing 8 concurrent tools don't trigger 8 gate evaluations — they share the parent task's context.


3. VM Execution Layer

3.1 Current State (What Exists)

Component Status File
Firecracker VMs on Hetzner Production APIs/sandbox-orchestrator/
Per-user snapshot/restore (~500ms) Implemented (S152-178) snapshot.service.ts, cleanup.service.ts
SandboxProvider interface Production sandbox-provider.interface.ts
InfrastructureClientService Production infrastructure-client.service.ts
SelfHostedProvider (ai-api → orchestrator) Production self-hosted.provider.ts
ProcessSandboxService (local isolation) Production process-sandbox.service.ts
ComputerUseSupervisor (VM vision loop) Production computer-use-supervisor.service.ts
Sub-Vitto tier definitions (TIER_TOOLS) Defined, partially wired sub-vitto-orchestrator.service.ts

3.2 What's Missing

The gap is a single service: SandboxedToolProxyService — intercepts tool calls from VM-tier sub-Vittos and routes them to a remote Firecracker VM via the existing infrastructure chain.

3.3 VmApiClientService (NEW — Bridge to Sandbox Operations)

The existing InfrastructureClientService only exposes browser/desktop/computer-use methods. VM lifecycle operations (snapshot, restore, create, destroy) and structured file ops exist on SelfHostedProvider in backend-ai-api but are NOT exposed via any HTTP endpoint that vitto-api can call.

PREREQUISITE (Phase 1): Add the following endpoints to backend-ai-api's infrastructure controller:

POST   /infrastructure/sandbox/create        → SelfHostedProvider.getOrCreateSandbox()
GET    /infrastructure/sandbox/user/:userId   → SelfHostedProvider.getUserVmState()
POST   /infrastructure/sandbox/user/:userId/restore → SelfHostedProvider.restoreFromSnapshot()
POST   /infrastructure/sandbox/:id/snapshot   → SelfHostedProvider.snapshotAndSuspend()
DELETE /infrastructure/sandbox/:id            → SelfHostedProvider.destroy()
POST   /infrastructure/sandbox/:id/exec       → SelfHostedProvider.runCommand() (with timeout param)
POST   /infrastructure/sandbox/:id/write-file → NEW: structured file write via Node fs over SSH
POST   /infrastructure/sandbox/:id/read-file  → NEW: structured file read via Node fs over SSH
POST   /infrastructure/sandbox/:id/inject-ssh-key → NEW: secure key injection via tmpfs
// NEW: APIs/backend-vitto-api/src/app/engine/vm/vm-api-client.service.ts

@Injectable()
class VmApiClientService {
  private readonly baseUrl: string;

  constructor(
    private httpService: HttpService,
    @Inject('BACKEND_API_INTERNAL_TOKEN') private token: string,
  ) {
    this.baseUrl = process.env.AI_API_URL || 'http://localhost:3001';
  }

  async createSandbox(sessionId: string, userId: string, template: string): Promise<SandboxHandle> {
    return this.post('/infrastructure/sandbox/create', { sessionId, userId, template });
  }

  async getUserVmState(userId: string): Promise<UserVmState> {
    return this.get(`/infrastructure/sandbox/user/${userId}`);
  }

  async restoreFromSnapshot(userId: string, sessionId: string): Promise<SandboxHandle> {
    return this.post(`/infrastructure/sandbox/user/${userId}/restore`, { sessionId });
  }

  async snapshotAndSuspend(vmId: string, userId: string): Promise<boolean> {
    const res = await this.post(`/infrastructure/sandbox/${vmId}/snapshot`, { userId });
    return res.success;
  }

  async destroySandbox(vmId: string): Promise<void> {
    await this.delete(`/infrastructure/sandbox/${vmId}`);
  }

  async sandboxExec(vmId: string, params: VmExecParams): Promise<ExecResult> {
    return this.post(`/infrastructure/sandbox/${vmId}/exec`, params);
  }

  async sandboxWriteFile(vmId: string, params: { path: string; content: string }): Promise<void> {
    await this.post(`/infrastructure/sandbox/${vmId}/write-file`, params);
  }

  async sandboxReadFile(vmId: string, params: { path: string }): Promise<string> {
    const res = await this.post(`/infrastructure/sandbox/${vmId}/read-file`, params);
    return res.content;
  }

  async injectSshKey(vmId: string, params: { keyContent: string; repoUrl: string }): Promise<void> {
    await this.post(`/infrastructure/sandbox/${vmId}/inject-ssh-key`, params);
  }

  private async post(path: string, body: unknown): Promise<any> {
    const { data } = await firstValueFrom(
      this.httpService.post(`${this.baseUrl}${path}`, body, {
        headers: { Authorization: `Bearer ${this.token}` },
        timeout: 120_000,
      }),
    );
    return data;
  }

  private async get(path: string): Promise<any> {
    const { data } = await firstValueFrom(
      this.httpService.get(`${this.baseUrl}${path}`, {
        headers: { Authorization: `Bearer ${this.token}` },
        timeout: 30_000,
      }),
    );
    return data;
  }

  private async delete(path: string): Promise<void> {
    await firstValueFrom(
      this.httpService.delete(`${this.baseUrl}${path}`, {
        headers: { Authorization: `Bearer ${this.token}` },
        timeout: 30_000,
      }),
    );
  }
}

3.4 SandboxedToolProxyService (NEW)

// NEW: APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.ts

@Injectable()
class SandboxedToolProxyService {
  constructor(
    private vmApiClient: VmApiClientService,
    private logger: Logger,
  ) {}

  /**
   * Execute a tool call on a remote VM.
   * Called by AgentLoopService when the AutonomyContext has resolvedTier='vm'.
   *
   * SECURITY: Uses structured API calls to sandbox-orchestrator, NOT shell
   * command interpolation. This prevents command injection from LLM-generated
   * tool inputs (paths, patterns, content).
   *
   * Tool mapping:
   *   run_command  → POST /sandboxes/:id/exec { command, cwd, timeoutMs }
   *   write_file   → POST /sandboxes/:id/write-file { path, content }
   *   read_file    → POST /sandboxes/:id/read-file { path }
   *   search_code  → POST /sandboxes/:id/exec { command: grep, cwd, timeoutMs }
   *
   * In-process tools (recall_memory, query_docs, etc.) are NOT proxied —
   * they execute locally as before.
   *
   * PREREQUISITE: Sandbox-orchestrator needs new endpoints:
   *   POST /sandboxes/:id/write-file  — structured file write (no shell)
   *   POST /sandboxes/:id/read-file   — structured file read (no shell)
   *   These are added in Phase 1 to avoid shell interpolation for file ops.
   */
  async executeOnVm(
    vmId: string,
    toolCall: ToolCall,
  ): Promise<ToolResult> {
    switch (toolCall.name) {
      case 'run_command':
        // run_command: the VM IS the security boundary.
        // The command runs inside the Firecracker VM with no access to
        // internal services (enforced by iptables on orchestrator host).
        return this.vmExec(vmId, {
          command: toolCall.input.command,
          cwd: toolCall.input.cwd || '/home/user',
          timeoutMs: 300_000,
        });

      case 'write_file':
        // Structured write — no shell interpolation.
        // Orchestrator writes directly via Node fs, not cat/heredoc.
        return this.vmWriteFile(vmId, {
          path: toolCall.input.path,
          content: toolCall.input.content,
        });

      case 'read_file':
        // Structured read — no shell interpolation.
        return this.vmReadFile(vmId, {
          path: toolCall.input.path,
        });

      case 'search_code': {
        // grep is safe as a command (pattern is a grep arg, not shell-interpreted)
        const escapedPattern = toolCall.input.pattern.replace(/'/g, "'\\''");
        const escapedPath = (toolCall.input.path || '.').replace(/'/g, "'\\''");
        return this.vmExec(vmId, {
          command: `grep -rn '${escapedPattern}' '${escapedPath}'`,
          cwd: toolCall.input.cwd || '/home/user',
          timeoutMs: 60_000,
        });
      }

      default:
        throw new Error(`Tool ${toolCall.name} cannot be proxied to VM`);
    }
  }

  private async vmExec(vmId: string, params: VmExecParams): Promise<ToolResult> {
    const result = await this.vmApiClient.sandboxExec(vmId, params);
    return {
      output: result.stdout,
      error: result.stderr,
      exitCode: result.exitCode,
      executedOn: 'vm',
      vmId,
    };
  }

  private async vmWriteFile(vmId: string, params: { path: string; content: string }): Promise<ToolResult> {
    await this.vmApiClient.sandboxWriteFile(vmId, params);
    return { output: `File written: ${params.path}`, executedOn: 'vm', vmId };
  }

  private async vmReadFile(vmId: string, params: { path: string }): Promise<ToolResult> {
    const content = await this.vmApiClient.sandboxReadFile(vmId, params);
    return { output: content, executedOn: 'vm', vmId };
  }
}

interface VmExecParams {
  command: string;
  cwd: string;
  timeoutMs: number;
}

3.4 VM Session Manager (NEW)

Manages the per-user snapshot lifecycle specifically for sub-Vitto tasks (distinct from computer-use sessions).

// NEW: APIs/backend-vitto-api/src/app/engine/vm/vm-session-manager.service.ts

@Injectable()
class VmSessionManagerService {
  private activeSessions = new Map<string, VmSession>();

  constructor(
    private vmApiClient: VmApiClientService,
    private logger: Logger,
  ) {}

  /**
   * Acquire a VM for a user's sub-Vitto task.
   *
   * Strategy (NOT a warm pool — uses existing per-user snapshots):
   * 1. Check if user already has an active VM session → reuse (bump TTL)
   * 2. Check if user has a snapshot → restore (~500ms)
   * 3. Cold boot from base image → create new (~10s, one-time cost)
   *
   * Template selection:
   * - 'developer': Node.js, Python, Git, SSH-only (no desktop). For code tasks.
   * - 'desktop': XFCE4, Chrome, VNC. For computer-use. (existing)
   */
  async acquire(
    userId: string,
    template: 'developer' | 'desktop' = 'developer',
  ): Promise<VmSession> {
    // 1. Reuse active session
    const existing = this.findActiveForUser(userId);
    if (existing) {
      existing.lastUsed = Date.now();
      this.logger.log(`Reusing active VM ${existing.vmId} for user ${userId}`);
      return existing;
    }

    // 2. Try snapshot restore
    const vmState = await this.infraClient.getUserVmState(userId);
    if (vmState.exists && vmState.snapshotAgeDays < 7) {
      const sessionId = `vitto-vm-${userId}-${Date.now()}`;
      const handle = await this.infraClient.restoreFromSnapshot(userId, sessionId);
      if (handle) {
        const session = this.registerSession(userId, handle.sandboxId, true);
        this.logger.log(`Restored VM from snapshot for user ${userId} in ~500ms`);
        return session;
      }
    }

    // 3. Cold boot
    const sessionId = `vitto-vm-${userId}-${Date.now()}`;
    const handle = await this.infraClient.createSandbox(sessionId, userId, template);
    const session = this.registerSession(userId, handle.sandboxId, false);
    this.logger.log(`Cold-booted VM for user ${userId} (~10s)`);
    return session;
  }

  /**
   * Release a VM after task completion.
   * Default: snapshot-and-suspend (preserves user's environment for next time).
   * Force destroy: only on error or explicit request.
   */
  async release(
    vmId: string,
    options: { snapshot?: boolean } = { snapshot: true },
  ): Promise<void> {
    const session = this.activeSessions.get(vmId);
    if (!session) return;

    if (options.snapshot) {
      await this.infraClient.snapshotAndSuspend(vmId, session.userId);
      this.logger.log(`Snapshot-suspended VM ${vmId} for user ${session.userId}`);
    } else {
      await this.infraClient.destroySandbox(vmId);
      this.logger.log(`Destroyed VM ${vmId}`);
    }

    this.activeSessions.delete(vmId);
  }

  private findActiveForUser(userId: string): VmSession | undefined {
    for (const session of this.activeSessions.values()) {
      if (session.userId === userId && session.status === 'running') {
        return session;
      }
    }
    return undefined;
  }

  private registerSession(userId: string, vmId: string, restored: boolean): VmSession {
    const session: VmSession = {
      vmId,
      userId,
      status: 'running',
      restored,
      createdAt: Date.now(),
      lastUsed: Date.now(),
    };
    this.activeSessions.set(vmId, session);
    return session;
  }
}

interface VmSession {
  vmId: string;
  userId: string;
  status: 'running' | 'snapshotting' | 'destroyed';
  restored: boolean;
  createdAt: number;
  lastUsed: number;
}

3.5 Developer Rootfs Image (NEW)

Current base-desktop.ext4 is optimized for computer-use (XFCE4, Chrome, VNC, ~800MB RAM overhead). Sub-Vitto code tasks don't need a desktop environment.

# NEW: deployment/sandbox-orchestrator/Dockerfile.rootfs-developer
# Headless developer image — SSH only, no desktop

FROM ubuntu:22.04

RUN apt-get update && apt-get install -y --no-install-recommends \
    openssh-server \
    git \
    curl \
    wget \
    build-essential \
    ca-certificates \
    jq \
    unzip \
    && rm -rf /var/lib/apt/lists/*

# Node.js LTS (v20)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
    && apt-get install -y nodejs \
    && npm install -g yarn

# Python 3 + pip
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3 python3-pip python3-venv \
    && rm -rf /var/lib/apt/lists/*

# SSH config
RUN mkdir /run/sshd
COPY vm-init-developer.sh /usr/local/bin/vm-init.sh
RUN chmod +x /usr/local/bin/vm-init.sh

# Working directory
RUN useradd -m -s /bin/bash user
WORKDIR /home/user

VM specs for developer image:

3.6 Deploy Key Injection for Git

When a sub-Vitto on a VM needs to clone private repos:

// NEW: APIs/backend-vitto-api/src/app/engine/vm/vm-credential-injector.service.ts

@Injectable()
class VmCredentialInjectorService {
  constructor(
    private secretVault: SecretVaultService,
    private vmApiClient: VmApiClientService,
  ) {}

  /**
   * Inject deploy keys into VM's SSH agent for private repo access.
   * Keys are retrieved from SecretVault (AES-256-GCM encrypted),
   * injected via the orchestrator's structured API, and NEVER stored
   * in the VM filesystem or shell history.
   *
   * Security measures:
   * 1. Key is sent via structured API (POST /sandboxes/:id/inject-ssh-key)
   *    — never interpolated into shell commands
   * 2. Orchestrator writes key to a tmpfs-backed path (/dev/shm/dk)
   *    — never touches disk, gone on unmount/reboot
   * 3. ssh-add loads key into agent, tmpfs file removed
   * 4. HISTFILE=/dev/null prevents shell history recording
   * 5. On VM release: ssh-agent killed, keys gone from memory
   *
   * PREREQUISITE: Sandbox-orchestrator needs new endpoint:
   *   POST /sandboxes/:id/inject-ssh-key { keyContent, repoUrl }
   *   This endpoint handles the key injection securely server-side.
   */
  async injectDeployKeys(
    vmId: string,
    userId: string,
    repos: string[],
  ): Promise<void> {
    for (const repo of repos) {
      const keyName = `deploy-key:${repo}`;
      const credential = await this.secretVault.getSecret(keyName, 'user', userId);
      if (!credential) continue;

      // Structured API call — key never appears in shell commands
      await this.vmApiClient.injectSshKey(vmId, {
        keyContent: credential.value,
        repoUrl: repo,
      });
    }
  }
}

4. Adaptive Trust Engine

4.1 Trust Scoring Service (NEW)

// NEW: APIs/backend-vitto-api/src/app/engine/autonomy/trust-scoring.service.ts

@Injectable()
class TrustScoringService {
  constructor(
    @InjectModel('TrustScore') private trustModel: Model<TrustScoreDocument>,
    private outcomeFeedback: OutcomeFeedbackService,
  ) {}

  /**
   * Get materialized trust score for a user+domain pair.
   * Scores are pre-computed (event-driven on outcome reports),
   * NOT computed live on every gate evaluation.
   *
   * Domain scoping: trust for "code_execution" ≠ trust for "send_message".
   * A user trusted to run tests autonomously may need approval for WhatsApp.
   */
  async getScore(
    userId: string,
    domain: ActionDomain,
  ): Promise<TrustResult> {
    const score = await this.trustModel.findOne({ userId, domain }).lean();

    if (!score) {
      // New user/domain — start at baseline
      return {
        score: 25, // SUGGEST level
        trajectory: 'stable',
        lastUpdated: new Date(),
      };
    }

    return {
      score: score.currentScore,
      trajectory: score.trajectory,
      lastUpdated: score.lastUpdated,
    };
  }

  /**
   * Map numeric score to autonomy level.
   * User's preferredAutonomyLevel (from VittoConfigService) is the FLOOR.
   *
   * NOTE: AutonomyLevel is a string union ('OBSERVE' | 'SUGGEST' | 'DIRECT' | 'AUTONOMOUS'),
   * not a TypeScript enum. Use LEVEL_ORDER for numeric comparison.
   */
  private readonly LEVEL_ORDER: Record<string, number> = {
    OBSERVE: 0, SUGGEST: 1, DIRECT: 2, AUTONOMOUS: 3,
  };

  scoreToLevel(score: number, userId: string): AutonomyLevel {
    let level: AutonomyLevel;
    if (score >= 75) level = 'AUTONOMOUS';
    else if (score >= 50) level = 'DIRECT';
    else if (score >= 25) level = 'SUGGEST';
    else level = 'OBSERVE';

    // User's configured floor — trust engine can go above but never below
    const floor = this.getUserFloor(userId);
    return this.LEVEL_ORDER[level] >= this.LEVEL_ORDER[floor] ? level : floor;
  }

  /**
   * Update trust score after an outcome report.
   * Called by OutcomeFeedbackService (event-driven, not in hot path).
   *
   * Scoring rules:
   * - Complex success: +3 points
   * - Moderate success: +2 points
   * - Simple success: +1 point
   * - Any failure: -5 points (immediate)
   * - Critical failure (user undid action): -15 points (immediate)
   * - Recency decay: outcomes >30 days weighted at 0.5x
   * - Max escalation: 1 level per 7 days (prevents gaming via trivial tasks)
   */
  async updateFromOutcome(
    userId: string,
    domain: ActionDomain,
    outcome: OutcomeReport,
  ): Promise<void> {
    const existing = await this.trustModel.findOne({ userId, domain });
    const current = existing?.currentScore ?? 25;

    let delta = 0;
    if (outcome.wasSuccessful) {
      const complexityWeight = { simple: 1, moderate: 2, complex: 3 };
      delta = complexityWeight[outcome.complexity] || 1;
    } else {
      delta = outcome.critical ? -15 : -5;
    }

    // Floor at 10 (SUGGEST range) — prevents dead-end where user can't
    // recover because OBSERVE means no actions → no outcomes → no trust gain.
    // At score 10, user gets SUGGEST: they can request actions with approval.
    const newScore = Math.max(10, Math.min(100, current + delta));

    // Enforce max 1-level escalation per 7 days
    const maxLevel = this.getMaxAllowedLevel(existing);
    const newLevel = this.scoreToLevel(newScore, userId);
    const cappedScore = newLevel > maxLevel
      ? this.levelToMaxScore(maxLevel)
      : newScore;

    // Compute trajectory from last 10 scores
    const trajectory = this.computeTrajectory(existing?.history || [], cappedScore);

    await this.trustModel.findOneAndUpdate(
      { userId, domain },
      {
        $set: {
          currentScore: cappedScore,
          trajectory,
          lastUpdated: new Date(),
          lastOutcome: outcome,
        },
        $push: {
          history: {
            $each: [{ score: cappedScore, at: new Date(), outcome: outcome.wasSuccessful }],
            $slice: -50, // Keep last 50
          },
        },
      },
      { upsert: true },
    );
  }

  private computeTrajectory(
    history: { score: number }[],
    current: number,
  ): 'rising' | 'stable' | 'declining' {
    if (history.length < 5) return 'stable';
    const recent = history.slice(-5).map(h => h.score);
    const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
    if (current > avg + 5) return 'rising';
    if (current < avg - 5) return 'declining';
    return 'stable';
  }
}

// Trust scores are per (userId, actionDomain)
type ActionDomain =
  | 'code_execution'    // run_command, write_file on VMs
  | 'web_search'        // fetch_webpage, web_search
  | 'messaging'         // send_message, whistle_user, WhatsApp
  | 'deployment'        // module generation, git operations
  | 'knowledge'         // recall_memory, query_docs (low risk)
  | 'delegation'        // deploy_agents, create_handoff
  | 'system'            // query_system_status (read-only)
  ;

4.2 Trust Score Schema

// NEW: APIs/backend-vitto-api/src/app/engine/autonomy/schemas/trust-score.schema.ts

@Schema({ collection: 'vitto-trust-scores', timestamps: true })
class TrustScore {
  @Prop({ required: true, index: true })
  userId: string;

  @Prop({ required: true, index: true })
  domain: ActionDomain;

  @Prop({ default: 25, min: 0, max: 100 })
  currentScore: number;

  @Prop({ enum: ['rising', 'stable', 'declining'], default: 'stable' })
  trajectory: string;

  @Prop({ type: [{ score: Number, at: Date, outcome: Boolean }], default: [] })
  history: TrustHistoryEntry[];

  @Prop({ type: Object })
  lastOutcome: Record<string, unknown>;

  @Prop()
  lastEscalationAt: Date;  // For 1-level-per-7-days enforcement

  @Prop()
  lastUpdated: Date;
}

Database: vitto-mind (same as memory — keeps Vitto's personal data together) TTL: None — trust scores persist indefinitely (they're the user relationship)

Migration from TrustEscalationService: An existing TrustEscalationService at engine/trust-escalation.service.ts provides basic trust tracking (success rate per userId+action). TrustScoringService REPLACES it with domain-scoped 0-100 scoring, complexity weighting, and trajectory tracking. Migration: copy existing ActionTrustScore documents to vitto-trust-scores, map successRate (0-1) to currentScore (0-100 via Math.round(rate * 100)), then deprecate TrustEscalationService. Both can coexist during transition — AutonomyService reads from TrustScoringService first, falls back to TrustEscalationService if no score exists.


5. Dynamic Tier Escalation

5.1 TierResolverService (NEW)

// NEW: APIs/backend-vitto-api/src/app/engine/autonomy/tier-resolver.service.ts

@Injectable()
class TierResolverService {
  constructor(
    private vmSessionManager: VmSessionManagerService,
    private logger: Logger,
  ) {}

  /**
   * Resolve the execution tier for a task.
   *
   * Rules:
   * 1. Start at the tier the orchestrator requests (or infer from tools)
   * 2. Check if VM is available and healthy
   * 3. If VM needed but unavailable, degrade gracefully
   *
   * Tool-to-tier mapping:
   *   run_command, write_file, read_file, search_code → needs 'vm'
   *   browser_navigate, browser_interact, browser_screenshot → needs 'browser'
   *   Everything else → 'in-process'
   */
  async resolve(task: TaskEvaluation): Promise<TierResolution> {
    const requiredTier = task.requestedTier || this.inferTier(task.tools || []);

    if (requiredTier === 'in-process') {
      return { tier: 'in-process', requiresIsolation: false };
    }

    if (requiredTier === 'browser') {
      // Browser tier: future work (S241+), fall back to computer-use supervisor
      this.logger.warn(`Browser tier requested, deferring to computer-use supervisor`);
      return { tier: 'browser', requiresIsolation: true };
    }

    // VM tier: acquire or reuse
    try {
      const session = await this.vmSessionManager.acquire(
        task.userId,
        'developer',
      );
      return {
        tier: 'vm',
        vmId: session.vmId,
        restored: session.restored,
        requiresIsolation: true,
      };
    } catch (err) {
      this.logger.error(`VM acquisition failed, degrading to in-process: ${err.message}`);
      return {
        tier: 'in-process',
        requiresIsolation: false,
        degraded: true,
        degradationReason: err.message,
      };
    }
  }

  /**
   * Infer required tier from tool list.
   */
  private inferTier(tools: string[]): SubVittoTier {
    const VM_TOOLS = ['run_command', 'write_file', 'read_file', 'search_code'];
    const BROWSER_TOOLS = ['browser_navigate', 'browser_interact', 'browser_screenshot'];

    if (tools.some(t => BROWSER_TOOLS.includes(t))) return 'browser';
    if (tools.some(t => VM_TOOLS.includes(t))) return 'vm';
    return 'in-process';
  }
}

interface TierResolution {
  tier: SubVittoTier;
  vmId?: string;
  restored?: boolean;
  requiresIsolation: boolean;
  degraded?: boolean;
  degradationReason?: string;
}

5.2 Per-Tool-Call Escalation with Sticky Claims

Escalation happens at the tool level, not the task level. But once a VM is claimed, it's "sticky" for the task duration.

// MODIFIED: APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts
// Add to tool execution logic:

private async executeToolCall(
  toolCall: ToolCall,
  context: AutonomyContext,
): Promise<ToolResult> {
  const isVmTool = ['run_command', 'write_file', 'read_file', 'search_code']
    .includes(toolCall.name);

  if (isVmTool && context.resolvedTier === 'vm' && context.activeVmId) {
    // Execute on VM via proxy
    return this.sandboxedToolProxy.executeOnVm(context.activeVmId, toolCall);
  }

  if (isVmTool && context.resolvedTier === 'in-process' && context.degraded) {
    // Degraded mode: use ProcessSandboxService with ulimits
    return this.processSandbox.execute(toolCall);
  }

  // In-process tool — execute locally as before
  return this.toolRegistry.execute(toolCall);
}

5.3 Graceful Degradation Rules

Escalation:  in-process → vm
Degradation: vm → in-process (with restrictions)

VM → in-process degradation (when VM unavailable):
  - run_command: degrades to ProcessSandboxService (ulimit: 30s CPU, 256MB RAM)
  - write_file: degrades to local tmp directory (auto-cleaned after task)
  - read_file: degrades to local file read (only within allowed paths)
  - search_code: degrades to local grep

  - browser_*: does NOT degrade — returns tool_unavailable error
  - computer_use: does NOT degrade — requires VM with desktop

Never auto-escalate to browser tier (expensive, requires Chrome).
Only on explicit tool request or computer-use task.

6. Proactive Intelligence

6.1 Trigger Engine (NEW)

Stored in a dedicated collection — NOT as VittoMemory — to avoid S239 admission control conflicts.

// NEW: APIs/backend-vitto-api/src/app/proactive/trigger-engine.service.ts

@Injectable()
class TriggerEngineService {
  constructor(
    @InjectModel('TriggerPattern') private triggerModel: Model<TriggerPatternDocument>,
    private proactiveDispatcher: ProactiveDispatcherService,
    private logger: Logger,
  ) {}

  /**
   * Check if any learned triggers match the current context.
   * Called by AutonomyGate during context assembly.
   * Returns matched triggers (for logging/UI), does NOT auto-execute.
   */
  async checkTriggers(
    userId: string,
    task: TaskEvaluation,
  ): Promise<TriggerPattern[]> {
    const triggers = await this.triggerModel.find({
      userId,
      confidence: { $gte: 0.5 },
      enabled: true,
    }).lean();

    return triggers.filter(t => this.matchesContext(t, task));
  }

  /**
   * Detect patterns from completed tasks.
   * Called AFTER task completion by OutcomeFeedbackService.
   *
   * Detection logic (deliberately simple, no ML):
   * 1. Query: has this user asked for similar things 3+ times in 14 days?
   * 2. If yes, check if a trigger already exists for this pattern
   * 3. If no trigger: create one at confidence 0.5
   * 4. If trigger exists: bump confidence by 0.1 (cap at 0.95)
   */
  async detectPattern(
    userId: string,
    completedTask: CompletedTask,
  ): Promise<TriggerPattern | null> {
    const recentSimilar = await this.countSimilarTasks(
      userId,
      completedTask.summary,
      14, // days
    );

    if (recentSimilar < 3) return null;

    const existing = await this.findSimilarTrigger(userId, completedTask.summary);
    if (existing) {
      existing.confidence = Math.min(0.95, existing.confidence + 0.1);
      existing.activations += 1;
      existing.lastTriggered = new Date();
      await existing.save();
      return existing;
    }

    // New pattern detected
    const trigger = new this.triggerModel({
      userId,
      pattern: completedTask.summary,
      action: completedTask.toolSequence, // What tools were used
      taskTemplate: completedTask.taskTemplate,
      confidence: 0.5,
      activations: 0,
      suppressedCount: 0,
      enabled: true,
    });
    await trigger.save();

    this.logger.log(`New trigger pattern detected for user ${userId}: ${completedTask.summary}`);
    return trigger;
  }

  /**
   * Handle user response to a proactive action.
   * Engaged → increase confidence.
   * Dismissed → decrease confidence. Below 0.3 → auto-disable.
   */
  async handleResponse(
    triggerId: string,
    response: 'engaged' | 'dismissed' | 'ignored',
  ): Promise<void> {
    const trigger = await this.triggerModel.findById(triggerId);
    if (!trigger) return;

    switch (response) {
      case 'engaged':
        trigger.confidence = Math.min(0.95, trigger.confidence + 0.15);
        break;
      case 'dismissed':
        trigger.confidence -= 0.2;
        trigger.suppressedCount += 1;
        break;
      case 'ignored':
        trigger.confidence -= 0.05;
        break;
    }

    if (trigger.confidence < 0.3) {
      trigger.enabled = false;
      this.logger.log(`Trigger ${triggerId} auto-disabled (confidence below threshold)`);
    }

    await trigger.save();
  }

  /**
   * Execute a proactive trigger.
   * Only called for triggers with confidence >= 0.8 AND user trust >= DIRECT.
   * Lower confidence triggers are dispatched as suggestions via ProactiveDispatcher.
   */
  async executeProactively(
    trigger: TriggerPattern,
    context: AutonomyContext,
  ): Promise<void> {
    const LEVEL_ORDER = { OBSERVE: 0, SUGGEST: 1, DIRECT: 2, AUTONOMOUS: 3 };
    if (trigger.confidence >= 0.8 && LEVEL_ORDER[context.trustLevel] >= LEVEL_ORDER['DIRECT']) {
      // Auto-execute and notify
      await this.proactiveDispatcher.dispatch({
        userId: context.userId,
        type: 'proactive_execution',
        severity: 'info',
        message: `Proactively executed: ${trigger.pattern}`,
        trigger: trigger._id,
      });
    } else {
      // Suggest only
      await this.proactiveDispatcher.dispatch({
        userId: context.userId,
        type: 'proactive_suggestion',
        severity: 'info',
        message: `I noticed you often ${trigger.pattern}. Want me to handle this?`,
        trigger: trigger._id,
        actions: [
          { label: 'Yes, do it', action: 'engage' },
          { label: 'Not now', action: 'dismiss' },
        ],
      });
    }
  }
}

6.2 Trigger Pattern Schema

// NEW: APIs/backend-vitto-api/src/app/proactive/schemas/trigger-pattern.schema.ts

@Schema({ collection: 'vitto-triggers', timestamps: true })
class TriggerPattern {
  @Prop({ required: true, index: true })
  userId: string;

  @Prop({ required: true })
  pattern: string;           // Human-readable description

  @Prop({ type: Object })
  action: Record<string, unknown>;  // Tool sequence template

  @Prop({ type: Object })
  taskTemplate: Record<string, unknown>;  // Replayable task

  @Prop({ default: 0.5, min: 0, max: 1 })
  confidence: number;

  @Prop({ default: 0 })
  activations: number;

  @Prop({ default: 0 })
  suppressedCount: number;

  @Prop({ default: true })
  enabled: boolean;

  @Prop()
  lastTriggered: Date;

  @Prop()
  schedule?: string;  // Optional cron expression for time-based triggers
}

Database: vitto-mind, collection: vitto-triggers


7. Checkpoint & Recovery

7.1 TaskRecoveryService (NEW)

Naming note: A CheckpointService already exists at engine/checkpoint.service.ts (Redis-backed, saves full message arrays for context windowing). This new service is distinct — it saves lightweight genome-based recovery points for crash resilience. Named TaskRecoveryService to avoid DI collision.

// NEW: APIs/backend-vitto-api/src/app/engine/recovery/task-recovery.service.ts

@Injectable()
class TaskRecoveryService {
  constructor(
    @InjectModel('Checkpoint') private checkpointModel: Model<CheckpointDocument>,
    private vmSessionManager: VmSessionManagerService,
    private logger: Logger,
  ) {}

  /**
   * Save checkpoint after successful tool execution.
   * Lightweight: stores genome + recent results, NOT full message history.
   *
   * When S239 ConversationGenomeService is available:
   *   genome = ~500 tokens (compressed conversation state)
   * Fallback (before S239):
   *   genome = last 5 messages as-is
   */
  async save(checkpoint: CheckpointData): Promise<void> {
    await this.checkpointModel.findOneAndUpdate(
      { taskId: checkpoint.taskId },
      {
        $set: {
          iteration: checkpoint.iteration,
          genome: checkpoint.genome,
          recentToolResults: checkpoint.recentToolResults.slice(-3),
          vmSnapshotId: checkpoint.vmSnapshotId,
          autonomyContext: checkpoint.autonomyContext,
          savedAt: new Date(),
        },
      },
      { upsert: true },
    );
  }

  /**
   * Attempt to recover a task from its last checkpoint.
   * Called by VittoEngineService when BullMQ picks up a previously-failed task.
   */
  async recover(taskId: string): Promise<RecoveryData | null> {
    const checkpoint = await this.checkpointModel.findOne({ taskId }).lean();
    if (!checkpoint) return null;

    // Check freshness — ignore checkpoints older than 24h
    const ageMs = Date.now() - checkpoint.savedAt.getTime();
    if (ageMs > 86_400_000) {
      this.logger.warn(`Checkpoint for ${taskId} is stale (${Math.round(ageMs / 3600000)}h old), ignoring`);
      return null;
    }

    // Restore VM if one was active
    let vmId: string | undefined;
    if (checkpoint.vmSnapshotId) {
      try {
        const session = await this.vmSessionManager.acquire(
          checkpoint.autonomyContext.userId,
          'developer',
        );
        vmId = session.vmId;
      } catch {
        this.logger.warn(`VM restore failed for checkpoint ${taskId}, continuing without VM`);
      }
    }

    return {
      iteration: checkpoint.iteration,
      genome: checkpoint.genome,
      recentToolResults: checkpoint.recentToolResults,
      vmId,
      resumeMessage: this.buildResumeMessage(checkpoint),
    };
  }

  /**
   * Build a resume injection message for the LLM.
   * Gives the model enough context to re-orient after interruption.
   */
  private buildResumeMessage(checkpoint: CheckpointDocument): string {
    return [
      `[RESUME] You were executing a task that was interrupted.`,
      `Iteration: ${checkpoint.iteration}`,
      `Context: ${checkpoint.genome}`,
      `Last completed steps: ${checkpoint.recentToolResults.map(r => r.toolName).join(', ')}`,
      `Continue from where you left off.`,
    ].join('\n');
  }
}

interface CheckpointData {
  taskId: string;
  iteration: number;
  genome: string;                       // ConversationGenome hydrated text or fallback
  recentToolResults: ToolResultSummary[];  // Last 3 tool results
  vmSnapshotId?: string;
  autonomyContext: Partial<AutonomyContext>;
}

7.2 Schema

// NEW: APIs/backend-vitto-api/src/app/engine/checkpoint/schemas/checkpoint.schema.ts

@Schema({ collection: 'vitto-checkpoints' })
class Checkpoint {
  @Prop({ required: true, unique: true, index: true })
  taskId: string;

  @Prop({ required: true })
  iteration: number;

  @Prop({ required: true })
  genome: string;

  @Prop({ type: [Object], default: [] })
  recentToolResults: Record<string, unknown>[];

  @Prop()
  vmSnapshotId?: string;

  @Prop({ type: Object })
  autonomyContext: Record<string, unknown>;

  @Prop({ required: true })
  savedAt: Date;
}

// TTL index: auto-delete after 24h
// db.vitto-checkpoints.createIndex({ savedAt: 1 }, { expireAfterSeconds: 86400 })

8. Cross-Agent Learning

8.1 Sub-Vitto Outcome Reporting

Currently, only main Vitto tasks report outcomes. Sub-Vitto outcomes are lost.

// MODIFIED: APIs/backend-vitto-api/src/app/engine/sub-vitto-orchestrator.service.ts
// Add after sub-Vitto completion:

private async reportSubVittoOutcome(
  subTask: SubVittoTask,
  result: AgentLoopResult,
): Promise<void> {
  await this.outcomeFeedback.report({
    source: 'sub-vitto',
    role: subTask.role,
    tier: subTask.tier,
    toolsUsed: result.toolCalls.map(tc => tc.name),
    wasSuccessful: result.completedViaTaskComplete,
    iterations: result.iterations,
    inputTokens: result.inputTokens,
    outputTokens: result.outputTokens,
    complexity: subTask.complexity || 'moderate',
    parentTaskId: subTask.parentTaskId,
  });

  // Feed into trust scoring
  await this.trustScoring.updateFromOutcome(
    subTask.userId,
    this.inferDomain(subTask),
    {
      wasSuccessful: result.completedViaTaskComplete,
      complexity: subTask.complexity || 'moderate',
      critical: false,
    },
  );
}

8.2 Pattern Propagation

Sub-Vitto success patterns automatically feed into the learning pipeline:

Sub-Vitto completes task
  → OutcomeFeedbackService.report() (existing)
  → TrustScoringService.updateFromOutcome() (new)
  → TriggerEngineService.detectPattern() (new)
  → LearningSyncService picks up at next 6AM cron (existing)
  → Golden patterns distributed to sub-agent knowledge docs (existing)

No new service needed — we just wire sub-Vitto outcomes into the existing pipeline.


9. Agent Nesting

9.1 Controlled Depth-2 Nesting

// MODIFIED: APIs/backend-vitto-api/src/app/engine/sub-vitto-orchestrator.service.ts

// Change: MAX_DEPTH = 1 → MAX_DEPTH = 2

private readonly MAX_DEPTH = 2;

// Depth-2 restrictions:
private getDepthRestrictions(depth: number, parentBudget: CostBudget): DepthRestrictions {
  if (depth === 1) {
    // First-level sub-Vittos: full capabilities
    return {
      allowedTiers: ['in-process', 'vm'],
      maxTools: undefined, // all tools for their tier
      costBudget: parentBudget, // inherits full parent budget
      canNest: true,
    };
  }

  if (depth === 2) {
    // Second-level sub-sub-Vittos: restricted
    return {
      allowedTiers: ['in-process'], // NO VM at depth 2
      maxTools: 5, // core + 2 role-specific
      costBudget: {
        maxIterations: Math.floor(parentBudget.remainingIterations * 0.5),
        maxTokens: Math.floor(parentBudget.remainingTokens * 0.5),
        maxDurationMs: Math.floor(parentBudget.remainingMs * 0.5),
      },
      canNest: false, // depth-2 cannot spawn depth-3
    };
  }

  throw new Error(`Nesting depth ${depth} exceeds maximum`);
}

Rationale: Depth-2 is in-process only to prevent VM resource explosion (a depth-1 sub-Vitto with 5 depth-2 children, each claiming a VM = 5 VMs from one task). The 50% budget inheritance ensures nested agents can't outspend their parent.


10. Chat Visibility & UX

10.1 Enhanced SubVittoOrchestrationRenderer

When a sub-Vitto runs on a VM, the chat UI should show it:

┌─ Deploying 3 agents ────────────────────────┐
│ ▸ Code Review (in-process) ✓ 12s            │
│ ▸ Test Runner (vm) ⏳ running...             │
│   VM: restored from snapshot (340ms)         │
│   Running: yarn test (45s elapsed)           │
│ ▸ Dependency Audit (vm → in-process ⚠️) ✓    │
│   VM unavailable, ran locally with limits    │
└──────────────────────────────────────────────┘

SSE events to emit:

// Events from SubVittoOrchestratorService:
'subvitto.spawned'    → { id, role, tier, depth }
'subvitto.vm_acquired' → { id, vmId, restored, acquireTimeMs }
'subvitto.vm_degraded' → { id, reason, fallbackTier }
'subvitto.progress'   → { id, iteration, currentTool }
'subvitto.completed'  → { id, success, duration, toolsUsed }
'subvitto.failed'     → { id, error, iteration }

// Events from TrustScoringService (for admin/developer dashboard):
'trust.updated'       → { userId, domain, oldScore, newScore, trajectory }

// Events from TriggerEngineService:
'trigger.detected'    → { userId, pattern, confidence }
'trigger.executed'    → { userId, triggerId, result }

11. Security Considerations

Concern Mitigation
VMs never hold OAuth tokens All API calls proxy through vitto-api → backend-api (existing CredentialBrokerService pattern)
Deploy keys are ephemeral Written to tmpfile, added to ssh-agent, deleted. Never in rootfs. Gone on VM release
CostGuard per sub-Vitto VM tier doesn't get unlimited iterations. Same budget enforcement as in-process
Network policy VMs have outbound internet (yarn install) but no access to internal services (backend-api, MongoDB). Enforced by bridge network + iptables on orchestrator host
SSH keys per-session Generated at VM create, stored in session dir, deleted at destroy. Snapshot copies to user dir for restore
Trust score manipulation Max 1-level escalation per 7 days. Simple tasks barely move the needle. Critical failures drop score immediately
Trigger injection Triggers only execute at DIRECT+ trust. New triggers start at confidence 0.5 (SUGGEST only). User must engage repeatedly to reach auto-execute threshold
Checkpoint data Stored in MongoDB with 24h TTL. Contains genome (compressed, not raw messages) + tool names (not full results). No credentials in checkpoints
Depth-2 resource limits In-process only, 50% budget, max 5 tools. Cannot claim VMs or escalate

12. S239 Memory v2 Compatibility

Integration Points

S240 Component S239 Dependency Compatibility
Trust scoring Reads OutcomeFeedbackService No conflict — S239 doesn't touch outcomes
Trust score storage vitto-trust-scores collection No conflict — separate collection
Trigger patterns vitto-triggers collection No conflict — intentionally separate from vitto-memories to avoid admission control
Sub-Vitto context Needs conversation history Uses S239's WindowManager + Genome when available. Fallback: raw messages
Checkpoint recovery Stores genome for resume Leverages S239's ConversationGenome — 500 tokens vs full history dump
VM tool execution Infrastructure layer Fully independent — S239 is memory-only
AutonomyGate Reads user preferences Must use S239's new RecallOptions — scope/scopeId fields replace deprecated layer

Timing Strategy

S240 works with or without S239 being complete:

What We Explicitly Avoid


13. Key Files Reference

New Files to Create

APIs/backend-vitto-api/src/app/engine/
  ├── autonomy/
  │   ├── autonomy-context.ts              # AutonomyContext interface
  │   ├── autonomy-gate.service.ts         # Gate that assembles context
  │   ├── trust-scoring.service.ts         # Adaptive trust engine
  │   ├── tier-resolver.service.ts         # Dynamic tier selection
  │   └── schemas/
  │       └── trust-score.schema.ts        # MongoDB schema for trust scores
  ├── vm/
  │   ├── vm-api-client.service.ts         # HTTP client to ai-api sandbox endpoints
  │   ├── sandboxed-tool-proxy.service.ts  # Routes tool calls to VM
  │   ├── vm-session-manager.service.ts    # Per-user VM lifecycle (snapshot-based)
  │   └── vm-credential-injector.service.ts # Deploy key injection
  └── recovery/
      ├── task-recovery.service.ts         # Save/recover task state (genome-based)
      └── schemas/
          └── task-recovery.schema.ts      # MongoDB schema with 24h TTL

APIs/backend-vitto-api/src/app/proactive/
  ├── trigger-engine.service.ts            # Learned trigger detection & execution
  └── schemas/
      └── trigger-pattern.schema.ts        # MongoDB schema for triggers

deployment/sandbox-orchestrator/
  ├── Dockerfile.rootfs-developer          # Headless developer VM image
  └── vm-init-developer.sh                 # SSH-only init script (no desktop)

Existing Files to Modify

APIs/backend-vitto-api/src/app/engine/
  ├── agent-loop.service.ts                # Add VM-aware tool execution routing
  ├── sub-vitto-orchestrator.service.ts    # Remove browser fallback, add:
  │                                        #   - AutonomyGate integration
  │                                        #   - VM session acquire/release
  │                                        #   - Outcome reporting for sub-Vittos
  │                                        #   - Depth-2 nesting support (MAX_DEPTH=2)
  ├── autonomy.service.ts                  # Extend: read trust score instead of static level
  └── vitto-engine.service.ts              # Add checkpoint save/recover hooks

APIs/backend-vitto-api/src/app/strategy/
  └── cost-guard.service.ts                # Add depth-aware budget inheritance

APIs/backend-vitto-api/src/app/proactive/
  └── proactive-dispatcher.service.ts      # Wire trigger execution into dispatch flow

APIs/backend-ai-api/src/app/infrastructure/
  └── controllers/sandbox.controller.ts    # ADD: 9 new endpoints proxying SelfHostedProvider
                                           # (create, getUserVmState, restore, snapshot, destroy,
                                           # exec w/ timeout, write-file, read-file, inject-ssh-key)

APIs/sandbox-orchestrator/src/app/sandbox/
  └── sandbox.controller.ts                # ADD: write-file, read-file, inject-ssh-key endpoints

14. Migration & Phasing

Phase 1: VM Execution (Sessions 1-3)

Goal: Sub-Vittos can execute code on Firecracker VMs.

  1. Backend-ai-api infrastructure controller — Add 9 new HTTP endpoints proxying SelfHostedProvider methods (create, getUserVmState, restore, snapshot, destroy, exec with timeout, write-file, read-file, inject-ssh-key). This is the critical prerequisite.
  2. Sandbox-orchestrator structured endpoints — Add POST /sandboxes/:id/write-file, POST /sandboxes/:id/read-file, POST /sandboxes/:id/inject-ssh-key (Node fs over SSH, not shell interpolation)
  3. Build base-developer.ext4 rootfs image (headless, Node/Python/Git)
  4. Deploy rootfs to Hetzner sandbox-orchestrator
  5. Implement VmApiClientService (HTTP client to backend-ai-api sandbox endpoints)
  6. Implement SandboxedToolProxyService (tool → structured API calls)
  7. Implement VmSessionManagerService (snapshot-based acquire/release)
  8. Implement VmCredentialInjectorService (deploy key injection via structured API)
  9. Wire into SubVittoOrchestratorService: remove browser fallback, add VM routing
  10. Modify AgentLoopService: route VM tools through proxy
  11. Integration test: sub-Vitto runs echo hello on VM, returns result

Phase 2: Adaptive Trust (Sessions 3-4)

Goal: Trust scores computed from outcomes, influencing autonomy levels.

  1. Create trust-score.schema.ts + MongoDB collection
  2. Implement TrustScoringService (materialized scores, event-driven updates)
  3. Wire OutcomeFeedbackService to trust updates
  4. Implement TierResolverService (tier inference + VM acquisition)
  5. Implement AutonomyGateService (assembles context from all three services)
  6. Extend AutonomyService: read trust score as dynamic level
  7. Wire sub-Vitto outcome reporting (close the feedback loop)

Phase 3: Proactive Intelligence (Sessions 6-7)

Goal: Vitto learns trigger patterns and acts proactively.

  1. Create trigger-pattern.schema.ts + MongoDB collection
  2. Implement TriggerEngineService (detection, matching, execution)
  3. Wire trigger detection into post-task completion flow
  4. Wire trigger execution into ProactiveDispatcherService
  5. Add trigger response handling (engage/dismiss → confidence adjustment)

Phase 4: Resilience & Nesting (Sessions 8-9)

Goal: Tasks survive crashes. Agents can nest.

  1. Create task-recovery.schema.ts + MongoDB collection with TTL
  2. Implement TaskRecoveryService (save after tool exec, recover on restart)
  3. Wire recovery into AgentLoopService (save) and VittoEngineService (recover)
  4. Increase MAX_DEPTH to 2 with depth-aware restrictions
  5. Add depth-aware budget inheritance to CostGuardService
  6. Chat visibility: SSE events for VM status, degradation, trust changes

Phase 5: Polish & Hardening (Sessions 10-12)

Goal: Production readiness.

  1. Graceful degradation testing (VM unavailable scenarios)
  2. Trust score edge cases (gaming prevention, cold start behavior)
  3. Trigger pattern quality (false positive rate, user satisfaction)
  4. Recovery accuracy (genome vs raw message comparison)
  5. Load testing (concurrent VM sessions, snapshot restore under load)
  6. TrustEscalationService migration + deprecation
  7. Documentation and operational runbooks

15. Risks & Mitigations

Risk Severity Mitigation
4-hop VM latency (vitto→ai-api→orchestrator→VM) Medium Sticky VM claims (first tool pays cost, rest reuse). Accept 500ms overhead for isolation
Snapshot restore failure Medium Fall back to cold boot (10s). If that fails, degrade to in-process
Trust score gaming (user runs 50 trivial tasks) Low Complexity weighting: simple=+1, moderate=+2, complex=+3. 50 simple tasks = +50 points = 1 level. Takes a week minimum (1-level-per-7-days cap)
Proactive triggers annoying users Medium Existing safety guards (quiet hours, frequency cap, dedup). Confidence decay on dismiss. Auto-disable below 0.3
Checkpoint recovery incoherent LLM state Medium Resume injection message. Genome-based (coherent summary, not raw dump). Limit to tasks <20 iterations
Agent nesting resource explosion Low Depth-2 = in-process only, 50% budget, max 5 tools. Cannot claim VMs
Developer rootfs image maintenance Low Track Node LTS + Python stable. Rebuild quarterly. Base image is minimal (~200MB)
S239 interface changes breaking S240 Medium S240 works without S239. New recall API adoption is additive, not breaking. Use feature flags
Over-engineering before product-market fit High Phase 1 (VM execution) is the must-ship. Everything else can be deferred if needed. Ship in order, validate each phase

16. Dependencies & Configuration

Infrastructure

Dependency Status Required For
Sandbox orchestrator on Hetzner (port 3010) Production All VM operations
SANDBOX_SERVER_URL env var (backend-ai-api) Set VM proxy chain
BACKEND_API_INTERNAL_TOKEN env var (backend-vitto-api) Set Auth to ai-api
base-developer.ext4 rootfs image NEW — needs build Developer VM tier
Redis (optional, for S239 compatibility) Not yet Speed layer (Phase 3+)

Environment Variables (NEW)

# Trust Engine
VITTO_TRUST_DEFAULT_SCORE=25              # New user starting score
VITTO_TRUST_MAX_ESCALATION_PER_WEEK=1     # Max 1 level per 7 days
VITTO_TRUST_FAILURE_PENALTY=5             # Score drop on failure
VITTO_TRUST_CRITICAL_PENALTY=15           # Score drop on critical failure

# VM Execution
VITTO_VM_DEVELOPER_TEMPLATE=developer     # Rootfs template for code tasks
VITTO_VM_REUSE_ACTIVE=true                # Reuse active VM for same user
VITTO_VM_SNAPSHOT_ON_RELEASE=true         # Snapshot when releasing VM

# Triggers
VITTO_TRIGGER_MIN_OCCURRENCES=3           # Min similar tasks before pattern detection
VITTO_TRIGGER_DETECTION_WINDOW_DAYS=14    # Look-back window for pattern detection
VITTO_TRIGGER_AUTO_EXECUTE_CONFIDENCE=0.8 # Min confidence for autonomous execution
VITTO_TRIGGER_DISABLE_THRESHOLD=0.3       # Auto-disable below this confidence

# Checkpoints
VITTO_CHECKPOINT_ENABLED=true             # Enable checkpoint saves
VITTO_CHECKPOINT_TTL_HOURS=24             # Auto-delete stale checkpoints
VITTO_CHECKPOINT_MAX_RECOVERY_AGE_HOURS=24 # Ignore checkpoints older than this

# Nesting
VITTO_MAX_NESTING_DEPTH=2                 # Max sub-Vitto nesting depth
VITTO_DEPTH2_BUDGET_RATIO=0.5             # Depth-2 gets 50% of parent budget
VITTO_DEPTH2_MAX_TOOLS=5                  # Max tools at depth-2

MongoDB Collections (NEW)

Collection Database Purpose TTL
vitto-trust-scores vitto-mind Materialized trust per (userId, domain) None
vitto-triggers vitto-mind Learned trigger patterns None
vitto-checkpoints vitto-mind Task recovery state 24h

Estimated Effort

Phase Sessions Risk Dependencies
1. VM Execution 3 Medium Rootfs build, Hetzner access, ai-api endpoints
2. Adaptive Trust 2 Low Phase 1 (for outcome data)
3. Proactive Intelligence 2 Medium Phase 2 (trust gates triggers)
4. Resilience & Nesting 2 Medium Phase 1 (VM checkpoints)
5. Polish & Hardening 3 Low All phases + TrustEscalation migration
Total 12

End of spec. This document supersedes S-future-vm-agent-tier-handoff.md for implementation guidance. Key insight: Don't build infrastructure that already exists. Leverage per-user snapshots (S152-178), extend the existing SandboxProvider chain, and make three disconnected systems (execution, trust, proactivity) share a single read-only context. Next step: Implementation plan via writing-plans skill.