/** * efficiencyCritic.ts — V7-4: LLM sanity check prima di azioni ad alto impatto. * * Eseguito su git_push / git_push_multiple / deploy_trigger / execute_shell * DOPO preExecCritic (statico) e PRIMA dell'esecuzione reale del tool. * * Caratteristiche: * - max 100 token output, temperatura 0 (deterministico) * - timeout 4s hard (non blocca mai il loop) * - cooldown 30s (protegge il free tier Groq/Cerebras) * - non-blocking: su timeout/errore → proceed:true (fail-open) * * Fix V7-4: usa callWithFallback(hfToken, payload, signal) — non-streaming — * e parsa la risposta come JSON dalla Response. Il precedente pattern streaming * (msgs, onChunk, opts) era errato: falliva silenziosamente → catch → skipped:true. */ import { callWithFallback, getHFToken } from "@/lib/providerChain"; import type { ChatMessage } from "@/lib/types"; // Tool soggetti al check — irreversibili e ad alto impatto const _CRITIC_TOOLS = new Set([ "git_push", "git_push_multiple", "deploy_trigger", "execute_shell", ]); const _COOLDOWN_MS = 30_000; // 30s — protegge il free tier const _TIMEOUT_MS = 4_000; // 4s — safe anche su cold-start Railway const _MAX_TOKENS = 100; // budget minimo let _lastFiredAt = -Infinity; export interface EfficiencyCriticResult { proceed: boolean; reason: string; skipped: boolean; // true = cooldown/timeout → allow by default (fail-open) } /** Ritorna true se il tool è soggetto all'efficiency critic. */ export function isEfficiencyCriticTool(name: string): boolean { return _CRITIC_TOOLS.has(name); } /** * Esegue il check LLM leggero (callWithFallback non-streaming). * * @param name - Nome del tool * @param args - Argomenti del tool * @param goal - Messaggio utente / obiettivo corrente (lastUserMsg) * @param recentSteps - Stringa compatta degli step recenti (ultimi 3) */ export async function runEfficiencyCritic( name: string, args: Record, goal: string, recentSteps: string, ): Promise { // Cooldown guard — evita over-firing sul free tier if (Date.now() - _lastFiredAt < _COOLDOWN_MS) { return { proceed: true, reason: "cooldown", skipped: true }; } _lastFiredAt = Date.now(); const _argsPreview = JSON.stringify(args).slice(0, 200); const _msgs: ChatMessage[] = [ { role: "system", content: "Sei un critic che valuta se un'azione ad alto impatto è necessaria ora. " + 'Rispondi SOLO con JSON valido: {"ok":true} o {"ok":false,"reason":"..."}. ' + "La reason deve essere max 15 parole. Nessun altro testo.", }, { role: "user", content: `Obiettivo: "${goal.slice(0, 150)}"\n` + `Step recenti: ${recentSteps.slice(0, 300) || "nessuno"}\n\n` + `Tool da eseguire: ${name}\n` + `Args: ${_argsPreview}\n\n` + "Questa azione è necessaria e corretta adesso per raggiungere l'obiettivo?", }, ]; // iOS-safe AbortController + setTimeout (AbortSignal.timeout non su iOS < 16.4) const _ctrl = new AbortController(); const _tid = setTimeout(() => _ctrl.abort(), _TIMEOUT_MS); try { const _hfToken = getHFToken() ?? ""; const _resp = await callWithFallback( _hfToken, { messages: _msgs, stream: false as const, max_tokens: _MAX_TOKENS, temperature: 0, }, _ctrl.signal, ); clearTimeout(_tid); if (!_resp.ok) { return { proceed: true, reason: "api_error", skipped: true }; } const _data = await _resp.json() as { choices?: Array<{ message?: { content?: string } }>; }; const _raw = _data.choices?.[0]?.message?.content ?? ""; // indexOf/lastIndexOf — immune a brace annidati in string values const _fb = _raw.indexOf('{'); const _lb = _raw.lastIndexOf('}'); if (_fb === -1 || _lb <= _fb) { return { proceed: true, reason: "parse_error", skipped: true }; } const _parsed = JSON.parse(_raw.slice(_fb, _lb + 1)) as { ok: boolean; reason?: string; }; if (_parsed.ok === false) { const _reason = (_parsed.reason ?? "azione non necessaria al momento").slice(0, 120); return { proceed: false, reason: _reason, skipped: false }; } return { proceed: true, reason: "ok", skipped: false }; } catch { clearTimeout(_tid); // Fail-open: su timeout/errore il tool viene eseguito normalmente return { proceed: true, reason: "timeout_or_error", skipped: true }; } }