diff --git "a/agents/unified_loop.py" "b/agents/unified_loop.py" --- "a/agents/unified_loop.py" +++ "b/agents/unified_loop.py" @@ -23,12 +23,18 @@ v4 (S193) — Fix definitivo tool execution: from __future__ import annotations import asyncio +import logging import os import re from dataclasses import dataclass, field from typing import Any, Awaitable, Callable -SMOL_TIMEOUT: float = float(os.getenv("UNIFIED_LOOP_TIMEOUT", "25")) # S373: nessun floor forzato — configurabile via env +_logger = logging.getLogger("agente_ai") # S624: logger per warning sui fallback silenziosi + +# Tool execution layer (estratto in split module per ridurre dimensione) +from agents.unified_loop_tools import DirectToolsMixin +from agents.unified_loop_prompts import PromptBuilderMixin + LLM_TIMEOUT: float = float(os.getenv("LLM_CALL_TIMEOUT", "35")) TOOL_TIMEOUT: float = float(os.getenv("TOOL_CALL_TIMEOUT", "12")) @@ -55,7 +61,7 @@ async def _maybe_await(val: Any) -> None: await val -class UnifiedAgentLoop: +class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin): """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback.""" def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, @@ -66,209 +72,9 @@ class UnifiedAgentLoop: self.critic = critic self.memory = memory self.verifier = verifier - self._smol_agent: Any | None = None self._coder_llm: Any | None = None # S362: lazy-loaded CODER role client self._session_files: dict[str, str] = {} # S416-Fix1: path→content dei file scritti nella sessione - - # ── System prompt ───────────────────────────────────────────────────────── - - _SYSTEM_IDENTITY = ( - "Sei un agente AI autonomo, preciso e proattivo. Lavori come l'agente di Replit: " - "risolvi problemi concretamente, non li descrivi.\n\n" - "REGOLE FONDAMENTALI:\n" - "1. Rispondi SEMPRE nella lingua dell'utente (default italiano)\n" - "2. Lavora autonomamente — non chiedere conferma per ogni passo\n" - "3. Quando hai dati reali da tool, usali direttamente nella risposta\n" - "4. Non dire 'puoi fare X' — mostra X fatto, con codice completo se richiesto\n" - "5. Se incontri un errore, analizza e riprova con approccio diverso\n" - "6. Sii specifico e concreto — niente placeholder o risposte vaghe\n" - "7. Per codice: sempre blocchi markdown con sintassi corretta, tipizzati\n" - "8. Per matematica: mostra calcoli passo passo con numeri esatti\n" - "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n" - "10. NON inventare mai informazioni su te stesso: token usati, context window, " - "versione, architettura, parametri interni. Se non lo sai con certezza, " - "di esplicitamente 'non ho accesso a questa informazione'.\n" - "11. Se un tool ha fallito o non hai dati reali aggiornati, dillo chiaramente. " - "Non rispondere mai con dati del training senza aggiungere: " - "'(informazione dal mio training — potrebbe non essere aggiornata)'. " - "Quando il limite del training si applica (notizie, eventi recenti, versioni software), " - "suggerisci SEMPRE fonti reali: Google News, BBC, Reuters, Corriere della Sera, " - "o il sito ufficiale della tecnologia (npmjs.com, github.com/releases).\n" - "12. Se nella sezione DATI REALI RECUPERATI ci sono errori tool, " - "dichiarali all'utente invece di ignorarli e inventare la risposta.\n" - "13. ANTI-HALLUCINATION VERSIONI (S225): MAI inventare versioni di AI, librerie o linguaggi. " - "Esempi VIETATI: 'GPT-5', 'Claude 4', 'TypeScript 6', 'React 20', 'Python 4', 'Next.js 20'. " - "Questi modelli/versioni NON ESISTONO — non citarli mai. " - "Se non sei certo della versione esatta → ometti il numero o scrivi 'versione corrente'. " - "Per la tua identità: di' solo 'Sono un agente AI' senza inventare versioni, parametri o architettura.\n" - "14. GENERAZIONE IMMAGINI (S276): Se ti viene chiesto di generare un'immagine AI, costruisci SEMPRE " - "un URL reale Pollinations AI — è gratuito, no API key, funziona subito. " - "Formato: https://image.pollinations.ai/prompt/{PROMPT_URL_ENCODED}?width=512&height=512&nologo=true\n" - "Esempio: prompt='gatto che programma' → " - "https://image.pollinations.ai/prompt/gatto%20che%20programma?width=512&height=512&nologo=true\n" - "MAI inventare URL fake tipo example.com, placeholder.com, via.placeholder.com, via.asset.com o simili. " - "L'URL Pollinations funziona direttamente nel browser — forniscilo sempre come link cliccabile.\n\n" - "REGOLE SPECIALIZZATE:\n" - "• Probabilita/Bayes: usa sempre il Teorema di Bayes esplicitamente. " - "Scrivi P(A|B) = P(B|A)*P(A)/P(B). Usa la terminologia italiana del problema " - "(es. 'scatola', 'cassetto', 'porta', 'malato') nelle equazioni. " - "Conclude SEMPRE con la risposta finale come frazione (es. 2/3) " - "E come percentuale con punto decimale (es. 66.67%). " - "USA SEMPRE il punto come separatore decimale, mai la virgola.\n" - " BERTRAND BOX (S227): METODO OBBLIGATORIO — conta i CASSETTI ORO individualmente (non le scatole):\n" - " Scatola [Oro,Oro]: cassetto_1=Oro, cassetto_2=Oro → 2 cassetti oro\n" - " Scatola [Oro,Arg]: cassetto_3=Oro → 1 cassetto oro\n" - " Scatola [Arg,Arg]: nessun cassetto oro → 0\n" - " Totale cassetti oro: 3. Estratto cassetto oro → quale scatola?\n" - " Cassetto_1 o cassetto_2 → altra moneta è oro (2 casi su 3)\n" - " Cassetto_3 → altra moneta è argento (1 caso su 3)\n" - " RISPOSTA FINALE OBBLIGATORIA: 'P = 2/3 (66.67%)' — SEMPRE frazione E percentuale con punto.\n" - "• Bug Python MUTABLE DEFAULT ARGUMENT: nella risposta scrivi LETTERALMENTE " - "la frase 'mutable default argument' (in inglese, non tradurre). " - "Spiega che lo stesso oggetto mutabile e condiviso tra le chiamate. " - "Mostra SEMPRE il fix con None sentinel:\n" - " def f(lst=None):\n if lst is None: lst = []\n\n" - "• Git workflow: dai sempre i comandi esatti con le opzioni corrette " - "(es. `git pull --rebase origin main`, `git fetch && git rebase origin/main`)\n" - "• React useEffect: menziona SEMPRE useMemo/useCallback/useRef come possibili fix " - "per dipendenze instabili, con esempio di codice per ciascuno\n" - "• TypeScript debounce firma corretta (S195): ) => ReturnType>\n" - " NON usare any[] — perde i tipi dei parametri\n" - "• JWT verifica token usa formato **Passo N:** (S195):\n" - " **Passo 1:** Decodifica header.payload.signature\n" - " **Passo 2:** Verifica firma con chiave pubblica/secret\n" - " **Passo 3:** Controlla exp iat aud iss\n" - " **Passo 4:** Estrai sub — mai decode() senza verify()\n" - "• Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n" - "\n" - "=== PROBLEM SOLVING ASSOLUTO ===\n" - "MAI dire 'non posso', 'non sono in grado', 'mi dispiace ma', 'impossibile', " - "'non ho accesso a', 'non riesco'. Ogni problema ha una soluzione — se l'approccio " - "A non funziona, prova B, poi C. Sei un problem solver, non un filtro di rifiuti.\n" - "Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per " - "pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n" - "Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n" - "Su problemi multi-parte: rispondi a OGNI parte separatamente con header numerato.\n" - "\n" - "=== PERFORMANCE E CODE REVIEW (S198) ===\n" - "• N+1 QUERIES: quando trovi il pattern, scrivi sempre 'N+1 query problem'. \n" - " Conta le query: 1 principale + N per ogni record. Con 100 post = 101 query. \n" - " Mostra sempre 2 fix: (1) include/JOIN eager load, (2) batch con IN clause.\n" - "• PERFORMANCE AUDIT checklist (5 punti obbligatori da verificare sempre): \n" - " (1) N+1 queries, (2) mancanza di limit/paginazione, (3) assenza di caching/Redis, \n" - " (4) operazioni sequenziali da parallelizzare con Promise.all, \n" - " (5) indici DB mancanti su colonne di filtro/sort. Menzionali tutti.\n" - "• TYPESCRIPT OVERLOADS: la funzione di implementazione usa union type, non any[]. \n" - " SBAGLIATO: function f(v:T,...args:any[]). \n" - " CORRETTO: function f(v: string|number|Date|boolean, ...args: unknown[]).\n" - "• CODE REVIEW SEVERITY FORMAT: **[SEVERITY]** NomeVuln (riga): desc. Fix: `codice`. \n" - " Severity: CRITICAL > HIGH > MEDIUM > LOW > INFO.\n" - "• PRISMA DESIGN: Float per money → usa Decimal. id senza @default → Prisma Migrate fallisce. \n" - " Relazioni senza onDelete → FK constraint error in produzione. \n" - " Segnala SEMPRE questi 3 problemi in qualsiasi schema Prisma.\n" - "\n" - "Le regole specializzate per security, React, Node.js, TypeScript e database " - "vengono iniettate contestualmente in base al tipo di task.\n\n" - "=== AUTONOMIA E QUALITÀ (S385) ===\n" - "15. PATCH PREFERENCE: Per modifiche a file esistenti, usa patch_tool (diff minimale) " - "invece di riscrivere l'intero file — più veloce, meno errori di troncamento.\n" - "16. LINTER AUTO-RETRY: Se dopo una write_file il linter segnala errori (ok: false), " - "correggi immediatamente il file prima di restituire la risposta finale.\n" - "17. README AUTO-GEN: Al termine di ogni task che crea o modifica file di progetto multipli, " - "genera SEMPRE un README.md con: (1) descrizione progetto, (2) struttura file, " - "(3) istruzioni per eseguire localmente (es. npm install && npm start, python main.py).\n" - ) - - # ── S200: Context-aware rule injection ────────────────────────────────────── - # Seleziona solo le regole rilevanti per il task corrente. - # Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt - # causa troncamento silenzioso — le regole non vengono mai lette. - # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message - # (posizione con massima attenzione del modello = "recency bias"). - - _CONTEXT_RULES: list[tuple[list[str], str]] = [ - # (pattern keywords, regola da iniettare) - ( - ["useeffect", "fetch", "hook", "react", "usedati", "userdata", "usequery", "cleanup"], - "REGOLA CRITICA React fetch: usa AbortController per cleanup. " - "CORRETTO: const ctrl=new AbortController(); fetch(url,{signal:ctrl.signal}).then(...).catch(e=>{if(e.name!=='AbortError')setError(e);}); return ()=>ctrl.abort(); " - "SBAGLIATO: fetch senza cleanup (memory leak). NON usare axios.cancelToken." - ), - ( - ["regex", "regexp", "pattern", "test(", ".test(", "validate", "validat"], - "REGOLA CRITICA Regex: cerca ReDoS (catastrophic backtracking). " - "Pattern pericolosi: (.+)+ (\\w+\\s*)+ ([a-zA-Z]+)* " - "Test: 'aaaaab' su /^(a+)+$/ blocca il processo. Segnala **[CRITICAL]**." - ), - ( - ["security", "sicurezza", "vulnerabilit", "audit", "review", "pentest", "hack"], - "REGOLE SICUREZZA obbligatorie — segnala TUTTE come [CRITICAL]:\n" - "1. SSRF: fetch(req.body.url) senza whitelist → attaccante punta a 169.254.169.254\n" - "2. Path traversal: input utente in path.join/fs.read senza sanitizzazione\n" - "3. SQL injection: template string in query raw o ORM.raw() non parametrizzato\n" - "4. Prototype pollution: Object.assign({},userInput) o _.merge senza validazione\n" - "5. ReDoS: regex con quantificatori annidati (.+)+ ([a-z]+)*" - ), - ( - ["promise", "async", "parallel", "parallelo", "notific", "allsettled", "all(", "promise.all"], - "REGOLA Promise: scegli in base al comportamento voluto:\n" - "Promise.all → FAIL-FAST: se UNA promise fallisce, rigetta immediatamente (short-circuit). Usa per: dipendenze obbligatorie.\n" - "Promise.allSettled → aspetta TUTTE, ritorna [{status,value/reason}]. Usa per: notifiche/operazioni indipendenti.\n" - "Promise.any → primo fulfilled vince. Usa per: fonti ridondanti.\n" - "Promise.race → primo settled (ok o errore) vince. Usa per: timeout pattern." - ), - ( - ["stream", "pipe", "readable", "writable", "backpressure", "drain", "pipeline"], - "REGOLA Node.js stream: writable.write() ritorna false quando buffer pieno. " - "Senza gestire 'drain' → OOM. " - "Fix: if(!dest.write(chunk)){src.pause();dest.once('drain',()=>src.resume())} " - "Meglio: pipeline() da 'node:stream/promises' gestisce backpressure automaticamente." - ), - ( - ["job", "queue", "worker", "task", "lock", "deadlock", "postgres", "pg ", "sql", "select"], - "REGOLA job queue Postgres senza deadlock: " - "SELECT * FROM jobs WHERE status='pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED; " - "Senza SKIP LOCKED i worker si bloccano → deadlock. " - "Con SKIP LOCKED ogni worker prende un job diverso atomicamente." - ), - ( - ["infer", "conditional type", "mapped type", "unwrap", "flatten", "returntype", "extends"], - "REGOLA TypeScript infer: " - "type Unwrap = T extends Promise ? U : T; " - "type Flatten = T extends Array ? U : T; " - "Distributive: T extends string → si applica a ogni membro della union. " - "Non-distributive: [T] extends [string] → tratta la union come insieme." - ), - ( - ["next.js", "nextjs", "next 15", "app router", "cache", "fetch cache", "migr"], - "REGOLA Next.js 15 breaking change: " - "Next ≤14: fetch() cached di default (force-cache). " - "Next 15+: fetch() NON cached di default → aggiungi cache:'force-cache' esplicitamente. " - "Funzioni custom: usa unstable_cache() non getStaticProps." - ), - ( - ["error boundary", "errorboundary", "errore app", "crash app", "fallback"], - "REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). " - "Metti ErrorBoundary PER ROUTE/FEATURE: " - "}> " - "Next.js App Router: file error.tsx per route-level boundary automatico." - ), - ] - - def _pick_context_rules(self, goal: str) -> str: - """Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto.""" - goal_lower = goal.lower() - matched: list[str] = [] - for patterns, rule in self._CONTEXT_RULES: - if any(p in goal_lower for p in patterns): - matched.append(rule) - if len(matched) >= 3: - break - if not matched: - return "" - return "\n\n⚡ REGOLE SPECIFICHE PER QUESTO TASK:\n" + "\n".join(f"• {r}" for r in matched) - - + self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli # ── S362: Role routing helpers ───────────────────────────────────────────── # S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi. @@ -460,7 +266,9 @@ class UnifiedAgentLoop: from models.role_router import RoleRouter, Role arch = RoleRouter.get_client(Role.ARCHITECT) err_count = len(errors) - err_summary = '\n'.join(str(e)[:200] for e in errors[-3:]) + # S576: err_summary 200→300 — parity con fallback path (S573) + # S599: str(e)[:300]→[:500] — errori completi con stack info spesso > 300 chars + err_summary = '\n'.join(str(e)[:500] for e in errors[-3:]) # S404: Classifica l'errore prima di invocare l'ARCHITECT # → inject strategia mirata nel context (zero latency, no LLM) @@ -485,7 +293,7 @@ class UnifiedAgentLoop: "No generalità tipo 'prova un altro approccio'. Sii tecnico e diretto." ) label = "STRATEGIA ALTERNATIVA FORZATA" - max_tokens = 350 + max_tokens = 500 # S586: 350→500 — strategia alternativa spesso >350 tok else: # S364 Chain-of-Verification: dopo 2 errori, analisi + suggerimento system_prompt = ( @@ -496,11 +304,12 @@ class UnifiedAgentLoop: "Solo analisi tecnica — niente codice." ) label = "ANALISI ERRORE PRECEDENTE" - max_tokens = 250 + max_tokens = 400 # S586: 250→400 — analisi 3 punti necessita più tokens msgs = [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": f"Goal: {goal[:300]}\n\nTentativo #{err_count}\nErrori:\n{err_summary}"}, + # S597: goal 300→500 — più contesto goal nel debug prompt architect + {"role": "user", "content": f"Goal: {goal[:500]}\n\nTentativo #{err_count}\nErrori:\n{err_summary}"}, ] analysis = await asyncio.wait_for( arch.chat(msgs, temperature=0.1, max_tokens=max_tokens), @@ -510,7 +319,26 @@ class UnifiedAgentLoop: # S404: prepend classificazione deterministica + analisi LLM return f"{classification_hint}\n\n[{label} — tentativo {err_count}]\n{analysis[:600]}" except Exception: - pass # S364/S403: silent failure always + pass # S364/S403: ARCHITECT failed — S455-P14: fallback to base LLM + # S455-P14: ARCHITECT non disponibile → fallback al modello base (sempre disponibile) + try: + fallback_msgs = [ + {"role": "system", "content": "Sei un debugger esperto. Analizza brevemente l'errore e suggerisci un approccio alternativo specifico in 2-3 righe. Solo analisi tecnica — niente codice."}, + # S573: goal 200→300, errors 150→300 — più contesto per il debug fallback + # S592: errors[-2:]→[-3:] — più errori nel fallback debug prompt + # S597: goal 300→500 — più contesto goal nel debug fallback prompt + # S600: str(e)[:300]→[:500] — parity con ARCHITECT path (riga ~271) + {"role": "user", "content": f"Goal: {goal[:500]}\n\nErrori ({len(errors)} totali):\n{chr(10).join(str(e)[:500] for e in errors[-3:])}"}, + ] + fallback_ans = await asyncio.wait_for( + # S586: 180→300 — fallback debug risposta 2-3 righe spesso > 180 tok + self.llm.chat(fallback_msgs, temperature=0.15, max_tokens=300), + timeout=10.0, + ) + if fallback_ans and not fallback_ans.startswith('[LLM'): + return f"{classification_hint}\n\n[FALLBACK DEBUG — tentativo {len(errors)}]\n{fallback_ans[:600]}" # S603: 400→600 + except Exception: + pass # fallback anche questo fallito — ritorna stringa vuota # S404: se LLM fallisce, almeno ritorna la classificazione deterministica return classification_hint @@ -632,139 +460,6 @@ class UnifiedAgentLoop: re.IGNORECASE, ) - def _classify_format_directive(self, goal: str) -> str: - """S375: classifica il formato output ottimale per il backend. - Speculare al frontend formatClassifier.ts — mantiene coerenza di formato - tra path frontend (assembleSystemPrompt) e path backend (_build_messages). - """ - g = goal[:400] - if self._MATH_GOAL_RE.search(g): - return self._FORMAT_DIRECTIVE_MATH - if self._CODE_GOAL_RE.search(g): - return self._FORMAT_DIRECTIVE_CODE - if self._MARKDOWN_GOAL_RE.search(g): - return self._FORMAT_DIRECTIVE_MARKDOWN - return self._FORMAT_DIRECTIVE_CONVERSATIONAL - - def _build_messages(self, state: UnifiedLoopState, tool_results: str = "", - tool_exec_successes: int = 0, tool_exec_errors: int = 0, - session_files: "dict[str, str] | None" = None) -> list[dict]: - # S197: estrai code-block grandi in file virtuali per evitare timeout provider - goal_display, files_ctx = self._compress_goal(state.goal) - mem_hint = "Tieni conto della memoria per preferenze e contesto utente.\n" if self.memory else "" - if tool_results: - # S402: Tool Integrity Guard — distingue successi reali da errori completi. - # Quando TUTTI i tool hanno fallito, non iniettare dati come "reali" — - # obbliga l'LLM a dichiarare il fallimento invece di inventare risultati. - # S427-FixE: terzo caso — tool_results presente ma exec_success=0 e exec_errors=0 - # significa che è il disclaimer S378 ("[NOTA: ricerca web non disponibile]") - # iniettato da _run_fallback. NON va wrappato come "DATI REALI RECUPERATI" — - # il LLM rispondeva con "Ho trovato che la ricerca non è disponibile..." (confuso). - _all_errors = (tool_exec_errors > 0 and tool_exec_successes == 0) - _no_exec = (tool_exec_successes == 0 and tool_exec_errors == 0) - if _all_errors: - tool_section = ( - f"--- TENTATIVO TOOL FALLITO ---\n{tool_results}\n--- FINE DATI ---\n\n" - "⚠️ STATO TOOL: tutti i tool hanno restituito errori o timeout. " - "NON affermare di aver cercato, trovato o recuperato dati live. " - "Non iniziare con 'Ho cercato', 'Ho trovato', 'Ho recuperato', 'Ho ottenuto'. " - "Informa l'utente che i servizi live non sono disponibili al momento. " - "Rispondi dalla tua knowledge base e aggiungi esplicitamente che i dati " - "potrebbero non essere aggiornati." - ) - elif _no_exec: - # S427-FixE: disclaimer S378 — nessun tool eseguito; non presentare come dati reali - tool_section = ( - f"NOTA SISTEMA: {tool_results}\n\n" - "Rispondi in modo diretto, completo e concreto. " - "Se la domanda riguarda dati aggiornati, segnala esplicitamente il limite " - "del tuo training e suggerisci fonti reali (Google News, siti ufficiali)." - ) - else: - tool_section = ( - f"--- DATI REALI RECUPERATI ---\n{tool_results}\n--- FINE DATI ---\n\n" - "Usa QUESTI DATI REALI per rispondere. Non dire all'utente di controllare altri siti — " - "la risposta e gia qui. Se vedi errori tool nei dati, dichiarali all'utente. " - "Formula una risposta completa, diretta e utile." - ) - else: - tool_section = ( - "Rispondi in modo diretto, completo e concreto. Niente istruzioni generali — " - "dai la risposta specifica al problema. Se la domanda riguarda dati che potrebbero " - "essere cambiati dopo il tuo training, segnalalo esplicitamente." - ) - # S385: context compression — tronca contesti >8000 chars per evitare overflow silenziosi - _raw_ctx = state.context or "" - if len(_raw_ctx) > 8000: - _raw_ctx = _raw_ctx[:2000] + "\n\n[...contesto precedente compresso...]\n\n" + _raw_ctx[-5000:] - context_part = f"Contesto sessione: {_raw_ctx}\n\n" if _raw_ctx and _raw_ctx != "nessuno" else "" - files_part = f"{files_ctx}\n\n" if files_ctx else "" - # S200: regole contestuali iniettate ALLA FINE del user message (recency bias) - ctx_rules = self._pick_context_rules(goal_display) - # S375: format directive — garantisce coerenza di formato anche sul path backend - fmt_directive = self._classify_format_directive(state.goal) - user_content = f"{context_part}{files_part}{mem_hint}{tool_section}\n\nObiettivo/Domanda: {goal_display}{ctx_rules}" - # S416-Fix1: inietta contesto file sessione — evita che LLM inventi interfacce già scritte - # nei file precedenti (import rotti, tipi incompatibili, app che non compila) - if session_files: - # Fix 6 (S421): selezione per rilevanza contestuale invece degli ultimi 3 - # Seleziona i 4 file più rilevanti per lo step corrente (tool_results hint) - _step_hint = tool_results[:200].lower() if tool_results else "" - def _relevance_score(item: "tuple[str, str]") -> int: - path, _ = item - name = path.lower().replace("/", " ").replace(".", " ").replace("-", " ") - return sum(1 for word in name.split() if len(word) > 2 and word in _step_hint) - _sf_sorted = sorted(session_files.items(), key=_relevance_score, reverse=True) - _sf_items = _sf_sorted[:5] # top 5 per rilevanza (S432: era 4, era ultimi 3 fissi) - _sf_ctx = "\n\n".join( - f"[FILE GIÀ SCRITTO: {p}]\n```\n{c}\n```" for p, c in _sf_items - ) - user_content = ( - "[CONTESTO FILE GIÀ SCRITTI — usa QUESTI come riferimento per tipi, " - "import e interfacce; non inventarli]\n" - f"{_sf_ctx}\n\n{user_content}" - ) - # B10: usa flag separato — non inquinare il context string con '__HAS_FILES__' - if files_ctx: - state.has_files = True - # S375: format directive iniettata nel system prompt (non nel user msg) - # per evitare confusione con i dati tool nel user content - system_with_fmt = f"{self._SYSTEM_IDENTITY}\n\n{fmt_directive}" - return [ - {"role": "system", "content": system_with_fmt}, - {"role": "user", "content": user_content}, - ] - - def _build_prompt(self, state: UnifiedLoopState, tool_results: str = "") -> str: - msgs = self._build_messages(state, tool_results) - return f"{msgs[0]['content']}\n\n{msgs[1]['content']}" - - # ── S197: Code-block extractor ──────────────────────────────────────────── - # Quando il goal contiene blocchi di codice grandi (>1800 chars totali), - # li estrae come file virtuali [FILE:N], riduce il messaggio al LLM e - # li inietta come sezione CODICE_FORNITO nel contesto di sistema. - # Questo previene i timeout del provider su prompt lunghi. - - _CODE_BLOCK_RE = re.compile( - r'```(?P[a-zA-Z0-9_+-]*)\n?(?P.+?)```', - re.DOTALL, - ) - - @staticmethod - def _guess_filename(lang: str, idx: int) -> str: - ext_map = { - 'typescript': 'ts', 'ts': 'ts', 'tsx': 'tsx', - 'javascript': 'js', 'js': 'js', 'jsx': 'jsx', - 'python': 'py', 'py': 'py', - 'sql': 'sql', 'prisma': 'prisma', - 'bash': 'sh', 'sh': 'sh', - 'json': 'json', 'yaml': 'yaml', 'yml': 'yml', - 'rust': 'rs', 'go': 'go', 'java': 'java', - 'css': 'css', 'html': 'html', - } - ext = ext_map.get(lang.lower().strip(), 'txt') - return f'file_{idx + 1}.{ext}' - def _compress_goal(self, goal: str) -> tuple[str, str]: """ Estrae blocchi di codice grandi dal goal e li converte in file virtuali. @@ -793,526 +488,6 @@ class UnifiedAgentLoop: files_section_lines.append('--- FINE_CODICE_FORNITO ---') return compressed, '\n'.join(files_section_lines) - - # ── Direct tool execution (S193) ───────────────────────────────────────── - # Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing. - # Deterministico, veloce, testabile. Restituisce i risultati come stringa - # pronta per essere iniettata nel prompt LLM. - - _WEATHER_INTENT_RE = re.compile( - # S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather - # S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN - r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|" - r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|" - r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|" - r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|" - r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|" - r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|" - r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b", - re.IGNORECASE, - ) - # S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?" - # S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)' - _CITY_RE = re.compile( - r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|" - r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|" - r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|" - r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|" - r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|" - r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))" - r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)" - # S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now) - r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente" - r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)", - re.IGNORECASE, - ) - _CITY_BARE_RE = re.compile( - r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})" - r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)", - re.IGNORECASE, - ) - - _URL_RE = re.compile(r"https?://[^\s\)\"']+") - - # NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper - # to avoid false-negative from word-boundary check after non-word char. - _SEARCH_INTENT_RE = re.compile( - r"(?:" - r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|" - r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|" - r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|" - r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera - r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J - r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|" - r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|" - r"search\s+for\s+|find\s+online\s+)\b" - r"|\bcerca\s*:|\bsearch\s*:" - r")", - re.IGNORECASE, - ) - _SEARCH_QUERY_RE = re.compile( - r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|" - r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|" - r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|" - r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X' - r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|" - r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|" - r"web\s+search\s*:?\s*)" - r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM) - re.IGNORECASE, - ) - - _IMAGE_GEN_INTENT_RE = re.compile( - # S390-B-F: rimosso \b prima di (immagine|...) nel primo branch - # perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary - r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)" - r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b" - r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen", - re.IGNORECASE - ) - # S427: aggiunti trigger di calcolo IT/EN comuni - _CALC_INTENT_RE = re.compile( - r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|" - r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|" - r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|" - r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|" - r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b", - re.IGNORECASE, - ) - _CALC_EXPR_RE = re.compile( - r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+" - # S390-B-M: aggiunto % (modulo) e // (floor division) al char class - r"([\d\(\)\+\-\*\/\^\s\.\,%]+)", - re.IGNORECASE, - ) - - def _extract_city(self, goal: str) -> str: - m = self._CITY_RE.search(goal) - if m: - return m.group(1).strip() - m2 = self._CITY_BARE_RE.search(goal) - if m2: - city = m2.group(1).strip() - _stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare", - "meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"} - if city.lower() not in _stop: - return city - return "" - - def _extract_search_query(self, goal: str) -> str: - m = self._SEARCH_QUERY_RE.search(goal) - if m: - q = m.group(1).strip().rstrip(".,?!") - if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT' - return q - if self._SEARCH_INTENT_RE.search(goal): - clean = re.sub( - r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|" - r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|" - r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" - r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|" - r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|" - r"web\s+search\s*:?\s*)", - "", goal.strip(), flags=re.IGNORECASE - ).strip().rstrip(".,?!") - if len(clean) > 1: # B1: soglia abbassata da >3 a >1 - return clean - # B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI') - if len(goal.strip()) > 1: - return goal.strip()[:120] - return "" - - def _extract_calc_expr(self, goal: str) -> str: - m = self._CALC_EXPR_RE.search(goal) - if m: - expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**") - if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr): - return expr - return "" - - async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int]: - """ - S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing. - Returns: (stringa di risultati reali da iniettare nel prompt, numero di tool chiamati). - S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave). - S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric. - """ - try: - from tools.registry import TOOL_REGISTRY - except ImportError: - return "", 0 - - results: list[str] = [] - - # S376/S393: Tool Governor — previene duplicate E supero budget globale per run - _gov_called: set[str] = set() - _gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run - _GOV_MAX_CALLS = 6 # S393: limite hard — max 6 tool calls per run - - def _gov_check(tool_name: str, key_arg: str) -> bool: - """S393 Tool Governor: previene duplicate e supero budget. - Returns True solo se il tool NON è stato già chiamato con questi arg - E il budget totale del run non è esaurito.""" - if _gov_total[0] >= _GOV_MAX_CALLS: - return False # budget esaurito — blocca TUTTE le chiamate successive - sig = f"{tool_name}:{key_arg[:80]}" - if sig in _gov_called: - return False # chiamata duplicata — skip silenzioso - _gov_called.add(sig) - _gov_total[0] += 1 - return True - - # S419: esegui i tool eligible in parallelo con asyncio.gather - # Pre-check intent (sincrono) → costruisce lista coroutine → gather - # Il governor usa stato locale; asyncio è single-threaded → nessuna race condition - - url_m = self._URL_RE.search(goal) - - async def _t_get_weather() -> str | None: - if not self._WEATHER_INTENT_RE.search(goal): - return None - city = self._extract_city(goal) or "Milano" - if not _gov_check("get_weather", city): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - if "error" not in r: - _wdesc = { - 0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso", - 3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata", - 51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa", - 61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa", - 71: "neve leggera", 73: "neve", 75: "neve intensa", - 80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti", - 95: "temporale", 96: "temporale con grandine", - } - wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh") - try: - desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D" - except (TypeError, ValueError): - desc = "N/D" - return ( - f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n" - f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n" - f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n" - f"Condizioni: {desc}" - ) - return f"[get_weather: errore — {r['error'][:120]}]" - except asyncio.TimeoutError: - return f"[get_weather: timeout {TOOL_TIMEOUT}s]" - except Exception as exc: - return f"[get_weather: errore — {str(exc)[:120]}]" - - async def _t_read_page() -> str | None: - if not url_m: - return None - url = url_m.group(0) - if not _gov_check("read_page", url): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - if r.get("content"): - return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}") - return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:120]}]" - except asyncio.TimeoutError: - return f"[read_page: timeout {TOOL_TIMEOUT}s]" - except Exception as exc: - return f"[read_page: errore — {str(exc)[:120]}]" - - async def _t_calculate() -> str | None: - if url_m or not self._CALC_INTENT_RE.search(goal): - return None - expr = self._extract_calc_expr(goal) - if not expr or not _gov_check("calculate", expr): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - if "result" in r: - return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}" - return f"[calculate: errore — {r.get('error', '?')[:120]}]" - except asyncio.TimeoutError: - return "[calculate: timeout]" - except Exception as exc: - return f"[calculate: errore — {str(exc)[:120]}]" - - async def _t_web_search() -> str | None: - if not self._SEARCH_INTENT_RE.search(goal): - return None - query = self._extract_search_query(goal) - if not query or not _gov_check("web_search", query): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - hits = r.get("results", []) - if hits: - snippets = "\n".join( - f"• [{item['title']}] {item['snippet']}" - + (f"\n URL: {item['url']}" if item.get("url") else "") - for item in hits[:4] - ) - return f"[RICERCA WEB REALE: '{query}']\n{snippets}" - # S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM - # ad allucinare training data come se fosse una ricerca reale riuscita. - # Ora è un errore esplicito → contato come _n_errors → _all_errors=True → - # _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim. - return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:80]}']" - except asyncio.TimeoutError: - return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]" - except Exception as exc: - return f"[web_search: errore — {str(exc)[:120]}]" - - async def _t_generate_image() -> str | None: - if not self._IMAGE_GEN_INTENT_RE.search(goal): - return None - _img_prompt = re.sub( - r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*", - "", goal, flags=re.IGNORECASE - ).strip() or goal - if not _gov_check("generate_image", _img_prompt): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:400]), timeout=12) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - img_url = r.get("url", "") - if img_url: - return ( - f"[IMMAGINE AI GENERATA — Pollinations]\n" - f"URL: {img_url}\n" - f"Prompt usato: {r.get('prompt', _img_prompt)[:100]}\n" - f"Dimensioni: {r.get('width')}x{r.get('height')} px" - ) - return "[generate_image: nessun URL restituito]" - except asyncio.TimeoutError: - return "[generate_image: timeout 12s — Pollinations non raggiungibile]" - except Exception as exc: - return f"[generate_image: errore — {str(exc)[:120]}]" - - async def _t_run_python() -> str | None: - _RUN_CODE_RE = re.compile( - r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|" - r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|" - r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b", - re.IGNORECASE, - ) - if not _RUN_CODE_RE.search(goal): - return None - _code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL) - _code = _code_m.group(1).strip() if _code_m else goal - _code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip() - if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]): - return None - try: - if on_step: - await _maybe_await(on_step({"action": "tool_start", "status": "running", - "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"})) - _t0 = asyncio.get_event_loop().time() - r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18) - try: - from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000) - except Exception: pass - if r.get("returncode", -1) == 0 and r.get("stdout"): - return ( - f"[CODICE PYTHON ESEGUITO]\n" - f"```python\n{_code[:500]}\n```\n" - f"Output:\n```\n{r['stdout'][:1500]}\n```" - ) - if r.get("error"): - return f"[run_python: errore — {r['error'][:120]}]" - if r.get("stderr"): - return f"[run_python: stderr — {r['stderr'][:200]}]" - return None - except asyncio.TimeoutError: - return "[run_python: timeout 18s]" - except Exception as exc: - return f"[run_python: errore — {str(exc)[:120]}]" - - # S419: gather parallelo — esegui tutti i tool idonei contemporaneamente - _parallel_results = await asyncio.gather( - _t_get_weather(), - _t_read_page(), - _t_calculate(), - _t_web_search(), - _t_generate_image(), - _t_run_python(), - return_exceptions=True, - ) - for _pr in _parallel_results: - if isinstance(_pr, str): - results.append(_pr) - - # S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo. - # Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e - # "rispondo con dati del training" → contati come successi → _build_messages - # wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale. - # Soluzione: whitelist di prefissi che certificano dati REALI verificati. - _REAL_DATA_PREFIXES = ( - "[RICERCA WEB REALE", - "[METEO", - "[CALCOLO REALE", - "[IMMAGINE AI GENERATA", - "[CODICE PYTHON ESEGUITO", - "[PAGINA WEB", - "[DATI REALI", - ) - _n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES)) - _n_errors = len(results) - _n_success - # Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima - if _n_errors > 0: - try: - from api.state import increment_stat as _inc_tf - _inc_tf("tool_failure_count") - except Exception: - pass - return "\n\n".join(results), len(results), _n_success, _n_errors - - # ── Claim Validation (S428 Sprint1-Fix3) ───────────────────────────────── - # Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato" - # nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer - # esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende. - _FALSE_CLAIM_RE = re.compile( - r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|" - r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|" - r"i\s+risultati\s+(?:mostrano|indicano|confermano)|" - r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|" - r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|" - r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|" - r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b", - re.IGNORECASE, - ) - _REALTIME_GOAL_RE = re.compile( - r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|" - r"weather|meteo|previsioni|temperatura|" - r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|" - r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b", - re.IGNORECASE, - ) - - @staticmethod - def _validate_claims( - response: str, - n_success: int, - n_errors: int, - goal: str, - false_claim_re: "re.Pattern[str]", - realtime_goal_re: "re.Pattern[str]", - ) -> str: - """S428 Sprint1-Fix3: Claim Validation. - Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta - contiene false claim di dati reali, aggiunge un disclaimer di trasparenza. - Non riscrive la risposta — la estende con una nota visibile all'utente. - """ - if n_success > 0 or n_errors == 0: - return response # dati reali presenti o nessun tool eseguito → ok - if not realtime_goal_re.search(goal): - return response # goal non richiede dati live → ok - if not false_claim_re.search(response): - return response # nessuna false claim → ok - # Rileva false claim + goal realtime + tutti tool falliti - disclaimer = ( - "\n\n---\n" - "⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano " - "raggiungibili durante questa risposta. Le informazioni sopra provengono " - "dal mio training e potrebbero non essere aggiornate. " - "Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera " - "o il sito ufficiale della tecnologia." - ) - return response + disclaimer - - # ── _needs_tools (S193) — regex ampliata ───────────────────────────────── - - # S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli - _TOOL_NEEDED_RE = re.compile( - r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|" - r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|" - r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|" - r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|" - r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|" - r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|" - r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|" - r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|" - r"euro|dollaro|yen|sterlina|libbra|release|changelog|" - r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|" - r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|" - r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|" - r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|" - r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|" - r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|" - r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|" - r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|" - r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|" - r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè]))\b", - re.IGNORECASE, - ) - - def _needs_tools(self, goal: str) -> bool: - return bool(self._TOOL_NEEDED_RE.search(goal)) - - # ── S402: Fast Path ─────────────────────────────────────────────────────── - # Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier. - # Target: <3s vs 20-60s per il full pipeline. - - # S427: aggiunti ack comuni IT/EN per fast path più ampio - _SIMPLE_CONV_RE = re.compile( - r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|" - r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|" - r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|" - r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|" - r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|" - r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|" - r"giusto|corretto|esattamente|d['\u2019]accordo|" - r"capisco|ho capito|inteso|compreso|ricevuto|" - r"s[iì] grazie|no grazie|va bene|va benissimo|" - r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|" - r"got it|sure|right|agreed|makes sense|correct|good|" - r"good morning|good evening|good night" - r")\.?\s*[!?]?$", - re.IGNORECASE, - ) - - def _is_simple_query(self, goal: str) -> bool: - """S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent). - Attiva il fast path che salta memoria, planner, verifier e self-healing.""" - g = goal.strip() - if len(g) > 70 or self._needs_tools(g): - return False - if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g): - return False - return bool(self._SIMPLE_CONV_RE.match(g)) - async def _run_fast_path(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any]: """S402: Path leggero per query conversazionali (<3s target). @@ -1359,139 +534,8 @@ class UnifiedAgentLoop: "fast_path": True, "timing_ms": t_ms, } - # ── Smolagents tools builder ────────────────────────────────────────────── - - def _build_smol_tools(self) -> list[Any]: - try: - from smolagents import Tool - from tools.registry import TOOL_REGISTRY - - smol_tools: list[Any] = [] - for tname, spec in TOOL_REGISTRY.items(): - async_fn = spec["_fn"] - tdesc = spec.get("description", spec.get("goal", tname)) - req_inputs = spec.get("required_inputs", []) - inputs: dict[str, dict[str, str]] = { - k: {"type": "string", "description": k} for k in req_inputs - } - - def _make(name: str, desc: str, fn: Any, inp: dict[str, dict[str, str]]) -> type: - class _T(Tool): # type: ignore[misc] - pass - _T.__name__ = f"Tool_{name}" - _T.name = name - _T.description = desc - _T.inputs = inp - _T.output_type = "string" - - def forward(self: Any, **kwargs: Any) -> str: - # S274-BUG4: asyncio.run() in nested event loop causa RuntimeError. - # run_coroutine_threadsafe è l'API corretta: schedula la coroutine - # sul loop esistente da un thread qualsiasi senza creare loop annidati. - try: - _loop = asyncio.get_event_loop() - _fut = asyncio.run_coroutine_threadsafe(fn(**kwargs), _loop) - return str(_fut.result(timeout=12)) - except Exception as exc: - return f"[errore {name}: {exc}]" - - _T.forward = forward # type: ignore[method-assign] - return _T - - smol_tools.append(_make(tname, tdesc, async_fn, inputs)()) - return smol_tools - except Exception: - return [] - - # ── Smolagents loader ───────────────────────────────────────────────────── - - def _load_smol_agent(self) -> Any | None: - if self._smol_agent is not None: - return self._smol_agent - try: - from smolagents import CodeAgent, LiteLLMModel - - MODEL_ENV = os.getenv("SMOLAGENTS_MODEL", "") - - def _prefix(mid: str, pfx: str) -> str: - if not mid: - return "" - known = ("openrouter/", "groq/", "huggingface/", "anthropic/", "openai/", "gpt-") - return mid if any(mid.startswith(p) for p in known) else f"{pfx}/{mid}" - - if os.getenv("GROQ_API_KEY"): - # S285-FIX2: Groq priorità (veloce). FIX3: forza 70b — modelli <14B - # falliscono il formato CodeAgent (Thought:/Code: strutturato). - # Solo override esplicito groq/model bypassa questo. - if MODEL_ENV and MODEL_ENV.startswith("groq/"): - model_id = MODEL_ENV # path esplicito groq/model — rispettato - else: - model_id = "groq/llama-3.3-70b-versatile" # default sicuro per CodeAgent - api_key = os.getenv("GROQ_API_KEY") - elif os.getenv("OPENROUTER_API_KEY"): - # S275-SMOL1: openrouter/ prefix richiesto da litellm per routing corretto - # S285-FIX: OpenRouter richiede "provider/model" — rifiuta bare IDs (es. llama-3.1-8b-instant) - _or_env = MODEL_ENV if (MODEL_ENV and "/" in MODEL_ENV) else "" - model_id = _prefix(_or_env, "openrouter") if _or_env else "openrouter/meta-llama/llama-3.3-70b-instruct:free" - api_key = os.getenv("OPENROUTER_API_KEY") - elif os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY"): - model_id = _prefix(MODEL_ENV, "huggingface") if MODEL_ENV else "huggingface/Qwen/Qwen2.5-Coder-32B-Instruct" - api_key = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY") - elif os.getenv("OPENAI_API_KEY"): - model_id = MODEL_ENV or "gpt-4o-mini" - api_key = os.getenv("OPENAI_API_KEY") - else: - return None - - model = LiteLLMModel(model_id=model_id, api_key=api_key) - tools = self._build_smol_tools() - self._smol_agent = CodeAgent( - tools=tools, - model=model, - max_steps=int(os.getenv("UNIFIED_LOOP_MAX_STEPS", "3")), - ) - return self._smol_agent - except Exception: - return None - - # ── Smolagents path ─────────────────────────────────────────────────────── - - async def _run_smolagents(self, state: UnifiedLoopState, - on_step: StepCallback | None) -> dict[str, Any] | None: - agent = self._load_smol_agent() - if agent is None: - return None - prompt = self._build_prompt(state) - if on_step: - await _maybe_await(on_step({"loop": 0, "action": "smolagents", "status": "started"})) - try: - result = await asyncio.wait_for( - asyncio.to_thread(agent.run, prompt), timeout=SMOL_TIMEOUT) - output = str(result) - state.steps.append({"action": "smolagents", "output": output}) - if self.memory: - await self.memory.save_episode("unified_loop", state.goal, output[:1000], True, tags=["smolagents"]) - if on_step: - await _maybe_await(on_step({"loop": 1, "action": "smolagents", "status": "done"})) - _smol_success = bool(output) and not output.startswith("[LLM") and not output.startswith("[Errore") - return {"success": _smol_success, "engine": "smolagents", "goal": state.goal, - "steps": state.steps, "output": output} - except asyncio.TimeoutError: - msg = f"smolagents timeout {SMOL_TIMEOUT}s — fallback" - state.errors.append(msg) - if on_step: - await _maybe_await(on_step({"loop": 1, "action": "smolagents", - "status": "timeout", "error": msg})) - return None - except Exception as exc: - # S195-Robust: smolagents error → only in steps (debug), NOT in state.errors. - # _run_fallback handles the request cleanly — no error surfaced to user. - _smol_err = str(exc)[:200] - state.steps.append({"action": "smolagents", "status": "error", "error": _smol_err}) - if on_step: - await _maybe_await(on_step({"loop": 1, "action": "smolagents", - "status": "error", "error": _smol_err})) - return None + # R1 S390 + S434: smolagents rimosso — dead code eliminato. + # _build_smol_tools / _load_smol_agent / _run_smolagents non chiamati da run(). # ── Fallback deterministico ─────────────────────────────────────────────── @@ -1533,77 +577,476 @@ class UnifiedAgentLoop: and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) and len(state.goal) > 50 ) - _t0_plan = asyncio.get_event_loop().time() # Sprint 5 ITEM 13: plan_ms timing + _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing if _should_plan: if on_step: await _maybe_await(on_step({ "loop": 0, "action": "plan", "status": "started", "title": "Pianificazione", - "explanation": "Analisi del goal e creazione piano di azione", + "explanation": "Analizzo la richiesta e preparo un piano", })) - plan = await self.planner.create_plan( - state.goal, context=[{"role": "system", "content": state.context}]) - state.steps.append({"action": "plan", "result": plan}) + # S640: timeout sul planner — DeepSeek-R1 può essere lento ma non deve bloccare + # 30s è il 95° percentile osservato su prompt lunghi; oltre è quasi certamente stall. + # Su timeout: plan=None → esecuzione diretta senza subtask (comportamento pre-planner). + try: + plan = await asyncio.wait_for( + self.planner.create_plan( + state.goal, context=[{"role": "system", "content": state.context}] + ), + timeout=30.0, + ) + except asyncio.TimeoutError: + plan = None + _logger.warning("S640 planner timeout (30s) su goal: %s", state.goal[:80]) + exec_warn.append("⚠ [S640] piano non disponibile (timeout pianificatore 30s)") + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": "plan", "status": "warning", + "title": "Pianificazione scaduta", + "explanation": "Il pianificatore ha impiegato troppo — procedo senza piano", + "visibility": "progress", + })) + if plan is not None: + state.steps.append({"action": "plan", "result": plan}) try: from api.state import record_timing as _rtc_pl - _rtc_pl("plan_ms", (asyncio.get_event_loop().time() - _t0_plan) * 1000) + _rtc_pl("plan_ms", (asyncio.get_running_loop().time() - _t0_plan) * 1000) except Exception: pass - if on_step: + # S641: guard plan is not None prima di on_step e executor + # piano può essere None dopo timeout S640 — plan.get() crasherebbe con AttributeError + if plan is not None and on_step: await _maybe_await(on_step({ "loop": 0, "action": "plan", "status": "done", "title": "Piano creato", - "explanation": f"Piano con {len(plan.get('subtasks', []))} step — avvio esecuzione", + "explanation": f"Piano con {len(plan.get('subtasks', []))} passaggi — inizio esecuzione", "subtasks": len(plan.get("subtasks", [])), })) - if self.executor and plan.get("subtasks"): + if self.executor and plan is not None and plan.get("subtasks"): + # S574-GAP4: completata _TOOL_MAP — read_page/code/calculate/image + # Prima: solo web_search eseguito; tutti gli altri subtask silenziosamente saltati + # Ora: 5 tool reali mappati → subtask del planner eseguiti davvero _TOOL_MAP: dict[str, tuple[str, Any]] = { - "web_search": ("web_search", lambda desc: {"query": desc}), - "read_page": (None, None), - "code": (None, None), - "calculate": (None, None), - "memory": (None, None), - "direct_response": (None, None), + "web_search": ("web_search", lambda desc: {"query": desc}), + "read_page": ("read_page", lambda desc: {"url": desc}), + "code": ("run_python", lambda desc: {"code": desc}), + "calculate": ("calculate", lambda desc: {"expression": desc}), + "image": ("generate_image", lambda desc: {"prompt": desc}), + # S601: nuovi tool V001-V007 aggiunti al planner — mappa anche questi + "web_research": ("web_research", lambda desc: {"topic": desc, "depth": 4, "synthesize": True}), + "generate_image": ("generate_image", lambda desc: {"prompt": desc}), + "run_python": ("run_python", lambda desc: {"code": desc}), + "send_email": ("send_email", lambda desc: { + # S643: estrai destinatario dalla descrizione — pattern "a " o "to " + "to": (lambda m: m.group(1) if m else "")( + __import__("re").search( + r"\b(?:a|to|invia\s+a|send\s+to)\s+([\w.+-]+@[\w-]+\.[\w.]+)", + desc, __import__("re").IGNORECASE + ) + ), + "subject": desc[:80], + "body": desc, + }), + "database_query": ("database_query", lambda desc: {"sql": desc}), + "execute_sql": ("execute_sql", lambda desc: {"sql": desc}), + "create_pdf": ("create_pdf", lambda desc: { + # S644+S645: estrai filename/title dalla prima frase (max 60 chars) + # S645: _create_pdf usa "filename" non "title" — fix campo ignorato + "content": desc, + "filename": ( + __import__("re").sub(r"[^\w\-]", "_", + desc.split(".")[0][:50].strip() or "documento" + ).lower() + ".pdf" + ), + }), + "call_api": ("call_api", lambda desc: { + # S644: estrai URL e method dalla description + "url": (lambda m: m.group(0) if m else desc)( + __import__("re").search(r"https?://[\S]+", desc) + ), + "method": ( + "POST" if __import__("re").search(r"\b(post|invia|crea|create|send)\b", desc, 2) else + "PUT" if __import__("re").search(r"\b(put|aggiorna|update|modifica)\b", desc, 2) else + "DELETE" if __import__("re").search(r"\b(delete|elimina|cancella|remove)\b", desc, 2) else + "GET" + ), + }), + # S659: write_file/read_file/apply_patch mancanti da _TOOL_MAP. + # Quando il planner generava subtask con questi tool, _TOOL_MAP.get() + # restituiva (None, None) → subtask silenziosamente saltati (nessuna esecuzione). + # Fix: aggiunta mapping con estrazione path dalla description. + "write_file": ("write_file", lambda desc: { + "path": (lambda m: m.group(1) if m else "output.txt")( + __import__("re").search( + r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", + desc + ) + ), + "content": desc, + }), + "read_file": ("read_file", lambda desc: { + "path": (lambda m: m.group(1) if m else desc.strip()[:200])( + __import__("re").search( + r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", + desc + ) + ), + }), + "apply_patch": ("apply_patch", lambda desc: { + "path": (lambda m: m.group(1) if m else "output.txt")( + __import__("re").search( + r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", + desc + ) + ), + "patch": desc, + }), + # S669: execute_shell mancava da _TOOL_MAP — il planner poteva assegnare + # tool="execute_shell" ma _TOOL_MAP.get() → (None, None) → subtask saltato + # silenziosamente. Aggiunto mapping con estrazione comando da description. + "execute_shell": ("execute_shell", lambda desc: { + "command": (lambda m: m.group(1) if m else desc.strip()[:200])( + __import__("re").search(r"[`'"]([w .\-/]{1,200})[`'"]", desc) + ) or desc.strip()[:200], + }), + "memory": (None, None), # gestito dalla memoria, non un tool + "direct_response": (None, None), # risposta LLM diretta, non un tool + "browser": (None, None), # browser tool non disponibile su HF } - exec_parts: list[str] = [] - for subtask in plan.get("subtasks", []): - if subtask.get("risk", "low") == "high": + exec_done: list[str] = [] # S629: subtask completati con successo + exec_warn: list[str] = [] # S628: subtask high-risk non eseguiti + # S627: tool read-only sicuri — eseguiti anche con risk=high + # S662: set safe-exec — tool read-only eseguibili anche con risk=high (no side-effect). + # S676: esteso con list_files (VFS read-only), validate_json/diff_text (computazione locale). + _SAFE_EXEC_TOOLS = {"web_search", "read_page", "web_research", "read_file", "recall", + "list_files", "validate_json", "diff_text"} + # S629: fase 1 — categorizza subtask (warning vs eseguibili) + + # S634: regex per routing validator — compilati una volta per il batch + _S634_URL_RE = re.compile(r"https?://", re.IGNORECASE) + _S634_CODE_HINT = re.compile( + r"(scrivi|genera|crea|costruisci|implementa|calcola|esegui" + r"|python|script|codice|funzione|classe|loop|if |for |while " + r"|def |return |import |print)", + re.IGNORECASE, + ) + + def _check_subtask_routing(s_tool: str, s_desc: str) -> str | None: + """S634: analisi statica — rileva mismatch tool/description PRIMA + che _resolve_inp invochi il CODER LLM. Non blocca mai l'esecuzione. + + Casi rilevati: + - run_python/execute_sql/database_query con URL → probabile 'read_page' + - read_page senza URL → il tool fallirà (attende un URL valido) + - code tool con description <8 chars → _resolve_inp avrà poco contesto + """ + if not s_desc: + return None + _is_code_tool = s_tool in ("run_python", "execute_sql", "database_query") + _has_url = bool(_S634_URL_RE.search(s_desc)) + _has_code_hint = bool(_S634_CODE_HINT.search(s_desc)) + _is_short = len(s_desc.strip()) < 8 + + if _is_code_tool and _has_url and not _has_code_hint: + return (f"[S634 routing] '{s_tool}' con URL senza hint codice " + f"→ potrebbe essere 'read_page' (desc: '{s_desc[:60]}')") + if s_tool == "read_page" and not _has_url: + return (f"[S634 routing] 'read_page' senza URL " + f"→ il tool si aspetta un URL valido (desc: '{s_desc[:60]}')") + if _is_code_tool and _is_short: + return (f"[S634 routing] '{s_tool}' con descrizione <8 chars " + f"→ _resolve_inp avrà contesto insufficiente (desc: '{s_desc}')") + return None + + _pending_exec: list[tuple[dict, str, Any]] = [] + for _s_idx, subtask in enumerate(plan.get("subtasks", []), start=1): + # S643: fallback id quando planner omette campo — evita None nei log + if "id" not in subtask or subtask["id"] is None: + subtask = {**subtask, "id": f"s{_s_idx}"} + _s_risk = subtask.get("risk", "low") + _s_tool = subtask.get("tool", "") + _s_desc_raw = subtask.get("description", "") + + # S634: static routing check — warning in exec_warn + logger, mai bloccante + _rt_warn = _check_subtask_routing(_s_tool, _s_desc_raw) + if _rt_warn: + _logger.warning("S634 %s", _rt_warn) + exec_warn.append(f"⚠ {_rt_warn}") + + if _s_risk == "high" and _s_tool not in _SAFE_EXEC_TOOLS: + # S627: alto rischio + tool destructive → inietta nota nel contesto + exec_warn.append( + f"\u26a0 subtask #{subtask.get('id')} " + f"'{subtask.get('description','')[:60]}' [{_s_tool}] \u2014 richiede approvazione" + ) + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": "plan", "status": "warning", + "title": "Subtask ad alto rischio", + "explanation": f"'{subtask.get('description','')[:60]}' \u2014 richiede approvazione", + "subtask_id": subtask.get("id"), "visibility": "progress", + })) continue - tool_key_pair = _TOOL_MAP.get(subtask.get("tool", ""), (None, None)) + tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None)) reg_name, inp_builder = tool_key_pair - if not reg_name or inp_builder is None: - continue - inputs = inp_builder(subtask.get("description", state.goal)) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{reg_name}", - "status": "started", "subtask_id": subtask.get("id"), - })) - res = await self.executor.run_tool(reg_name, inputs) - if res.get("success"): - snippet = str(res.get("output", ""))[:500] - label = f"[subtask {subtask.get('id')} — {subtask.get('description','')[:60]}]" - exec_parts.append(f"{label}: {snippet}") - state.steps.append({ - "action": f"executor:{reg_name}", - "subtask_id": subtask.get("id"), - "output": snippet, - }) + if reg_name and inp_builder is not None: + _pending_exec.append((subtask, reg_name, inp_builder)) + # S629: fase 2 — parallel dispatch con asyncio.gather + # Provider diversi per tool diversi → rate limit indipendenti, nessun bottleneck + # (web_search/read_page → HTTP provider; run_python → sandbox; generate_image → HF) + # asyncio è single-thread: list.append e state.steps sono race-condition safe + + # S632: tool che richiedono codice reale — la descrizione NL non è eseguibile diretta + _CODE_TOOLS: set[str] = {"run_python", "execute_sql", "database_query"} + + async def _resolve_inp(tool_name: str, desc: str) -> str: + """S632: per tool code/SQL usa CODER LLM per convertire la descrizione NL + in codice/SQL eseguibile. Per tutti gli altri tool: passthrough diretto. + + Timeout 10s con fallback alla descrizione grezza — zero regressioni. + I/O parallelo via asyncio.gather: nessun overhead sequenziale aggiunto.""" + if tool_name not in _CODE_TOOLS: + return desc + try: + from models.role_router import RoleRouter, Role + _coder_client = RoleRouter.get_client(Role.CODER) + if tool_name == "run_python": + _sys = "Sei un esperto Python. Scrivi solo il codice Python, nessuna spiegazione." + _usr = f"Scrivi codice Python eseguibile per: {desc}" + else: # execute_sql / database_query + _sys = "Sei un esperto SQL. Scrivi solo la query SQL, nessuna spiegazione." + _usr = f"Scrivi una query SQL per: {desc}" + _resolved = await asyncio.wait_for( + _coder_client.chat( + [{"role": "system", "content": _sys}, + {"role": "user", "content": _usr}], + temperature=0.1, max_tokens=512, + ), + timeout=10.0, + ) + # Rimuovi markdown fence se l'LLM ha aggiunto ```python...``` + _resolved = _resolved.strip() + if _resolved.startswith("```"): + _lines = _resolved.splitlines() + _resolved = "\n".join( + l for l in _lines + if not l.strip().startswith("```") + ).strip() + return _resolved if _resolved else desc + except Exception: + return desc # fallback: descrizione grezza (comportamento pre-S632) + + # S646: guard piano vuoto — plan non None ma subtasks=[] → warning degrado graceful + # Senza guard: exec_done=[], exec_warn=[] → nessun exec_block → LLM risponde senza contesto + if plan is not None and not plan.get("subtasks"): + _plan_goal_empty = plan.get("goal", state.goal)[:120] + exec_warn.append( + f"⚠ [S646] Piano generato senza subtask per: '{_plan_goal_empty}'. " + f"Nessuna azione eseguita — risposta basata solo su ragionamento LLM." + ) + + if _pending_exec: + async def _run_subtask( + st: dict, rn: str, ib: Any, _goal: str = state.goal + ) -> tuple[dict, str, dict]: if on_step: await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{reg_name}", - "status": "done", "subtask_id": subtask.get("id"), + "loop": 0, "action": f"executor:{rn}", + "status": "started", "subtask_id": st.get("id"), })) - if exec_parts: - exec_block = "\n".join(exec_parts) - tool_results = (f"{tool_results}\n{exec_block}".strip() + # S632: risolvi description → codice/SQL prima di chiamare il tool + _raw_desc = st.get("description", _goal) + _inp_desc = await _resolve_inp(rn, _raw_desc) + _r = await self.executor.run_tool(rn, ib(_inp_desc)) + # S635: retry una volta su fallimento non-timeout con back-off 0.5s + # Motivo: errori transitori (rate limit provider, cold-start sandbox) + # si auto-risolvono al secondo tentativo nella maggior parte dei casi. + # Mai retrya su TimeoutError — il tool è già lento, un secondo tentativo + # aggraverebbe la latenza. Il flag _s635_retry evita loop infiniti. + if not _r.get("success") and not _r.get("_s635_retry"): + _err_str = str(_r.get("error", "")).lower() + _is_timeout = "timeout" in _err_str or "timed out" in _err_str + if not _is_timeout: + await asyncio.sleep(0.5) + _inp2 = await _resolve_inp(rn, _raw_desc) + _r2 = await self.executor.run_tool(rn, ib(_inp2)) + _r2["_s635_retry"] = True # marca per evitare loop + _logger.warning( + "S635 retry subtask #%s [%s]: %s → %s", + st.get("id"), rn, + "ok" if _r2.get("success") else "ancora fallito", + str(_r2.get("error", ""))[:80], + ) + _r = _r2 + return st, rn, _r + # S639: timeout globale sul gather — singolo tool stalled non blocca tutto + # 90s è conservativo: retry S635 (0.5s) + CODER LLM x2 (10s each) = ~21s/subtask + # Con 4 subtask paralleli il worst-case è ~21s; 90s lascia margine ampio. + try: + _exec_results = await asyncio.wait_for( + asyncio.gather( + *[_run_subtask(s, rn, ib) for s, rn, ib in _pending_exec], + return_exceptions=True, + ), + timeout=90.0, + ) + except asyncio.TimeoutError: + _logger.warning( + "S639 gather timeout (90s) su %d subtask — risultati parziali", + len(_pending_exec), + ) + exec_warn.append( + f"⚠ [S639] timeout globale executor ({len(_pending_exec)} subtask): " + "nessun risultato disponibile da questa fase" + ) + _exec_results = [] + for _er in _exec_results: + if isinstance(_er, Exception): + # S636: eccezioni da asyncio.gather erano silenziosamente ignorate. + # Ora logga tipo + messaggio per diagnostica. Mai blocca il loop. + _exc_type = type(_er).__name__ + _exc_msg = str(_er)[:120] + _logger.error( + "S636 gather exception [%s]: %s", _exc_type, _exc_msg + ) + exec_warn.append( + f"⚠ [S636] eccezione subtask [{_exc_type}]: {_exc_msg}" + ) + continue + _st, _rn, _res = _er + if _res.get("success"): + _snippet = str(_res.get("output", "")).strip()[:500] + # S647: hollow success — tool ok ma output vuoto → nota esplicita + # LLM vedeva "[subtask s1 — ...]:" con stringa vuota dopo i due punti + # Non capiva cosa era avvenuto → rispondeva senza contesto utile. + if not _snippet: + _snippet = "(nessun output — operazione completata senza testo di risposta)" + _rtag = " \u26a0" if _st.get("risk", "low") == "high" else "" + _label = f"[subtask {_st.get('id')}{_rtag} \u2014 {_st.get('description','')[:60]}]" + exec_done.append(f"{_label}: {_snippet}") + state.steps.append({ + "action": f"executor:{_rn}", + "subtask_id": _st.get("id"), + "output": _snippet, + }) + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": f"executor:{_rn}", + "status": "done", "subtask_id": _st.get("id"), + })) + # S628: sintesi strutturata — sezioni separate done/warn invece di stringa piatta + else: + # S637: subtask fallito anche dopo retry (S635) → feedback UI + exec_warn + # Prima: nessun evento, exec_warn vuoto → UI non sapeva del fallimento + _fail_err = str(_res.get("error", "errore sconosciuto"))[:100] + _fail_retry = _res.get("_s635_retry", False) + _fail_label = ( + f"[subtask {_st.get('id')} \u2014 {_st.get('description','')[:50]}]" + ) + _fail_note = " (dopo retry S635)" if _fail_retry else "" + exec_warn.append( + f"\u26a0 {_fail_label} fallito{_fail_note}: {_fail_err}" + ) + _logger.warning( + "S637 subtask #%s [%s] failed%s: %s", + _st.get("id"), _rn, _fail_note, _fail_err, + ) + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": f"executor:{_rn}", + "status": "failed", + "subtask_id": _st.get("id"), + "explanation": _fail_err, + "visibility": "progress", + })) + # Prima: "\n".join(exec_parts) → blob grezzo, LLM non distingue risultati da warning + # Ora: ## PIANO — goal / ### Risultati / ### Attenzione → guida la risposta finale + if exec_done or exec_warn: + _plan_goal = plan.get("goal", state.goal)[:120] + _synth: list[str] = [f"## Piano eseguito — {_plan_goal}"] + if exec_done: + _synth.append(f"\n### Risultati ({len(exec_done)} subtask completati):") + _synth.extend(exec_done) + if exec_warn: + _synth.append(f"\n### Non eseguiti — richiedono attenzione ({len(exec_warn)}):") + _synth.extend(exec_warn) + # S638: sintesi totale failure — guida LLM verso risposta degrado graceful + # Prima: nessun avviso se exec_done=[] → LLM non capiva che TUTTO aveva fallito + if exec_warn and not exec_done: + _n_planned = len(plan.get("subtasks", [])) + _synth.append( + f"\n### ⚠ Tutti i subtask ({_n_planned}) non hanno prodotto risultati. " + f"Rispondi in modo onesto su cosa non è stato possibile eseguire." + ) + exec_block = "\n".join(_synth) + tool_results = (f"{tool_results}\n\n{exec_block}".strip() if tool_results else exec_block) + # S642: aggiorna _tool_exec_successes/_tool_exec_errors da subtask results + # Prima: Tool Integrity Guard riceveva solo i contatori pre-executor (tool diretti) + # senza sapere quanti subtask del planner erano andati a buon fine o no. + _tool_exec_successes += len(exec_done) + _tool_exec_errors += len([w for w in exec_warn + if w.startswith("⚠") and "S640" not in w + and "S634" not in w and "S639" not in w]) + # S638: save_episode success=True solo se almeno 1 subtask completato + # Prima: True hardcoded anche con 0 risultati → episodi falsi in memoria + _ep_success = bool(exec_done) if self.memory: + _mem_src = "\n".join(exec_done)[:800] if exec_done else exec_warn[0][:400] await self.memory.save_episode( - "executor", state.goal, exec_block[:800], True, + "executor", state.goal, _mem_src, _ep_success, tags=["executor", "plan"]) + # S575-GAP1: ReasoningCore gate per task complessi + # Trigger: tok_budget >= 6144 (task grandi) + piano con 3+ subtask + # Azione: run_loop_to_answer() con max 5 iterazioni → inietta nel contesto + # Il loop multi-step arricchisce tool_results; l'LLM finale sintetizza la risposta. + # Timeout 55s — conservativo, mai blocca l'utente più di 1 min totale. + _n_subtasks = len(plan.get("subtasks", [])) if plan else 0 + _should_reason = ( + self._max_tokens_for_goal(state.goal) >= 6144 + and _n_subtasks >= 3 + ) + if _should_reason: + try: + from agents.reasoning_core import ReasoningCore as _RC + _rc = _RC( + llm_client=self._get_llm_for_goal(state.goal), + planner=self.planner, + critic=self.critic, + executor=self.executor, + ) + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": "reasoning_core", + "status": "started", + "title": "Analisi multi-step", + "explanation": f"ReasoningCore attivato — {_n_subtasks} subtask, loop fino a 5", + })) + _rc_ctx = await asyncio.wait_for( + _rc.run_loop_to_answer( + state.goal, context=state.context or "", + on_step=on_step, max_loops=8, # S701: 5→8 + ), + timeout=55.0, + ) + if _rc_ctx: + tool_results = ( + f"{tool_results}\n\n[REASONING CORE]\n{_rc_ctx}".strip() + if tool_results else f"[REASONING CORE]\n{_rc_ctx}" + ) + if on_step: + await _maybe_await(on_step({ + "loop": 0, "action": "reasoning_core", + "status": "done", + "title": "Analisi multi-step completata ✓", + })) + except asyncio.TimeoutError: + pass # timeout → continua con tool_results già disponibili + except Exception: + pass # silente — non blocca il loop principale + # LLM call con dati tool iniettati # S402: passa exec counts per Tool Integrity Guard in _build_messages() messages = self._build_messages( @@ -1630,15 +1073,18 @@ class UnifiedAgentLoop: ) if _summary and not _summary.startswith('[LLM'): # S423-Fix8: preserva sempre l'ultimo user message — evita che la domanda - # corrente venga persa nella compressione quando è fuori da messages[-2:] + # corrente venga persa nella compressione quando è fuori da messages[-3:] + # S590: messages[-2:]→[-3:] — preserva più turns nella coda di compressione _last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None) + _tail = list(messages[-3:]) + # S458: inserisci _last_user PRIMA della coda (user→assistant), non dopo + if _last_user and _last_user not in _tail: + _tail.insert(0, _last_user) _compressed = [ messages[0], {"role": "system", "content": f"[STORIA COMPRESSA]\n{_summary}"}, - *messages[-2:], + *_tail, ] - if _last_user and _last_user not in _compressed: - _compressed.append(_last_user) messages = _compressed except Exception: pass # compressione fallita — usa messages originali @@ -1646,7 +1092,7 @@ class UnifiedAgentLoop: await _maybe_await(on_step({ "loop": 1, "action": "llm", "status": "started", "title": "Elaborazione AI", - "explanation": "Generazione risposta con modello linguistico ottimale", + "explanation": "Sto elaborando la risposta…", })) # B10: usa state.has_files — non più '__HAS_FILES__' nel context string @@ -1654,16 +1100,26 @@ class UnifiedAgentLoop: _llm_timeout = LLM_TIMEOUT * 1.8 if _has_files else LLM_TIMEOUT # S197 never-give-up: frasi di rifiuto che triggerano retry forzato - # S375: aggiunti pattern italiani mancanti + varianti inglesi comuni + # S456-X2: SET CANONICO — sincronizzato con REFUSAL_RE in outputValidator.ts. + # Soglia: 600 chars (retry aggressivo, cheap). Frontend usa 350 (quality penalization). + # Soglie SEPARATE per design — qualsiasi aggiunta qui deve aggiornare anche il TS. _REFUSAL_PHRASES = ( + # ── Italiano ────────────────────────────────────────────────────── 'non posso', 'non sono in grado', 'mi dispiace ma non', 'impossibile per me', 'non riesco', 'non ho accesso', 'mi scuso ma non', 'purtroppo non posso', 'purtroppo non sono', 'mi dispiace, non', 'non mi è possibile', 'non è possibile per me', + 'non ho trovato', # S456-X2: da TS REFUSAL_RE + 'sono spiacente', # S456-X2: da TS REFUSAL_RE + 'come ia non', # S456-X2: da TS REFUSAL_RE + # ── Inglese ─────────────────────────────────────────────────────── 'i cannot', 'i am unable', 'i\'m unable', 'i\'m sorry but i', - 'as an ai', 'come ia non', 'as an language model', + 'as an ai', 'as an language model', 'as a language model', 'i\'m not able to', 'that\'s not something i can', 'sorry, i can\'t', 'unfortunately i cannot', 'i\'m afraid i cannot', + 'i lack the capability', # S456-X2: da TS REFUSAL_RE + "i don't have the ability", # S456-X2: da TS REFUSAL_RE + "i don't have information about", # S456-X2: da TS REFUSAL_RE ) def _is_refusal(text: str) -> bool: @@ -1698,7 +1154,8 @@ class UnifiedAgentLoop: return 'logic' return 'unknown' - _error_severity = _classify_error_severity(' '.join(str(e) for e in state.errors[-2:])) + # S590: errors[-2:]→[-3:] — più errori per classificazione severità accurata + _error_severity = _classify_error_severity(' '.join(str(e) for e in state.errors[-3:])) # S376: severity-based retry hints _SEVERITY_HINTS = { @@ -1754,7 +1211,7 @@ class UnifiedAgentLoop: } _temp = _temp_by_try.get(_error_severity, [0.2, 0.4, 0.4])[min(_llm_try, 2)] # S385: latency telemetry — misura durata chiamata LLM - _t0_llm = asyncio.get_event_loop().time() + _t0_llm = asyncio.get_running_loop().time() # S420: stream tokens to frontend while accumulating full answer _stream_parts: list[str] = [] try: @@ -1781,7 +1238,7 @@ class UnifiedAgentLoop: ) try: from api.state import record_timing as _rec_timing - _llm_elapsed = (asyncio.get_event_loop().time() - _t0_llm) * 1000 + _llm_elapsed = (asyncio.get_running_loop().time() - _t0_llm) * 1000 _rec_timing("llm_total", _llm_elapsed) _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing except Exception: @@ -1790,7 +1247,8 @@ class UnifiedAgentLoop: state.steps.append({"action": f"llm_attempt_{_llm_try}", "output": answer}) continue if _is_refusal(answer) and not _is_last: - state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:200]}) + # S576: 200→400 — cattura rifiuto completo per debug + state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:600]}) # S603: 400→600 continue break # risposta reale non-rifiuto except asyncio.TimeoutError: @@ -1807,12 +1265,12 @@ class UnifiedAgentLoop: if answer.startswith("[LLM"): state.errors.append(answer) # S364: Chain-of-Verification — dopo 2+ errori, usa ARCHITECT per reflection - if len(state.errors) >= 2: + if len(state.errors) >= 1: # S701: reflection da 1 errore (era 2) _reflection = await self._reflective_debug(state.goal, state.errors) if _reflection: state.context = (state.context or '') + _reflection state.steps.append({"action": "reflective_debug", - "analysis": _reflection[:200]}) # S364 + "analysis": _reflection[:400]}) # S573: 200→400 state.steps.append({"action": "llm", "output": answer}) @@ -1849,14 +1307,15 @@ class UnifiedAgentLoop: except SyntaxError as _se: _py_errs.append(f"{_vp}:{_se.lineno}: {_se.msg}") if _py_errs: - _syn_rpt = "AUTO-VALIDATE sintassi: " + "; ".join(_py_errs[:3]) + # S594: _py_errs[:3]→[:5] — riporta più errori di sintassi per fix completo + _syn_rpt = "AUTO-VALIDATE sintassi: " + "; ".join(_py_errs[:5]) state.errors.append(_syn_rpt) if on_step: await _maybe_await(on_step({ "action": "validate_project", "status": "needs_fix", "title": "Validazione automatica", - "explanation": _syn_rpt[:200], + "explanation": _syn_rpt[:400], # S576: 200→400 })) try: from api.state import increment_stat as _inc_syn @@ -1878,8 +1337,12 @@ class UnifiedAgentLoop: # così "\n\n".join(outputs) riflette la risposta completamente riparata. # (Prima: outputs.append(answer) qui → tutti i fix venivano scartati in silenzio) - # B6: ExecutionValidator fire-and-forget — in agent.py era già async task; - # qui era await bloccante 18s. Ora notifica via on_step senza trattenere la risposta. + # Doc2-3a-FIX: quality_guardian integrato nel loop di repair. + # Prima: fire-and-forget → fix_hint emesso via SSE ma mai usato → codice bugato consegnato. + # Ora: await con timeout breve (8s). + # - Se risulta FAIL + fix_hint → 1 repair LLM call prima di restituire la risposta. + # - Se timeout → fire-and-forget solo per notifica SSE (comportamento precedente). + # Invariante B6 rispettata: solo timeout avvia il task async — nessun await bloccante lungo. if answer and not answer.startswith('[LLM') and '```' in answer: try: import importlib as _imp_ev @@ -1889,26 +1352,100 @@ class UnifiedAgentLoop: _qg_mod = None _qc_fn = getattr(_qg_mod, 'run_quality_check', None) if _qg_mod else None if _qc_fn: - _answer_snap = answer # cattura snapshot per closure + _answer_snap = answer + _qc_result: dict | None = None - async def _ev_task() -> None: - try: - _qc = await asyncio.wait_for( - _qc_fn(task_id='exec_val', goal=state.goal, - llm_output=_answer_snap, on_event=None), - timeout=18.0, - ) - if _qc.get('passed') is False and _qc.get('fix_hint') and on_step: + # Tenta quality check con timeout breve (8s) — permette repair integrato + try: + _qc_result = await asyncio.wait_for( + _qc_fn(task_id=self._run_task_id, goal=state.goal, # S568-A: era 'exec_val' hardcoded + llm_output=_answer_snap, on_event=on_step) # S701-R6: abilita test_result SSE events, + timeout=8.0, + ) + except asyncio.TimeoutError: + _qc_result = None # troppo lento → fire-and-forget sotto + except Exception: + _qc_result = None + + if _qc_result is not None: + # Risultato disponibile — repair integrato se FAIL + fix_hint + if _qc_result.get('passed') is False and _qc_result.get('fix_hint'): + # S594: fix_hint 300→500 — hint correttivo spesso multi-riga (era [:300] che limitava il successivo [:400]) + _fix_hint = str(_qc_result['fix_hint'])[:500] + if on_step: await _maybe_await(on_step({ 'action': 'execution_validator_fix', - 'fix_hint': _qc['fix_hint'][:200], - 'status': 'needs_fix', + 'fix_hint': _fix_hint, # S573: 200→400; S594: cap spostato a riga sopra + 'status': 'repairing', })) - except Exception: - pass - asyncio.create_task(_ev_task()) # fire-and-forget — non blocca risposta - except Exception: - pass # ExecutionValidator failure always silent + try: + # Usa messages originali (non _msgs con hint iniettati) + # per evitare confusion nel contesto del repair LLM + # S590: messages[-4:]→[-6:] — più contesto per repair LLM + _repair_msgs = [ + *messages[-6:], + {"role": "assistant", "content": answer}, + {"role": "user", "content": ( + f"Il tester automatico ha rilevato un bug:\n{_fix_hint}\n\n" + "Correggi SOLO il codice difettoso. " + "Riscrivi completi i file che contengono il bug." + )}, + ] + _repaired = await asyncio.wait_for( + _active_llm.chat( + _repair_msgs, temperature=0.1, + max_tokens=min(_tok_budget, 4096), + ), + timeout=25.0, + ) + if _repaired and not _repaired.startswith('[LLM'): + answer = _repaired + if on_step: + await _maybe_await(on_step({ + 'action': 'execution_validator_fix', + 'status': 'done', + 'title': 'Fix automatico applicato ✓', + })) + try: + from api.state import increment_stat as _inc_qg + _inc_qg("repair_success_count") + except Exception: + pass + except Exception: + pass # repair silente — risposta originale invariata + elif _qc_result.get('passed') is False and on_step: + # FAIL senza hint → notifica UI + await _maybe_await(on_step({ + 'action': 'execution_validator_fix', + 'fix_hint': 'Quality check: bug rilevato — nessun hint specifico', + 'status': 'needs_fix', + })) + else: + # Timeout 8s → fire-and-forget per notifica SSE (B6 invariant) + _ff_snap = answer + _run_tid = self._run_task_id # S568-A: cattura prima del closure + async def _ev_task() -> None: + try: + _qc = await asyncio.wait_for( + _qc_fn(task_id=_run_tid, goal=state.goal, # S568-A: era 'exec_val' hardcoded + llm_output=_ff_snap, on_event=on_step) # S701-R6: SSE test_result in fire-and-forget, + timeout=18.0, + ) + if _qc.get('passed') is False and _qc.get('fix_hint') and on_step: + await _maybe_await(on_step({ + 'action': 'execution_validator_fix', + 'fix_hint': _qc['fix_hint'][:400], # S573: 200→400 + 'status': 'needs_fix', + })) + except Exception: + pass + # S455-P10: task supervisionato + _ev_t = asyncio.create_task(_ev_task()) + _ev_t.add_done_callback( + lambda t: t.exception() if not t.cancelled() and t.exception() is not None else None + ) + except Exception as _ev_exc: + _logger.warning("S624 ExecutionValidator failed (silent): %s", _ev_exc) # S624 # S274-BUG3: ResponseVerifier era salvato in self.verifier ma MAI chiamato. # Wire-in: verifica JSON, markdown, coerenza. Retry con hint se suggerito. @@ -1928,10 +1465,10 @@ class UnifiedAgentLoop: timeout=LLM_TIMEOUT) if _retry_ans and not _retry_ans.startswith('[LLM'): answer = _retry_ans - except Exception: - pass # retry fallito — usa risposta verificata - except Exception: - pass # verifier non disponibile — usa answer originale + except Exception as _rv_retry_exc: + _logger.warning("S624 ResponseVerifier retry failed (silent): %s", _rv_retry_exc) # S624 + except Exception as _rv_exc: + _logger.warning("S624 ResponseVerifier failed (silent): %s", _rv_exc) # S624 # S403: GoalVerifier — verifica semantica "obiettivo raggiunto" vs "azione eseguita" # S410: adaptive threshold + double-pass re-verify per chiudere il loop di verifica. @@ -1974,15 +1511,15 @@ class UnifiedAgentLoop: except Exception: _gv2_reqs = None # Sprint 2: usa verify_v2 se requisiti trovati, altrimenti verify v1 - _t0_gv = asyncio.get_event_loop().time() # Sprint 5 ITEM 14: verifier_ms + _t0_gv = asyncio.get_running_loop().time() # Sprint 5 ITEM 14: verifier_ms if _gv2_reqs: _gvr = await asyncio.wait_for( - _gv.verify_v2(state.goal, answer, _gv2_reqs), timeout=15.0) + _gv.verify_v2(state.goal, answer, _gv2_reqs), timeout=5.0) # S434: 15→5s else: - _gvr = await asyncio.wait_for(_gv.verify(state.goal, answer), timeout=12.0) + _gvr = await asyncio.wait_for(_gv.verify(state.goal, answer), timeout=5.0) # S434: 12→5s try: from api.state import record_timing as _rtgv - _rtgv("verifier_ms", (asyncio.get_event_loop().time() - _t0_gv) * 1000) + _rtgv("verifier_ms", (asyncio.get_running_loop().time() - _t0_gv) * 1000) except Exception: pass # S416-Fix6: README check — Regola 17 verifica per task multi-file @@ -1997,10 +1534,9 @@ class UnifiedAgentLoop: "action": "goal_verifier", "status": "running", "visibility": "progress", - "title": "Goal Verifier", + "title": "Controllo qualità", "explanation": ( - f"Completamento goal: {int(_gvr.coverage_score * 100)}% " - f"(soglia {int(_threshold * 100)}%) — repair in corso" + f"Risposta al {int(_gvr.coverage_score * 100)}% — ottimizzazione in corso" ), })) _missing_str = "; ".join(_gvr.missing_items[:2]) if _gvr.missing_items else _gvr.repair_hint @@ -2011,7 +1547,7 @@ class UnifiedAgentLoop: if _is_unknown: _repair_content = ( f"Rivedi e completa la risposta al seguente goal: " - f"{state.goal[:200]}. " + f"{state.goal[:300]}. " # S576: 200→300 "Assicurati di coprire tutti gli aspetti richiesti " "in modo completo, corretto e dettagliato." ) @@ -2035,50 +1571,166 @@ class UnifiedAgentLoop: _gv_ans = await asyncio.wait_for( _gv_repair_llm.chat(_gv_msgs, temperature=0.2, max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=20.0, + timeout=10.0, # S434: 20→10s ) if _gv_ans and not _gv_ans.startswith('[LLM'): - # S410: double-pass — re-verify che il repair abbia davvero migliorato + # S434: accetta repair immediatamente, re-verify fire-and-forget (telemetria) + answer = _gv_ans try: - _gvr2 = await asyncio.wait_for( - _gv.verify(state.goal, _gv_ans), timeout=8.0) - _repaired_score = _gvr2.coverage_score - _improvement = _repaired_score - _initial_score - if _improvement > -0.05: - # Accetta se migliorato o stabile (tolleranza 5%) - answer = _gv_ans - _inc_stat("goal_verify_repaired") - else: - # Repair peggiorativo: mantieni la risposta originale - _inc_stat("goal_verify_no_improvement") - _repaired_score = _initial_score - except Exception: - # Re-verify fallito: accetta il repair per non perdere il lavoro - answer = _gv_ans - _repaired_score = _initial_score _inc_stat("goal_verify_repaired") - if on_step: - _delta = _repaired_score - _initial_score - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "done", - "visibility": "progress", - "title": "Goal Verifier", - "explanation": ( - f"Goal: {int(_repaired_score * 100)}% (Δ{_delta:+.0%}) ✓" - if _delta >= 0 else - f"Goal: {int(_repaired_score * 100)}% (repair non migliorativo)" - ), - "initial_score": round(_initial_score, 3), - "repaired_score": round(_repaired_score, 3), - })) - except Exception: + except Exception: + pass + try: + _inc_stat("repair_success_count") # S453: aggregato riparazioni riuscite + except Exception: + pass + _gv_snap = _gv_ans + _is_snap = _initial_score + _gv_ref = _gv + _goal_snap = state.goal + _ostep_ref = on_step + async def _reverify_task( + _s=_gv_snap, _is=_is_snap, + _gref=_gv_ref, _g=_goal_snap, _os=_ostep_ref + ) -> None: + try: + _gvr2 = await asyncio.wait_for( + _gref.verify(_g, _s), timeout=8.0) + _rscore = _gvr2.coverage_score + _delta = _rscore - _is + if _delta < -0.05: + try: + from api.state import increment_stat as _inc_gi + _inc_gi("goal_verify_no_improvement") + except Exception: + pass + if _os: + await _maybe_await(_os({ + "action": "goal_verifier", + "status": "done", + "visibility": "progress", + "title": "Controllo qualità", + "explanation": ( + f"Qualità risposta: {int(_rscore * 100)}% ✓" + if _delta >= 0 else + f"Risposta migliorata: {int(_rscore * 100)}%" + ), + "initial_score": round(_is, 3), + "repaired_score": round(_rscore, 3), + })) + except Exception: + if _os: + try: + await _maybe_await(_os({ + "action": "goal_verifier", "status": "done", + "visibility": "progress", "title": "Controllo qualità", + "explanation": f"Miglioramento inviato ({int(_is * 100)}% completato)", + "initial_score": round(_is, 3), + "repaired_score": round(_is, 3), + })) + except Exception: + pass + # S455-P10: task supervisionato — done_callback logga eccezioni silenziate + _rv_t = asyncio.create_task(_reverify_task()) + _rv_t.add_done_callback( + lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None + ) pass # goal repair fallito — usa answer originale + except Exception: + pass # repair LLM silenzioso — answer originale invariato else: # Goal già soddisfatto al primo check — nessun repair necessario _inc_stat("goal_verify_initial_pass") + except Exception as _gv_exc: + _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624 + + # Sprint 3b ITEM 8: Browser Goal Verification — Playwright headless su app live + # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.) + # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs). + # Silent failure se Playwright non installato o URL non raggiungibile. + _DEPLOY_PATTERNS = ('.pages.dev', '.vercel.app', '.netlify.app', '.railway.app', + '.render.com', '.fly.dev', 'localhost:') + _browser_url: str | None = None + if answer and not answer.startswith('[LLM'): + import re as _re_bv + _url_candidates = _re_bv.findall(r'https?://[^\s\)\"\'<>]+', answer) + for _uc in _url_candidates: + if any(pat in _uc for pat in _DEPLOY_PATTERNS): + _browser_url = _uc.rstrip('.,;)') + break + if _browser_url and os.getenv("PLAYWRIGHT_ENABLED", "1") != "0": # S701: abilitato di default (playwright in requirements.txt) + try: + from api.browser import verify_goal_browser as _vgb + # Usa i requisiti già estratti dal blocco GoalVerifier v2 (se disponibili) + _bv_reqs = None + try: + _bv_reqs = _gv2_reqs # type: ignore[name-defined] + except NameError: + pass + if on_step: + await _maybe_await(on_step({ + "action": "browser_verifier", + "status": "running", + "visibility": "progress", + "title": "Test app in tempo reale", + "explanation": f"Verifica live: {_browser_url[:60]}…", + })) + _t0_bv = asyncio.get_running_loop().time() + _bv_result = await asyncio.wait_for( + _vgb(state.goal, _browser_url, _bv_reqs, timeout_s=25.0), + timeout=28.0, + ) + _bv_ms = (asyncio.get_running_loop().time() - _t0_bv) * 1000 + # Registra browser_ms per il phase_breakdown (Sprint 5 ITEM 14) + try: + from api.state import record_timing as _rt_bv + _rt_bv("browser_ms", _bv_ms) + except Exception: + pass + # Telemetria: esito browser verifier + try: + from api.state import increment_stat as _inc_bv + _inc_bv(f"browser_verify_{_bv_result.get('overall', 'UNKNOWN').lower()}") + except Exception: + pass + if on_step: + _bv_overall = _bv_result.get("overall", "UNKNOWN") + _bv_per = _bv_result.get("per_criterion", {}) + _bv_pass_n = sum(1 for v in _bv_per.values() if v == "PASS") + _bv_total = len(_bv_per) + _bv_summary = ( + f"{_bv_pass_n}/{_bv_total} criteri OK" + if _bv_total > 0 else "nessun criterio testato" + ) + await _maybe_await(on_step({ + "action": "browser_verifier", + "status": "done", + "visibility": "progress", + "title": "Test app in tempo reale", + "explanation": f"Verifica live: {_bv_overall} — {_bv_summary}", + "url": _browser_url, + "overall": _bv_overall, + "per_criterion": _bv_per, + })) + # Se FAIL con requisiti → aggiungi nota all'answer (non modifica il codice) + if _bv_result.get("overall") == "FAIL" and _bv_per: + _failed_criteria = [c for c, v in _bv_per.items() if v == "FAIL"] + if _failed_criteria and answer: + _bv_note = ( + f"\n\n> ⚠️ **Test app live**: verifica su `{_browser_url}` " + f"ha rilevato {len(_failed_criteria)} criterio/i non soddisfatto/i: " + # S591: _failed_criteria[:3]→[:5] — mostra più criteri falliti + + ", ".join(f"`{c}`" for c in _failed_criteria[:5]) + "." + ) + answer += _bv_note + except asyncio.TimeoutError: + try: + from api.state import increment_stat as _inc_bv2 + _inc_bv2("browser_verify_timeout") + except Exception: + pass except Exception: - pass # GoalVerifier sempre silent + pass # Browser verifier sempre silent # S393 Priority 2: Self-Healing — inline Python syntax repair loop (max 1 cycle, 20s budget) # Il fire-and-forget precedente non correggeva la risposta finale al client. @@ -2101,7 +1753,7 @@ class UnifiedAgentLoop: "action": "execution_validator_fix", "status": "running", "title": "Auto-fix sintassi", - "explanation": f"SyntaxError: {str(_syn_err)[:100]} — repair loop attivo", + "explanation": "Errore di sintassi rilevato — correzione automatica in corso", })) _fix_msgs = [ *messages, @@ -2116,7 +1768,7 @@ class UnifiedAgentLoop: _repaired = await asyncio.wait_for( _active_llm.chat(_fix_msgs, temperature=0.05, max_tokens=min(_tok_budget, 4096)), - timeout=20.0, + timeout=10.0, # S434: 20→10s ) if _repaired and not _repaired.startswith('[LLM'): answer = _repaired @@ -2127,12 +1779,17 @@ class UnifiedAgentLoop: _inc_s2("syntax_repaired") except Exception: pass + try: + from api.state import increment_stat as _inc_rs2 + _inc_rs2("repair_success_count") # S453: aggregato riparazioni riuscite + except Exception: + pass if on_step: await _maybe_await(on_step({ "action": "execution_validator_fix", "status": "done", "title": "Auto-fix completato", - "explanation": "Codice Python riparato con successo (S393)", + "explanation": "Codice corretto automaticamente ✓", })) else: try: @@ -2162,7 +1819,7 @@ class UnifiedAgentLoop: await _maybe_await(on_step({ "action": "execution_validator_fix", "status": "running", "title": "Test esecuzione", - "explanation": "Eseguo il codice per verificare errori runtime…", + "explanation": "Eseguo il codice per verificare…", })) _run_r = await asyncio.wait_for( _TR_rt["run_python"]["_fn"](code=_blk.strip()), @@ -2180,14 +1837,15 @@ class UnifiedAgentLoop: if on_step: await _maybe_await(on_step({ "action": "execution_validator_fix", "status": "running", - "title": "Runtime error rilevato", - "explanation": f"{_stderr_rt[:120]} — avvio repair…", + "title": "Errore nel codice — correzione automatica", + "explanation": "Errore nel codice rilevato — avvio correzione automatica…", })) _rt_fix_msgs = [ *messages, {"role": "assistant", "content": answer}, {"role": "user", "content": ( - f"Il codice ha prodotto un errore runtime:\n{_stderr_rt[:400]}\n" + # S593: 400→600 — stderr runtime può contenere traceback completo + f"Il codice ha prodotto un errore runtime:\n{_stderr_rt[:600]}\n" "Correggi SOLO il bug — NON cambiare la logica. " "Rispondi con la versione corretta completa." )}, @@ -2202,19 +1860,24 @@ class UnifiedAgentLoop: answer = _rt_repaired state.steps.append({ "action": "execution_validator_fix", - "output": f"Runtime error riparato: {_stderr_rt[:80]}", + "output": f"Runtime error riparato: {_stderr_rt[:300]}", # S605: 200→300 }) try: from api.state import increment_stat as _inc_rt2 _inc_rt2("runtime_repaired") except Exception: pass + try: + from api.state import increment_stat as _inc_rrt + _inc_rrt("repair_success_count") # S453: aggregato riparazioni riuscite + except Exception: + pass if on_step: await _maybe_await(on_step({ "action": "execution_validator_fix", "status": "running", - "title": "Verifica GREEN post-repair…", - "explanation": "Re-eseguo il codice riparato per confermare", + "title": "Verifica finale…", + "explanation": "Verifico che il codice funzioni correttamente", })) # S395: GREEN confirmation — re-run repaired code (max 15s) try: @@ -2240,8 +1903,8 @@ class UnifiedAgentLoop: await _maybe_await(on_step({ "action": "execution_validator_fix", "status": "done", - "title": "GREEN ✓ — codice confermato", - "explanation": "Re-esecuzione post-repair: nessun errore", + "title": "✓ Codice funzionante", + "explanation": "Nessun errore rilevato ✓", })) else: try: @@ -2254,7 +1917,7 @@ class UnifiedAgentLoop: "action": "execution_validator_fix", "status": "warning", "title": "⚠️ Repair parziale", - "explanation": f"Errore residuo: {_green_stderr[:100]}", + "explanation": "Correzione parziale — potrebbe esserci un errore residuo", })) except Exception: pass # GREEN check non bloccante @@ -2277,7 +1940,7 @@ class UnifiedAgentLoop: "action": "execution_validator_fix", "status": "done", "title": "Codice verificato ✓", - "explanation": "Esecuzione OK — nessun errore rilevato", + "explanation": "Codice eseguito correttamente ✓", })) except Exception: pass # run_python non disponibile — skip gracefully @@ -2317,7 +1980,8 @@ class UnifiedAgentLoop: _cnt[_tl] = _cnt.get(_tl, 0) - 1 _unbal = [_t for _t, _c in _cnt.items() if _c != 0] if _unbal: - _wissues.append(f"Tag non bilanciati: {', '.join(_unbal[:4])}") + # S594: _unbal[:4]→[:6] — più tag sbilanciati visibili nel report + _wissues.append(f"Tag non bilanciati: {', '.join(_unbal[:6])}") if _wblk.count(''): _wissues.append('Tag