2026 03 10 Unified Vitto Master Plan
Documentation for Plans
Unified Vitto Master Plan — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Transform the GoGrab platform from a functional-but-fragile AI system into a resilient, cost-optimized, self-improving super agent infrastructure across all 4 backend APIs.
Architecture: The plan builds bottom-up: instrumentation first (Phase 0), then resilience (Phase 1), then cost optimization (Phase 2-3), then migration cleanup (Phase 4), then new capabilities (Phase 5-9). Each phase produces independently testable, deployable improvements. The shared resilience library (@libs-api/backend-core) is the foundation that all backends consume.
Tech Stack: NestJS backends (3000/3001/3002/3003), MongoDB (5 named connections), Redis (ioredis), BullMQ, Anthropic/OpenAI/Google LLM SDKs, Angular 20+ frontend, Cloudflare Workers (edge layer).
Source Documents:
UNIFIED-VITTO-MASTER-PLAN.md— merged architecture & phase planVITTO-EXPANDED-RESEARCH-UNICORN-PATH.md— business strategy & competitive moatsVITTO-SuperAgent-Research-March2026.md— technical research & innovations
Codebase Reality Check (from exploration)
Before diving into tasks, here's what actually exists vs. what the plan assumes:
| Assumption | Reality |
|---|---|
| No circuit breakers anywhere | backend-vitto-api HAS CircuitBreakerService + DeadLetterQueueService in security/resilience/. backend-ai-api has a scattered one in MCPIntegrationService. backend-api has NONE. |
| No cost tracking | Two parallel systems exist: LlmCostTrackerService (only ComputerUse) + LLMUsageTrackingService (only Workflow). ChatService direct calls are UNTRACKED. |
| No model routing | backend-vitto-api HAS SmartRouterService with complexity-based routing + failover chain. backend-ai-api has NO routing — ad-hoc model selection. |
| No retry on LLM calls | backend-vitto-api HAS retry in BaseLLMProvider.withRetry(). backend-ai-api direct Anthropic SDK calls have ZERO retry. |
| Channel identity has no caching | Correct — resolveChannelIdentity() is a raw MongoDB findOne every time. |
| User preferences have no caching | Correct — getMergedPreferences() does 3 parallel MongoDB queries every time. |
| Redis is available | Yes — RedisModule is global in all backends, but only used for rate limiting + Socket.IO in backend-api. |
| BullMQ is available | Only in backend-vitto-api (TaskQueueService). backend-api has NO BullMQ. backend-ai-api has queue infrastructure in WorkflowModule. |
Key insight: The plan's Phase 1 task "Extract CircuitBreakerService to @libs-api/backend-core" is the critical first step — Vitto already has the implementation, we just need to lift it to the shared library so all 3 backends can use it.
Chunk 1: Phase 0 (Baselines) + Phase 1 (Resilience Foundation)
Task 1: LLM Call Instrumentation Middleware (Phase 0)
Why: You cannot measure cost reduction without a baseline. Currently, ChatService and ConversationGenomeService LLM calls are completely untracked. This adds universal logging to every LLM call across all backends.
Files:
Create:
libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.service.tsCreate:
libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.module.tsCreate:
libs/libs-api/backend-core/src/lib/schemas/llm-call-log.schema.tsModify:
libs/libs-api/backend-core/src/index.ts(export new module)Modify:
APIs/backend-ai-api/src/app/chat/chat.service.ts(wrap Anthropic calls)Modify:
APIs/backend-ai-api/src/app/sessions/conversation-genome.service.ts(wrap Anthropic calls)Modify:
APIs/backend-ai-api/src/app/sessions/conversation-compressor.service.ts(wrap Anthropic calls)Modify:
APIs/backend-ai-api/src/app/module-files/module-files.service.ts(wrap Anthropic calls)Modify:
APIs/backend-ai-api/src/app/infrastructure/services/computer-use-loop.service.ts(use unified logger alongside existing LlmCostTrackerService)Test:
libs/libs-api/backend-core/src/lib/instrumentation/__tests__/llm-call-logger.service.spec.tsStep 1: Create the LLM call log schema
// libs/libs-api/backend-core/src/lib/schemas/llm-call-log.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema({ collection: 'llm_call_logs', timestamps: true })
export class LlmCallLog extends Document {
@Prop({ required: true }) provider: string; // 'anthropic' | 'openai' | 'google'
@Prop({ required: true }) model: string; // 'claude-opus-4-20250514' etc.
@Prop({ required: true }) callerService: string; // 'ChatService' | 'ConversationGenomeService' etc.
@Prop({ required: true }) callerMethod: string; // 'generateResponse' | 'compile' etc.
@Prop() userId?: string;
@Prop() spaceId?: string;
@Prop() sessionId?: string;
@Prop({ required: true }) inputTokens: number;
@Prop({ required: true }) outputTokens: number;
@Prop() cacheReadTokens?: number;
@Prop() cacheCreationTokens?: number;
@Prop({ required: true }) latencyMs: number;
@Prop({ required: true }) success: boolean;
@Prop() errorType?: string; // 'rate_limit' | 'overloaded' | 'timeout' | 'unknown'
@Prop({ type: Number }) estimatedCostUsd?: number; // calculated from AI_MODEL_CAPABILITIES pricing
@Prop() taskType?: string; // 'chat' | 'genome' | 'compression' | 'computer-use' etc.
@Prop({ type: Object }) metadata?: Record<string, unknown>;
}
export const LlmCallLogSchema = SchemaFactory.createForClass(LlmCallLog);
LlmCallLogSchema.index({ createdAt: 1 }, { expireAfterSeconds: 90 * 24 * 3600 }); // 90-day TTL
LlmCallLogSchema.index({ callerService: 1, createdAt: -1 });
LlmCallLogSchema.index({ userId: 1, createdAt: -1 });
LlmCallLogSchema.index({ provider: 1, model: 1, createdAt: -1 });
- Step 2: Create the LlmCallLoggerService
// libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { LlmCallLog } from '../schemas/llm-call-log.schema';
export interface LlmCallEntry {
provider: string;
model: string;
callerService: string;
callerMethod: string;
userId?: string;
spaceId?: string;
sessionId?: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
latencyMs: number;
success: boolean;
errorType?: string;
estimatedCostUsd?: number;
taskType?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class LlmCallLoggerService {
private readonly logger = new Logger(LlmCallLoggerService.name);
private buffer: LlmCallEntry[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private readonly BUFFER_SIZE = 20;
private readonly FLUSH_INTERVAL_MS = 5000;
constructor(
@InjectModel(LlmCallLog.name) private readonly llmCallLogModel: Model<LlmCallLog>,
) {
this.flushTimer = setInterval(() => this.flush(), this.FLUSH_INTERVAL_MS);
}
/** Fire-and-forget log — never throws, never blocks the caller. */
log(entry: LlmCallEntry): void {
this.buffer.push(entry);
if (this.buffer.length >= this.BUFFER_SIZE) {
this.flush();
}
}
/** Wraps an async LLM call, logs timing + tokens automatically. */
async wrap<T extends { usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } }>(
meta: Pick<LlmCallEntry, 'provider' | 'model' | 'callerService' | 'callerMethod' | 'userId' | 'spaceId' | 'sessionId' | 'taskType' | 'metadata'>,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
const result = await fn();
const latencyMs = Date.now() - start;
this.log({
...meta,
inputTokens: result.usage?.input_tokens ?? 0,
outputTokens: result.usage?.output_tokens ?? 0,
cacheReadTokens: result.usage?.cache_read_input_tokens,
cacheCreationTokens: result.usage?.cache_creation_input_tokens,
latencyMs,
success: true,
});
return result;
} catch (error: unknown) {
const latencyMs = Date.now() - start;
const errType = this.classifyError(error);
this.log({
...meta,
inputTokens: 0,
outputTokens: 0,
latencyMs,
success: false,
errorType: errType,
});
throw error;
}
}
private classifyError(error: unknown): string {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('rate_limit') || msg.includes('429')) return 'rate_limit';
if (msg.includes('overloaded') || msg.includes('529')) return 'overloaded';
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) return 'timeout';
return 'unknown';
}
private flush(): void {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0);
this.llmCallLogModel.insertMany(batch, { ordered: false }).catch((err) => {
this.logger.warn(`Failed to flush ${batch.length} LLM call logs: ${err.message}`);
});
}
onModuleDestroy(): void {
if (this.flushTimer) clearInterval(this.flushTimer);
this.flush();
}
}
- Step 3: Create the NestJS module
// libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.module.ts
import { Module, Global } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { LlmCallLog, LlmCallLogSchema } from '../schemas/llm-call-log.schema';
import { LlmCallLoggerService } from './llm-call-logger.service';
@Global()
@Module({
imports: [
MongooseModule.forFeature([{ name: LlmCallLog.name, schema: LlmCallLogSchema }]),
],
providers: [LlmCallLoggerService],
exports: [LlmCallLoggerService],
})
export class LlmCallLoggerModule {}
- Step 4: Export from backend-core index
Add to libs/libs-api/backend-core/src/index.ts:
// Instrumentation
export { LlmCallLoggerModule } from './lib/instrumentation/llm-call-logger.module';
export { LlmCallLoggerService, LlmCallEntry } from './lib/instrumentation/llm-call-logger.service';
export { LlmCallLog, LlmCallLogSchema } from './lib/schemas/llm-call-log.schema';
- Step 5: Write failing test for LlmCallLoggerService
// libs/libs-api/backend-core/src/lib/instrumentation/__tests__/llm-call-logger.service.spec.ts
import { Test } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { LlmCallLoggerService } from '../llm-call-logger.service';
import { LlmCallLog } from '../../schemas/llm-call-log.schema';
describe('LlmCallLoggerService', () => {
let service: LlmCallLoggerService;
let mockModel: { insertMany: jest.Mock };
beforeEach(async () => {
mockModel = { insertMany: jest.fn().mockResolvedValue([]) };
const module = await Test.createTestingModule({
providers: [
LlmCallLoggerService,
{ provide: getModelToken(LlmCallLog.name), useValue: mockModel },
],
}).compile();
service = module.get(LlmCallLoggerService);
});
afterEach(() => service.onModuleDestroy());
it('should buffer logs and flush on threshold', () => {
for (let i = 0; i < 20; i++) {
service.log({
provider: 'anthropic', model: 'claude-haiku-4-5-20251001',
callerService: 'TestService', callerMethod: 'test',
inputTokens: 100, outputTokens: 50, latencyMs: 200, success: true,
});
}
expect(mockModel.insertMany).toHaveBeenCalledTimes(1);
expect(mockModel.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ provider: 'anthropic', callerService: 'TestService' }),
]), { ordered: false });
});
it('should wrap an async call and log success', async () => {
const result = await service.wrap(
{ provider: 'anthropic', model: 'test', callerService: 'Test', callerMethod: 'run', taskType: 'chat' },
async () => ({ usage: { input_tokens: 500, output_tokens: 200 }, content: 'hello' }),
);
expect(result.content).toBe('hello');
// Force flush
service.onModuleDestroy();
expect(mockModel.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ inputTokens: 500, outputTokens: 200, success: true })]),
{ ordered: false },
);
});
it('should wrap an async call and log failure, then rethrow', async () => {
await expect(service.wrap(
{ provider: 'anthropic', model: 'test', callerService: 'Test', callerMethod: 'run' },
async () => { throw new Error('429 rate_limit exceeded'); },
)).rejects.toThrow('429 rate_limit exceeded');
service.onModuleDestroy();
expect(mockModel.insertMany).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ success: false, errorType: 'rate_limit' })]),
{ ordered: false },
);
});
});
- Step 6: Run test to verify it passes
Run: yarn nx test backend-core --testPathPattern=llm-call-logger
Expected: 3 tests PASS
- Step 7: Wire LlmCallLoggerModule into backend-ai-api AppModule
In APIs/backend-ai-api/src/app/app.module.ts, add LlmCallLoggerModule to imports array.
- Step 8: Wrap ChatService direct Anthropic calls with logger
In APIs/backend-ai-api/src/app/chat/chat.service.ts, inject LlmCallLoggerService and wrap the this.anthropic.messages.create() call at line ~249 with this.llmCallLogger.wrap(...).
- Step 9: Wrap ConversationGenomeService Anthropic calls
In APIs/backend-ai-api/src/app/sessions/conversation-genome.service.ts, inject and wrap calls at lines ~158 and ~723.
- Step 10: Wrap ConversationCompressorService Anthropic calls
In APIs/backend-ai-api/src/app/sessions/conversation-compressor.service.ts, inject and wrap calls at lines ~218 and ~297.
- Step 11: Wrap ModuleFilesService Anthropic calls
In APIs/backend-ai-api/src/app/module-files/module-files.service.ts, inject and wrap call at line ~210.
- Step 12: Commit
git add libs/libs-api/backend-core/src/lib/instrumentation/ libs/libs-api/backend-core/src/lib/schemas/llm-call-log.schema.ts libs/libs-api/backend-core/src/index.ts APIs/backend-ai-api/src/app/chat/chat.service.ts APIs/backend-ai-api/src/app/sessions/ APIs/backend-ai-api/src/app/module-files/ APIs/backend-ai-api/src/app/app.module.ts
git commit -m "[S201.1] feat: add universal LLM call instrumentation to backend-core"
Task 2: Context Size Logging (Phase 0)
Why: Measures what Vitto actually sends to sub-agents. Determines whether delta-passing (Phase 5) is a 5% or 50% win.
Files:
Modify:
APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts(log context size before each LLM call)Modify:
APIs/backend-vitto-api/src/app/engine/delegation-manager.service.ts(log delegation payload size)Step 1: Add context size logging to AgentLoopService
Before each SmartRouterService.sendWithFailover() call in agent-loop.service.ts, add:
const contextTokenEstimate = messages.reduce((sum, m) => {
const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
return sum + Math.ceil(text.length / 3.5); // rough token estimate
}, 0);
this.logger.log(`[CONTEXT_SIZE] taskId=${task.id} iteration=${iteration} messages=${messages.length} estimatedTokens=${contextTokenEstimate}`);
- Step 2: Add delegation payload size logging to DelegationManagerService
In delegate() or createDelegation(), before BackendAiClientService.createHandoff():
const payloadSize = JSON.stringify(delegationPayload).length;
this.logger.log(`[DELEGATION_SIZE] parentTask=${parentTaskId} payloadBytes=${payloadSize} estimatedTokens=${Math.ceil(payloadSize / 3.5)}`);
- Step 3: Commit
git add APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts APIs/backend-vitto-api/src/app/engine/delegation-manager.service.ts
git commit -m "[S201.2] feat: add context size logging for baseline measurement"
Task 3: Extract CircuitBreakerService to @libs-api/backend-core (Phase 1)
Why: backend-vitto-api has a production-proven circuit breaker. backend-ai-api and backend-api need the same thing. Extract to shared library.
Files:
Create:
libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.service.ts(extracted from vitto)Create:
libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.types.tsCreate:
libs/libs-api/backend-core/src/lib/resilience/retry-with-backoff.ts(utility function)Create:
libs/libs-api/backend-core/src/lib/resilience/dead-letter-queue.service.ts(extracted from vitto)Create:
libs/libs-api/backend-core/src/lib/resilience/resilience.module.tsModify:
libs/libs-api/backend-core/src/index.ts(export resilience module)Modify:
APIs/backend-vitto-api/src/app/security/resilience/circuit-breaker.service.ts(re-export from backend-core)Modify:
APIs/backend-vitto-api/src/app/security/resilience/dead-letter-queue.service.ts(re-export from backend-core)Test:
libs/libs-api/backend-core/src/lib/resilience/__tests__/circuit-breaker.service.spec.tsTest:
libs/libs-api/backend-core/src/lib/resilience/__tests__/retry-with-backoff.spec.tsStep 1: Create circuit breaker types
// libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.types.ts
export type CircuitState = 'closed' | 'open' | 'half_open';
export interface CircuitBreakerConfig {
failureThreshold: number; // consecutive failures to open (default 5)
resetTimeMs: number; // time in open before half_open (default 60000)
halfOpenMaxAttempts: number; // test calls in half_open (default 2)
}
export interface CircuitStatus {
key: string;
state: CircuitState;
failures: number;
lastFailureAt?: Date;
lastSuccessAt?: Date;
openedAt?: Date;
}
- Step 2: Extract CircuitBreakerService from backend-vitto-api
Read the existing implementation at APIs/backend-vitto-api/src/app/security/resilience/circuit-breaker.service.ts and create the shared version at libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.service.ts with the same 3-state logic (closed → open → half_open → closed), canExecute(), execute(), recordSuccess(), recordFailure(), configure(), getAll().
- Step 3: Create retryWithBackoff utility
// libs/libs-api/backend-core/src/lib/resilience/retry-with-backoff.ts
export interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableErrors?: string[]; // error message patterns
onRetry?: (attempt: number, error: Error) => void;
}
const DEFAULT_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
};
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
config: Partial<RetryConfig> = {},
): Promise<T> {
const cfg = { ...DEFAULT_CONFIG, ...config };
let lastError: Error;
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === cfg.maxRetries) break;
if (cfg.retryableErrors?.length) {
const isRetryable = cfg.retryableErrors.some((pattern) =>
lastError.message.includes(pattern),
);
if (!isRetryable) break;
}
const delay = Math.min(
cfg.baseDelayMs * Math.pow(2, attempt) + Math.random() * 500,
cfg.maxDelayMs,
);
cfg.onRetry?.(attempt + 1, lastError);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
- Step 4: Extract DeadLetterQueueService
Read existing at APIs/backend-vitto-api/src/app/security/resilience/dead-letter-queue.service.ts and create shared version at libs/libs-api/backend-core/src/lib/resilience/dead-letter-queue.service.ts.
- Step 5: Create ResilienceModule
// libs/libs-api/backend-core/src/lib/resilience/resilience.module.ts
import { Module, Global } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';
import { DeadLetterQueueService } from './dead-letter-queue.service';
@Global()
@Module({
providers: [CircuitBreakerService, DeadLetterQueueService],
exports: [CircuitBreakerService, DeadLetterQueueService],
})
export class ResilienceModule {}
- Step 6: Export from backend-core index
Add to libs/libs-api/backend-core/src/index.ts:
// Resilience
export { ResilienceModule } from './lib/resilience/resilience.module';
export { CircuitBreakerService } from './lib/resilience/circuit-breaker.service';
export { CircuitBreakerConfig, CircuitState, CircuitStatus } from './lib/resilience/circuit-breaker.types';
export { DeadLetterQueueService } from './lib/resilience/dead-letter-queue.service';
export { retryWithBackoff, RetryConfig } from './lib/resilience/retry-with-backoff';
- Step 7: Write failing tests for CircuitBreakerService
// libs/libs-api/backend-core/src/lib/resilience/__tests__/circuit-breaker.service.spec.ts
describe('CircuitBreakerService', () => {
let service: CircuitBreakerService;
beforeEach(() => { service = new CircuitBreakerService(); });
it('starts in closed state', () => {
expect(service.canExecute('test')).toBe(true);
});
it('opens after 5 consecutive failures', async () => {
for (let i = 0; i < 5; i++) {
service.recordFailure('test');
}
expect(service.canExecute('test')).toBe(false);
});
it('transitions to half_open after reset time', async () => {
service.configure('test', { failureThreshold: 2, resetTimeMs: 50, halfOpenMaxAttempts: 1 });
service.recordFailure('test');
service.recordFailure('test');
expect(service.canExecute('test')).toBe(false);
await new Promise((r) => setTimeout(r, 60));
expect(service.canExecute('test')).toBe(true); // half_open
});
it('closes again after success in half_open', async () => {
service.configure('test', { failureThreshold: 2, resetTimeMs: 50, halfOpenMaxAttempts: 1 });
service.recordFailure('test');
service.recordFailure('test');
await new Promise((r) => setTimeout(r, 60));
service.recordSuccess('test');
const status = service.getAll().find((s) => s.key === 'test');
expect(status?.state).toBe('closed');
});
it('execute() wraps fn and records success/failure', async () => {
const result = await service.execute('test', async () => 42);
expect(result).toBe(42);
await expect(service.execute('test', async () => { throw new Error('fail'); })).rejects.toThrow('fail');
});
});
- Step 8: Write failing tests for retryWithBackoff
// libs/libs-api/backend-core/src/lib/resilience/__tests__/retry-with-backoff.spec.ts
import { retryWithBackoff } from '../retry-with-backoff';
describe('retryWithBackoff', () => {
it('returns result on first success', async () => {
const result = await retryWithBackoff(async () => 42);
expect(result).toBe(42);
});
it('retries on failure and succeeds', async () => {
let attempts = 0;
const result = await retryWithBackoff(async () => {
attempts++;
if (attempts < 3) throw new Error('transient');
return 'ok';
}, { maxRetries: 3, baseDelayMs: 10 });
expect(result).toBe('ok');
expect(attempts).toBe(3);
});
it('throws after max retries', async () => {
await expect(retryWithBackoff(
async () => { throw new Error('permanent'); },
{ maxRetries: 2, baseDelayMs: 10 },
)).rejects.toThrow('permanent');
});
it('respects retryableErrors filter', async () => {
let attempts = 0;
await expect(retryWithBackoff(
async () => { attempts++; throw new Error('not retryable'); },
{ maxRetries: 3, baseDelayMs: 10, retryableErrors: ['transient'] },
)).rejects.toThrow('not retryable');
expect(attempts).toBe(1); // no retries
});
});
- Step 9: Run tests
Run: yarn nx test backend-core --testPathPattern=resilience
Expected: All tests PASS
- Step 10: Update backend-vitto-api to re-export from backend-core
Replace the body of APIs/backend-vitto-api/src/app/security/resilience/circuit-breaker.service.ts with:
export { CircuitBreakerService } from '@libs-api/backend-core';
Same for dead-letter-queue.service.ts:
export { DeadLetterQueueService } from '@libs-api/backend-core';
- Step 11: Verify backend-vitto-api still builds
Run: yarn nx build backend-vitto-api --skip-nx-cache
Expected: BUILD SUCCESS
- Step 12: Commit
git add libs/libs-api/backend-core/src/lib/resilience/ libs/libs-api/backend-core/src/index.ts APIs/backend-vitto-api/src/app/security/resilience/
git commit -m "[S201.3] feat: extract CircuitBreaker + retryWithBackoff to @libs-api/backend-core"
Task 4: Add Circuit Breakers to backend-ai-api LLM Calls (Phase 1)
Why: ChatService, ConversationGenomeService, and other direct Anthropic SDK callers have zero resilience. A single API failure throws to the user.
Files:
Modify:
APIs/backend-ai-api/src/app/app.module.ts(import ResilienceModule)Modify:
APIs/backend-ai-api/src/app/chat/chat.service.ts(add circuit breaker + retry)Modify:
APIs/backend-ai-api/src/app/sessions/conversation-genome.service.ts(add circuit breaker)Modify:
APIs/backend-ai-api/src/app/sessions/conversation-compressor.service.ts(add circuit breaker)Modify:
APIs/backend-ai-api/src/app/module-files/module-files.service.ts(add circuit breaker)Step 1: Import ResilienceModule into backend-ai-api AppModule
In APIs/backend-ai-api/src/app/app.module.ts, add ResilienceModule to imports.
- Step 2: Wrap ChatService Anthropic calls with circuit breaker + retry
In chat.service.ts, inject CircuitBreakerService, wrap the this.anthropic.messages.create() call:
const CB_KEY = 'llm:anthropic:chat';
const response = await retryWithBackoff(
() => this.circuitBreaker.execute(CB_KEY, () =>
this.llmCallLogger.wrap(
{ provider: 'anthropic', model, callerService: 'ChatService', callerMethod: 'generateResponse', userId, taskType: 'chat' },
() => this.anthropic.messages.create({ model, messages, system, max_tokens }),
),
),
{ maxRetries: 2, baseDelayMs: 1000, retryableErrors: ['rate_limit', 'overloaded', '529', '429', 'ECONNRESET'] },
);
- Step 3: Wrap ConversationGenomeService calls similarly
Circuit breaker key: llm:anthropic:genome. Max retries: 1 (these are background tasks, don't delay).
- Step 4: Wrap ConversationCompressorService calls
Circuit breaker key: llm:anthropic:compressor. Max retries: 1.
- Step 5: Wrap ModuleFilesService calls
Circuit breaker key: llm:anthropic:module-files. Max retries: 2.
- Step 6: Verify backend-ai-api builds
Run: yarn nx build backend-ai-api --skip-nx-cache
Expected: BUILD SUCCESS
- Step 7: Commit
git add APIs/backend-ai-api/src/app/
git commit -m "[S201.4] feat: add circuit breakers + retry to backend-ai-api LLM calls"
Task 5: Add Circuit Breakers to backend-api External Calls (Phase 1)
Why: Stripe, Cloudflare, and GitLab calls have zero resilience. A Stripe timeout blocks the billing flow.
Files:
Modify:
APIs/backend-api/src/app/app.module.ts(import ResilienceModule)Modify:
APIs/backend-api/src/app/billing/billing.service.ts(wrap Stripe calls)Modify:
APIs/backend-api/src/app/cloudflare/cloudflare-pages.service.ts(wrap CF calls)Modify:
APIs/backend-api/src/app/git-provider/gitlab-provider.service.ts(wrap GitLab calls)Step 1: Import ResilienceModule into backend-api AppModule
Step 2: Wrap Stripe calls in BillingService
Inject CircuitBreakerService. Wrap stripe.customers.create(), stripe.checkout.sessions.create(), stripe.subscriptions.update() etc. with circuit breaker key external:stripe. Use retryWithBackoff with 2 retries for transient errors (network, 5xx), 0 retries for 4xx.
- Step 3: Wrap Cloudflare calls in CloudflarePagesService
Circuit breaker key: external:cloudflare. Retry on network errors only.
- Step 4: Wrap GitLab calls in the private request() method
Circuit breaker key: external:gitlab. Retry on RATE_LIMITED and network errors. The existing error mapping in request() makes this clean — just wrap the single HttpService.request() call.
- Step 5: Verify backend-api builds
Run: yarn nx build backend-api --skip-nx-cache
Expected: BUILD SUCCESS
- Step 6: Commit
git add APIs/backend-api/src/app/
git commit -m "[S201.5] feat: add circuit breakers to backend-api external service calls"
Task 6: Add Redis Caching for Channel Identity Resolution (Phase 1)
Why: Channel identity resolution is called on every WhatsApp/Telegram message. Currently hits MongoDB every time. A 5-minute Redis cache eliminates redundant lookups for active conversations.
Files:
Modify:
APIs/backend-api/src/app/users/users.service.ts(add Redis cache layer)Test:
APIs/backend-api/src/app/users/__tests__/channel-identity-cache.spec.tsStep 1: Write failing test
describe('resolveChannelIdentity with cache', () => {
it('returns cached result on second call', async () => {
// First call hits MongoDB, second call hits Redis
});
it('invalidates cache on completeChannelLink', async () => {
// After linking, cached result should be cleared
});
});
- Step 2: Add Redis caching to resolveChannelIdentity()
In users.service.ts, inject RedisService from @libs-api/backend-core. Before the findOne:
const cacheKey = `channel:${channelType}:${channelId}`;
const cached = await this.redisService.get(cacheKey);
if (cached) return JSON.parse(cached);
// ... existing findOne logic ...
await this.redisService.set(cacheKey, JSON.stringify(result), 'EX', 300); // 5min TTL
return result;
- Step 3: Invalidate cache on completeChannelLink()
After successful link completion:
await this.redisService.del(`channel:${identity.channelType}:${identity.channelId}`);
- Step 4: Run tests
Expected: PASS
- Step 5: Commit
git add APIs/backend-api/src/app/users/
git commit -m "[S201.6] feat: add Redis cache for channel identity resolution (5min TTL)"
Task 7: Add Redis Caching for User Preferences (Phase 1)
Why: getMergedPreferences() does 3 parallel MongoDB queries on every call. A 10-minute cache reduces load significantly for active users.
Files:
Modify:
APIs/backend-api/src/app/preferences/preferences.service.ts(add cache layer)Step 1: Add cache to getMergedPreferences()
const cacheKey = `prefs:${userId}:${spaceId}`;
const cached = await this.redisService.get(cacheKey);
if (cached) return JSON.parse(cached);
// ... existing merge logic ...
await this.redisService.set(cacheKey, JSON.stringify(merged), 'EX', 600); // 10min TTL
return merged;
- Step 2: Invalidate on preference updates
In the PUT handler for user preferences, delete the cache key.
- Step 3: Commit
git add APIs/backend-api/src/app/preferences/
git commit -m "[S201.7] feat: add Redis cache for user preferences (10min TTL)"
Task 8: Provider Health Endpoints (Phase 1)
Why: No way to check if external providers are healthy from monitoring/alerting. Each backend needs a /health/providers endpoint.
Files:
Create:
libs/libs-api/backend-core/src/lib/resilience/health-reporter.service.tsModify:
APIs/backend-ai-api/src/app/infrastructure/infrastructure.controller.ts(add endpoint)Modify:
APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts(expose circuit states)Step 1: Create HealthReporterService
// libs/libs-api/backend-core/src/lib/resilience/health-reporter.service.ts
import { Injectable } from '@nestjs/common';
import { CircuitBreakerService } from './circuit-breaker.service';
export interface ProviderHealth {
providers: Array<{
key: string;
state: string;
failures: number;
lastFailure?: Date;
lastSuccess?: Date;
}>;
overall: 'healthy' | 'degraded' | 'unhealthy';
}
@Injectable()
export class HealthReporterService {
constructor(private readonly circuitBreaker: CircuitBreakerService) {}
getProviderHealth(): ProviderHealth {
const circuits = this.circuitBreaker.getAll();
const openCount = circuits.filter((c) => c.state === 'open').length;
return {
providers: circuits,
overall: openCount === 0 ? 'healthy' : openCount < circuits.length ? 'degraded' : 'unhealthy',
};
}
}
Step 2: Add to ResilienceModule exports and backend-core index
Step 3: Add GET /ai-api/infrastructure/health/providers to backend-ai-api
Step 4: Add GET /vitto-api/health/providers to backend-vitto-api
Step 5: Commit
git add libs/libs-api/backend-core/src/lib/resilience/ libs/libs-api/backend-core/src/index.ts APIs/backend-ai-api/src/app/infrastructure/ APIs/backend-vitto-api/
git commit -m "[S201.8] feat: add provider health endpoints across all backends"
Task 9: Add Retries to Inter-Service Calls (Phase 1)
Why: All inter-service HTTP calls use firstValueFrom() with no retry. A single network blip loses the request.
Files:
Modify:
APIs/backend-ai-api/src/app/platform-api-client/platform-api-client.service.ts(add retry)Modify:
APIs/backend-ai-api/src/app/orchestration/vitto-client.service.ts(add retry)Modify:
APIs/backend-vitto-api/src/app/connectivity/backend-ai-client.service.ts(add retry)Modify:
APIs/backend-vitto-api/src/app/connectivity/infrastructure-client.service.ts(add retry)Modify:
APIs/backend-vitto-api/src/app/connectivity/backend-docs-client.service.ts(add retry)Step 1: Add retryWithBackoff to PlatformApiClientService
Wrap HTTP calls with retryWithBackoff({ maxRetries: 2, baseDelayMs: 500, retryableErrors: ['ECONNRESET', 'ETIMEDOUT', '503'] }).
- Step 2: Add retry to VittoClientService
Same pattern. Circuit breaker key: interservice:vitto.
- Step 3: Add retry to BackendAiClientService in Vitto
Circuit breaker key: interservice:ai-api. Max retries: 2.
- Step 4: Add retry to InfrastructureClientService
Circuit breaker key: interservice:infrastructure. Max retries: 1 (these are often long-running).
- Step 5: Add retry to BackendDocsClientService
Circuit breaker key: interservice:docs. Max retries: 2.
- Step 6: Commit
git add APIs/backend-ai-api/src/app/platform-api-client/ APIs/backend-ai-api/src/app/orchestration/ APIs/backend-vitto-api/src/app/connectivity/
git commit -m "[S201.9] feat: add retries + circuit breakers to all inter-service HTTP calls"
Chunk 2: Phase 2 (Semantic Cache + Model Router) + Phase 3 (BullMQ Alerting)
Task 10: Exact-Match Response Cache for LLM Calls (Phase 2)
Why: Start with exact-match caching (hash of normalized prompt + model + temperature). Free wins before investing in semantic similarity.
Files:
Create:
libs/libs-api/backend-core/src/lib/cache/llm-response-cache.service.tsCreate:
libs/libs-api/backend-core/src/lib/cache/llm-response-cache.module.tsModify:
libs/libs-api/backend-core/src/index.tsModify:
APIs/backend-ai-api/src/app/chat/chat.service.ts(check cache before LLM call)Test:
libs/libs-api/backend-core/src/lib/cache/__tests__/llm-response-cache.service.spec.tsStep 1: Create LlmResponseCacheService
// libs/libs-api/backend-core/src/lib/cache/llm-response-cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import * as crypto from 'crypto';
export interface CacheableRequest {
model: string;
messages: Array<{ role: string; content: string }>;
system?: string;
temperature?: number;
userId?: string; // MUST be part of cache key for tenant isolation
spaceId?: string; // MUST be part of cache key for tenant isolation
}
@Injectable()
export class LlmResponseCacheService {
private readonly logger = new Logger(LlmResponseCacheService.name);
private readonly TTL_SECONDS = 3600; // 1 hour default
private hits = 0;
private misses = 0;
constructor(private readonly redis: RedisService) {}
private buildKey(req: CacheableRequest): string {
const normalized = JSON.stringify({
model: req.model,
messages: req.messages.map((m) => ({ role: m.role, content: m.content.trim() })),
system: req.system?.trim(),
temperature: req.temperature ?? 1,
userId: req.userId,
spaceId: req.spaceId,
});
return `llm-cache:${crypto.createHash('sha256').update(normalized).digest('hex')}`;
}
async get(req: CacheableRequest): Promise<string | null> {
if (!this.redis.isConnected()) return null;
try {
const cached = await this.redis.get(this.buildKey(req));
if (cached) { this.hits++; return cached; }
this.misses++;
return null;
} catch { return null; }
}
async set(req: CacheableRequest, response: string, ttlSeconds?: number): Promise<void> {
if (!this.redis.isConnected()) return;
try {
await this.redis.set(this.buildKey(req), response, 'EX', ttlSeconds ?? this.TTL_SECONDS);
} catch (err) {
this.logger.warn(`Cache set failed: ${err}`);
}
}
getStats(): { hits: number; misses: number; hitRate: number } {
const total = this.hits + this.misses;
return { hits: this.hits, misses: this.misses, hitRate: total > 0 ? this.hits / total : 0 };
}
}
Important design decision: Cache key includes userId and spaceId for tenant isolation. "What's my order status?" for user A must NOT return user B's cached answer.
Important exclusion: Do NOT cache requests that include tool definitions (agentic tool-calling responses). Only cache stateless Q&A.
Step 2: Write tests
Step 3: Wire into ChatService
Before the LLM call, check cache. After successful response, store in cache. Skip caching if tools are present in the request.
- Step 4: Commit
git commit -m "[S201.10] feat: add exact-match LLM response cache with tenant isolation"
Task 11: Unified Model Router for backend-ai-api (Phase 2)
Why: backend-ai-api has no model routing — every service hardcodes its model. Use the task-type → model-tier mapping from SmartRouterService pattern.
Files:
Create:
libs/libs-api/backend-core/src/lib/routing/model-router.service.tsCreate:
libs/libs-api/backend-core/src/lib/routing/model-router.types.tsModify:
libs/libs-api/backend-core/src/index.tsModify:
APIs/backend-ai-api/src/app/chat/chat.service.ts(use model router instead of hardcoded model)Step 1: Create model router types
// libs/libs-api/backend-core/src/lib/routing/model-router.types.ts
export type TaskComplexity = 'simple' | 'moderate' | 'complex';
export type ModelTier = 'frontier' | 'standard' | 'fast';
export interface ModelRouterConfig {
frontier: string[]; // ['claude-opus-4-20250514', 'gpt-5']
standard: string[]; // ['claude-sonnet-4-20250514', 'gpt-4.1']
fast: string[]; // ['claude-haiku-4-5-20251001', 'gpt-4.1-mini']
}
- Step 2: Create ModelRouterService
Rule-based routing by task type. Map: chat → standard, genome → fast, compression → fast, module-generation → frontier, computer-use → frontier, pattern-mining → standard.
- Step 3: Wire into ChatService
Replace hardcoded model selection with modelRouter.getModel(taskType, complexity).
- Step 4: Commit
git commit -m "[S201.11] feat: add rule-based model router to backend-core"
Task 12: BullMQ Event-Driven Alerting (Phase 3)
Why: No alerting when queues back up, jobs fail, or circuit breakers open. Need event-driven alerts.
Files:
Create:
libs/libs-api/backend-core/src/lib/alerting/alert.service.tsCreate:
libs/libs-api/backend-core/src/lib/alerting/alert.types.tsCreate:
libs/libs-api/backend-core/src/lib/alerting/alerting.module.tsModify:
libs/libs-api/backend-core/src/lib/resilience/circuit-breaker.service.ts(emit events on state change)Modify:
libs/libs-api/backend-core/src/index.tsStep 1: Create AlertService
// libs/libs-api/backend-core/src/lib/alerting/alert.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
export interface Alert {
severity: 'info' | 'warning' | 'critical';
source: string; // 'circuit-breaker', 'queue', 'cost'
message: string;
metadata?: Record<string, unknown>;
timestamp: Date;
}
@Injectable()
export class AlertService {
private readonly logger = new Logger(AlertService.name);
private readonly recentAlerts: Alert[] = [];
private readonly MAX_RECENT = 100;
constructor(private readonly eventEmitter: EventEmitter2) {}
emit(alert: Omit<Alert, 'timestamp'>): void {
const fullAlert = { ...alert, timestamp: new Date() };
this.recentAlerts.push(fullAlert);
if (this.recentAlerts.length > this.MAX_RECENT) this.recentAlerts.shift();
this.logger.warn(`[ALERT:${alert.severity}] ${alert.source}: ${alert.message}`);
this.eventEmitter.emit('alert', fullAlert);
// Future: send to Slack webhook, email, etc.
}
getRecent(limit = 20): Alert[] {
return this.recentAlerts.slice(-limit);
}
}
- Step 2: Add circuit breaker state change alerts
In CircuitBreakerService, when state transitions to open, call alertService.emit({ severity: 'critical', source: 'circuit-breaker', message: \Circuit ${key} OPENED after ${failures} failures` })`.
- Step 3: Add agent-level metrics to a new endpoint
Create GET /ai-api/infrastructure/metrics/agents that returns: active agent sessions, tokens consumed in last hour, model distribution, cache hit rate.
- Step 4: Commit
git commit -m "[S201.12] feat: add event-driven alerting system with circuit breaker integration"
Chunk 3: Phase 4 (AI Module Migration) + Phase 5 (Agent Runtime Layer)
Task 13: Unified LLM Cost Tracking (Phase 4)
Why: Two parallel, unconnected cost tracking systems exist. Unify them into the LlmCallLoggerService from Task 1, which now becomes the single source of truth.
Files:
Modify:
APIs/backend-ai-api/src/app/infrastructure/services/llm-cost-tracker.service.ts(delegate to LlmCallLoggerService)Modify:
libs/libs-api/llm-integration/src/lib/services/usage-tracking.service.ts(delegate to LlmCallLoggerService)Create:
libs/libs-api/backend-core/src/lib/instrumentation/llm-cost-aggregator.service.ts(query aggregations)Step 1: Create LlmCostAggregatorService
Provides getHourlyCost(), getDailyCost(), getCostByUser(), getCostByModel(), getCostByService() — all querying the unified llm_call_logs collection.
- Step 2: Refactor LlmCostTrackerService to use LlmCallLoggerService
Instead of maintaining its own in-memory array + separate MongoDB collection, delegate to the unified logger. Keep the public API (getSummary, getDailyTotals) but read from llm_call_logs.
Step 3: Refactor LLMUsageTrackingService similarly
Step 4: Add cost alerting
In LlmCostAggregatorService, add a check: if LLM spend in the last hour exceeds 2x the trailing 7-day hourly average, emit a critical alert via AlertService.
- Step 5: Commit
git commit -m "[S201.13] feat: unify LLM cost tracking into single llm_call_logs collection"
Task 14: AI Module Proxy Controllers (Phase 4)
Why: Some AI endpoints live in backend-api (3000) that should be in backend-ai-api (3001). Create proxy controllers in backend-api that forward to backend-ai-api, then deprecate the originals.
Files:
Identify: Which AI endpoints in backend-api should proxy to backend-ai-api
Create: Proxy controllers in backend-api that forward requests
Modify: Frontend services to point to new backend-ai-api endpoints (long-term)
Step 1: Audit AI endpoints in backend-api
Find all controllers in APIs/backend-api/src/app/ that handle AI-related requests (generation, platform-ai, ai-agents).
- Step 2: Create proxy controllers
For each endpoint, create a thin proxy in backend-api that forwards to backend-ai-api via PlatformApiClientService pattern (reversed direction).
- Step 3: Add deprecation headers
Proxy responses include Deprecation: true and Link: <new-endpoint-url> headers.
- Step 4: Commit
git commit -m "[S201.14] feat: add proxy controllers for AI endpoint migration"
Task 15: Agent Session Checkpointing (Phase 5.1)
Why: When a long-running agent task fails (LLM timeout, Railway restart), the entire task is lost. Add state checkpointing at decision boundaries.
Files:
Create:
APIs/backend-vitto-api/src/app/engine/session-checkpoint.service.tsCreate:
APIs/backend-vitto-api/src/app/mind/schemas/session-checkpoint.schema.tsModify:
APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts(checkpoint before/after tool calls)Modify:
APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts(resume from checkpoint on recovery)Test:
APIs/backend-vitto-api/src/app/engine/__tests__/session-checkpoint.service.spec.tsStep 1: Create checkpoint schema
// APIs/backend-vitto-api/src/app/mind/schemas/session-checkpoint.schema.ts
@Schema({ collection: 'session_checkpoints', timestamps: true })
export class SessionCheckpoint extends Document {
@Prop({ required: true, index: true }) taskId: string;
@Prop({ required: true }) sessionId: string;
@Prop({ required: true }) iteration: number;
@Prop({ required: true, enum: ['pre_tool', 'post_tool', 'pre_llm', 'post_llm'] }) boundary: string;
@Prop({ type: Object, required: true }) messages: unknown[]; // conversation state
@Prop({ type: Object }) toolState?: Record<string, unknown>;
@Prop({ type: Object }) taskTransient?: Record<string, unknown>;
@Prop({ required: true, enum: ['ANALYZE', 'PLAN', 'EXECUTE', 'VERIFY'] }) phase: string;
@Prop({ type: Date, default: Date.now, index: true }) createdAt: Date;
}
TTL index: expire checkpoints after 24 hours (they're only needed for crash recovery).
- Step 2: Create SessionCheckpointService
Methods: checkpoint(taskId, state), getLatestCheckpoint(taskId), clearCheckpoints(taskId).
Use Redis for fast writes (primary), MongoDB as durable backup. On checkpoint():
- Write to Redis:
checkpoint:{taskId}with 1-hour TTL - Fire-and-forget write to MongoDB
On getLatestCheckpoint(): try Redis first, fall back to MongoDB.
- Step 3: Add checkpoint calls to AgentLoopService
Before each tool execution: await this.checkpointService.checkpoint(task.id, { iteration, messages, toolState, phase: 'EXECUTE', boundary: 'pre_tool' }).
After tool results processed: checkpoint(..., { boundary: 'post_tool' }).
- Step 4: Add resume logic to VittoEngineService.recoverActiveTasks()
When recovering tasks on restart, check for checkpoints. If found, resume from the checkpoint state instead of re-running from scratch.
Step 5: Write tests
Step 6: Commit
git commit -m "[S201.15] feat: add session checkpointing for crash recovery"
Task 16: ContextBroker — Delta-Only State Passing (Phase 5.2)
Why: When Vitto delegates to sub-agents, it currently sends more context than necessary. A ContextBroker assembles minimal payloads.
Files:
Create:
APIs/backend-vitto-api/src/app/engine/context-broker.service.tsModify:
APIs/backend-vitto-api/src/app/engine/delegation-manager.service.ts(use ContextBroker)Test:
APIs/backend-vitto-api/src/app/engine/__tests__/context-broker.service.spec.tsStep 1: Create ContextBrokerService
// APIs/backend-vitto-api/src/app/engine/context-broker.service.ts
@Injectable()
export class ContextBrokerService {
constructor(
private readonly spaceContext: SpaceContextService,
private readonly userBrief: UserBriefService,
) {}
/** Assemble minimal context for a sub-agent delegation. */
assembleDelegationContext(params: {
taskDescription: string;
parentMessages: unknown[];
lastDelegationResult?: string;
userId: string;
spaceId: string;
}): DelegationContext {
return {
task: params.taskDescription,
// Only the delta — what changed since last delegation
relevantContext: this.extractRelevantMessages(params.parentMessages, 5),
previousResult: params.lastDelegationResult,
userPreferences: this.userBrief.getCompact(params.userId),
spaceContext: this.spaceContext.getCompact(params.spaceId),
// Explicitly EXCLUDE: full history, other sub-agent outputs, raw tool logs
};
}
/** Extract only the most recent relevant messages (not full history). */
private extractRelevantMessages(messages: unknown[], maxMessages: number): unknown[] {
return messages.slice(-maxMessages);
}
}
- Step 2: Wire into DelegationManagerService
Replace direct payload construction with contextBroker.assembleDelegationContext().
- Step 3: Log token savings
Add logging that compares old payload size vs. new payload size for baseline measurement.
- Step 4: Write tests and commit
git commit -m "[S201.16] feat: add ContextBroker for delta-only state passing to sub-agents"
Task 17: Break Circular Dependency via BullMQ (Phase 5.3)
Why: backend-ai-api and backend-vitto-api call each other synchronously. If AI-API needs to delegate a task to Vitto, it should use BullMQ (async) instead of synchronous HTTP.
Files:
Create:
APIs/backend-ai-api/src/app/orchestration/vitto-task-queue.service.tsModify:
APIs/backend-ai-api/src/app/orchestration/vitto-client.service.ts(add async path)Create:
APIs/backend-vitto-api/src/app/engine/external-task-consumer.service.ts(BullMQ processor)Step 1: Create VittoTaskQueueService in backend-ai-api
Uses BullMQ to enqueue tasks for Vitto processing instead of synchronous HTTP POST.
@Injectable()
export class VittoTaskQueueService {
constructor(@InjectQueue('vitto-tasks') private readonly queue: Queue) {}
async submitAsync(task: VittoTaskPayload): Promise<string> {
const job = await this.queue.add('vitto-task', task, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100,
removeOnFail: 50,
});
return job.id;
}
}
- Step 2: Create ExternalTaskConsumerService in backend-vitto-api
BullMQ processor that picks up tasks from the shared queue and routes to VittoEngineService.submitTask().
- Step 3: Update VittoClientService to prefer async path
When VITTO_USE_ASYNC_QUEUE=true (default for non-urgent tasks), use the queue. Synchronous HTTP remains for chat messages that need real-time response.
- Step 4: Commit
git commit -m "[S201.17] feat: break circular dependency with BullMQ async task delegation"
Chunk 4: Phase 6 (Hierarchical Memory + Skills) + Phase 7 (Cloudflare Edge)
Task 18: FocusService — Active Context Compression (Phase 6, Tier 2)
Why: Sub-agents complete work but raw interaction history stays in the parent context. FocusService compresses completed sub-task output into compact summaries.
Files:
Create:
APIs/backend-vitto-api/src/app/mind/focus.service.tsModify:
APIs/backend-vitto-api/src/app/engine/delegation-manager.service.ts(compress on child completion)Test:
APIs/backend-vitto-api/src/app/mind/__tests__/focus.service.spec.tsStep 1: Create FocusService
@Injectable()
export class FocusService {
constructor(private readonly smartRouter: SmartRouterService) {}
/** Compress raw sub-agent output into a compact summary via fast model. */
async completeFocus(params: {
topic: string;
rawOutput: string;
sessionId: string;
}): Promise<FocusSummary> {
const summary = await this.smartRouter.sendWithFailover({
messages: [{
role: 'user',
content: `Summarize this agent work output in 3-5 bullet points. Focus on: what was done, what was decided, what artifacts were produced.\n\nTopic: ${params.topic}\n\nRaw output:\n${params.rawOutput}`,
}],
model: 'claude-haiku-4-5-20251001', // cheap model for compression
maxTokens: 500,
});
return {
topic: params.topic,
summary: summary.content,
compressedAt: new Date(),
originalTokens: Math.ceil(params.rawOutput.length / 3.5),
compressedTokens: Math.ceil(summary.content.length / 3.5),
};
}
}
- Step 2: Wire into DelegationManagerService.onChildCompleted()
After receiving sub-agent results, run through FocusService. Store the summary in the session, discard the raw output from context.
- Step 3: Write tests and commit
git commit -m "[S201.18] feat: add FocusService for active context compression (Tier 2 memory)"
Task 19: Cross-Session Contextual Memory (Phase 6, Tier 3)
Why: Vitto should remember user preferences and interaction patterns across sessions. "This user prefers concise answers" should persist.
Files:
Create:
APIs/backend-vitto-api/src/app/mind/schemas/user-context-memory.schema.tsCreate:
APIs/backend-vitto-api/src/app/mind/user-context-memory.service.tsModify:
APIs/backend-vitto-api/src/app/engine/context-assembly.service.ts(load user context at session start)Modify:
APIs/backend-vitto-api/src/app/engine/memory-persistence.service.ts(update user context at session end)Step 1: Create UserContextMemory schema
@Schema({ collection: 'user_context_memories', timestamps: true })
export class UserContextMemory extends Document {
@Prop({ required: true, index: true }) userId: string;
@Prop({ index: true }) spaceId?: string; // null = platform-level
@Prop({ required: true }) category: string; // 'preference' | 'pattern' | 'expertise' | 'fact'
@Prop({ required: true }) key: string; // 'response_style', 'tech_stack', etc.
@Prop({ required: true }) value: string; // 'prefers concise answers'
@Prop({ default: 1 }) confidence: number; // 0-1, increases with confirmation
@Prop({ default: 0 }) confirmations: number;
@Prop() source?: string; // 'inferred' | 'explicit' | 'observed'
}
- Step 2: Create UserContextMemoryService
Methods: getForUser(userId, spaceId?), upsert(userId, category, key, value, source), incrementConfidence(id), getHighConfidence(userId, minConfidence).
Tenant isolation: queries ALWAYS filter by userId. Platform-level memories (spaceId = null) are loaded for all spaces. Space-specific memories only for that space.
- Step 3: Load at session start in ContextAssemblyService
In buildWebSystemPrompt(), load high-confidence user memories and inject as a "User Profile" section in the system prompt.
- Step 4: Update at session end
In MemoryPersistenceService.extractUserProfileFacts(), persist new facts as UserContextMemory entries.
- Step 5: Commit
git commit -m "[S201.19] feat: add cross-session contextual memory (Tier 3) with tenant isolation"
Task 20: Skill Document Generation (Phase 6, Tier 4)
Why: When Vitto solves a complex task successfully, it should write a "Skill Document" — procedural memory for similar future tasks.
Files:
Create:
APIs/backend-vitto-api/src/app/mind/schemas/skill-document.schema.tsCreate:
APIs/backend-vitto-api/src/app/mind/skill-registry.service.tsModify:
APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts(generate skill on complex task success)Step 1: Create SkillDocument schema
@Schema({ collection: 'skill_documents', timestamps: true })
export class SkillDocument extends Document {
@Prop({ required: true }) title: string;
@Prop({ required: true }) taskType: string;
@Prop({ required: true }) approach: string; // step-by-step
@Prop({ type: [String] }) toolsUsed: string[];
@Prop({ type: [String] }) prerequisites: string[];
@Prop({ required: true }) outcome: string;
@Prop({ default: 1 }) successCount: number;
@Prop({ default: 0 }) failureCount: number;
@Prop() spaceId?: string; // null = platform-level
@Prop({ type: [Number] }) embedding?: number[]; // vector for similarity search
@Prop({ default: 'active' }) status: string;
}
- Step 2: Create SkillRegistryService
Methods: generateSkill(task, result), findRelevantSkills(taskDescription, limit), recordOutcome(skillId, success).
findRelevantSkills() uses MongoDB text search initially (vector search as a future enhancement).
Tenant isolation: Skills with spaceId are only loaded for that space. Platform-level skills (null spaceId) are loaded for all.
- Step 3: Wire into VittoEngineService task completion
After a complex task completes successfully (complexity !== 'simple'), call skillRegistry.generateSkill().
- Step 4: Load relevant skills in ContextAssemblyService
Before starting a task, call skillRegistry.findRelevantSkills(task.description) and inject matches into the system prompt.
- Step 5: Commit
git commit -m "[S201.20] feat: add skill document generation + retrieval (Tier 4 memory)"
Task 21: Cloudflare Edge — vitto-gateway Worker (Phase 7)
Why: WhatsApp/Telegram webhooks need instant acknowledgment. If Railway is slow, messages are lost. A Cloudflare Worker acknowledges immediately and forwards async.
Files:
Create:
deployment/cloudflare-workers/vitto-gateway/src/index.tsCreate:
deployment/cloudflare-workers/vitto-gateway/wrangler.tomlCreate:
deployment/cloudflare-workers/vitto-gateway/package.jsonStep 1: Create vitto-gateway Worker
// deployment/cloudflare-workers/vitto-gateway/src/index.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// Webhook routes
if (url.pathname.startsWith('/webhook/')) {
const body = await request.text();
const headers = Object.fromEntries(request.headers);
// Acknowledge immediately
ctx.waitUntil(forwardToBackend(env.VITTO_API_URL, url.pathname, body, headers, env));
return new Response('OK', { status: 200 });
}
// Health check
if (url.pathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', edge: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', { status: 404 });
},
};
async function forwardToBackend(
baseUrl: string, path: string, body: string, headers: Record<string, string>, env: Env,
): Promise<void> {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const resp = await fetch(`${baseUrl}${path}`, {
method: 'POST',
body,
headers: { ...headers, 'X-Forwarded-By': 'vitto-gateway' },
});
if (resp.ok) return;
if (resp.status < 500) return; // don't retry client errors
} catch { /* retry */ }
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
// After 3 failures, write to Durable Object dead letter
// (implement in Task 22)
}
Step 2: Create wrangler.toml
Step 3: Commit
git commit -m "[S201.21] feat: add vitto-gateway Cloudflare Worker for webhook edge handling"
Task 22: Cloudflare Edge — vitto-pulse Health Monitor (Phase 7)
Why: External health monitoring that doesn't depend on Railway being up.
Files:
Create:
deployment/cloudflare-workers/vitto-pulse/src/index.tsCreate:
deployment/cloudflare-workers/vitto-pulse/wrangler.tomlStep 1: Create vitto-pulse Worker
Cron-triggered Worker that checks health of all 4 backends every 5 minutes. Stores results in KV. Exposes /status endpoint for public status page.
- Step 2: Commit
git commit -m "[S201.22] feat: add vitto-pulse Cloudflare Worker for external health monitoring"
Chunk 5: Phase 8 (MCP Gateway) + Phase 9 (Self-Improvement) + Phase 10 (Cleanup)
Task 23: MCP Per-Platform Tool Filtering (Phase 8)
Why: Different agent platforms (Cursor, Claude Code, Claude Desktop, Vitto) need different tool sets. Currently all platforms see all tools.
Files:
Modify:
MCP/mcp-gograb-ai/src/server.ts(add platform-based tool filtering)Modify:
MCP/mcp-gograb-ai/src/tools/(add platform metadata to each tool definition)Step 1: Add platform tier metadata to tool definitions
Each tool gets a platforms array: ['cursor', 'claude-code', 'claude-desktop', 'vitto', 'cowork'].
- Step 2: Filter tools on listTools based on detected platform
In server.ts, when handling tools/list, filter by the detected platform from detectPlatform().
- Step 3: Add tier mapping
| Platform | Available Tools |
|---|---|
| cursor | All development tools, session, intelligence |
| claude-code | All development tools, session, intelligence |
| claude-desktop | Read-only tools, session info, find_similar |
| cowork | Testing tools, session, outcome, intelligence |
| vitto | Agent tools, task management |
- Step 4: Commit
git commit -m "[S201.23] feat: add per-platform tool filtering to MCP gateway"
Task 24: Prompt A/B Testing Infrastructure (Phase 9)
Why: Self-improving prompts need safe A/B testing. Build the infrastructure for comparing prompt variants.
Files:
Create:
APIs/backend-vitto-api/src/app/strategy/prompt-experiment.service.tsCreate:
APIs/backend-vitto-api/src/app/mind/schemas/prompt-experiment.schema.tsStep 1: Create PromptExperiment schema
@Schema({ collection: 'prompt_experiments', timestamps: true })
export class PromptExperiment extends Document {
@Prop({ required: true }) name: string;
@Prop({ required: true }) scope: string; // which prompt section
@Prop({ required: true }) controlPrompt: string;
@Prop({ required: true }) candidatePrompt: string;
@Prop({ default: 0.1 }) candidateTrafficPct: number; // 10% default
@Prop({ default: 'running' }) status: string; // 'running' | 'paused' | 'completed'
@Prop({ type: Object }) metrics: {
control: { completionRate: number; avgTokens: number; avgLatencyMs: number; userRating: number };
candidate: { completionRate: number; avgTokens: number; avgLatencyMs: number; userRating: number };
};
@Prop() minSampleSize: number;
@Prop() maxDurationDays: number;
}
- Step 2: Create PromptExperimentService
Methods: getActiveExperiment(scope), selectVariant(experimentId, userId) (deterministic based on userId hash), recordOutcome(experimentId, variant, metrics), evaluate(experimentId) (statistical comparison, requires ALL 4 metrics equal or better).
Critical rule: Candidates are NEVER auto-promoted. evaluate() produces a recommendation that a human reviews.
- Step 3: Wire into ContextAssemblyService
Check for active experiments on the relevant prompt scope. If found, select variant and use the appropriate prompt.
- Step 4: Commit
git commit -m "[S201.24] feat: add prompt A/B testing infrastructure (Phase 9 foundation)"
Task 25: Per-User Rate Limiting for LLM Calls (Missing from both plans)
Why: Neither plan addresses what happens when one user floods Vitto. Without per-user rate limiting, a single user can exhaust the LLM budget.
Files:
Create:
libs/libs-api/backend-core/src/lib/resilience/user-rate-limiter.service.tsModify:
APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts(check rate limit before task execution)Modify:
APIs/backend-ai-api/src/app/chat/chat.service.ts(check rate limit before LLM call)Step 1: Create UserRateLimiterService
@Injectable()
export class UserRateLimiterService {
constructor(private readonly redis: RedisService) {}
/** Check if user is within their rate limit. Returns { allowed: boolean, remaining: number, resetAt: Date }. */
async checkLimit(userId: string, tier: 'free' | 'pro' | 'business' | 'enterprise'): Promise<RateLimitResult> {
const limits = { free: 50, pro: 200, business: 500, enterprise: 2000 }; // calls per hour
const key = `rate:llm:${userId}`;
const current = await this.redis.incr(key);
if (current === 1) await this.redis.expire(key, 3600);
const limit = limits[tier] ?? limits.free;
return {
allowed: current <= limit,
remaining: Math.max(0, limit - current),
resetAt: new Date(Date.now() + (await this.redis.ttl(key)) * 1000),
};
}
}
Step 2: Wire into VittoEngineService and ChatService
Step 3: Commit
git commit -m "[S201.25] feat: add per-user LLM rate limiting"
Task 26: Cost Alert Circuit Breaker (Missing from both plans)
Why: A bad prompt loop or stuck agent can burn through hundreds of dollars in an hour.
Files:
Modify:
libs/libs-api/backend-core/src/lib/instrumentation/llm-call-logger.service.ts(add per-session cost tracking)Modify:
APIs/backend-vitto-api/src/app/strategy/cost-guard.service.ts(add dollar-amount ceiling)Step 1: Add dollar-amount session ceiling to CostGuardService
// In CostGuardService, add:
private readonly SESSION_COST_CEILING_USD = parseFloat(process.env.VITTO_SESSION_COST_CEILING_USD ?? '5');
async checkCostCeiling(taskId: string, userId: string): Promise<boolean> {
const sessionCost = await this.costAggregator.getSessionCost(taskId);
if (sessionCost >= this.SESSION_COST_CEILING_USD) {
this.alertService.emit({
severity: 'critical',
source: 'cost-guard',
message: `Session ${taskId} for user ${userId} hit cost ceiling: $${sessionCost.toFixed(2)} >= $${this.SESSION_COST_CEILING_USD}`,
});
return false; // block further LLM calls
}
return true;
}
- Step 2: Wire into AgentLoopService
Before each LLM call iteration, check cost ceiling. If exceeded, pause and notify user.
- Step 3: Commit
git commit -m "[S201.26] feat: add per-session cost ceiling with automatic pause"
Task 27: Environment Variable and Health Standardization (Phase 10)
Why: Cleanup — standardize health endpoints, env vars, and remove deprecated configurations.
Files:
Audit: All
.env.examplefiles for missing/deprecated varsCreate:
libs/libs-api/backend-core/src/lib/health/standard-health.controller.tsModify: All 3 backends' health endpoints to use standard format
Step 1: Create standard health response format
interface StandardHealth {
status: 'ok' | 'degraded' | 'unhealthy';
version: string;
uptime: number;
providers: ProviderHealth;
redis: { connected: boolean };
mongodb: { connected: boolean };
queues?: { active: number; waiting: number; failed: number };
}
Step 2: Implement in all backends
Step 3: Audit env vars and update .env.example
Step 4: Commit
git commit -m "[S201.27] feat: standardize health endpoints and env configuration"
Execution Order & Session Mapping
| Session | Tasks | Phase | Est. Effort |
|---|---|---|---|
| S201 | Tasks 1-9 | Phase 0 + Phase 1 (Resilience) | 2-3 sessions |
| S202 | Tasks 10-12 | Phase 2 + Phase 3 (Cache + Router + Alerting) | 2 sessions |
| S203 | Tasks 13-14 | Phase 4 (AI Migration) | 1-2 sessions |
| S204 | Tasks 15-17 | Phase 5 (Agent Runtime Layer) | 3-4 sessions |
| S205 | Tasks 18-20 | Phase 6 (Hierarchical Memory + Skills) | 3-4 sessions |
| S206 | Tasks 21-22 | Phase 7 (Cloudflare Edge) | 2-3 sessions |
| S207 | Task 23 | Phase 8 (MCP Gateway) | 1-2 sessions |
| S208 | Tasks 24-26 | Phase 9 + Missing Items (Self-Improvement + Rate Limits + Cost) | 2-3 sessions |
| S209 | Task 27 | Phase 10 (Cleanup) | 1 session |
Total: ~20-27 tasks across ~9 sessions (S201-S209)
Decision Framework: When to Stop
| Priority | Tasks | Why |
|---|---|---|
| Must build | Tasks 1-9 (Phase 0-1) | Production is fragile without these |
| Should build | Tasks 10-17 (Phase 2-5) | Cost savings + architectural correctness |
| High-value optional | Tasks 18-22 (Phase 6-7) | Competitive differentiation |
| Experimental | Tasks 23-26 (Phase 8-9) | "10x" bets — build only after foundation is solid |
| Maintenance | Task 27 (Phase 10) | Do whenever there's spare capacity |