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:
- Picks the right execution tier (in-process → VM → browser) based on what the task needs
- Checks/updates trust based on the user's outcome history with Vitto
- Decides whether to act, suggest, or wait — adapting in real-time
- Checkpoints progress so long-running tasks survive failures
Key design decisions:
- No warm pool — leverage existing per-user snapshot system (500ms restore) instead of pre-booting idle VMs
- Developer rootfs variant — headless, with Node/Python/Git, no desktop overhead
- Triggers stored separately — NOT as VittoMemory (avoids S239 admission control conflicts)
- Genome-based checkpoint recovery — integrates with S239's ConversationGenomeService
- Per-tool-call escalation with sticky claims — VM claimed on first need, held for task duration
Table of Contents
- Architecture Overview
- Autonomy Gate & Context
- VM Execution Layer
- Adaptive Trust Engine
- Dynamic Tier Escalation
- Proactive Intelligence
- Checkpoint & Recovery
- Cross-Agent Learning
- Agent Nesting
- Chat Visibility & UX
- Security Considerations
- S239 Memory v2 Compatibility
- Key Files Reference
- Migration & Phasing
- Risks & Mitigations
- 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:
TrustScoringService— computes trust from outcome history (independent)TierResolverService— resolves execution tier from task requirements + trust (reads trust)AutonomyService(existing, extended) — decides act/suggest/wait (reads trust + tier)
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:
- CPU: 2 vCPU (same)
- Memory: 1024 MiB (half of desktop — no Xvfb/Chrome/VNC)
- Boot time: ~5s cold (faster than desktop's ~10s)
- Snapshot restore: ~300ms (smaller memory dump)
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
TrustEscalationServiceatengine/trust-escalation.service.tsprovides basic trust tracking (success rate per userId+action).TrustScoringServiceREPLACES it with domain-scoped 0-100 scoring, complexity weighting, and trajectory tracking. Migration: copy existingActionTrustScoredocuments tovitto-trust-scores, mapsuccessRate(0-1) tocurrentScore(0-100 viaMath.round(rate * 100)), then deprecateTrustEscalationService. Both can coexist during transition —AutonomyServicereads fromTrustScoringServicefirst, falls back toTrustEscalationServiceif 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
CheckpointServicealready exists atengine/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. NamedTaskRecoveryServiceto 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:
- Without S239: Trust, VM, triggers all work standalone. Context uses existing raw message approach.
- With S239 Phase 1+: Sub-Vittos get genome-compressed context. Checkpoints use genome. Recall goes through DopamineRouter.
What We Explicitly Avoid
- Do NOT store trigger patterns as
VittoMemory— they'd get quarantined by admission control (lowfactualConfidence, lowsemanticNovelty) - Do NOT bypass S239's recall API — sub-Vittos must go through the new DopamineRouter when available
- Do NOT duplicate context assembly logic — extend
context-assembly.service.ts, don't create a parallel path
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.
- Backend-ai-api infrastructure controller — Add 9 new HTTP endpoints proxying
SelfHostedProvidermethods (create, getUserVmState, restore, snapshot, destroy, exec with timeout, write-file, read-file, inject-ssh-key). This is the critical prerequisite. - 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) - Build
base-developer.ext4rootfs image (headless, Node/Python/Git) - Deploy rootfs to Hetzner sandbox-orchestrator
- Implement
VmApiClientService(HTTP client to backend-ai-api sandbox endpoints) - Implement
SandboxedToolProxyService(tool → structured API calls) - Implement
VmSessionManagerService(snapshot-based acquire/release) - Implement
VmCredentialInjectorService(deploy key injection via structured API) - Wire into
SubVittoOrchestratorService: remove browser fallback, add VM routing - Modify
AgentLoopService: route VM tools through proxy - Integration test: sub-Vitto runs
echo helloon VM, returns result
Phase 2: Adaptive Trust (Sessions 3-4)
Goal: Trust scores computed from outcomes, influencing autonomy levels.
- Create
trust-score.schema.ts+ MongoDB collection - Implement
TrustScoringService(materialized scores, event-driven updates) - Wire
OutcomeFeedbackServiceto trust updates - Implement
TierResolverService(tier inference + VM acquisition) - Implement
AutonomyGateService(assembles context from all three services) - Extend
AutonomyService: read trust score as dynamic level - Wire sub-Vitto outcome reporting (close the feedback loop)
Phase 3: Proactive Intelligence (Sessions 6-7)
Goal: Vitto learns trigger patterns and acts proactively.
- Create
trigger-pattern.schema.ts+ MongoDB collection - Implement
TriggerEngineService(detection, matching, execution) - Wire trigger detection into post-task completion flow
- Wire trigger execution into ProactiveDispatcherService
- Add trigger response handling (engage/dismiss → confidence adjustment)
Phase 4: Resilience & Nesting (Sessions 8-9)
Goal: Tasks survive crashes. Agents can nest.
- Create
task-recovery.schema.ts+ MongoDB collection with TTL - Implement
TaskRecoveryService(save after tool exec, recover on restart) - Wire recovery into AgentLoopService (save) and VittoEngineService (recover)
- Increase MAX_DEPTH to 2 with depth-aware restrictions
- Add depth-aware budget inheritance to CostGuardService
- Chat visibility: SSE events for VM status, degradation, trust changes
Phase 5: Polish & Hardening (Sessions 10-12)
Goal: Production readiness.
- Graceful degradation testing (VM unavailable scenarios)
- Trust score edge cases (gaming prevention, cold start behavior)
- Trigger pattern quality (false positive rate, user satisfaction)
- Recovery accuracy (genome vs raw message comparison)
- Load testing (concurrent VM sessions, snapshot restore under load)
- TrustEscalationService migration + deprecation
- 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.mdfor 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.