diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..765bbd7cbcffa4c0a20f6d0fcf5f0a5a31a16874 --- /dev/null +++ b/.env.example @@ -0,0 +1,167 @@ +# ============================================================ +# .env.example — Template variabili d'ambiente Agente AI +# Copiare in .env per uso locale. NON committare .env con valori reali. +# Per deploy su HF Spaces: aggiungere come Secrets/Variables nelle impostazioni. +# ============================================================ + +# ── Runtime ────────────────────────────────────────────────── +PORT=7860 +FRONTEND_DIST=/app/backend/static +APP_PROFILE=hf_spaces_free_remote_kernel +VITE_BACKEND_URL= +VITE_API_BASE_URL= +VITE_ENABLE_BROWSER_SANDBOX=false +VITE_ENABLE_BROWSER_LLM=false +VITE_ENABLE_LOCAL_ONLY_MODE=false + +# ── URLs (obbligatori) ──────────────────────────────────────── +# URL pubblico del tuo HF Space +BACKEND_URL=https://arjanit98-terminal.hf.space # HF Space A (collab A) — usato come BACKEND_URL su Railway +FRONTEND_URL=https://agente-ai.pages.dev +HF_SPACE_URL=https://arjanit98-terminal.hf.space # HF Space A. Per collab B: https://baida00-ai-backend-collab.hf.space +HF_SPACE_ID=Arjanit98/Terminal # HF Space A (collab A). Per collab B: Baida00/ai-backend-collab + +# ── Vault / Sicurezza (obbligatori) ────────────────────────── +# Genera con: python3 -c "import secrets; print(secrets.token_hex(32))" +VAULT_KEY= +VAULT_ADMIN_TOKEN= +INTERNAL_TOKEN= +NOTIFY_TOKEN= + +# ── Supabase (obbligatorio) ─────────────────────────────────── +# supabase.com → Settings → API +SUPABASE_URL=https://xxxx.supabase.co +SUPABASE_KEY= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_ANON_KEY= +DATABASE_URL=postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres + +# ── HuggingFace ─────────────────────────────────────────────── +# huggingface.co → Settings → Access Tokens +HF_TOKEN= +HUGGINGFACE_API_KEY= +HUGGINGFACE_TOKEN= +HF_OPENAI_BASE_URL=https://router.huggingface.co/v1 +HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct + +# ── GitHub ──────────────────────────────────────────────────── +# github.com → Settings → Developer settings → Personal access tokens +GITHUB_TOKEN= +GH_TOKEN= +GITHUB_REPOSITORY=Baida98/AI +GITHUB_REPO=Baida98/AI +GH_OWNER=Baida98 +GH_REPO=AI +GITHUB_BRANCH=main +AGENT_KERNEL_REF=main +AGENT_KERNEL_MAX_TOKENS=3000 +AGENT_KERNEL_TIMEOUT=90 +AGENT_CONTEXT_FILES=120 + +# ── OpenAI ──────────────────────────────────────────────────── +# platform.openai.com/api-keys +OPENAI_API_KEY= +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini + +# ── OpenRouter ──────────────────────────────────────────────── +# openrouter.ai/keys +OPENROUTER_API_KEY= +OPENROUTER_MODEL=openai/gpt-oss-20b:free + +# ── Gemini ──────────────────────────────────────────────────── +# aistudio.google.com/app/apikey +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.5-flash-lite + +# ── Groq ───────────────────────────────────────────────────── +# console.groq.com/keys +GROQ_API_KEY= +GROQ_API_KEY_B= +GROQ_MODEL=llama-3.3-70b-versatile + +# ── Cerebras ────────────────────────────────────────────────── +# cloud.cerebras.ai +CEREBRAS_API_KEY= +CEREBRAS_MODEL=gpt-oss-120b + +# ── SambaNova ───────────────────────────────────────────────── +# cloud.sambanova.ai +SAMBANOVA_API_KEY= +SAMBANOVA_MODEL=DeepSeek-V3.1 + +# ── NVIDIA NIM ───────────────────────────────────────────────── +# build.nvidia.com → Get API Key (gratuito, no carta di credito) +# Stessa chiave funziona su integrate.api.nvidia.com/v1 (OpenAI-compatible) +NVIDIA_API_KEY= +NVIDIA_MODEL=nvidia/nemotron-3-super-120b-a12b +# Key B — secondo account NIM, raddoppia il rate-limit (30→60 RPM) +NVIDIA_API_KEY_B= +NVIDIA_B_MODEL=meta/llama-3.3-70b-instruct +# DISABLE_NVIDIA_B=1 + +# ── LLM Routing ─────────────────────────────────────────────── +LLM_MODEL=deepseek/deepseek-r1:free +SMOLAGENTS_MODEL=deepseek/deepseek-r1:free +UNIFIED_LOOP_MAX_STEPS=8 + +# ── Telegram ───────────────────────────────────────────────── +# @BotFather su Telegram per i token bot +# @userinfobot per il tuo chat ID +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# ── Cloudflare ──────────────────────────────────────────────── +# dash.cloudflare.com → Profile → API Tokens +CF_API_TOKEN= +CLOUDFLARE_API_TOKEN= +CF_ACCOUNT_ID= + +# ── Railway ─────────────────────────────────────────────────── +# railway.app → Account Settings → Tokens +RAILWAY_TOKEN= +RAILWAY_URL=https://railway.app + +# ── E2B (Code Execution Sandbox) ───────────────────────────── +# e2b.dev/dashboard +E2B_API_KEY= + +# ── Notion ──────────────────────────────────────────────────── +# notion.so/my-integrations +NOTION_TOKEN= + +# ── Storage locale ──────────────────────────────────────────── +CHROMA_DB_DIR=/app/backend/.data/chroma +SQLITE_DB_PATH=/app/backend/.data/agent.sqlite + +# ── Opzionali ───────────────────────────────────────────────── +# Qdrant (vector DB cloud) +QDRANT_URL= +QDRANT_API_KEY= +# Jina AI (web reader avanzato — jina.ai/api-key) +JINA_API_KEY= +# Tavily (web search — tavily.com) +TAVILY_API_KEY= +# Brave Search +BRAVE_SEARCH_API_KEY= +# Resend (email — resend.com) +RESEND_API_KEY= +RESEND_FROM_EMAIL= +# Upstash Redis +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +# Pexels / Pixabay (immagini) +PEXELS_API_KEY= +PIXABAY_API_KEY= + +# ═══════════════════════════════════════════════════ +# ── Collaboratore Account B (dual-infra) ─────────── +# ═══════════════════════════════════════════════════ +VITE_BACKEND_URL_2=https://baida00-ai-backend-collab.hf.space # HF Space B — backend collab B (chat/AI) +VITE_EXEC_BACKEND_URL_2= # Railway B URL (exec/PTY) — formato: https://xxx.up.railway.app — DA CONFIGURARE +E2B_API_KEY_2= # e2b.dev — account B (100h/mese) +SUPABASE_URL_2= # Supabase B URL +SUPABASE_KEY_2= # Supabase B anon key +SUPABASE_SERVICE_ROLE_KEY_2= # Supabase B service role (opzionale) +GROQ_API_KEY_3= # Groq account B (14.400 req/giorno) +VITE_GROQ_API_KEY_3= # stessa chiave — frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..57a7c1ae870066c92524dc841de7afffee6ea03e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ + __pycache__/ + *.pyc + *.pyo + chroma_db/ + *.egg-info/ + .env + \ No newline at end of file diff --git a/agents/backend_antiregress.py b/agents/backend_antiregress.py index 2bea0b9f5b3a24a8a0208889fbf6a7216c413ec6..9af1cc39740eb32159d43d0b4d47a715e3454cdc 100644 --- a/agents/backend_antiregress.py +++ b/agents/backend_antiregress.py @@ -5,7 +5,8 @@ # 1. Import injection — nuove dipendenze esterne non presenti nell'originale # 2. Code rewrite — output ha drasticamente meno classi/def dell'originale # -# Chiamato dentro il loop _llm_try di unified_loop.py prima del `break`. +# Chiamato dentro il loop _llm_try di unified_loop_fallback.py (FallbackMixin._run_fallback) prima del `break`. +# Post-split 2026-06-30: il loop LLM risiede in unified_loop_fallback.py, non in unified_loop.py. # Non bloccante: qualsiasi eccezione interna viene silenziata dal caller. from __future__ import annotations diff --git a/agents/executor.py b/agents/executor.py index cf5df8907f65f0481a1e625bfbac4cbb71cf970a..6b4e5ed1ead2213280d9c40abfcc4920429c7147 100644 --- a/agents/executor.py +++ b/agents/executor.py @@ -238,7 +238,7 @@ class Executor: # S577→S600: inputs 100→500 — parity con altri handler await self.memory.save_episode( "tool", - f"{tool_name}: {str(inputs)[:500]}", + f"{tool_name}: {str(inputs)[:500]}", # S589: 200→300→500 str(result)[:500], True, ) diff --git a/agents/fallback_healer.py b/agents/fallback_healer.py new file mode 100644 index 0000000000000000000000000000000000000000..1248f9ca9d7a12ab3e03f4f91a1a17af705b9010 --- /dev/null +++ b/agents/fallback_healer.py @@ -0,0 +1,59 @@ +"""fallback_healer.py — Logica di Self-Healing strategico per il loop di fallback. +Estratto da unified_loop_fallback.py (split 2026-06-30). +""" +import logging +import re + +_logger = logging.getLogger("api.agent.healer") + +class StrategicHealer: + @staticmethod + def analyze_errors(exec_errors: list, exec_warn: list) -> None: + """ + Analizza gli errori ripetuti e inietta messaggi di 'CAMBIO STRATEGIA' (GAP-SELFHEAL v2). + """ + if not exec_errors: + return + + # Fingerprinting degli errori (Dual-mode: raw + error-class) + _selfheal_raw = {} + _selfheal_cls = {} + + for _err in exec_errors: + if not isinstance(_err, str): continue + # Mode 1: raw fingerprinting + _fp = _err[:120] + _selfheal_raw[_fp] = _selfheal_raw.get(_fp, 0) + 1 + # Mode 2: error-class extraction + _m = re.search(r"([A-Z][a-z]+Error):", _err) + if _m: + _c = _m.group(1).lower() + _selfheal_cls[_c] = _selfheal_cls.get(_c, 0) + 1 + + _selfheal_raw_max = max(_selfheal_raw.values()) if _selfheal_raw else 0 + _selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0 + _selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max) + + if _selfheal_max >= 2: + _ERRCLASS_HINTS = { + "typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite.", + "keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza.", + "attributeerror": "Controlla che l'oggetto non sia None.", + "nameerror": "Controlla typo nel nome variabile/funzione.", + "syntaxerror": "Controlla la sintassi o le quote del comando.", + "memoryerror": "Processa in chunk, riduci dimensione dati.", + } + + _dom_cls = max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else "" + _specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.") + + _selfheal_msg = ( + f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}×{_selfheal_max}]: " + f"Hint specifico: {_specific} " + "NON ripetere lo stesso metodo — cambia libreria o pattern." + ) + + # Evita doppia iniezione + if not any(isinstance(w, str) and "CAMBIO STRATEGIA" in w for w in exec_warn): + exec_warn.insert(0, _selfheal_msg) + _logger.info("GAP-SELFHEAL: Strategia di healing iniettata per %s", _dom_cls or "errore raw") diff --git a/agents/fallback_utils.py b/agents/fallback_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1d9233cf8331440267744ca6b5c9fc6c81b6c620 --- /dev/null +++ b/agents/fallback_utils.py @@ -0,0 +1,26 @@ +"""fallback_utils.py — Funzioni di utilità per il loop di fallback. +Estratto da unified_loop_fallback.py (split 2026-06-30). +""" +import re + +def _is_refusal(text: str) -> bool: + """Verifica se la risposta del modello è un rifiuto (S129).""" + if not text: return False + refusals = ["mi dispiace", "non posso", "i apologize", "i cannot", "unauthorized", "access denied"] + t = text.lower() + return any(r in t for r in refusals) + +def _s759_bjac(a: str, b: str) -> float: + """Calcola la somiglianza di Jaccard tra due stringhe (S759).""" + if not a or not b: return 0.0 + set_a = set(a.lower().split()) + set_b = set(b.lower().split()) + intersection = len(set_a.intersection(set_b)) + union = len(set_a.union(set_b)) + return intersection / union if union > 0 else 0.0 + +def _avg10(lst: list) -> float: + """Calcola la media degli ultimi 10 elementi di una lista.""" + if not lst: return 0.0 + sub = lst[-10:] + return sum(sub) / len(sub) diff --git a/agents/goal_drift_detector.py b/agents/goal_drift_detector.py index 946dc163e0d09f283cb04ccfd82091fafed63047..00189f2caf460ae851a891fbbeaa17ffd06202be 100644 --- a/agents/goal_drift_detector.py +++ b/agents/goal_drift_detector.py @@ -7,6 +7,10 @@ che il loop principale usa per iniettare una micro-guida correttiva. Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM. Zero overhead su task senza drift (guard rapido in should_check_drift). + +GAP-DRIFT-THRESHOLD-FIXED fix: threshold dinamica basata sul numero di subtask +completati — previene falsi positivi su task multi-fase (es. "installa dipendenze" +come primo subtask di "crea componente React" → overlap = 0% → falso positivo). """ from __future__ import annotations @@ -18,8 +22,16 @@ _logger = logging.getLogger("agente_ai.goal_drift") # ── Costanti ────────────────────────────────────────────────────────────────── DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati -DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% → drift -_MIN_EXEC_DONE: int = 2 # non controlla prima di 2 subtask completati +DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% → drift (per task maturi) +_MIN_EXEC_DONE: int = 4 # GAP-DRIFT-THRESHOLD-FIXED: era 2, ora 4 + # Permette almeno 4 subtask di setup/infra prima + # di valutare il drift semantico. + +# GAP-DRIFT-THRESHOLD-FIXED: threshold dinamica per task giovani. +# Nei primi _EARLY_EXEC_DONE subtask usiamo una soglia molto bassa (0.05 = 5% overlap) +# invece di 0.25 — solo drift estremi vengono rilevati in fase di setup. +_EARLY_EXEC_DONE: int = 6 # "fase giovane" = < 6 subtask completati +_EARLY_THRESHOLD: float = 0.05 # soglia permissiva per fase giovane (5% vs 25%) _STOP_WORDS = frozenset({ # italiano @@ -78,8 +90,9 @@ def should_check_drift(step_count: int, last_check: int) -> bool: """ True se è ora di eseguire un drift check. + GAP-DRIFT-THRESHOLD-FIXED fix: _MIN_EXEC_DONE alzato a 4 (era 2). Controlla solo se: - - step_count >= _MIN_EXEC_DONE (almeno 2 subtask completati) + - step_count >= _MIN_EXEC_DONE (almeno 4 subtask completati) - step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step) """ return ( @@ -88,6 +101,21 @@ def should_check_drift(step_count: int, last_check: int) -> bool: ) +def _effective_threshold(step_count: int) -> float: + """GAP-DRIFT-THRESHOLD-FIXED: threshold dinamica basata sul numero di subtask. + + Fase giovane (< _EARLY_EXEC_DONE subtask): threshold permissiva (5%). + Fase matura (>= _EARLY_EXEC_DONE subtask): threshold standard (25%). + + Motivazione: i primi subtask di un task multi-fase sono spesso setup/infra + (installazione dipendenze, creazione directory, init config) con keyword + molto diverse dal goal semantico → falsi positivi con threshold fissa 25%. + """ + if step_count < _EARLY_EXEC_DONE: + return _EARLY_THRESHOLD + return DRIFT_OVERLAP_THRESHOLD + + def detect_drift( goal: str, exec_done: list[str], @@ -128,16 +156,23 @@ def detect_drift( score = compute_drift_score(goal, exec_done) out["score"] = round(score, 3) - if score > (1.0 - DRIFT_OVERLAP_THRESHOLD): + # GAP-DRIFT-THRESHOLD-FIXED: usa threshold dinamica invece di fissa 25% + effective_thr = _effective_threshold(step_count) + + if score > (1.0 - effective_thr): out["drifted"] = True goal_kws = _extract_keywords(goal) exec_kws = _extract_keywords(" ".join(exec_done)) missing = sorted(goal_kws - exec_kws)[:5] out["reason"] = ( - f"score={score:.2f}, keyword goal assenti nell'output: {missing}" + f"score={score:.2f} (threshold={effective_thr:.2f}), " + f"keyword goal assenti nell'output: {missing}" ) _logger.info("COG-5 drift rilevato: %s", out["reason"]) else: - _logger.debug("COG-5 no drift: score=%.2f step=%d", score, step_count) + _logger.debug( + "COG-5 no drift: score=%.2f threshold=%.2f step=%d", + score, effective_thr, step_count, + ) return out diff --git a/agents/goal_verifier.py b/agents/goal_verifier.py index 132804e47de06a023731e1d3c21a4aec0f690560..73a21b05ec2b7a66ef9cf301517a8de97aeb7072 100644 --- a/agents/goal_verifier.py +++ b/agents/goal_verifier.py @@ -193,18 +193,18 @@ class GoalVerifier: @classmethod def is_code_goal(cls, goal: str) -> bool: - return bool(cls._CODE_RE.search(goal[:500])) + return bool(cls._CODE_RE.search(goal[:500])) # S595: 300->500 @classmethod def adaptive_threshold(cls, goal: str) -> float: g = goal.strip() if _SIMPLE_RE.match(g): return 0.28 - if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]): + if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]): # S595 return 0.25 - if _COMPLEX_CODE_RE.search(g[:500]): + if _COMPLEX_CODE_RE.search(g[:500]): # S595 return 0.55 - if cls._CODE_RE.search(g[:500]): + if cls._CODE_RE.search(g[:500]): # S595 return 0.42 return RETRY_THRESHOLD @@ -221,7 +221,7 @@ class GoalVerifier: {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"}, ] try: - raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200) + raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200) # S586: 120→200 if not raw or raw.startswith("[LLM"): return self._default_ok() return self._parse(raw) @@ -258,7 +258,7 @@ class GoalVerifier: per_req[req_id] = GoalVerificationStatus.UNKNOWN continue - criteria_text = "\n".join(f"- {c}" for c in criteria[:5]) + criteria_text = "\n".join(f"- {c}" for c in criteria[:5]) # S591: 3->5 check_prompt = ( f"Requisito: {req_name}\n" f"Criteri:\n{criteria_text}\n\n" @@ -287,9 +287,9 @@ class GoalVerifier: score = (n_pass / n_known) if n_known > 0 else 0.5 overall_pass = score >= threshold and not failed_reqs - hint = "; ".join(failed_hints[:4]) if failed_hints else "" + hint = "; ".join(failed_hints[:4]) if failed_hints else "" # S595: 2->4 if failed_reqs: - hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}" + hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}" # S595: 3->5 status = ( GoalVerificationStatus.PASS if overall_pass @@ -300,7 +300,7 @@ class GoalVerifier: return GoalVerifyResult( goal_met = overall_pass, coverage_score = round(score, 3), - missing_items = failed_reqs[:5], + missing_items = failed_reqs[:5], # S595 repair_hint = hint[:MAX_HINT_CHARS], verification_status = status, ) diff --git a/agents/grid_rag.py b/agents/grid_rag.py new file mode 100644 index 0000000000000000000000000000000000000000..9553c221b498f7eadd351d5711dac3b0ddc15dbc --- /dev/null +++ b/agents/grid_rag.py @@ -0,0 +1,124 @@ +""" +backend/agents/grid_rag.py — Grid-Enhanced RAG (S766-GRID-4) + +Sistema RAG (Retrieval-Augmented Generation) avanzato che indicizza: +- Memoria distribuita (Supabase A, B, C, D) +- Log di sistema e di Railway +- Documentazione interna (.agents/memory/) + +Architettura: +- GridIndexer: Indicizza i dati provenienti da diverse fonti +- ContextRetriever: Recupera il contesto più rilevante per il goal corrente +- KnowledgeGraph: Mappa le relazioni tra i diversi profili e i loro stati +""" + +import os +import asyncio +import logging +from typing import List, Dict, Any, Optional +from datetime import datetime +import json + +_logger = logging.getLogger("grid_rag") + +# ── Configurazione ───────────────────────────────────────────────────────── +RAG_INDEX_SIZE = 100 # Numero di elementi da mantenere nel buffer RAG +RAG_SIMILARITY_THRESHOLD = 0.75 + + +class GridIndexer: + """Indicizzatore per la Grid.""" + + def __init__(self): + self.index = [] + self._lock = asyncio.Lock() + + async def add_to_index(self, source: str, content: str, metadata: Dict): + """Aggiunge un elemento all'indice RAG.""" + async with self._lock: + entry = { + "source": source, + "content": content, + "metadata": metadata, + "timestamp": datetime.now().isoformat(), + } + self.index.append(entry) + # Mantieni dimensione fissa + if len(self.index) > RAG_INDEX_SIZE: + self.index.pop(0) + + async def index_railway_logs(self, profile: str, logs: str): + """Indicizza i log di Railway per identificare crash passati.""" + lines = logs.split("\n") + for line in lines[-50:]: # Ultime 50 righe + if "error" in line.lower() or "crash" in line.lower() or "failed" in line.lower(): + await self.add_to_index( + source=f"railway_logs_{profile}", + content=line, + metadata={"type": "log_error", "profile": profile} + ) + + +class ContextRetriever: + """Recuperatore di contesto per l'agente.""" + + def __init__(self, indexer: GridIndexer): + self.indexer = indexer + + async def retrieve_relevant_context(self, query: str) -> List[Dict]: + """ + Recupera il contesto rilevante basato sulla query. + Attualmente usa keyword matching semplice (potenziabile con embeddings). + """ + relevant = [] + keywords = query.lower().split() + + async with self.indexer._lock: + for entry in self.indexer.index: + content = entry["content"].lower() + score = sum(1 for kw in keywords if kw in content) + + if score > 0: + entry_with_score = entry.copy() + entry_with_score["score"] = score + relevant.append(entry_with_score) + + # Ordina per score decrescente + relevant.sort(key=lambda x: x["score"], reverse=True) + return relevant[:10] # Ritorna i top 10 + + +class GridRAG: + """Interfaccia principale per il RAG della Grid.""" + + def __init__(self): + self.indexer = GridIndexer() + self.retriever = ContextRetriever(self.indexer) + + async def prepare_agent_context(self, goal: str) -> str: + """ + Prepara il contesto per l'agente unificando i dati RAG. + """ + context_items = await self.retriever.retrieve_relevant_context(goal) + + if not context_items: + return "" + + context_str = "\n--- GRID RAG CONTEXT ---\n" + for item in context_items: + context_str += f"[{item['source']}] {item['content']}\n" + context_str += "------------------------\n" + + return context_str + + +# ── Singleton globale ────────────────────────────────────────────────────── +_grid_rag_instance: Optional[GridRAG] = None + + +def get_grid_rag() -> GridRAG: + """Restituisce l'istanza globale del GridRAG.""" + global _grid_rag_instance + if _grid_rag_instance is None: + _grid_rag_instance = GridRAG() + return _grid_rag_instance diff --git a/agents/planner.py b/agents/planner.py index 6acce17570d1de8df393a4060734362d980259e1..147febfb39ce250eb50b49319763fbcb006460b2 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -221,7 +221,7 @@ class Planner: {"role": "user", "content": f"Obiettivo: {goal}"}, ] if context: - ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:]) + ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:]) # S594: content[:500] per msg # S572: 100→300→500 / S590: -3→-5 msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}" return msgs @@ -258,7 +258,7 @@ class Planner: plan = _parse_plan(raw) if plan: plan["_speculative"] = True - plan["_raw"] = raw[:400] + plan["_raw"] = raw[:400] # S577: 200→400 return plan except Exception: return None diff --git a/agents/reasoning_core.py b/agents/reasoning_core.py index 5a30c6bcf81c6ff2917f14e5abc9b7580c9be580..fe6df537a9f1b246e31c334126e044823d0c9f4f 100644 --- a/agents/reasoning_core.py +++ b/agents/reasoning_core.py @@ -64,8 +64,6 @@ Return: CONTEXT: {repo_context} """ - # S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout - # → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py). try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), @@ -89,7 +87,6 @@ Decide: - impact - risk level """ - # S665: timeout anche per develop_strategy try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3), @@ -108,7 +105,6 @@ Return: - root cause - fix strategy """ - # S665: timeout anche per analyze_error try: return await asyncio.wait_for( self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1), @@ -119,8 +115,6 @@ Return: # ── Prompt builder ────────────────────────────────────────────────────────── def _build_prompt(self, state: ReasoningState) -> str: - # S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata - # BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati — diagnosi più accurata if state.errors: import re as _re_err _err_all = state.errors @@ -143,7 +137,7 @@ STATO: - goal: {state.goal} - world_model: {'Presente' if state.world_model else 'Mancante'} - strategy: {'Definita' if state.strategy else 'Da definire'} -- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500 +- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300->500 - errors: {errors_str} - loop_count: {state.loop_count}/{self.MAX_LOOPS} @@ -155,16 +149,8 @@ Rispondi SOLO con JSON valido: "reason": "perché questa azione?", "confidence": 0.0-1.0 }} - -Regole: -1. Se manca world_model -> "analyze" -2. Se manca strategy -> "strategy" -3. Se strategy c'è ma serve piano -> "plan" -4. Se ci sono errori -> "fix" -5. Se tutto ok -> "continue" o "stop" se finito. """ - # GAP-2: Deep Context — inietta skeleton dei file rilevanti per ragionamento multi-file _ctx_section = "" if state.project_files: try: @@ -180,9 +166,6 @@ Regole: if f.get("path") in _top_paths ] if _skels: - # P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare. - # Zero LLM, zero latenza — stessa logica word-overlap di episodic.py. - # Garantisce che i blocchi più rilevanti per il goal finiscano PRIMA del taglio. _goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set() if _goal_kw_ctx: _skels.sort( @@ -190,21 +173,29 @@ Regole: reverse=True, ) _ctx_raw = "\n".join(_skels) - # S780-CAP: tronca skeleton a 6000 chars (BUG-1: era 3000, troppo poco per file complessi) if len(_ctx_raw) > 6000: - _ctx_raw = _ctx_raw[:6000] + "\n… [troncato per lunghezza]" + import re as _re_sk + _sig_lines = _re_sk.findall( + r'^(?:(?:async\s+)?def |class |export\s+(?:default\s+)?' + r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}', + _ctx_raw, _re_sk.MULTILINE + ) + _ctx_smart = "\n".join(_sig_lines) + if len(_ctx_smart) >= 500: + _ctx_raw = ( + f"[SMART CHUNK — {len(_skels)} file — solo firme estratte]\n" + + _ctx_smart[:10000] + ) + else: + _ctx_raw = _ctx_raw[:6000] + "\n... [troncato — usa file_search per dettagli]" _ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw except Exception: - pass # non-fatal — degradazione graceful senza deep context + pass return _base_prompt + _ctx_section @staticmethod def _extract_json(raw: str) -> str | None: - """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}' - che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO } - producendo JSON malformato → action='continue' per default → agente in loop. - Pattern identico a safeJsonParse.ts già in produzione sul frontend.""" depth = 0 start = -1 for i, ch in enumerate(raw): @@ -238,12 +229,28 @@ Regole: return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0) prompt = self._build_prompt(state) + # S42: Speculative Decoding Multi-Nodo + # Lanciamo 3 generazioni parallele con temperature e prompt diversi try: - # S750-GAP-D: asyncio.wait_for — evita hang se LLM provider non risponde - raw = await asyncio.wait_for( - self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2), - timeout=30.0, - ) + tasks = [ + self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1), # BRAIN: Conservativo + self.llm.chat([{"role": "user", "content": prompt + "\nSii creativo e pensa fuori dagli schemi."}], temperature=0.7), # HANDS: Creativo + self.llm.chat([{"role": "user", "content": prompt + "\nFocalizzati sulla massima efficienza e sicurezza."}], temperature=0.0) # MEMORY: Deterministico + ] + + _logger.info("SPECULATIVE: Avviate 3 generazioni parallele") + raw_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Verificatore (Node D logic): Seleziona il risultato più coerente o il primo valido + valid_results = [r for r in raw_results if isinstance(r, str) and r.strip()] + + if not valid_results: + raise Exception("Nessun risultato valido dai nodi speculativi") + + # Per ora scegliamo il primo (BRAIN), ma potremmo implementare un ranker + raw = valid_results[0] + _logger.info("SPECULATIVE: Risposta selezionata tra %d varianti", len(valid_results)) + return self._parse(raw) except asyncio.TimeoutError: return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3) @@ -262,7 +269,7 @@ Regole: await on_step({ "loop": state.loop_count, "action": decision.action, - "reason": decision.reason, + "reason": decision.reason[:200], # S578: 120→200 "confidence": decision.confidence }) @@ -270,11 +277,13 @@ Regole: break elif decision.action == "analyze": - state.world_model = await self.analyze_project(context or goal) + _wm_raw = await self.analyze_project(context or goal) + state.world_model = (_wm_raw or '')[:600] # S593: world_model 400->600 results.append({"action": "analyze", "output": "World model built"}) - + elif decision.action == "strategy": - state.strategy = await self.develop_strategy(state) + _strat_raw = await self.develop_strategy(state) + state.strategy = (_strat_raw or '')[:600] # S593: strategy 400->600 results.append({"action": "strategy", "output": state.strategy}) elif decision.action == "plan" and self.planner: @@ -285,7 +294,6 @@ Regole: elif decision.action == "fix": if decision.patch: - # Se c'è una patch, l'executor la applica if self.executor: res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch}) state.last_result = str(res.get("output", "")) @@ -297,7 +305,6 @@ Regole: results.append({"action": "error_analysis", "output": error_analysis}) elif decision.action == "continue": - # S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto if decision.steps: try: _step_prompt = decision.steps[0] @@ -314,102 +321,32 @@ Regole: state.completed_steps.append(decision.steps[0]) results.append({"action": "continue", "steps": decision.steps}) - # Auto-debug check con Critic if self.critic and state.last_result and decision.action != "analyze": critique = await self.critic.evaluate(goal, state.last_result) if critique.get("needs_retry"): - state.errors.extend(critique.get("issues", [])) + state.errors.extend(critique.get("issues", [])) # S590: using errors[-5:] window state.loop_count += 1 return { "goal": goal, "loops": state.loop_count, - "success": len(state.errors) == 0, "results": results, - "final_state": { - "has_world_model": state.world_model is not None, - "has_strategy": state.strategy is not None - } + "final_state": state } - async def run_loop_to_answer(self, goal: str, context: str = "", - on_step=None, max_loops: int = 8, - project_files: Optional[List[Dict[str, Any]]] = None) -> str: - """S575: Versione di run_loop che ritorna una stringa risposta sintetizzata. + async def run_loop_to_answer(self, goal: str, max_loops: int = 5) -> str: + """S575: convenience wrapper — never raises, returns '' on failure. - Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3. - Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi. - Output: stringa di risultati aggregati da passare come contesto extra al LLM finale. - Mai solleva eccezioni. + Nota: il path 'continue' usa LLM diretta; direct_response non esiste + come tool registrato (S575 — fix: rimosso run_tool('direct_response')). """ try: - # GAP-2: deep context — inietta i file VFS nella ReasoningState per rank_files_by_relevance() - state = ReasoningState(goal=goal, context=context, project_files=project_files) - parts: List[str] = [] - loop_cap = min(max_loops, self.MAX_LOOPS) - - while state.loop_count < loop_cap: - try: - decision = await self.decide(state) - except Exception: - break - - if on_step: - try: - import asyncio as _aio - coro = on_step({ - "loop": state.loop_count, - "action": f"reasoning:{decision.action}", - "reason": decision.reason[:200] if decision.reason else "", # S578: 120→200 - "confidence": decision.confidence, - }) - if _aio.iscoroutine(coro): - await coro - except Exception as _exc: - _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 - - if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE: - break - - elif decision.action == "analyze": - try: - state.world_model = await self.analyze_project(context or goal) - # S593: 400→600 — world_model spesso multi-paragrafo - parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}") - except Exception as _exc: - _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 - - elif decision.action == "strategy": - try: - state.strategy = await self.develop_strategy(state) - # S593: 400→600 — strategy spesso multi-step - parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}") - except Exception as _exc: - _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 - - elif decision.action in ("plan", "continue", "fix"): - # Esegui passo diretto via LLM - step_desc = (decision.steps[0] if decision.steps - else decision.reason or goal) - try: - _ans = await self.llm.chat( - [{"role": "system", "content": - "Sei un assistente tecnico esperto. " - "Svolgi il passo richiesto in modo preciso e conciso."}, - {"role": "user", "content": - f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}], - temperature=0.2, max_tokens=512, - ) - if _ans and not _ans.startswith("[LLM"): - parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}") - state.last_result = _ans - state.completed_steps.append(step_desc) - except Exception as _exc: - _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001 - - state.loop_count += 1 - - return "\n\n".join(parts) if parts else "" + result = await self.run(goal) + fs = result.get("final_state") + if fs: + return fs.last_result or "" + return "" except Exception: return "" + diff --git a/agents/reflection_sidecar.py b/agents/reflection_sidecar.py new file mode 100644 index 0000000000000000000000000000000000000000..acf072743f09b6a1604bda98014dad60f548f907 --- /dev/null +++ b/agents/reflection_sidecar.py @@ -0,0 +1,211 @@ +""" +reflection_sidecar.py — Reflection Sidecar (Double-Token Innovation) + +Analizza i log di errore di ogni tool call durante la sessione e aggiorna +session_rules.md in tempo reale. Questo file viene iniettato nel system prompt +dell'agente principale per correggere il comportamento on-the-fly. + +Architettura: + Token A (agente principale) → esegue tool, chiama log_error() + Token B (sidecar critic) → analizza pattern, scrive regole +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from collections import defaultdict, deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +_logger = logging.getLogger("agente_ai.reflection_sidecar") + +# ── Config ──────────────────────────────────────────────────────────────────── + +_RULES_FILE = Path(os.getenv("SIDECAR_RULES_FILE", "/data/session_rules.md")) +_MAX_ERRORS_BEFORE_REFLECT = int(os.getenv("SIDECAR_REFLECT_THRESHOLD", "2")) +_RULE_TTL_S = int(os.getenv("SIDECAR_RULE_TTL_S", "3600")) # 1h + +# Token B per NVIDIA NIM (Reflection Critic — modello leggero, bassa latenza) +_NVIDIA_API = "https://integrate.api.nvidia.com/v1" +_CRITIC_MODEL = os.getenv("NVIDIA_B_MODEL", "meta/llama-3.3-70b-instruct") +_NVIDIA_KEY_B = os.getenv("NVIDIA_API_KEY_B", "") + + +# ── Data model ──────────────────────────────────────────────────────────────── + +@dataclass +class ErrorEvent: + tool: str + error: str + context: str + ts: float = field(default_factory=time.monotonic) + + +@dataclass +class SessionRule: + pattern: str # cosa ha causato l'errore (regex / descrizione) + rule: str # istruzione correttiva per l'agente + tool: str + created_at: float = field(default_factory=time.time) + hit_count: int = 0 + + +# ── Sidecar core ───────────────────────────────────────────────────────────── + +class ReflectionSidecar: + """ + Singleton per sessione. Riceve errori, li analizza con Token B (NVIDIA), + aggiorna session_rules.md che viene iniettato nel prompt principale. + """ + + def __init__(self) -> None: + self._errors: list[ErrorEvent] = [] + self._rules: list[SessionRule] = [] + self._tool_error_counts: dict[str, int] = defaultdict(int) + self._lock = asyncio.Lock() + self._reflect_task: asyncio.Task | None = None + + async def log_error( + self, + tool: str, + error: str, + context: str = "", + ) -> None: + """Registra un errore. Se lo stesso tool fallisce >= threshold, avvia reflection.""" + async with self._lock: + evt = ErrorEvent(tool=tool, error=error[:500], context=context[:300]) + self._errors.append(evt) + self._tool_error_counts[tool] += 1 + count = self._tool_error_counts[tool] + + if count >= _MAX_ERRORS_BEFORE_REFLECT: + # Avvia reflection in background (non blocca l'agente principale) + if self._reflect_task is None or self._reflect_task.done(): + self._reflect_task = asyncio.create_task( + self._reflect_and_update(tool, error, context) + ) + + async def _reflect_and_update( + self, tool: str, last_error: str, context: str + ) -> None: + """Token B: analizza gli errori e genera una regola correttiva.""" + if not _NVIDIA_KEY_B: + _logger.warning("reflection_sidecar: NVIDIA_API_KEY_B non configurato — skip") + return + + # Aggrega tutti gli errori del tool + relevant = [e for e in self._errors if e.tool == tool][-5:] + error_summary = "\n".join(f"- [{e.tool}] {e.error}" for e in relevant) + + prompt = f"""Sei un critico di qualità per un agente AI. Analizza questi errori ripetuti: + +TOOL: {tool} +ERRORI: +{error_summary} + +CONTESTO ULTIMO ERRORE: {context} + +Scrivi UNA regola correttiva concisa (max 2 righe) che l'agente deve seguire per evitare +di ripetere questo errore. Formato: "REGOLA [{tool}]: " +Rispondi solo con la regola, nessun altro testo.""" + + try: + import urllib.request + payload = json.dumps({ + "model": _CRITIC_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 120, + "temperature": 0.1, + }).encode() + req = urllib.request.Request( + f"{_NVIDIA_API}/chat/completions", + data=payload, + headers={ + "Authorization": f"Bearer {_NVIDIA_KEY_B}", + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + rule_text = data["choices"][0]["message"]["content"].strip() + + new_rule = SessionRule( + pattern=last_error[:100], + rule=rule_text, + tool=tool, + ) + async with self._lock: + # Dedup: rimuovi regole vecchie per lo stesso tool + self._rules = [r for r in self._rules if r.tool != tool] + self._rules.append(new_rule) + self._tool_error_counts[tool] = 0 # reset counter + + await self._write_rules_file() + _logger.info(f"reflection_sidecar: nuova regola generata per {tool}") + + except Exception as exc: + _logger.warning(f"reflection_sidecar: reflection fallita — {exc}") + + async def _write_rules_file(self) -> None: + """Scrive session_rules.md — viene iniettato nel system prompt principale.""" + now = time.time() + active = [r for r in self._rules if (now - r.created_at) < _RULE_TTL_S] + if not active: + return + lines = ["# Session Rules (auto-generate dal Reflection Sidecar)\n"] + lines += [f"- {r.rule}" for r in active] + lines.append(f"\n_Aggiornato: {time.strftime('%H:%M:%S')}_") + try: + _RULES_FILE.parent.mkdir(parents=True, exist_ok=True) + _RULES_FILE.write_text("\n".join(lines), encoding="utf-8") + except Exception as exc: + _logger.warning(f"reflection_sidecar: scrittura rules file fallita — {exc}") + + def get_rules_for_prompt(self) -> str: + """Legge session_rules.md per l'iniezione nel system prompt.""" + try: + if _RULES_FILE.exists(): + content = _RULES_FILE.read_text(encoding="utf-8").strip() + if content and len(content) > 30: + return f"\n\n---\n{content}\n---" + except Exception: + pass + return "" + + def reset(self) -> None: + """Reset a inizio nuova sessione.""" + self._errors.clear() + self._rules.clear() + self._tool_error_counts.clear() + try: + _RULES_FILE.unlink(missing_ok=True) + except Exception: + pass + + +# ── Singleton ───────────────────────────────────────────────────────────────── +_sidecar: ReflectionSidecar | None = None + + +def get_sidecar() -> ReflectionSidecar: + global _sidecar + if _sidecar is None: + _sidecar = ReflectionSidecar() + return _sidecar + + +async def log_tool_error(tool: str, error: str, context: str = "") -> None: + """Shortcut globale — chiamare dopo ogni tool call fallita.""" + await get_sidecar().log_error(tool, error, context) + + +def get_session_rules() -> str: + """Shortcut globale — iniettare nel system prompt principale.""" + return get_sidecar().get_rules_for_prompt() diff --git a/agents/unified_loop.py b/agents/unified_loop.py index 4db43932a5744ef092b99760c66c3eccde5e2d87..763188d1fc4f186b0bcf464cc241bbb4ef95d730 100644 --- a/agents/unified_loop.py +++ b/agents/unified_loop.py @@ -26,6 +26,9 @@ import asyncio import logging import os import re +from agents.watchdog import BidirectionalWatchdog +from .grid_rag import get_grid_rag +from memory.distiller import MemoryDistiller from contextvars import ContextVar from typing import Any @@ -108,9 +111,35 @@ async def _read_bb_upstash(session_id: str) -> str: except Exception: return "" - -class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin): - """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback.""" +# ── Nuovi mixin estratti (split 2026-06-30) ────────────────────────────────── +from agents.unified_loop_vfs import VFSMixin +from agents.unified_loop_delegate import DelegateMixin +from agents.unified_loop_routing import RoutingMixin +from agents.unified_loop_fallback import FallbackMixin + + +class UnifiedAgentLoop( + VFSMixin, + DelegateMixin, + RoutingMixin, + FallbackMixin, + DirectToolsMixin, + PromptBuilderMixin, + LLMSelectionMixin, + HelpersMixin, +): + """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback. + + MRO (sinistra = priorità alta): + VFSMixin → _rollback_writes, _vfs_git_backup, _get_vfs_lock + DelegateMixin → _reflective_debug, _budget_replan_check, _run_in_loop_delegate + RoutingMixin → _CODE_RE, _FILE_BLOCK_RE, _extract_written_files + FallbackMixin → _run_fallback + DirectToolsMixin → _run_direct_tools, _needs_tools, _is_simple_query + PromptBuilderMixin → _build_messages, _compress_goal + LLMSelectionMixin → _get_llm_for_goal, _get_fast_llm, _sanitize_agent_output + HelpersMixin → _run_fast_path, _proactive_reflect, _budget_replan_check (override) + """ def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, critic: Any = None, memory: Any = None, verifier: Any = None) -> None: @@ -129,3219 +158,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer # ── GAP-3: Rollback atomico scritture ───────────────────────────────────────── - async def _rollback_writes(self, on_step=None) -> None: - """ - GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà. - Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente. - Ogni file in _write_snapshots viene ripristinato al suo contenuto originale. - File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro). - """ - if not self._write_snapshots or not self.executor: - return - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n", - "status": "streaming", - })) - _rolled = 0 - for path, original in self._write_snapshots.items(): - if original is None: - continue # file non esisteva prima — saltiamo (non eliminiamo) - try: - await asyncio.wait_for( - self.executor.run_tool("write_file", {"path": path, "content": original}), - timeout=10.0, - ) - _rolled += 1 - except Exception: - pass # non-fatal — best effort rollback - _total = len(self._write_snapshots) # salva prima del clear - self._write_snapshots = {} - _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total) - - # ── GAP-NEW-4: Git VFS auto-snapshot ──────────────────────────────────────── - async def _vfs_git_backup(self) -> None: - """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub. - - Fire-and-forget — non blocca mai il loop principale, non solleva eccezioni. - Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo". - Crea automaticamente il branch vfs-backup se non esiste. - Force-push consentito su vfs-backup (non è main — nessun rischio di perdita). - """ - import os as _os_vfs - gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip() - gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip() - if not gh_token or not gh_repo: - return - files = dict(self._session_files) # snapshot immutabile - if not files: - return - run_id = self._run_task_id[:8] or "unknown" - try: - import httpx as _hx4 - headers = { - "Authorization": f"Bearer {gh_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "agente-ai-vfs/1.0", - } - base = f"https://api.github.com/repos/{gh_repo}" - async with _hx4.AsyncClient(timeout=20.0) as _cli: - # 1. Leggi (o crea) branch vfs-backup - r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers) - if r_ref.status_code == 404: - r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers) - if r_main.status_code != 200: - return - r_cr = await _cli.post(f"{base}/git/refs", headers=headers, - json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]}) - if r_cr.status_code not in (200, 201): - return - backup_head = r_main.json()["object"]["sha"] - elif r_ref.status_code == 200: - backup_head = r_ref.json()["object"]["sha"] - else: - return - - # 2. Leggi base tree del backup HEAD - r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers) - if r_c.status_code != 200: - return - base_tree = r_c.json()["tree"]["sha"] - - # 3. Crea blob per ogni file (max 20 per backup, max 50KB per file) - tree_items = [] - for _path, _content in list(files.items())[:20]: - rb = await _cli.post(f"{base}/git/blobs", headers=headers, - json={"content": str(_content)[:50_000], "encoding": "utf-8"}) - if rb.status_code == 201: - tree_items.append({ - "path": f"vfs/{_path.lstrip('/')}", - "mode": "100644", - "type": "blob", - "sha": rb.json()["sha"], - }) - - if not tree_items: - return - - # 4. Tree + commit + force-push su vfs-backup - rt = await _cli.post(f"{base}/git/trees", headers=headers, - json={"base_tree": base_tree, "tree": tree_items}) - if rt.status_code != 201: - return - rc = await _cli.post(f"{base}/git/commits", headers=headers, - json={ - "message": f"vfs-backup: {len(tree_items)} file (run {run_id})", - "tree": rt.json()["sha"], - "parents": [backup_head], - }) - if rc.status_code != 201: - return - # force=True consentito: vfs-backup non è main, nessun rischio - await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers, - json={"sha": rc.json()["sha"], "force": True}) - _logger.info( - "GAP-NEW-4: vfs-backup aggiornato — %d file, run %s", - len(tree_items), run_id, - ) - except Exception as _vfs_err: - # Silent: il backup non deve MAI bloccare o crashare il loop principale - _logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80]) - - # ── GAP-VFS: per-path write lock ───────────────────────────────────────── - def _get_vfs_lock(self, path: str) -> asyncio.Lock: - """GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS. - Previene race condition quando subtask paralleli (asyncio.gather) - scrivono lo stesso file contemporaneamente. - Lock creato lazy: zero overhead per run che non usano write paralleli.""" - if path not in self._vfs_write_locks: - self._vfs_write_locks[path] = asyncio.Lock() - return self._vfs_write_locks[path] - - # ── GAP-1: Delega Dinamica In-Loop ───────────────────────────────────── - _DELEGATE_RESEARCH_RE = re.compile( - r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|' - r'news|notizie|fetch|scrape|pagina|sito|http)\b', - re.IGNORECASE, - ) - - async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict: - """GAP-1: Delega Dinamica In-Loop. - Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale. - Architettura: - - Stesso executor del parent → accesso ai tool reali (write_file, run_python, ...) - - LLM selezionato per ruolo → RESEARCHER, CODER o REASONER in base al goal - - _is_delegate_child = True → blocca ricorsione (max 1 livello di delega) - - max_steps = 4 → micro-agente leggero, non un loop completo - - output troncato a 4000 chars → evita context-window explosion nel parent - """ - # P18: defensive anti-recursion guard at entry point - if getattr(self, '_is_delegate_child', False): - _logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry") - return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False} - try: - from models.role_router import RoleRouter as _RR_d, Role as _Role_d - # Seleziona LLM specializzato in base al tipo di sotto-obiettivo - if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash - elif self._CODE_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout - else: - _sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B - except Exception: - _sub_llm = self.llm # fallback: usa LLM del parent - - # Crea loop figlio: stessi executor/planner/memory, LLM specializzato - _sub_loop = UnifiedAgentLoop( - llm_client=_sub_llm, - planner=self.planner, - executor=self.executor, - critic=None, # no critic — micro-agente leggero - memory=self.memory, - verifier=None, # no verifier — massima velocità - ) - # Anti-ricorsione: il figlio non può delegare ulteriormente - _sub_loop._is_delegate_child = True - # Propaga session_id per isolare sandbox backend-exec - _sub_loop._run_task_id = self._run_task_id + "_d" - # GAP-6: condividi dict mutabile _session_files con il parent loop - # Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent - # Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate - _sub_loop._session_files = self._session_files - - # P17-F1: buffer output parziale via on_step — sopravvive al timeout - _partial_steps: list[dict] = [] - async def _capture_partial(step: dict) -> None: - if step.get("output") or step.get("explanation"): - _partial_steps.append(step) - - try: - _res = await asyncio.wait_for( - _sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial), - timeout=timeout, - ) - _out = (_res.get("output") or "")[:4000] - _logger.info( - "GAP-1 delegate OK [%s] steps=%d: %s", - _res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60], - ) - return { - "success": _res.get("success", False), - "output": _out, - "engine": _res.get("engine", "delegate"), - "steps": len(_res.get("steps", [])), - } - except asyncio.TimeoutError: - # P17-F1: esponi stato parziale invece di stringa vuota - # _session_files già condiviso con parent → parent vede file scritti - _partial_files = list(getattr(_sub_loop, "_session_files", {}).keys()) - _partial_out = " ".join( - (s.get("output") or s.get("explanation") or "")[:300] - for s in _partial_steps[-3:] - ).strip()[:1500] - _logger.warning( - "GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s", - timeout, len(_partial_steps), len(_partial_files), sub_goal[:60], - ) - return { - "success": False, - "output": _partial_out, - "error": f"delegate timeout ({timeout:.0f}s) — risultato parziale", - "partial": True, - "partial_files": _partial_files, - "steps_done": len(_partial_steps), - } - except Exception as _de: - _logger.warning("GAP-1 delegate error: %s", _de) - return {"success": False, "output": "", "error": str(_de)[:200]} - - # ── S362: Role routing helpers ───────────────────────────────────────────── - - # S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi. - # Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM. - _CODE_RE = re.compile( - r'\b(scrivi|crea|genera|implementa|refactor|bug|fix|debug|test|codice|' - r'funzione|classe|componente|api|endpoint|typescript|javascript|python|' - r'react|vue|swift|kotlin|write|create|generate|implement|code|function|' - r'class|component|frontend|backend|server|client|hook|store|type|' - r'interface|migration|query|schema|dockerfile|workflow|' - # S427: verbi italiani azione-codice mancanti - r'sistema|sistemi|correggi|corregge|debugga|patch|patcha|rinomina|' - r'sostituisci|rimpiazza|ottimizza|refactorizza|ristruttura|' - r'aggiungi|aggiorna|integra|rimuovi|elimina|cancella|inserisci|' - # S427: verbi inglesi azione-codice mancanti - r'rename|replace|remove|delete|patch|optimize|restructure|' - r'add|update|integrate|insert|scaffold|bootstrap|deploy|' - # S427: framework/librerie/pattern aggiuntivi - r'svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|' - r'fastapi|flask|django|express|rails|laravel|spring|' - r'graphql|grpc|websocket|rest|sql|nosql|' - r'prisma|drizzle|sqlalchemy|mongoose|sequelize|' - r'css|scss|sass|html|rust|go|java|kotlin|dart|flutter|' - r'service|repository|controller|middleware|utility|helper|' - r'decorator|enum|zod|vite|webpack|eslint|prettier|jest|vitest)\b', - re.IGNORECASE, - ) - - # S416-Fix1: estrae path→content dei file scritti nella risposta LLM - # Pattern: "path/file.ext:" o "### file.ext" o "FILE: file.ext" seguito da code block - # S422-Fix1: esteso con 4 formati aggiuntivi (bold, inline code, lista, commento inline) - # Copre 9/9 formati LLM più comuni — S416 era silenziosamente rotto al 60-70% - _EXT = r'(?:tsx?|jsx?|py|css|html|md|json|ya?ml|sh|toml|sql|go|rs|rb|java|kt|swift|vue|svelte)' - _FILE_BLOCK_RE = re.compile( - r'(?:' - # p1: FILE: path o ## FILE: path - r'(?:^|\n)\s*(?:#{1,3}\s*)?(?:FILE|file|File):\s*[`"]?(?P[\w./\-]+\.\w+)[`"]?\s*\n' - # p2: path: o path- (solo con estensione nota) - r'|(?:^|\n)\s*[`"]?(?P[\w./\-]+\.' + _EXT + r')[`"]?\s*[:\-–]\s*\n' - # p3: ## path (markdown heading) - r'|(?:^|\n)#{1,3}\s+(?P[\w./\-]+\.' + _EXT + r')\s*\n' - # p4: **path** (bold) — formato più comune GPT/OpenRouter/Claude - r'|(?:^|\n)\s*\*\*(?P[\w./\-]+\.' + _EXT + r')\*\*\s*.*?\n' - # p5: `path` (inline code) prima del blocco - r'|(?:^|\n)\s*`(?P[\w./\-]+\.' + _EXT + r')`\s*.*?\n' - # p6: 1. **path** o - **path** (lista) - r'|(?:^|\n)\s*(?:\d+\.|[-*])\s+\*\*?(?P[\w./\-]+\.' + _EXT + r')\*?\*?\s*.*?\n' - r')' - # blocco codice — opzionale commento // path o # path come prima riga (p7) - r'```(?:\w+\n(?:(?://|#)\s*(?P[\w./\-]+\.' + _EXT + r')\s*\n))?' - r'(?P.+?)```', - re.DOTALL | re.MULTILINE, - ) - - @classmethod - def _extract_written_files(cls, answer: str) -> dict[str, str]: - """S422-Fix1: estrae file path→content dall'output LLM per iniettarli come contesto. - Copre tutti i formati comuni: FILE:, ##, **bold**, `inline`, lista, commento inline.""" - result: dict[str, str] = {} - for m in cls._FILE_BLOCK_RE.finditer(answer): - path = (m.group("p1") or m.group("p2") or m.group("p3") or - m.group("p4") or m.group("p5") or m.group("p6") or - m.group("p7") or "") - content = m.group("content") or "" - if path and content.strip(): - result[path.strip()] = content.strip()[:3000] - return result - - async def _run_fallback(self, state: UnifiedLoopState, - on_step: StepCallback | None, - preloaded_tool_results: str = "", - preloaded_tool_exec_successes: int = 0, - preloaded_tool_exec_errors: int = 0) -> dict[str, Any]: - outputs: list[str] = [] - try: - from api.state import record_timing as _rtc_ttfa - import time as _ttf_t - _t_rs = getattr(self, '_t_run_start', None) - if _t_rs is not None: - _rtc_ttfa("ttfa_ms", (_ttf_t.monotonic() - _t_rs) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S402: Tool Integrity Guard — propagato da run() tramite _run_direct_tools() - _tool_exec_successes = preloaded_tool_exec_successes - _tool_exec_errors = preloaded_tool_exec_errors - exec_warn: list[str] = [] # S-LOOP1: init precoce — evita NameError se planner va in timeout (S640) - - if self.memory: - mem_ctx = await self.memory.get_context(state.goal, code_length=len(state.context or '')) - if mem_ctx: - state.context = f"{state.context}\n\nMEMORIA:\n{mem_ctx}".strip() - - tool_results = preloaded_tool_results - - # S378: disclaimer quando la query è di tipo ricerca/notizie ma nessun dato - # reale è disponibile — evita che l'LLM risponda in silenzio dal training. - # S428: rimosso "rispondo con conoscenza al cut-off" — invitava hallucination. - if not tool_results and re.search( - r'\b(notizie|news|ultime|latest|breaking|recenti|aggiornamenti|' - r'cerca\s+(?:online|sul\s+web|in\s+rete)|cerca\s*:|search\s*:|' - r'ricerca\s+web|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente))\b', - state.goal, re.IGNORECASE - ): - tool_results = ( - "[NOTA: strumenti di ricerca web non disponibili al momento]" - ) - - # F17+B7: planner per task di progettazione/implementazione — soglia ridotta a 10 chars - # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50). - # _NEEDS_PLAN_RE filtra già query semplici — len guard serve solo per 1-8 char input. - _should_plan = ( - self.planner - and not tool_results - and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) - and len(state.goal) > 10 - ) - # S-FMT-ORCH FIX-FASTFIX: piano sintetico per fix singoli (<180 chars, pattern typo/rename/change-to) - # Salta ARCHITECT DeepSeek-R1 -> risparmio ~15s. Fallback safe: se no match, planner normale. - _fast_fix_plan = None - if (_should_plan - and len(state.goal) < 180 - and bool(self._FAST_FIX_RE.search(state.goal[:200]))): - _fast_fix_plan = { - "summary": state.goal[:80], - "subtasks": [{"id": 1, "description": state.goal, "tool": "apply_patch", "requires": []}], - "complexity": "low", - } - _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT") - _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": "Analizzo la richiesta e preparo un piano", - })) - # S640: timeout planner + S-FMT-ORCH fast-fix bypass - # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati) - if _fast_fix_plan is not None: - plan = _fast_fix_plan - _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato") - else: - # 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_running_loop().time() - _t0_plan) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # 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', []))} passaggi — inizio esecuzione", - "subtasks": len(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": ("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": next(iter(__import__("re").findall(r"`([^`]{1,200})`", desc)), desc.strip()[:200]), - }), - # S764: 10 nuovi tool (S763 registry) aggiunti a _TOOL_MAP - "directory_tree": ("directory_tree", lambda desc: { - "path": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "max_depth": 3, - }), - "file_search": ("file_search", lambda desc: { - "pattern": (lambda m: m.group(1) if m else desc.strip()[:80])( - __import__("re").search( - r"(?:grep\s+|cerca\s+|trova\s+|pattern[:\s]+)['\s]*([\w.\-\(\)\[\]]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "path": ".", - }), - "git_status": ("git_status", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - }), - "git_clone": ("git_clone", lambda desc: { - "url": (lambda m: m.group(0) if m else "")( - __import__("re").search( - r"https?://[\S]+\.git|https?://github\.com/[\S]+", desc - ) - ), - "depth": 1, - }), - "git_diff": ("git_diff", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "staged": bool(__import__("re").search( - r"\b(staged|cached|index)\b", desc, __import__("re").IGNORECASE - )), - }), - "get_image": ("get_image", lambda desc: { - "prompt": desc.strip()[:500], - "width": 512, - "height": 512, - }), - "create_project": ("create_project", lambda desc: { - "project_type": (lambda m: m.group(1) if m else "generic")( - __import__("re").search( - r"\b(react|vue|angular|python|node|fastapi|express|nextjs|flask|django)\b", - desc, __import__("re").IGNORECASE, - ) - ), - "project_name": (lambda m: m.group(1) if m else "my-project")( - __import__("re").search( - r"(?:chiama(?:to)?|nome|project|progetto)[:\s]+['\"\s]*([\w-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "description": desc.strip()[:200], - "path": ".", - }), - "recall": ("recall", lambda desc: { - "query": desc.strip()[:200], - "limit": 5, - }), - "list_files": ("list_files", lambda desc: { - "path": (__import__("re").search(r"[./\\][\w./\\]+", desc) or type("m",(),({"group":lambda s,n:n and "."}))() ).group(0) if __import__("re").search(r"[./\\][\w./\\]+", desc) else ".", - "recursive": bool(__import__("re").search(r"\b(ricorsiv|recursive|all|tutto|tutta|tutti)\b", desc, __import__("re").IGNORECASE)), - "max_items": 100, - }), - "diff_text": ("diff_text", lambda desc: { - "text_a": "", - "text_b": desc.strip()[:2000], - "context_lines": 3, - }), - "validate_json": ("validate_json", lambda desc: { - "json_str": desc.strip()[:8000], - "schema": None, - }), - "lint_code": ("lint_code", lambda desc: { - "content": desc.strip()[:8000], - "language": "auto", - "path": (lambda m: m.group(1) if m else "")( - __import__("re").search( - r"(?:file|path|percorso)[:\s]+['\"\s]*(\S+\.\w+)", - desc, __import__("re").IGNORECASE, - ) - ), - }), - "git_push": ("git_push", lambda desc: { - "remote": (lambda m: m.group(1).strip() if m else "origin")( - __import__("re").search( - r"(?:remote|origin|push\s+to)[:\s]+([\w\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "branch": (lambda m: m.group(1).strip() if m else "")( - __import__("re").search( - r"(?:branch|ramo|sul\s+branch)[:\s]+([\w\-\/]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - }), - "git_commit": ("git_commit", lambda desc: { - "message": (lambda m: m.group(1).strip() if m else desc.strip()[:80])( - __import__("re").search( - r"(?:messaggio|message|msg|commit\s+message)[:\s]+[']*(.{3,120}?)[']*(?:\.|$)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - "push": bool(__import__("re").search( - r"\b(push|pubblica|invia)\b", desc, __import__("re").IGNORECASE - )), - }), - "npm_install": ("npm_install", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "npm_run": ("npm_run", lambda desc: { - "script": (lambda m: m.group(1).strip() if m else "dev")( - __import__("re").search( - r"(?:npm\s+run|pnpm\s+run|yarn\s+run|run\s+script)[:\s]+([\w:_\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "pip_install": ("pip_install", lambda desc: { - "packages": (lambda m: m.group(1).strip() if m else desc.strip()[:200])( - __import__("re").search( - r"(?:pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett[oi])[:\s]+([\w\s,>= 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(_s_tool, (None, None)) - reg_name, inp_builder = tool_key_pair - if reg_name and inp_builder is not None: - _pending_exec.append((subtask, reg_name, inp_builder)) - elif _s_tool: - # COG-4: tool non in _TOOL_MAP — tenta generazione dinamica - try: - from agents.tool_generator import needs_dynamic_tool, generate_and_register - if needs_dynamic_tool(_s_tool, _s_desc_raw): - _dyn_ok, _dyn_rn = await asyncio.wait_for( - generate_and_register(_s_desc_raw, _s_tool, self.llm, self.executor), - timeout=25.0, - ) - if _dyn_ok and _dyn_rn: - # tool_fn() non ha argomenti — inp_builder ritorna sempre {} - _dyn_ib = lambda _d: {} - _pending_exec.append((subtask, _dyn_rn, _dyn_ib)) - _logger.info( - "COG-4 tool generato dinamicamente: %s per subtask #%s", - _dyn_rn, subtask.get("id"), - ) - else: - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non in TOOL_MAP, " - f"generazione dinamica fallita" - ) - except Exception as _cog4_err: - _logger.warning("COG-4 tool_generator error: %s", str(_cog4_err)[:120]) - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non disponibile " - f"(tool_generator error: {str(_cog4_err)[:60]})" - ) - # 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"} - - # S744: research tools → RESEARCHER (Gemini) formula query strutturata - _RESEARCH_TOOLS: set[str] = {"web_research", "web_search"} - - async def _resolve_inp(tool_name: str, desc: str) -> str: - """S632/S744: converte descrizione NL → input ottimale per il tool. - - S632 (Groq/CODER): run_python/execute_sql/database_query → codice eseguibile - S744 (Gemini/RESEARCHER): web_research/web_search → query strutturata - Tutti gli altri: passthrough diretto. - - Timeout conservativo + fallback grezza — zero regressioni. - I/O parallelo via asyncio.gather: nessun overhead sequenziale aggiunto.""" - if tool_name in _CODE_TOOLS: - # S632: CODER path — Groq genera codice/SQL eseguibile (invariante) - 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 il modello ha aggiunto ``` code block ``` - _resolved = _resolved.strip() - if _resolved.startswith("```"): - _lines_r = _resolved.splitlines() - _resolved = "\n".join( - l for l in _lines_r - if not l.strip().startswith("```") - ).strip() - return _resolved if _resolved else desc - except Exception: - return desc # fallback: descrizione grezza (comportamento pre-S632) - - elif tool_name in _RESEARCH_TOOLS: - # S744: RESEARCHER path — Gemini formula query strutturata per ricerca - # Vantaggio: query più precise → risultati meno rumorosi - # Timeout 8s (< code tools 10s) — query corta, Gemini è veloce - try: - from models.role_router import RoleRouter, Role - _researcher = RoleRouter.get_client(Role.RESEARCHER) - if tool_name == "web_research": - _sys = ( - "Sei un esperto di ricerca. Dato un obiettivo, formula un " - "topic di ricerca preciso e strutturato (max 200 chars). " - "Risposta: solo il topic ottimizzato, nessuna spiegazione." - ) - _usr = f"Obiettivo di ricerca: {desc}" - else: # web_search - _sys = ( - "Sei un esperto di ricerca. Formula la query di ricerca web " - "ottimale per il seguente obiettivo (max 100 chars). " - "Solo la query, nessuna spiegazione." - ) - _usr = f"Obiettivo: {desc}" - _resolved = await asyncio.wait_for( - _researcher.chat( - [{"role": "system", "content": _sys}, - {"role": "user", "content": _usr}], - temperature=0.1, max_tokens=256, - ), - timeout=8.0, - ) - _resolved = _resolved.strip() - # Sanity: accetta solo se la query ha senso (>= 8 chars) - if _resolved and len(_resolved) >= 8: - _logger.debug( - "S744 RESEARCHER query [%s]: '%s' → '%s'", - tool_name, desc[:60], _resolved[:80], - ) - return _resolved - except Exception: - pass # fallback: descrizione grezza (comportamento pre-S744) - - return desc # passthrough per tutti gli altri tool - - # 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: - # GAP-A: arricchisce started event con reason e description - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{rn}", - "status": "started", "subtask_id": st.get("id"), - "reason": self._TOOL_NARRATION.get(rn, self._TOOL_NARRATION_DEFAULT), - "description": str(st.get("description", ""))[:80], - })) - # scaffold_project live preview: mostra albero file PRIMA dell'esecuzione - # Zero latency: O(1) dict lookup — utente vede struttura prima che il tool scriva - if rn == "scaffold_project" and on_step: - _desc_scaf = str(st.get("description", "react")).lower() - _fw_scaf = next( - (k for k in self._SCAFFOLD_FILE_TREE if k in _desc_scaf), - "react", - ) - _tree_files = self._SCAFFOLD_FILE_TREE.get(_fw_scaf, []) - if _tree_files: - _n = len(_tree_files) - _tree_lines = "\n".join( - f" {chr(0x251C) + chr(0x2500) if i < _n - 1 else chr(0x2514) + chr(0x2500)} {f}" - for i, f in enumerate(_tree_files) - ) - await _maybe_await(on_step({ - "action": "text_chunk", - "token": ( - f"_Scaffold **{_fw_scaf}** \u2014 struttura che verr\u00e0 creata:_\n" - f"```\nmy-project/\n{_tree_lines}\n```\n\n" - ), - "status": "streaming", - })) - # S632: risolvi description → codice/SQL prima di chiamare il tool - _raw_desc = st.get("description", _goal) - # S-ORCH-8GAP FIX-DAG-3: inietta output delle dipendenze come contesto - # Quando B richiede A, B vede l'output reale di A → _resolve_inp più preciso. - # Max 300 chars per parent (contesto senza context-window explosion). - _parent_ctx_parts = [ - f"[Output subtask #{_rid}]: {_subtask_outputs.get(str(_rid), '')[:300]}" - for _rid in st.get("requires", []) - if str(_rid) in _subtask_outputs - ] - if _parent_ctx_parts: - _raw_desc = ( - "\n".join(_parent_ctx_parts) - + "\n\nTask corrente: " + _raw_desc - ) - _inp_desc = await _resolve_inp(rn, _raw_desc) - # F4: pre-warning per tool lenti (>30s) — imposta aspettative prima dell'attesa - # List statica: no overhead runtime, aggiorna se aggiungi nuovi tool lenti - if rn in {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell","type_check","write_file","apply_patch"} and on_step: - _f16_secs = "20–30" if rn in {"write_file","apply_patch"} else "30–60" - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_⏳ {self._TOOL_NARRATION.get(rn, rn)} — può richiedere {_f16_secs} secondi…_\n", - "status": "streaming", - })) - # F5: timeout tool-specifico — override il default 30s dell'executor - # _npm_install/_git_clone hanno wait_for interno 120s che veniva cancellato a 30s - _TOOL_EXEC_TIMEOUT: dict[str, float] = { - "npm_install": 135.0, "npm_run": 135.0, - "pip_install": 135.0, "git_clone": 135.0, - "git_push": 70.0, "execute_shell": 105.0, - "type_check": 75.0, "web_research": 60.0, - } - _exec_timeout = _TOOL_EXEC_TIMEOUT.get(rn, 30.0) - # GAP-1: Delega Dinamica In-Loop — intercetta __delegate__ prima del routing - # Lancia micro-agente specializzato; anti-ricorsione via _is_delegate_child. - # early-return: non esegue write_file/executor path per tool delegati. - if rn == "__delegate__" and not getattr(self, '_is_delegate_child', False): - _delegate_result = {"success": False, "output": "", "error": "init"} - try: - _delegate_result = await asyncio.wait_for( - self._run_in_loop_delegate(st.get("description", _goal)), - timeout=45.0, - ) - except Exception as _de: - _delegate_result = {"success": False, "output": "", - "error": str(_de)[:200]} - return st, rn, _delegate_result - # F12: write_file/apply_patch — genera codice reale via CODER prima di scrivere - # Bug: _resolve_inp passava la descrizione NL as-is → - # write_file("main.py", "Scrivi FastAPI app") scriveva testo nel file - # Fix: CODER genera codice da path+descrizione → contenuto corretto - _wf_direct_inputs: dict | None = None - if rn in {"write_file", "apply_patch"}: - try: - _wf_path = ib(_raw_desc).get("path", "output.txt") - _wf_ext = _wf_path.rsplit(".", 1)[-1] if "." in _wf_path else "" - _wf_lang = { - "py": "Python", "ts": "TypeScript", "tsx": "TypeScript React", - "js": "JavaScript", "jsx": "JavaScript React", - "html": "HTML", "css": "CSS", "sql": "SQL", - "json": "JSON", "yaml": "YAML", "yml": "YAML", - "sh": "Bash", "md": "Markdown", "toml": "TOML", - }.get(_wf_ext, "codice") - from models.role_router import RoleRouter as _RR_wf, Role as _Role_wf - _coder_wf = _RR_wf.get_client(_Role_wf.CODER) - if rn == "write_file": - _wf_sys = ( - f"Sei un esperto {_wf_lang}. " - f"Scrivi SOLO il contenuto completo del file {_wf_path}. " - "Niente spiegazioni. Niente markdown fence. Solo il codice." - ) - _wf_usr = f"Scrivi {_wf_path}: {_raw_desc[:1000]}" - else: # apply_patch - _wf_sys = ( - "Sei un esperto di patch unified-diff. " - f"Genera SOLO la patch diff per {_wf_path}. " - "Formato: --- a/file\n+++ b/file\n@@ -N,M +N,M @@" - ) - _wf_usr = f"Patch per {_wf_path}: {_raw_desc[:1000]}" - _wf_generated = await asyncio.wait_for( - _coder_wf.chat( - [{"role": "system", "content": _wf_sys}, - {"role": "user", "content": _wf_usr}], - temperature=0.1, max_tokens=2000, - ), - timeout=20.0, - ) - if _wf_generated and not _wf_generated.startswith("[LLM"): - _wf_generated = _wf_generated.strip() - # Strip markdown fences se il modello le ha aggiunte - if _wf_generated.startswith("```"): - _wf_generated = "\n".join( - _wfl for _wfl in _wf_generated.splitlines() - if not _wfl.strip().startswith("```") - ).strip() - else: - _wf_generated = _raw_desc # fallback NL - except Exception as _wf_exc: - _wf_path = ib(_raw_desc).get("path", "output.txt") if ib else "output.txt" - _wf_generated = _raw_desc - _logger.debug("F12 CODER write_file fallback: %s", _wf_exc) - _wf_direct_inputs = ( - {"path": _wf_path, "content": _wf_generated} if rn == "write_file" - else {"path": _wf_path, "patch": _wf_generated} - ) - # GAP-3: snapshot pre-write — cattura originale per rollback atomico - if rn == "write_file" and _wf_path not in self._write_snapshots: - try: - _snap_r = await asyncio.wait_for( - self.executor.run_tool("read_file", {"path": _wf_path}), - timeout=4.0, - ) - self._write_snapshots[_wf_path] = ( - _snap_r.get("output") if _snap_r.get("success") else None - ) - except Exception: - self._write_snapshots[_wf_path] = None # file non esisteva - # GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file - _vfs_lock = self._get_vfs_lock(_wf_path) - async with _vfs_lock: - _r = await self.executor.run_tool(rn, _wf_direct_inputs, timeout=_exec_timeout) - else: - _r = await self.executor.run_tool(rn, ib(_inp_desc), timeout=_exec_timeout) - # GAP-SKILL-SYNC: registra successo/fallimento tool nel session skill tracker - # Sincrono (GIL-safe) — aggiorna Wilson score per routing adattivo futuro - try: - from agents.skill_tracker import get_skill_tracker as _gst - _gst().record(self._run_task_id, rn, bool(_r.get("success"))) - except Exception: - pass # mai bloccare tool execution per tracking - # COG-3: TypeScript TDD — dopo write_file/apply_patch su .ts/.tsx esegue type_check - # Zero overhead su file non-TS (_should_test_ts guard in run_tdd_check_ts) - if rn in {"write_file", "apply_patch"} and _r.get("success") and _wf_direct_inputs: - try: - _cog3_path = _wf_direct_inputs.get("path", "") - if _cog3_path.endswith((".ts", ".tsx")): - from agents.tdd_runner import run_tdd_check_ts - _cog3_content = _wf_direct_inputs.get( - "content", _wf_direct_inputs.get("patch", "") - ) - _cog3_res = await asyncio.wait_for( - run_tdd_check_ts(_cog3_content, _cog3_path, self.executor, on_step), - timeout=22.0, - ) - if _cog3_res.get("ran") and not _cog3_res.get("passed"): - exec_warn.append( - f"⚠ [COG-3] TypeScript error in {_cog3_path}: " - f"{str(_cog3_res.get('output', ''))[:200]}" - ) - _logger.info( - "COG-3 type_check failed: %s — warn aggiunti", _cog3_path - ) - except Exception as _cog3_err: - _logger.debug("COG-3 tdd_runner error: %s", str(_cog3_err)[:80]) - # COG-4: Python TDD — dopo run_python con codice complesso, genera micro-test e verifica - # Zero overhead su codice semplice (_should_test guard) o re-esecuzione TDD (anti-loop marker) - if rn == "run_python" and _r.get("success") and _wf_direct_inputs: - _cog4_code = _wf_direct_inputs.get("code", "") - # Anti-loop: skip se il codice è già un test TDD generato da run_tdd_check - if _cog4_code and "AUTO-TEST S-GAP3" not in _cog4_code: - try: - from agents.tdd_runner import run_tdd_check - _cog4_res = await asyncio.wait_for( - run_tdd_check(_cog4_code, self.executor, None), - timeout=32.0, - ) - if _cog4_res.get("ran") and not _cog4_res.get("passed"): - _cog4_warn = ( - f"⚠ [COG-4] Python TDD failed: " - f"{str(_cog4_res.get('output', ''))[:300]}" - ) - exec_warn.append(_cog4_warn) - self._tdd_fail_inject = _cog4_warn - _logger.info( - "COG-4 Python TDD failed — warn + inject set (%d chars)", - len(_cog4_warn), - ) - except Exception as _cog4_err: - _logger.debug("COG-4 tdd_runner error: %s", str(_cog4_err)[:80]) - # 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) - # S635+UI: retry visibile — utente capisce il ritardo - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_🔄 Errore transitorio ({rn}), riprovo…_\n", - "status": "streaming", - })) - _inp2 = await _resolve_inp(rn, _raw_desc) - # F12: retry usa direct inputs per write_file (evita NL fallback) - _retry_inp = _wf_direct_inputs if _wf_direct_inputs is not None else ib(_inp2) - _r2 = await self.executor.run_tool(rn, _retry_inp, timeout=_exec_timeout) - _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 - # GAP-1: emetti file_written per VFS sync frontend — dopo write riuscito - if rn == "write_file" and _r.get("success") and _wf_direct_inputs and on_step: - await _maybe_await(on_step({ - "action": "file_written", - "path": _wf_direct_inputs.get("path", ""), - "content": _wf_direct_inputs.get("content", ""), - })) - # GAP-9: se scaffold fallisce emetti warning — evita preview albero orfano - # Il live-preview dell'albero e gia stato emesso PRE-esecuzione - if rn == "scaffold_project" and not _r.get("success") and on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": "\n_\u26a0 Scaffold non completato \u2014 riprovo con approccio alternativo..._\n", - "status": "streaming", - })) - # COG-3: type_check post-scaffold — verifica TS sull'intero progetto - # scaffold_project crea molti .ts/.tsx senza passare per write_file - if rn == "scaffold_project" and _r.get("success"): - try: - _scaf_out = _r.get("output", {}) - _scaf_path = ( - _scaf_out.get("path") if isinstance(_scaf_out, dict) - else ib(_raw_desc).get("path", ".") if ib else "." - ) - _scaf_path = _scaf_path or "." - from agents.tdd_runner import run_tdd_check_ts - _SCAF_TS_STUB = ( - "import React from 'react';\n" - "import { useState } from 'react';\n" - "const App: React.FC = () => null;\n" - "export type AppProps = Record;\n" - "export default App;\n" - ) - _scaf_res = await asyncio.wait_for( - run_tdd_check_ts( - _SCAF_TS_STUB, - f"{_scaf_path}/src/App.tsx", - self.executor, - on_step, - ), - timeout=25.0, - ) - if _scaf_res.get("ran") and not _scaf_res.get("passed"): - exec_warn.append( - f"\u26a0 [COG-3] TypeScript errors nel progetto scaffoldato " - f"'{_scaf_path}': {str(_scaf_res.get('output', ''))[:200]}" - ) - _logger.info("COG-3 scaffold type_check failed: %s", _scaf_path) - except Exception as _cog3_scaf: - _logger.debug("COG-3 scaffold type_check: %s", str(_cog3_scaf)[:80]) - return st, rn, _r - # GAP-A: narrazione strategia pre-gather — text_chunk visibile in chat - # Sintetizza i tool in 1-2 frasi prima di avviare l'esecuzione parallela. - # Mostra max 2 tool per non sovraccaricare; usa _TOOL_NARRATION lookup O(1). - if on_step and _pending_exec: - _narr_tools = [rn for _, rn, _ in _pending_exec] - _narr_parts = [ - self._TOOL_NARRATION.get(t, "") for t in _narr_tools[:2] - ] - _narr_str = " · ".join(p for p in _narr_parts if p) - if _narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_narr_str}…_\n\n", - "status": "streaming", - })) - - # F11+S639+F8: esecuzione a FASI con topological sort — rispetta "requires" - # Bug: gather flat → npm_run partiva prima che npm_install finisse (requires ignorato). - # Fix: fase 0 = subtask senza deps, fase 1 = subtask che dipendono dalla fase 0, etc. - # Ogni fase usa gather adattivo (150s se slow tool, 90s altrimenti). - # Invariante: max 8 fasi per prevenire loop infiniti su piani malformati. - _SLOW_GATHER_TOOLS = {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell"} - _completed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-1: cascade-skip su deps fallite - _failed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-3: output injection per subtask dipendenti - _subtask_outputs: dict[str, str] = {} - _phase_remaining = list(_pending_exec) - - for _phase_n in range(8): - if not _phase_remaining: - break - - # Partiziona: pronti (deps soddisfatte) vs bloccati - _phase_ready: list[tuple] = [] - _phase_blocked: list[tuple] = [] - for _ps, _prn, _pib in _phase_remaining: - _reqs = {str(r) for r in _ps.get("requires", [])} - # S-ORCH-8GAP FIX-DAG-1: cascade-skip se una dep è fallita - # Senza questo, il deadlock guard avrebbe eseguito il subtask - # senza l'output della sua dipendenza → tool call sprecata. - _failed_deps = _reqs & _failed_subtask_ids - if _failed_deps: - _dep_ids_str = ", ".join(sorted(_failed_deps)) - exec_warn.append( - f"\u26a0 [DAG] subtask #{_ps.get('id')} saltato — " - f"dipendenza fallita: {_dep_ids_str}" - ) - _failed_subtask_ids.add(str(_ps.get("id"))) # propaga cascade - _logger.info( - "DAG cascade-skip subtask #%s (failed deps: %s)", - _ps.get("id"), _dep_ids_str, - ) - elif _reqs.issubset(_completed_subtask_ids): - _phase_ready.append((_ps, _prn, _pib)) - else: - _phase_blocked.append((_ps, _prn, _pib)) - - # Deadlock guard — esegui i rimanenti comunque (plan malformato) - if not _phase_ready: - _phase_ready = _phase_remaining - _phase_blocked = [] - _logger.warning( - "F11 fase %d deadlock — eseguo %d subtask bloccati", - _phase_n, len(_phase_ready), - ) - - _has_slow_in_phase = any( - _prn in _SLOW_GATHER_TOOLS for _, _prn, _ in _phase_ready - ) - _gather_timeout = 150.0 if _has_slow_in_phase else 90.0 - - if _phase_n > 0: - _logger.info( - "F11 fase %d — %d subtask pronti (timeout %.0fs)", - _phase_n, len(_phase_ready), _gather_timeout, - ) - # F15: narrazione per fasi 1+ — mostra cosa sta per eseguire - # Fase 0 ha già narrazione da GAP-A (pre-gather); fasi successive erano silenziose. - if on_step and _phase_ready: - _ph_narr_parts = [ - self._TOOL_NARRATION.get(_prn, "") - for _, _prn, _ in _phase_ready[:2] - ] - _ph_narr_str = " · ".join(p for p in _ph_narr_parts if p) - if _ph_narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_ph_narr_str}…_\n", - "status": "streaming", - })) - - # S-ORCH-8GAP FIX-DAG-2: Semaphore(3) per fase — max 3 subtask - # simultanei per non saturare TCP su iPhone (max 6 conn totali). - # asyncio single-thread: il semaforo è local-safe, zero race condition. - _phase_sem = asyncio.Semaphore(3) - - async def _sem_subtask(s, rn, ib, _psem=_phase_sem): - async with _psem: - return await _run_subtask(s, rn, ib) - - try: - _exec_results = await asyncio.wait_for( - asyncio.gather( - *[_sem_subtask(s, rn, ib) for s, rn, ib in _phase_ready], - return_exceptions=True, - ), - timeout=_gather_timeout, - ) - except asyncio.TimeoutError: - _logger.warning( - "S639 gather timeout (%.0fs) fase %d su %d subtask", - _gather_timeout, _phase_n, len(_phase_ready), - ) - exec_warn.append( - f"\u26a0 [S639] timeout globale executor fase {_phase_n} " - f"({len(_phase_ready)} subtask): nessun risultato disponibile" - ) - _exec_results = [] - - for _er in _exec_results: - if isinstance(_er, Exception): - # S636: eccezioni da asyncio.gather erano silenziosamente ignorate. - _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"\u26a0 [S636] eccezione subtask [{_exc_type}]: {_exc_msg}" - ) - continue - _st, _rn, _res = _er - if _res.get("success"): - _completed_subtask_ids.add(str(_st.get("id"))) - # S-ORCH-8GAP FIX-DAG-3: memorizza output per injection dipendenti - # F20: dict output → JSON (standard) invece di Python repr - # F21: scaffold/write_file → summary human-readable - _out_raw = _res.get("output", "") - if isinstance(_out_raw, dict): - # F21: output speciale per tool che producono file - _fw = _out_raw.get("framework") - _files = _out_raw.get("files_created", []) - _dir = _out_raw.get("directory", "") - _path = _out_raw.get("path", "") - _size = _out_raw.get("size") - if _fw and _files: - # scaffold_project: summary concisa - _flist = ", ".join(str(f) for f in _files[:6]) - _fmore = f" (+{len(_files)-6} altri)" if len(_files) > 6 else "" - _snippet = ( - f"Progetto {_fw} creato in {_dir} — " - f"{len(_files)} file: {_flist}{_fmore}" - ) - elif _path and _size is not None: - # write_file: conferma creazione file - _snippet = f"File scritto: {_path} ({_size} bytes)" - else: - try: - import json as _jmod, re as _re_jmod - _snippet = _jmod.dumps(_re_jmod.sub(r'[\ud800-\udfff]', '', str(_out_raw)) if isinstance(_out_raw, str) else _out_raw, ensure_ascii=False)[:500] - except Exception: - _snippet = str(_out_raw).strip()[:500] - else: - _snippet = str(_out_raw).strip()[:500] - # S647: hollow success — tool ok ma output vuoto → nota esplicita - 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}") - # S-ORCH-8GAP FIX-DAG-3: salva output per injection subtask dipendenti - _subtask_outputs[str(_st.get("id"))] = _snippet[:400] - 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 → feedback UI + exec_warn - # S-ORCH-8GAP FIX-DAG-1: traccia id falliti per cascade-skip - _failed_subtask_ids.add(str(_st.get("id"))) - _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", - })) - - _phase_remaining = _phase_blocked # prossima fase: subtask rimasti - # COG-1: Dynamic Re-planner — rigenera piano se ci sono fallimenti reali - _cog1_real_failures = [ - w for w in exec_warn - if any(kw in w.lower() for kw in - ("fallito", "failed", "timeout", "exception", "error", "eccezione")) - ] - if _cog1_real_failures and not exec_warn == [] and not plan.get("_replanned"): - try: - from agents.dynamic_replanner import should_replan, replan - if should_replan(exec_warn, exec_done): - _logger.info( - "COG-1 should_replan=True (warn=%d done=%d)", - len(exec_warn), len(exec_done), - ) - _replan_goal = plan.get("goal", state.goal) - _new_plan = await asyncio.wait_for( - replan(self.planner, _replan_goal, exec_warn, exec_done, plan=plan), # P25-R1 - timeout=20.0, - ) - if _new_plan and _new_plan.get("subtasks"): - plan = _new_plan - exec_done.clear() - exec_warn.clear() - _logger.info( - "COG-1 replan ok: %d nuovi subtask", - len(plan.get("subtasks", [])), - ) - _pending_exec2: list[tuple] = [] - for _s2 in plan.get("subtasks", []): - _t2 = _s2.get("tool", "") - _tk2 = _TOOL_MAP.get(_t2, (None, None)) - _rn2, _ib2 = _tk2 - if _rn2 and _ib2 is not None: - _pending_exec2.append((_s2, _rn2, _ib2)) - _replan_sem = asyncio.Semaphore(3) - async def _replan_subtask(s, rn, ib, _sem=_replan_sem): - async with _sem: - return await _run_subtask(s, rn, ib) - try: - _replan_results = await asyncio.wait_for( - asyncio.gather( - *[_replan_subtask(s, rn, ib) for s, rn, ib in _pending_exec2], - return_exceptions=True, - ), - timeout=90.0, - ) - for _rr in _replan_results: - if isinstance(_rr, Exception): - exec_warn.append( - f"⚠ [COG-1 replan] eccezione: {str(_rr)[:80]}" - ) - continue - _rr_st, _rr_rn, _rr_res = _rr - if _rr_res.get("success"): - _out_r = str(_rr_res.get("output", ""))[:400] - exec_done.append( - f"[replan subtask {_rr_st.get('id')}]: {_out_r}" - ) - else: - exec_warn.append( - f"⚠ [COG-1 replan] subtask #{_rr_st.get('id')} " - f"fallito: {str(_rr_res.get('error',''))[:80]}" - ) - except asyncio.TimeoutError: - exec_warn.append("⚠ [COG-1 replan] timeout 90s sul piano alternativo") - except Exception as _cog1_err: - _logger.warning("COG-1 dynamic_replanner error: %s", str(_cog1_err)[:120]) - # COG-5: Goal Drift Detector — controlla ogni DRIFT_CHECK_EVERY_N subtask completati. - # Non-blocking: sincrono, nessun I/O. Se l'agente si è allontanato dal goal - # originale, inietta una micro-guida correttiva in exec_warn prima del LLM call. - try: - from agents.goal_drift_detector import detect_drift as _cog5_detect - _cog5_res = _cog5_detect( - goal=state.goal, - exec_done=exec_done, - step_count=len(exec_done), - last_check=_cog5_last_check, - ) - _cog5_last_check = _cog5_res["new_last_check"] - if _cog5_res.get("drifted"): - _drift_msg = ( - f"[COG-5 ⚠] Deriva dal goal rilevata " - f"({_cog5_res['reason']}). " - f"Goal originale: \"{state.goal[:80]}\". " - f"Concentra la risposta su questo obiettivo." - ) - exec_warn.append(_drift_msg) - _logger.info("COG-5 drift iniettato in exec_warn: %s", _cog5_res["reason"]) - except Exception as _cog5_err: - _logger.debug("COG-5 error (non-blocking): %s", str(_cog5_err)[:80]) - # GAP-NEW-2: TDD FAIL inject — se _t_run_python() ha rilevato un test fallito, - # inietta il traceback in exec_warn PRIMA del campionamento StrategicHealer. - # Questo chiude il ciclo: TDD FAIL → exec_warn → healer fingerprinting → strategia alternativa. - if getattr(self, '_tdd_fail_inject', None): - exec_warn.insert(0, self._tdd_fail_inject) - _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject)) - self._tdd_fail_inject = None - # GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction. - # PROBLEMA v1: MD5("ModuleNotFoundError: requests") ≠ MD5("ModuleNotFoundError: pandas") - # → 3 librerie diverse con stesso errore NON triggheravano il cambio strategia. - # SOLUZIONE v2: dual-mode — conta sia raw fingerprint sia classe di eccezione. - # max(raw_max, class_max) decide il trigger → cattura pattern nascosti. - try: - # Cap detection: analizza solo gli ultimi 50 item (più recenti = più rilevanti). - # Con 100+ subtask falliti analizzare tutta exec_warn è ridondante; - # i pattern recenti sono quelli su cui l'agente sta ancora iterando. - _SH_MAX_SAMPLE = 50 - _sh_sample = exec_warn[-_SH_MAX_SAMPLE:] if len(exec_warn) > _SH_MAX_SAMPLE else exec_warn - import hashlib as _selfheal_hs, re as _selfheal_re - # Mode 1: raw fingerprint (MD5 primi 120 chars) — errori identici alla lettera - _selfheal_fps: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: exec_warn può contenere None/dict da moduli esterni - _fp = _selfheal_hs.md5(_w.lower()[:120].encode(), usedforsecurity=False).hexdigest() - _selfheal_fps[_fp] = _selfheal_fps.get(_fp, 0) + 1 - _selfheal_raw_max = max(_selfheal_fps.values()) if _selfheal_fps else 0 - # Mode 2: error-class extraction — raggruppa per tipo di eccezione Python/JS - # Cattura ModuleNotFoundError×3 anche con moduli diversi (requests/pandas/numpy) - _ERRCLASS_RE = _selfheal_re.compile( - r'\b([A-Z][a-zA-Z]*(?:Error|Exception|Timeout|Warning|Failure|Fault))\b' # UL-BUG-1: era 0x08 backspace → ora word-boundary reale - ) - _selfheal_cls: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: stesso motivo del loop precedente - _cm = _ERRCLASS_RE.search(_w) - if _cm: - _ck = _cm.group(1).lower() - _selfheal_cls[_ck] = _selfheal_cls.get(_ck, 0) + 1 - _selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0 - _selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max) - if _selfheal_max >= 3: - # Hint specifico per classe di errore dominante - _ERRCLASS_HINTS: dict[str, str] = { - "modulenotfounderror": "Installa con pip o usa un'alternativa stdlib (es. json/csv/re/pathlib).", - "importerror": "Riorganizza gli import o usa un'alternativa built-in.", - "timeouterror": "Aumenta il timeout, usa asyncio con timeout maggiore, o spezza l'operazione.", - "connectionerror": "Verifica la rete, usa retry con backoff esponenziale, o usa dati cached.", - "filenotfounderror": "Verifica il path (usa os.path.exists), crea file se mancante.", - "permissionerror": "Usa un path alternativo con accesso in scrittura.", - "valueerror": "Valida l'input (None/empty/tipo errato) prima di processarlo.", - "typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite (str/int/list).", - "keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza prima.", - "attributeerror": "Controlla che l'oggetto non sia None con 'if obj is not None:'.", - "runtimeerror": "Decomponi in passi più piccoli, verifica lo stato dell'ambiente.", - # R2: 10 classi aggiunte — errori comuni che ricevevano hint generico - "nameerror": "Controlla typo nel nome variabile/funzione; verifica che sia definita prima dell'uso.", - "syntaxerror": "Esegui ast.parse() per trovare la riga esatta; usa un f-string o quote corrette.", - "indentationerror": "Usa solo spazi (4 per livello) o solo tab — non mescolare.", - "indexerror": "Controlla len() prima dell'accesso; usa slice o enumerate invece di indice fisso.", - "assertionerror": "Verifica i dati in ingresso con print/log prima dell'assert; aggiungi messaggio all'assert.", - "notimplementederror": "Implementa il metodo mancante o usa l'implementazione concreta invece della base class.", - "recursionerror": "Aggiungi caso base esplicito; converti la ricorsione in loop iterativo.", - "memoryerror": "Processa in chunk (es. itertools.islice), riduci dimensione dati in memoria.", - "oserror": "Controlla permessi e spazio disco; usa pathlib per path cross-platform.", - "zerodivisionerror": "Aggiungi guard 'if denominator != 0' prima della divisione.", - } - _dom_cls = ( - max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else "" - ) - _specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.") - _trigger_mode = "class" if _selfheal_cls_max >= _selfheal_raw_max else "raw" - _selfheal_msg = ( - f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}×{_selfheal_max}]: " - "lo stesso errore si è ripetuto senza progressi. " - f"Hint specifico: {_specific} " - "In ogni caso: NON ripetere lo stesso metodo — cambia libreria, " - "pattern o decomposizione del problema." - ) - # Deduplication: evita doppia iniezione se CAMBIO STRATEGIA già presente. - # Scenario reale: exec_warn.clear() a riga ~2058 non è sempre raggiunto - # prima del secondo trigger (es. doppio replan nello stesso batch). - _sh_already = any( - isinstance(_ew, str) and "CAMBIO STRATEGIA" in _ew - for _ew in exec_warn - ) - if not _sh_already: - exec_warn.insert(0, _selfheal_msg) - _logger.info( - "GAP-SELFHEAL v2: %s mode × %d [class=%s] → CAMBIO STRATEGIA%s", - _trigger_mode, _selfheal_max, _dom_cls or "n/a", - " (già presente, skip dedup)" if _sh_already else " iniettato", - ) - try: - from api.state import increment_stat as _inc_sh # type: ignore[import] - _inc_sh("selfheal_strategy_change_triggered") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # selfheal detection non-blocking — nessun impatto sul loop - # 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: - # Cap display: al LLM arrivano al massimo 50 avvisi (i più recenti). - # exec_warn con 100+ item produce ### Attenzione di decine di KB che - # satura il context window; warning più vecchi già processati in iter. precedenti. - _WARN_DISPLAY_CAP = 50 - _warn_omitted = max(0, len(exec_warn) - _WARN_DISPLAY_CAP) - _warn_display = exec_warn[-_WARN_DISPLAY_CAP:] if _warn_omitted > 0 else exec_warn - _cap_note = f', mostrati ultimi {_WARN_DISPLAY_CAP}' if _warn_omitted > 0 else '' - _synth.append( - f"\n### Non eseguiti — richiedono attenzione ({len(exec_warn)} totale{_cap_note}):" - ) - if _warn_omitted > 0: - _synth.append( - f'[... {_warn_omitted} avvisi precedenti omessi — ' - f'focus sui {_WARN_DISPLAY_CAP} più recenti]' - ) - _synth.extend(_warn_display) - # 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, _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", - })) - # GAP-2: converti _session_files (path→content) in project_files per deep context - _rc_pf = [ - {"path": _pf_path, "content": _pf_content, "language": _pf_path.rsplit(".", 1)[-1].lower() if "." in _pf_path else ""} - for _pf_path, _pf_content in (self._session_files or {}).items() - ] or None - _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 - project_files=_rc_pf, # GAP-2: deep context multi-file - ), - 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 - - # RF-2: Skeleton Injection — se >=3 file in sessione, inietta skeleton compatto - # Attiva il context_manager (S364/S752-A): firme funzioni invece di file interi. - # Riduce token ~60% su sessioni multi-file senza perdere informazione strutturale. - if self._session_files and len(self._session_files) >= 3: - try: - _gcfg = _get_context_manager() - _cm_files = [ - { - "path": _p, - "content": _c, - "language": _p.rsplit(".", 1)[-1].lower() if "." in _p else "", - } - for _p, _c in self._session_files.items() - ] - _skeleton_ctx = await asyncio.wait_for( - _gcfg(state.goal, active_files=[], all_files=_cm_files, top_k=4), - timeout=2.0, - ) - if _skeleton_ctx and not _skeleton_ctx.startswith('[LLM'): - tool_results = ( - f"[SKELETON PROGETTO]\n{_skeleton_ctx}\n\n{tool_results}".strip() - if tool_results else f"[SKELETON PROGETTO]\n{_skeleton_ctx}" - ) - except Exception: - pass # RF-2: fail-safe, mai blocca il loop principale - - # GAP-4-TOOLCOMP: comprimi tool_results se > 3000 chars - # Evita context saturation con output grezzi di read_file/web_search. - # Usa fast_llm (8B), timeout 4s, fail-open — mai blocca il loop. - if tool_results and len(tool_results) > 3000: - try: - _tr_llm = self._get_fast_llm() - _tr_comp = await asyncio.wait_for( - _tr_llm.chat([ - {"role": "system", "content": ( - "Riassumi i risultati tool seguenti preservando: " - "dati concreti (URL, numeri, path file, errori esatti, codice), " - "risultati critici per il goal. Elimina verbosità e ridondanza. " - "Max 1500 chars. Sii chirurgico." - )}, - {"role": "user", "content": ( - f"GOAL: {state.goal[:200]}\n\nTOOL RESULTS:\n{tool_results[:4000]}" - )}, - ], temperature=0.1, max_tokens=400), - timeout=4.0, - ) - if _tr_comp and not _tr_comp.startswith('[LLM') and len(_tr_comp) < len(tool_results): - tool_results = f"[TOOL RESULTS COMPRESSI — GAP-4]\n{_tr_comp}" - except Exception: - pass # fail-open: usa tool_results originali se compressione fallisce - - # LLM call con dati tool iniettati - # S402: passa exec counts per Tool Integrity Guard in _build_messages() - messages = self._build_messages( - state, tool_results=tool_results, - tool_exec_successes=_tool_exec_successes, - tool_exec_errors=_tool_exec_errors, - session_files=self._session_files or None, # S416-Fix1 - ) - # S418-F3: Role.CONTEXT — comprime storia se > 20 messaggi per prevenire context bloat - if len(messages) > 20: - try: - from models.role_router import RoleRouter, Role as _Role - _ctx_llm = RoleRouter.get_client(_Role.CONTEXT) - _comp_input = [ - {"role": "system", "content": ( - "Riassumi questa conversazione in max 5 punti chiave. " - "Preserva dati concreti (URL, numeri, risultati tool). Sii molto conciso." - )}, - *messages[1:-2], - ] - _summary = await asyncio.wait_for( - _ctx_llm.chat(_comp_input, temperature=0.1, max_tokens=512), - timeout=4.0, # S423: ridotto da 10s a 4s — evita bottleneck su 429 - ) - 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[-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}"}, - *_tail, - ] - messages = _compressed - except Exception: - pass # compressione fallita — usa messages originali - if on_step: - await _maybe_await(on_step({ - "loop": 1, "action": "llm", "status": "started", - "title": "Elaborazione AI", - "explanation": "Sto elaborando la risposta…", - })) - - # B10: usa state.has_files — non più '__HAS_FILES__' nel context string - _has_files = state.has_files - _llm_timeout = LLM_TIMEOUT * 1.8 if _has_files else LLM_TIMEOUT - - # S197 never-give-up: frasi di rifiuto che triggerano retry forzato - # 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', '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 - # ── Estensioni S-REFUSAL-EXT ───────────────────────────────── - 'non so come', # IT: mancava da _REFUSAL_PHRASES - 'non posso aiutarti', # IT: mancava da _REFUSAL_PHRASES - 'questo va oltre', # IT: va oltre capacità agente - 'non posso rispondere', # IT: rifiuto esplicito - 'i cannot assist', # EN: variante i cannot - "i'm not able", # EN: variante i'm not able to - 'beyond my capability', # EN: limite capacità - 'not within my', # EN: not within my capability/scope - 'i apologize but', # EN: scuse + rifiuto - 'mi scusi ma', # IT: scuse formali - ) - - def _is_refusal(text: str) -> bool: - low = text.lower() - # S-REFUSAL-EARLY: controlla anche i primi 400 chars per refusal verbosi - # Alcuni LLM premettono lunghe spiegazioni al rifiuto â len<600 li perdeva. - return any(p in low for p in _REFUSAL_PHRASES) and ( - len(text) < 600 or any(p in low[:400] for p in _REFUSAL_PHRASES) - ) - - # GAP-3: EscalationLadder — routing dinamico: attempt 0→CODER, 1→REASONER, 2+→DEFAULT - # Attempt 0: CODER (Llama 4 Scout) · Attempt 1: REASONER (Cerebras 120B) · Attempt 2+: DEFAULT - from agents.escalation_ladder import EscalationLadder as _EscLadder - _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal) - - # S376: error severity classifier — adatta la strategia di retry in base al tipo di errore - # Senza questo, tutti gli errori ricevono lo stesso trattamento (temperature 0.4, stesso hint) - # Con questo: syntax → fix preciso, runtime → retry tool, logic → ri-pianifica - # S376/GAP-3.3: usa error_classifier.py unificato (11 categorie, regex precisi) - # Rimussa funzione locale duplicata — mapping ErrorCategory → severity per _SEVERITY_HINTS - _EC_TO_SEVERITY = { - "syntax": "syntax", - "runtime": "runtime", "selector": "runtime", "navigation": "runtime", - "frame": "runtime", "auth": "runtime", "network": "runtime", - "limit": "runtime", - "logic": "logic", "db_error": "logic", - "unknown": "unknown", - } - try: - _clf_fn, _ = _get_classifier() - _clf_result = _clf_fn([str(e) for e in state.errors[-3:]]) - _error_severity = _EC_TO_SEVERITY.get(_clf_result.category.value, "unknown") - except Exception: - _error_severity = "unknown" - - # S376: severity-based retry hints - _SEVERITY_HINTS = { - 'syntax': ( - "ERRORE DI SINTASSI RILEVATO: correggi SOLO la sintassi — " - "non cambiare la logica. Verifica parentesi, virgole, indentazione." - ), - 'runtime': ( - "ERRORE RUNTIME RILEVATO: l'approccio precedente ha prodotto un errore " - "a runtime. Prova un approccio alternativo più robusto con gestione errori." - ), - 'logic': ( - "ERRORE LOGICO RILEVATO: il risultato ottenuto non è corretto. " - "Ripensa la logica dall'inizio — usa un approccio diverso." - ), - } - - # S195-Robust + S197: retry su errore/placeholder/rifiuto - # S385: adaptive retry budget — Q&A semplice 1 try, code 2, app multi-feature 3 - _tok_budget = self._max_tokens_for_goal(state.goal) - _max_llm_tries = 3 if _tok_budget >= 6144 else 2 if _tok_budget >= 4096 else 1 - answer = "" - _prev_llm_answer = "" # S759: repeated-answer stuck detection - for _llm_try in range(_max_llm_tries): - _is_last = _llm_try == _max_llm_tries - 1 - # GAP-3: aggiorna il client LLM per questo tentativo (escalation dinamica) - _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity) - try: - _msgs = messages - # S385-fix4: inietta force-response SOLO se ci sono stati tentativi precedenti - # (quando _max_llm_tries=1, _is_last è True al primo try — non iniettiamo mai l'istruzione aggressiva) - if _is_last and _llm_try > 0: - # Ultimo di più tentativi: inietta istruzione forza-risposta + severity hint - _force_content = ( - "ISTRUZIONE FINALE: NON puoi rifiutarti di rispondere. " - "Trova UN MODO alternativo, anche parziale, per aiutare. " - "Approccio A fallito? Prova B. Non scrivere mai 'non posso'. " - "Dai almeno una risposta parziale concreta." - ) - _sev_hint = _SEVERITY_HINTS.get(_error_severity, '') - if _sev_hint: - _force_content = f"{_sev_hint}\n\n{_force_content}" - _force = {"role": "system", "content": _force_content} - _msgs = [messages[0], _force, *messages[1:]] - elif _llm_try == _max_llm_tries - 2 and _max_llm_tries > 1 and _error_severity in _SEVERITY_HINTS: - # Penultimo tentativo: inietta solo il severity hint (meno aggressivo) - _sev_msg = {"role": "system", "content": _SEVERITY_HINTS[_error_severity]} - _msgs = [messages[0], _sev_msg, *messages[1:]] - # S376: temperatura adattiva in base alla severity - # syntax → bassa (0.1, precisione), logic → alta (0.5, creatività) - _temp_by_try = { - 'syntax': [0.1, 0.15, 0.2], - 'runtime': [0.2, 0.3, 0.4], - 'logic': [0.3, 0.45, 0.5], - 'unknown': [0.2, 0.4, 0.4], - } - _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_running_loop().time() - # S420: stream tokens to frontend while accumulating full answer - _stream_parts: list[str] = [] - try: - async def _collect_stream(_msgs=_msgs, _temp=_temp, _tok_budget=_tok_budget) -> str: - async for _tok in _active_llm.stream_chat( - _msgs, temperature=_temp, max_tokens=_tok_budget - ): - _stream_parts.append(_tok) - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": _tok, - "status": "streaming", - })) - return "".join(_stream_parts) - answer = await asyncio.wait_for(_collect_stream(), timeout=_llm_timeout) - if not answer: - raise ValueError("stream vuoto") - except Exception: - _stream_parts.clear() - answer = await asyncio.wait_for( - _active_llm.chat(_msgs, temperature=_temp, max_tokens=_tok_budget), - timeout=_llm_timeout, - ) - try: - from api.state import record_timing as _rec_timing - _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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B4: segnala truncation SSE se finish_reason == "length" - _fr = getattr(_active_llm, '_last_finish_reason', 'stop') - if _fr == 'length' and on_step: - await _maybe_await(on_step({ - "action": "step", "step": state.current_step, - "output": "⚠️ [TRUNCATION] Risposta LLM troncata (max_tokens raggiunto). Tenta riduzione contesto.", - "truncated": True, - })) - if answer.startswith('[LLM'): - state.steps.append({"action": f"llm_attempt_{_llm_try}", "output": answer}) - continue - if _is_refusal(answer) and not _is_last: - # S576: 200→400 — cattura rifiuto completo per debug - state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:600]}) # S603: 400→600 - continue - # S759: repeated-answer stuck detection - # Se risposta simile all'ultima (Jaccard bigram >0.75) e non è l'ultimo try → forza retry - if _llm_try > 0 and _prev_llm_answer and answer and not answer.startswith('[LLM'): - def _s759_bjac(_a: str, _b: str) -> float: - try: - _na, _nb = _a[:100].lower(), _b[:100].lower() - _sa = {_na[_i:_i+2] for _i in range(max(0, len(_na)-1))} - _sb = {_nb[_i:_i+2] for _i in range(max(0, len(_nb)-1))} - _inter = len(_sa & _sb); _union = len(_sa | _sb) - return _inter / _union if _union else 1.0 - except Exception: - return 0.0 - if _s759_bjac(answer, _prev_llm_answer) > 0.75 and not _is_last: - state.steps.append({ - "action": f"llm_stuck_{_llm_try}", - "output": "risposta ripetuta — cambio temperatura e strategia", - }) - _prev_llm_answer = answer[:100] - continue # riprova con temperatura più alta - _prev_llm_answer = answer[:100] if answer and not answer.startswith('[LLM') else _prev_llm_answer - - # S-BACKEND-ANTIREGRESS: rileva import injection e code rewrite. - # Se rilevato E non ultimo try, inietta hint chirurgico e riprova. - if not _is_last and answer and '```' in answer: - try: - from agents.backend_antiregress import check_regression as _ar_chk - _ar_hint = _ar_chk(state.goal, answer, state.context or "") - if _ar_hint: - state.steps.append({ - "action": "antiregress_retry", - "hint": _ar_hint[:200], - }) - _ar_msg = ( - "\n\n[CORREZIONE RICHIESTA]\n" - + _ar_hint - + "\n\nRiscrivi SOLO la parte difettosa. " - "Mantieni TUTTE le classi e funzioni originali. " - "Non aggiungere nuove dipendenze." - ) - _msgs = [_msgs[0], {"role": "user", "content": state.goal + _ar_msg}] - continue # retry con hint chirurgico - except Exception: - pass # S-BACKEND-ANTIREGRESS: non bloccante - - break # risposta reale non-rifiuto - except asyncio.TimeoutError: - answer = f"[LLM timeout {_llm_timeout:.0f}s]" - if not _is_last: - continue # riprova su timeout - break - except Exception as exc: - answer = f"[LLM error: {exc}]" - if not _is_last: - continue - break - - if answer.startswith("[LLM"): - state.errors.append(answer) - # S364: Chain-of-Verification — dopo 2+ errori, usa ARCHITECT per reflection - if len(state.errors) >= 1: # S701: reflection da 1 errore (era 2) - # GAP-D: progress card visibile PRIMA del reflection — utente sa che stiamo analizzando - if on_step: - _rd_n = len(state.errors) - _rd_label = "Strategia alternativa forzata" if _rd_n >= 3 else "Analisi dell'errore" - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "started", - "title": f"🔍 {_rd_label} (tentativo {_rd_n})", - "explanation": ( - "Ho riscontrato un ostacolo ripetuto. Sto elaborando una strategia completamente diversa con il modello Architect…" - if _rd_n >= 3 else - "Ho riscontrato un errore. Sto analizzando la causa principale con il modello Architect per cambiare approccio…" - ), - })) - # B4: strategic_ctx già presente → degrada ARCHITECT→fast_llm (-10-15s) - _b4_has_strategic = ( - '[GAP-SELFHEAL:' in (state.context or '') - or '♻️ Re-planning' in (state.context or '') - ) - _reflection = await self._reflective_debug( - state.goal, state.errors, - _force_fast=_b4_has_strategic, - ) - if _reflection: - state.context = (state.context or '') + _reflection - state.steps.append({"action": "reflective_debug", - "analysis": _reflection[:400]}) # S573: 200→400 - # GAP-D: progress card "done" con la nuova strategia — trasforma il fallimento in fiducia - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "💡 Nuova strategia identificata", - "explanation": _reflection[:300], - })) - # GAP-SELFHEAL: dopo 3+ errori, inietta regole concrete di cambio strategia - # Il reflective_debug da solo non rompe il loop di allucinazione (63% closure fail). - # R3: aggiunta dedup guard — senza di essa ogni iterazione LLM con state.errors>=3 - # appendeva un [GAP-SELFHEAL] blocco distinto a state.context (crescita O(n_errors)). - # Pattern: inietta SOLO SE state.context non contiene già "[GAP-SELFHEAL:". - if len(state.errors) >= 3: - _n_err = len(state.errors) - _sh2_already = "[GAP-SELFHEAL:" in (state.context or "") - if not _sh2_already: - _selfheal_inj = ( - "\n\n[GAP-SELFHEAL: tentativo " + str(_n_err) + " - CAMBIO STRATEGIA OBBLIGATORIO]\n" - "I precedenti " + str(_n_err) + " approcci sono falliti. Applica QUESTE regole:\n" - "1. NON ripetere il codice fallito - smontalo in passi atomici\n" - "2. Prima di scrivere usa read_file per verificare lo stato attuale\n" - "3. Scrivi SOLO la parte minima che fa passare UN test alla volta\n" - "4. Se libreria X fallisce, prova libreria Y alternativa\n" - "5. Se pattern A fallisce, usa pattern B completamente diverso." - ) - state.context = (state.context or "") + _selfheal_inj - state.steps.append({"action": "selfheal_strategy_injection", "n_errors": _n_err}) - - # GAP-1: Probabilistic Re-planning Trigger - # Chiamato dopo selfheal: step count = numero step completati finora. - # Agisce su state.context (append) — non modifica messages correnti. - _gap1_step_count = len([s for s in state.steps if s.get("action") == "llm"]) - _gap1_hint = await self._budget_replan_check(state, _gap1_step_count, on_step) - if _gap1_hint: - state.context = (state.context or '') + f'\n\n[GAP-1-REPLAN]\nNuovo approccio: {_gap1_hint}' - state.steps.append({"action": "budget_replan", "hint": _gap1_hint[:200]}) - - state.steps.append({"action": "llm", "output": answer}) - - # S428 Sprint1-Fix3: Claim Validation — safety net post-LLM. - # Anche quando _build_messages inietta "TENTATIVO TOOL FALLITO" con istruzione - # "NON affermare di aver trovato dati live", il LLM può ignorarla. - # Questo check è il secondo strato di difesa: aggiunge un disclaimer visibile - # se e solo se rileva false claim + goal realtime + tutti tool falliti. - if answer and not answer.startswith("[LLM"): - answer = self._validate_claims( - response=answer, - n_success=_tool_exec_successes, - n_errors=_tool_exec_errors, - goal=state.goal, - false_claim_re=self._FALSE_CLAIM_RE, - realtime_goal_re=self._REALTIME_GOAL_RE, - ) - - # S416-Fix1: aggiorna _session_files con file scritti in questa risposta - # così il prossimo run() li inietta come contesto (evita import rotti tra step) - if answer: - _written = self._extract_written_files(answer) - if _written: - self._session_files.update(_written) - # Sprint 3b ITEM 7: auto validate_project post-write - # Se _tok_budget >= 4096 e ci sono file Python scritti, verifica sintassi AST - if _tok_budget >= 4096: - import ast as _ast_chk - _py_errs: list[str] = [] - for _vp, _vc in list({p: c for p, c in _written.items() - if p.endswith(".py")}.items())[:5]: - try: - _ast_chk.parse(_vc) - except SyntaxError as _se: - _py_errs.append(f"{_vp}:{_se.lineno}: {_se.msg}") - if _py_errs: - # 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[:400], # S576: 200→400 - })) - try: - from api.state import increment_stat as _inc_syn - _inc_syn("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif on_step: - _n_py = sum(1 for p in _written if p.endswith(".py")) - if _n_py > 0: - await _maybe_await(on_step({ - "action": "validate_project", - "status": "done", - "title": "Validazione automatica ✓", - "explanation": f"{_n_py} file Python — sintassi OK", - })) - # GAP-C: Ciclo di Test Automatizzato - # Trigger: sintassi OK + file Python scritti + task complesso (>=8192 tok) - # Genera test minimale via LLM (8s) → esegue via exec engine (20s) - # Fallimento → _reflective_debug → state.context aggiornato per il loop successivo - # Best-effort: Exception catturata in fondo — mai blocca la risposta utente - if not _py_errs: - _gac_py = {p: c for p, c in _written.items() if p.endswith(".py")} - if _gac_py and _tok_budget >= 8192: - try: - _gac_name, _gac_code = next(iter(_gac_py.items())) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "started", - "title": "🧪 Test automatico", - "explanation": f"Genero ed eseguo un test minimale per {_gac_name}…", - })) - _gac_msgs = [ - {"role": "system", "content": ( - "Scrivi UN test Python minimale (stdlib only, no pytest) per il codice.\n" - "Deve: importare funzioni principali, avere 1-3 assert concreti,\n" - "stampare 'PASS' o 'FAIL: '. Solo codice Python, niente markdown." - )}, - {"role": "user", "content": f"# {_gac_name}\n{_gac_code[:1500]}"}, - ] - _gac_raw = await asyncio.wait_for( - self.llm.chat(_gac_msgs, temperature=0.05, max_tokens=350), - timeout=8.0, - ) - import re as _gac_re - _gac_m = _gac_re.search(r'```python\n([\s\S]+?)```', _gac_raw or "") - _gac_run = _gac_m.group(1) if _gac_m else (_gac_raw or "").strip() - if len(_gac_run) > 10: - from tools.registry import _call_exec_engine as _gac_exec - _gac_res = await asyncio.wait_for( - _gac_exec({"code": _gac_run, "lang": "python", "timeout": 15}), - timeout=20.0, - ) or {} - _gac_exit = _gac_res.get("exit_code", 1) - _gac_out = ( - (_gac_res.get("stdout") or "") + (_gac_res.get("stderr") or "") - )[:300] - if _gac_exit == 0 and "FAIL" not in _gac_out.upper(): - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "done", - "title": "🧪 Test automatico ✅ PASS", - "explanation": _gac_out[:200] or "Tutti i test superati.", - })) - else: - state.errors.append( - f"Auto-test {_gac_name} exit={_gac_exit}: {_gac_out}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "needs_fix", - "title": "🧪 Test automatico ⚠ FAIL", - "explanation": _gac_out[:200], - })) - _gac_fix = await self._reflective_debug(state.goal, state.errors) - if _gac_fix: - state.context = ( - (state.context or "") - + f"\n\n[AUTO-TEST FAIL — {_gac_name}]\n{_gac_fix}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "💡 Fix suggerito da test fallito", - "explanation": _gac_fix[:300], - })) - except Exception: - pass # GAP-C best-effort — mai blocca la risposta utente - # S403-FIX: NON appendere a outputs qui — i repair loop (verifier, goal_verifier, - # self-healing Python/HTML) modificano `answer` ma non `outputs`. - # L'append viene fatto DOPO tutti i repair, appena prima di final_output, - # così "\n\n".join(outputs) riflette la risposta completamente riparata. - # (Prima: outputs.append(answer) qui → tutti i fix venivano scartati in silenzio) - - # 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 - try: - _qg_mod = _imp_ev.import_module('api.quality_guardian') - except ImportError: - _qg_mod = None - _qc_fn = getattr(_qg_mod, 'run_quality_check', None) if _qg_mod else None - if _qc_fn: - _answer_snap = answer - _qc_result: dict | None = None - - # 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, - llm_output=_answer_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg - 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': _fix_hint, # S573: 200→400; S594: cap spostato a riga sopra - 'status': 'repairing', - })) - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - 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, - llm_output=_ff_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg ff - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # 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. - if self.verifier and answer and not answer.startswith('[LLM'): - try: - _vr = self.verifier.verify_and_repair(state.goal, answer) - answer = _vr.output - if getattr(_vr, 'retry_suggested', False): - _hint_msg = [*messages, {"role": "assistant", "content": answer}, - {"role": "user", "content": f"Migliora: {getattr(_vr, 'retry_hint', 'rendi la risposta più completa')}"}] - try: - # S427-FixF: usa _active_llm (CODER per task di codice) invece del - # base self.llm — il retry del verifier usava il modello sbagliato - # per task di codice complessi (es. Groq 8B invece di 70B). - _retry_ans = await asyncio.wait_for( - _active_llm.chat(_hint_msg, temperature=0.3, max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=LLM_TIMEOUT) - if _retry_ans and not _retry_ans.startswith('[LLM'): - answer = _retry_ans - 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 - - # ── MIN-LENGTH-GATE (Checklist Item 1) ──────────────────────────────── - # Retry automatico per goal analitici con risposta troppo corta. - # Recupera RY (riassumi) e DA (data analysis) failures — output <150 parole. - # Trigger: _ANALYTICAL_VERBS_RE match + risposta < 150 parole. Fail-open. - if answer and not answer.startswith('[LLM'): - _mlg_words = len(answer.split()) - _is_goal_analytical = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - if _is_goal_analytical and _mlg_words < 150: - try: - _mlg_reinforce = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"La risposta è troppo breve ({_mlg_words} parole) " - f"rispetto a quanto richiesto dal goal. " - f"Sviluppa ogni punto in modo completo e dettagliato: " - f"almeno 200 parole, coprendo esaustivamente tutti gli aspetti." - )}, - ] - _mlg_retry = await asyncio.wait_for( - _active_llm.chat( - _mlg_reinforce, - temperature=0.3, - max_tokens=self._max_tokens_for_goal(state.goal), - ), - timeout=LLM_TIMEOUT, - ) - if (_mlg_retry and not _mlg_retry.startswith('[LLM') - and len(_mlg_retry.split()) > _mlg_words): - answer = _mlg_retry - _logger.debug( - "[unified_loop] min_length_gate: %d→%d words (goal=%s…)", - _mlg_words, len(answer.split()), state.goal[:40], - ) - try: - from api.state import increment_stat as _inc_mlg - _inc_mlg("min_length_gate_retry") - except Exception: - pass - except Exception: - pass # fail-open — mantieni risposta originale - - # S403: GoalVerifier — verifica semantica "obiettivo raggiunto" vs "azione eseguita" - # S410: adaptive threshold + double-pass re-verify per chiudere il loop di verifica. - # Il ciclo: verify → repair → re-verify → accept/reject conferma che il repair - # abbia davvero migliorato la coverage, non solo cambiato la risposta. - # S416-Fix2: attivato per is_code_goal anche senza backtick (app multi-file descrittiva) - # Sprint 2: GoalVerifier 2.0 — se RequirementEngine trova requisiti, usa verify_v2 - try: - from agents.goal_verifier import GoalVerifier as _GV_pre - _gv_should_run = _GV_pre.is_code_goal(state.goal) or '```' in answer - except Exception: - _gv_should_run = '```' in answer - if answer and not answer.startswith('[LLM') and _gv_should_run: - try: - from agents.goal_verifier import GoalVerifier as _GV - from api.state import increment_stat as _inc_stat - if _GV.is_code_goal(state.goal): - _gv = _GV(self._get_verifier_llm()) # P25-B4: cross-model - _threshold = _GV.adaptive_threshold(state.goal) # S410: adattivo - # Sprint 2: tenta verify_v2 se RequirementEngine disponibile e goal complesso - _gv2_reqs = None - if _tok_budget >= 4096: - try: - from agents.requirement_engine import RequirementEngine as _RE - from api.state import increment_stat as _inc_re - _re_engine = _RE(llm=self.llm) # BUG-5: LLM come fallback per goal complessi - _gv2_reqs = await _re_engine.decompose(state.goal) # P16-B1: async con LLM fallback — decompose_sync ignorava llm=self.llm - if _gv2_reqs: - _inc_re("req_engine_used") - try: - from api.state import increment_stat as _inc_re2 - _inc_re2.__module__ # no-op, just exist check - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - import api.state as _st_mod - _st_mod._REPAIR_STATS["req_engine_reqs_total"] += len(_gv2_reqs) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - _gv2_reqs = None - # FIX-2: fast-pass euristico — salta LLM verify se risposta gia completa. - # Condizioni: >600 chars + >=1 blocco codice + 60% keyword goal + no errori. - # Risparmio: -5s/iter su task dove LLM ha gia risposto bene (caso comune). - _goal_words_fp = set(re.findall(r'\w{4,}', state.goal.lower())) - _ans_words_fp = set(re.findall(r'\w{4,}', answer.lower())) - _kw_cov_fp = len(_goal_words_fp & _ans_words_fp) / max(len(_goal_words_fp), 1) - # B2: fast-pass ampliato — fast-fix senza errori saltano goal_verifier. - # Conseguenza: -5/-22s per ogni fix atomico andato a buon fine. - # Zero cons: FAST_FIX_RE+no errors garantisce completezza senza LLM. - _is_fast_fix_clean = ( - not getattr(state, 'errors', None) - and len(state.goal) < 200 - and bool(self._FAST_FIX_RE.search(state.goal[:200])) - and bool(answer.strip()) - ) - # P16-B5: soglia keyword adattiva in base alla lunghezza del goal - # Goal brevi (<80 chars): molto specifici → soglia più bassa (0.60) - # Goal medi (80-200 chars): default (0.72) - # Goal lunghi (>200 chars): molti requisiti → soglia più alta (0.82) - _gl = len(state.goal) - _fp_threshold = 0.60 if _gl < 80 else (0.82 if _gl > 200 else 0.72) - # Item 5: fast-pass non-coding branch — keyword coverage su prosa - _is_goal_analytical_fp = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - _fast_pass = ( - _is_fast_fix_clean - or ( - # Existing: code-heavy answers (4+ code blocks) - len(answer) > 1200 - and answer.count('```') >= 4 - and _kw_cov_fp >= _fp_threshold # P16-B5: adattivo - and not getattr(state, 'errors', None) - ) - or ( - # NEW — Item 5: goal analitici — fast-pass via keyword coverage senza codice - # Evita LLM verify su risposte analitiche già esaustive (≥150 parole, 55% kw) - _is_goal_analytical_fp - and len(answer.split()) >= 150 - and _kw_cov_fp >= 0.55 - and not getattr(state, 'errors', None) - ) - ) - # P25-B2: Risk gate — blocca fast_pass se ci sono requisiti ad alto rischio. - # Previene shortcut euristico su operazioni sensibili (auth/pagamenti/delete/security). - # Solo per goal non-trivial (non _is_fast_fix_clean) con requisiti già estratti. - _P25_HIGH_RISK = {"auth", "payments", "crud", "security"} - if _fast_pass and not _is_fast_fix_clean and _gv2_reqs: - _has_risk_req = any( - r.get("feature", "") in _P25_HIGH_RISK for r in _gv2_reqs - ) - if _has_risk_req: - _fast_pass = False - try: - _inc_stat("fast_pass_blocked_risk") - except Exception: - pass - _logger.debug( - "[unified_loop] _fast_pass=%s kw_cov=%.2f goal_len=%d threshold=%.2f", - _fast_pass, _kw_cov_fp, _gl, _fp_threshold, - ) - if _fast_pass: - _inc_stat("goal_verify_fast_pass") - _gvr = type('_FPR', (), dict(goal_met=True, coverage_score=0.85, - missing_items=[], repair_hint=''))() - else: - # Sprint 2: usa verify_v2 se requisiti trovati, altrimenti verify v1 - _t0_gv = asyncio.get_running_loop().time() # Sprint 5 ITEM 14: verifier_ms - # GAP-1: Hard Gate — verify_with_execution() (esecuzione reale del codice) - # semantic(verify_v2) → extract code block → exec backend → PASS/FAIL - # exit_code != 0 → FAIL + traceback reale come repair_hint → self-healing loop - _gvr = await asyncio.wait_for( - _gv.verify_with_execution(state.goal, answer, requirements=_gv2_reqs or None), - timeout=22.0, # semantic(4s) + execution(18s) = 22s budget - ) - try: - from api.state import record_timing as _rtgv - _rtgv("verifier_ms", (asyncio.get_running_loop().time() - _t0_gv) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # REMOVE-1: rimossa regola 17 README check (S416-Fix6). - # Causava -0.15 coverage su task senza 'readme' >= 6144 token — - # inclusi 'ottimizza funzione', 'spiega codice', 'crea grafico'. - # Falsi positivi sistematici -> repair spurio -> LLM call inutile. - _initial_score = _gvr.coverage_score - # S-CRITIC-1: rileva UNKNOWN prima del repair — on-demand Critic su task codice - _is_unknown = _gvr.repair_hint.startswith("[verifier_unavailable") - _skip_gv_repair = False - if (_is_unknown - and not _gvr.goal_met - and _gvr.coverage_score < _threshold - and _GV.is_code_goal(state.goal)): - try: - from agents.goal_verifier import CriticJudge as _CJ - _cj = _CJ(self._get_fast_llm()) - _cv = await asyncio.wait_for( - _cj.judge(state.goal, answer), timeout=8.0) - try: - _inc_stat(f"critic_j_{_cv.verdict.lower()}") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if _cv.verdict == "PASS": - _skip_gv_repair = True - try: - _inc_stat("critic_promoted_to_pass") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif ( - _cv.verdict in ("UNKNOWN", "ERROR") - or str(getattr(_cv, "raw", "")).startswith("[LLM") - ): - # GAP-8: verdict inaffidabile (rate limit 429 o timeout) - # Non triggerare repair spurio — CriticJudge non ha risposto - _skip_gv_repair = False # comportamento invariato ma esplicito - try: - _inc_stat("critic_unreliable") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # silent — UNKNOWN comportamento invariato - if not _skip_gv_repair and not _gvr.goal_met and _gvr.coverage_score < _threshold: - _inc_stat("goal_verify_repair_triggered") - if on_step: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Controllo qualità", - "explanation": ( - 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 - # S-ORCH-8GAP FIX-GAP3+GAP6: Requirement-Driven Repair - # Arricchisce il repair context con acceptance_criteria specifici - # dei requisiti FAIL — repair "chirurgico" invece di generico. - # L'LLM sa ESATTAMENTE cosa implementare, non solo "manca qualcosa". - _criteria_hints: list[str] = [] - if _gv2_reqs and _gvr.missing_items: - _failed_ids = {m.lower().replace(" ", "_") for m in _gvr.missing_items} - for _req in _gv2_reqs: - _rname = getattr(_req, 'feature', '').lower().replace(' ', '_') - _rid = getattr(_req, 'id', '').lower() - if (_rname in _failed_ids or _rid in _failed_ids or - any(_fid in _rname or _fid in _rid for _fid in _failed_ids)): - _ac = getattr(_req, 'acceptance_criteria', []) - if _ac: - _criteria_hints.extend(_ac[:2]) - _criteria_block = ( - "\nCriteri di accettazione mancanti:\n" - + "\n".join(f" - {c}" for c in _criteria_hints[:4]) - if _criteria_hints else "" - ) - # Sprint1b: messaggio repair diversificato per UNKNOWN vs FAIL - # UNKNOWN = verifier non disponibile → non sappiamo cosa manca - # FAIL = sappiamo cosa manca → repair chirurgico - # _is_unknown già rilevato sopra (S-CRITIC-1) - if _is_unknown: - _repair_content = ( - f"Rivedi e completa la risposta al seguente goal: " - f"{state.goal[:300]}. " # S576: 200→300 - "Assicurati di coprire tutti gli aspetti richiesti " - f"in modo completo, corretto e dettagliato.{_criteria_block}" - ) - else: - _repair_content = ( - f"GOAL NON COMPLETATO ({int(_gvr.coverage_score*100)}%): " - f"{_missing_str}. " - "Completa esattamente quello che manca senza ripetere " - f"quanto già scritto.{_criteria_block}" - ) - _gv_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": _repair_content}, - ] - _repaired_score = _initial_score # default: nessun miglioramento - try: - # Fix 3 (S421): repair con il modello più capace per goal complessi - # self.llm = provider race winner (spesso 8B); app complesse hanno bisogno del 70B - _gv_repair_llm = self._get_llm_for_goal(state.goal) - _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=10.0, # S434: 20→10s - ) - if _gv_ans and not _gv_ans.startswith('[LLM'): - # S434: accetta repair immediatamente, re-verify fire-and-forget (telemetria) - answer = _gv_ans - try: - _inc_stat("goal_verify_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - _inc_stat("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - _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_with_execution(_g, _s), timeout=20.0) # BUG-4: exec verify - _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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B2: notifica UI che re-verify è in corso - if on_step: - try: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Verifica qualità in corso…", - "explanation": ( - f"Copertura corrente: {int(_initial_score*100)}% " - "— verifica repair in corso" - ), - })) - except Exception: - pass - # S455-P10: task supervisionato — done_callback logga eccezioni silenziate - 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") - # COG-2: record successful strategy for lesson injection - if self.memory and hasattr(self.memory, 'reflection'): - try: - _last_act = state.steps[-1].get('action', 'direct') if state.steps else 'direct' - self.memory.reflection.record_success( - state.goal[:300], f"goal_verify_pass|{_last_act}" - ) - except Exception: - pass # never blocks the response - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - 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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - 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. - # Ora: rileva SyntaxError → repair prompt → sostituisce answer inline prima del return. - if answer and not answer.startswith('[LLM') and '```python' in answer.lower(): - import re as _re_sh - _py_blocks = _re_sh.findall(r'```python\s*(.*?)```', answer, _re_sh.DOTALL | _re_sh.IGNORECASE) - for _blk in _py_blocks[:1]: # solo primo blocco — fast path, non blocca la risposta - try: - compile(_blk.strip(), '', 'exec') - except SyntaxError as _syn_err: - # S395: telemetria - try: - from api.state import increment_stat as _inc_s - _inc_s("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Auto-fix sintassi", - "explanation": "Errore di sintassi rilevato — correzione automatica in corso", - })) - _fix_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"Il codice Python ha un SyntaxError: {_syn_err}\n" - "Correggi SOLO la sintassi — NON cambiare la logica. " - "Rispondi con la versione corretta completa del codice." - )}, - ] - try: - _repaired = await asyncio.wait_for( - _active_llm.chat(_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=10.0, # S434: 20→10s - ) - if _repaired and not _repaired.startswith('[LLM'): - answer = _repaired - state.steps.append({"action": "execution_validator_fix", - "output": "SyntaxError riparato dal repair loop"}) - try: - from api.state import increment_stat as _inc_s2 - _inc_s2("syntax_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rs2 - _inc_rs2("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Auto-fix completato", - "explanation": "Codice corretto automaticamente ✓", - })) - else: - try: - from api.state import increment_stat as _inc_s3 - _inc_s3("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_s4 - _inc_s4("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair fallito — usa answer originale - break # un solo ciclo di repair - else: - # S394: Runtime self-healing — compile() OK → esegui e ripara runtime errors (max 1 ciclo, 35s) - _RUN_INTENT_RT = _re_sh.compile( - r"\b(esegui|run|execute|lancia|testa|prova|verifica)\b.*\b(codice|script|programma|code)\b", # UL-BUG-2: era r"\\b" (literal backslash-b non word-boundary) → self-healing S394 ora attivo, - _re_sh.IGNORECASE, - ) - if _RUN_INTENT_RT.search(state.goal): - try: - from tools.registry import TOOL_REGISTRY as _TR_rt - if "run_python" in _TR_rt: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "title": "Test esecuzione", - "explanation": "Eseguo il codice per verificare…", - })) - _run_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_blk.strip()), - timeout=15.0, - ) - _stderr_rt = (_run_r.get("stderr") or "").strip() - _rc_rt = _run_r.get("returncode", 0) - if _rc_rt != 0 and _stderr_rt: - # S395: telemetria runtime error - try: - from api.state import increment_stat as _inc_rt - _inc_rt("runtime_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "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": ( - # 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." - )}, - ] - try: - _rt_repaired = await asyncio.wait_for( - _active_llm.chat(_rt_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=20.0, - ) - if _rt_repaired and not _rt_repaired.startswith("[LLM"): - answer = _rt_repaired - state.steps.append({ - "action": "execution_validator_fix", - "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 as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rrt - _inc_rrt("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Verifica finale…", - "explanation": "Verifico che il codice funzioni correttamente", - })) - # S395: GREEN confirmation — re-run repaired code (max 15s) - try: - _green_blks = _re_sh.findall( - r'```python\s*(.*?)```', - _rt_repaired, - _re_sh.DOTALL | _re_sh.IGNORECASE, - ) - _green_code = _green_blks[0].strip() if _green_blks else _rt_repaired.strip() - _green_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_green_code), - timeout=15.0, - ) - _green_rc = _green_r.get("returncode", 0) - _green_stderr = (_green_r.get("stderr") or "").strip() - if _green_rc == 0 and not _green_stderr: - try: - from api.state import increment_stat as _inc_g - _inc_g("green_confirmed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "✓ Codice funzionante", - "explanation": "Nessun errore rilevato ✓", - })) - else: - try: - from api.state import increment_stat as _inc_gf - _inc_gf("green_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "warning", - "title": "⚠️ Repair parziale", - "explanation": "Correzione parziale — potrebbe esserci un errore residuo", - })) - except Exception: - pass # GREEN check non bloccante - else: - try: - from api.state import increment_stat as _inc_rtf - _inc_rtf("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_rtf2 - _inc_rtf2("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair runtime fallito — usa answer originale - else: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Codice verificato ✓", - "explanation": "Codice eseguito correttamente ✓", - })) - except Exception: - pass # run_python non disponibile — skip gracefully - - # S401: HTML/JS repair loop — rileva blocchi strutturalmente rotti e li ripara (max 1 ciclo, 20s) - # Copre ciò che il repair Python non tocca: HTML unclosed tags, JS unbalanced braces. - if answer and not answer.startswith('[LLM') and ( - '```html' in answer.lower() or - '```javascript' in answer.lower() or - '```js\n' in answer.lower() - ): - import re as _re_web - _WEB_PATTERNS = [ - (r'```html\s*(.*?)```', 'HTML', 'html'), - (r'```(?:javascript|js)\s*(.*?)```', 'JavaScript', 'javascript'), - ] - _VOID_TAGS = {'area','base','br','col','embed','hr','img','input', - 'link','meta','param','source','track','wbr'} - for _wpat, _wname, _wlang in _WEB_PATTERNS: - _wblocks = _re_web.findall(_wpat, answer, _re_web.DOTALL | _re_web.IGNORECASE) - if not _wblocks: - continue - _wblk = _wblocks[0] - _wissues: list[str] = [] - - if _wlang == 'html': - # Tag bilanciamento - _open = _re_web.findall(r'<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>', _wblk) - _close = _re_web.findall(r'', _wblk) - _cnt: dict[str, int] = {} - for _t in _open: - _tl = _t.lower() - if _tl not in _VOID_TAGS: - _cnt[_tl] = _cnt.get(_tl, 0) + 1 - for _t in _close: - _tl = _t.lower() - _cnt[_tl] = _cnt.get(_tl, 0) - 1 - _unbal = [_t for _t, _c in _cnt.items() if _c != 0] - if _unbal: - # 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 '): + _wissues.append('Tag \n' - ), - "src/main.tsx": ( - 'import { StrictMode } from "react";\n' - 'import { createRoot } from "react-dom/client";\n' - 'import App from "./App";\n' - 'createRoot(document.getElementById("root")!).render();' - ), - "src/App.tsx": ( - 'export default function App() {\n' - ' return
\n' - '

' + _pn + '

\n' - '

Modifica src/App.tsx per iniziare.

\n' - '
;\n}' - ), - "src/index.css": "body{margin:0;font-family:system-ui,sans-serif}", - "vite.config.ts": ( - 'import { defineConfig } from "vite";\nimport react from "@vitejs/plugin-react";\n' - 'export default defineConfig({ plugins: [react()] });' - ), - }, - "nextjs": { - "package.json": ( - '{"name":"' + _safe + '","version":"0.1.0",' - '"scripts":{"dev":"next dev","build":"next build","start":"next start"},' - '"dependencies":{"next":"^15.1.0","react":"^19","react-dom":"^19"},' - '"devDependencies":{"typescript":"^5","@types/node":"^20","@types/react":"^19",\"@types/react-dom\":\"^19\"}}' - ), - "app/layout.tsx": ( - 'export const metadata = { title: "' + _pn + '" };\n' - 'export default function Layout({ children }: { children: React.ReactNode }) {\n' - ' return {children};\n}' - ), - "app/page.tsx": ( - '"use client";\n' - 'export default function Page() {\n' - ' return

' + _pn + '

;\n}' - ), - "next.config.mjs": "const nextConfig = {};\nexport default nextConfig;", - }, - "fastapi": { - "main.py": ( - 'from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\n' - 'app = FastAPI(title="' + _pn + '", version="0.1.0")\n' - 'app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])\n\n' - '@app.get("/health")\nasync def health(): return {"status": "ok"}\n\n' - '@app.get("/")\nasync def root(): return {"message": "Benvenuto in ' + _pn + '"}\n' - ), - "requirements.txt": "fastapi>=0.115.0\nuvicorn[standard]>=0.30.0\nhttpx>=0.27.0\n", - "Dockerfile": ( - 'FROM python:3.11-slim\nWORKDIR /app\n' - 'COPY requirements.txt .\nRUN pip install -r requirements.txt\n' - 'COPY . .\nEXPOSE 8000\nCMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]' - ), - ".gitignore": "__pycache__/\n*.pyc\n.env\n", - }, - "flask": { - "app.py": ( - 'from flask import Flask, jsonify\nfrom flask_cors import CORS\n\n' - 'app = Flask(__name__)\nCORS(app)\n\n' - '@app.get("/health")\ndef health(): return jsonify({"status": "ok"})\n\n' - '@app.get("/")\ndef root(): return jsonify({"message": "Benvenuto in ' + _pn + '"})\n\n' - 'if __name__ == "__main__":\n app.run(debug=True, host="0.0.0.0", port=5000)\n' - ), - "requirements.txt": "flask>=3.0.0\nflask-cors>=4.0.0\ngunicorn>=21.2.0\n", - ".gitignore": "__pycache__/\n*.pyc\n.env\n", - }, - "django": { - "manage.py": ( - '#!/usr/bin/env python\nimport os, sys\n' - 'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings")\n' - 'from django.core.management import execute_from_command_line\nexecute_from_command_line(sys.argv)\n' - ), - "requirements.txt": "django>=5.0\ndjangorestframework>=3.15\ndjango-cors-headers>=4.3\ngunicorn>=21.2.0\n", - "config/__init__.py": "", - "config/settings.py": ( - 'from pathlib import Path\nBASE_DIR=Path(__file__).resolve().parent.parent\n' - 'SECRET_KEY="change-me-in-production"\nDEBUG=True\nALLOWED_HOSTS=["*"]\n' - 'INSTALLED_APPS=["django.contrib.contenttypes","django.contrib.auth","rest_framework","corsheaders"]\n' - 'MIDDLEWARE=["corsheaders.middleware.CorsMiddleware","django.middleware.common.CommonMiddleware"]\n' - 'ROOT_URLCONF="config.urls"\nDEFAULT_AUTO_FIELD="django.db.models.BigAutoField"\nCORS_ALLOW_ALL_ORIGINS=True\n' - ), - "config/urls.py": 'from django.urls import path, include\nurlpatterns=[path("api/", include("api.urls"))]\n', - "api/__init__.py": "", - "api/views.py": ( - 'from rest_framework.decorators import api_view\nfrom rest_framework.response import Response\n\n' - '@api_view(["GET"])\n' - 'def hello(request): return Response({"message": "Benvenuto in ' + _pn + '"})\n' - ), - "api/urls.py": 'from django.urls import path\nfrom . import views\nurlpatterns=[path("", views.hello)]\n', - }, - "express": { - "package.json": ( - '{"name":"' + _safe + '","version":"0.1.0","type":"module",' - '"scripts":{"start":"node src/index.js","dev":"node --watch src/index.js"},' - '"dependencies":{"express":"^4.19.2"}}' - ), - "src/index.js": ( - 'import express from "express";\n' - 'const app=express(), PORT=process.env.PORT||3000;\n' - 'app.use(express.json());\n' - 'app.get("/health", (_, res) => res.json({ status: "ok" }));\n' - 'app.get("/", (_, res) => res.json({ message: "Benvenuto in ' + _pn + '" }));\n' - 'app.listen(PORT, () => console.log("Server: http://localhost:" + PORT));\n' - ), - ".gitignore": "node_modules/\n.env\n", - }, - "astro": { - "package.json": ( - '{"name":"' + _safe + '","version":"0.1.0","type":"module",' - '"scripts":{"dev":"astro dev","build":"astro build","preview":"astro preview"},' - '"dependencies":{"astro":"^4.11.0"}}' - ), - "astro.config.mjs": ( - 'import { defineConfig } from "astro/config";\n' - 'export default defineConfig({});\n' - ), - "src/pages/index.astro": ( - '---\nconst title = "' + _pn + '";\n---\n' - '\n {title}\n' - ' \n

{title}

\n' - '

Modifica src/pages/index.astro per iniziare.

\n' - ' \n\n' - ), - "src/layouts/Layout.astro": ( - '---\nconst { title } = Astro.props;\n---\n' - '\n\n' - ' {title}\n' - ' \n\n' - ), - ".gitignore": "node_modules/\ndist/\n.astro/\n.env\n", - }, - "sveltekit": { - "package.json": ( - '{"name":"' + _safe + '","version":"0.1.0","type":"module",' - '"scripts":{"dev":"vite dev","build":"vite build","preview":"vite preview"},' - '"dependencies":{"@sveltejs/kit":"^2.5.0","svelte":"^4.2.0"},' - '"devDependencies":{"@sveltejs/adapter-auto":"^3.2.0","vite":"^5.3.0"}}' - ), - "svelte.config.js": ( - 'import adapter from "@sveltejs/adapter-auto";\n' - 'export default { kit: { adapter: adapter() } };\n' - ), - "vite.config.js": ( - 'import { sveltekit } from "@sveltejs/kit/vite";\n' - 'import { defineConfig } from "vite";\n' - 'export default defineConfig({ plugins: [sveltekit()] });\n' - ), - "src/routes/+page.svelte": ( - '\n' - '
\n

{title}

\n' - '

Modifica src/routes/+page.svelte per iniziare.

\n' - '
\n' - ), - "src/routes/+layout.svelte": '\n', - ".gitignore": "node_modules/\nbuild/\n.svelte-kit/\n.env\n", - }, - } - - _match = 'react' - for _k in _TEMPLATES: - if _k in _fw or _fw.startswith(_k[:4]): - _match = _k - break - - _tpl = _TEMPLATES[_match] - _created: list[str] = [] - _errs_scaf: list[str] = [] - for _rel, _content in _tpl.items(): - _full = _os.path.join(_base, _rel) - _os.makedirs(_os.path.dirname(_full), exist_ok=True) - try: - with open(_full, 'w', encoding='utf-8') as _f: - _f.write(_content) - _created.append(_rel) - except Exception as _e: - _errs_scaf.append(f'{_rel}: {_e}') - - _steps_map = { - 'react': 'npm install && npm run dev', - 'nextjs': 'npm install && npm run dev', - 'fastapi': 'pip install -r requirements.txt && uvicorn main:app --reload', - 'flask': 'pip install -r requirements.txt && python app.py', - 'django': 'pip install -r requirements.txt && python manage.py runserver', - 'express': 'npm install && npm run dev', - 'astro': 'npm install && npm run dev', - 'sveltekit': 'npm install && npm run dev', - } - _next_step = _steps_map.get(_match, 'installa le dipendenze') - _out = ( - f'Scaffold **{_match}** per **{_pn}** — {len(_created)} file creati:\n' - + '\n'.join(f' {p}' for p in _created) - + f'\n\nDirectory: {_base}' - + f'\n\nProssimi passi: cd {_safe} && {_next_step}' - ) - if _errs_scaf: - _out += f'\n\nErrori: {"; ".join(_errs_scaf)}' - # GAP-X6: restituisce anche il dict files → usato da /api/scaffold_project - # Il frontend li scrive nel VFS con vfsAsync.write() — zero passaggi via agent. - return { - 'success': True, - 'output': _out, - 'files': _tpl, # dict {rel_path: content} — già con project_name interpolato - 'framework': _match, - 'project_name': _pn, - 'project_dir': f'/{_safe}', # percorso VFS suggerito - 'created': _created, - } - -# ─── S-GAP15: create_chart ────────────────────────────────────────────────── -async def _create_chart( - chart_type: str = "bar", - data: dict | None = None, - title: str = "", - x_label: str = "", - y_label: str = "", - labels: list | None = None, - values: list | None = None, -) -> dict: - """ - S-GAP15: Genera un grafico come immagine PNG base64 usando matplotlib (se disponibile), - fallback SVG testuale per ambienti senza display. - chart_type: bar | line | pie | scatter - data: dict {label: value} oppure usa labels/values separati - """ - import base64 - import io - - # Normalizza input - if data and isinstance(data, dict): - _labels = list(data.keys()) - _values = [float(v) for v in data.values()] - else: - _labels = labels or [] - _values = [float(v) for v in (values or [])] - - if not _labels or not _values: - return {"error": "Devi fornire 'data' (dict) oppure 'labels' e 'values' (list)."} - - # Tentativo matplotlib (potrebbe non essere disponibile in tutti gli ambienti) - try: - import matplotlib - matplotlib.use("Agg") # Headless — nessun display necessario - import matplotlib.pyplot as plt - - fig, ax = plt.subplots(figsize=(8, 5)) - ct = chart_type.lower().strip() - if ct == "bar": - ax.bar(_labels, _values) - elif ct == "line": - ax.plot(_labels, _values, marker="o") - elif ct == "pie": - ax.pie(_values, labels=_labels, autopct="%1.1f%%") - elif ct == "scatter": - ax.scatter(range(len(_values)), _values) - ax.set_xticks(range(len(_labels))) - ax.set_xticklabels(_labels, rotation=45, ha="right") - else: - ax.bar(_labels, _values) # default bar - - if title: ax.set_title(title) - if x_label: ax.set_xlabel(x_label) - if y_label: ax.set_ylabel(y_label) - plt.tight_layout() - - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=100) - plt.close(fig) - buf.seek(0) - b64 = base64.b64encode(buf.read()).decode("utf-8") - return { - "type": "image/png", - "format": "base64", - "data": b64, - "chart_type": ct, - "title": title, - "note": f"Grafico {ct} generato con matplotlib. Mostra l'immagine con: ", - } - except ImportError: - pass # matplotlib non disponibile → fallback SVG - - # Fallback SVG testuale (sempre disponibile) - _max_v = max(_values) if _values else 1 - _bar_w = 60 - _gap = 10 - _h = 200 - _svg_w = len(_labels) * (_bar_w + _gap) + 60 - bars = "" - for i, (lbl, val) in enumerate(zip(_labels, _values)): - bh = int((_h - 40) * val / _max_v) if _max_v else 1 - x = 40 + i * (_bar_w + _gap) - y = _h - bh - 20 - bars += f'' - bars += f'{str(lbl)[:10]}' - bars += f'{val}' - title_tag = f'{title}' if title else "" - svg = f'{title_tag}{bars}' - import base64 as _b64 - svg_b64 = _b64.b64encode(svg.encode()).decode() - return { - "type": "image/svg+xml", - "format": "base64", - "data": svg_b64, - "chart_type": "bar_svg_fallback", - "title": title, - "note": "matplotlib non disponibile — grafico SVG testuale (fallback). Installa matplotlib per grafici PNG di qualità.", - } - - - -# ─── P17-F4-REG: trigger_webhook — registra il tool nel registry ──────────── - -# ─── P24-F1: Macro tools — workflow compositi ───────────────────────────────── - -async def _write_and_check( - path: str, - content: str, - language: str = "auto", - run_lint: bool = True, -) -> dict: - """Macro: write_file → lint_code — scrive e verifica in un unico step.""" write_res = await _write_file(path=path, content=content) - lint_res: "dict | None" = None - if run_lint: - ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" - if ext in ("py", "js", "ts", "tsx", "jsx", "json", "css", "html"): - lint_res = await _lint_code_tool(content=content, language=language, path=path) - has_errors = bool(lint_res and (lint_res.get("errors") or lint_res.get("error"))) - return { - "ok": True, - "path": path, - "written": True, - "bytes_written": len(content.encode("utf-8")), - "lint": lint_res, - "has_errors": has_errors, - "message": ( - f"Scritto {path} ({len(content)} chars)" - + (" — lint OK" if lint_res and not has_errors else " — errori lint" if has_errors else "") - ), - } - - -async def _bulk_write_files( - files: dict, - stop_on_error: bool = False, - run_lint: bool = False, -) -> dict: - """Macro: write_file x N in parallelo — scrive più file in un solo step.""" if not isinstance(files, dict) or not files: - return {"ok": False, "error": "files deve essere un dict {path: content} non vuoto"} - - async def _single(p: str, c: str) -> "tuple[str, bool, str]": # type: ignore[type-arg] - try: - await _write_file(path=p, content=str(c)) - if run_lint: - ext = p.rsplit(".", 1)[-1].lower() if "." in p else "" - if ext in ("py", "js", "ts", "tsx", "jsx", "json"): - lr = await _lint_code_tool(content=str(c), path=p) - if lr.get("errors"): - return p, False, f"lint errors: {str(lr['errors'])[:200]}" - return p, True, "" - except Exception as exc: # noqa: BLE001 - return p, False, str(exc)[:200] - - results = await asyncio.gather(*[_single(p, c) for p, c in files.items()]) - written = [p for p, ok, _ in results if ok] - failed = [{"path": p, "error": e} for p, ok, e in results if not ok] - return { - "ok": len(failed) == 0, - "written": written, - "failed": failed, - "total": len(files), - "written_count": len(written), - "message": f"Scritti {len(written)}/{len(files)} file" + (f" — {len(failed)} errori" if failed else ""), - } - - -async def _fetch_and_extract(url: str, extract: str = "all") -> dict: - """Macro: read_page → estrazione strutturata (title, testo, word_count, links).""" import re as _re - page = await _read_page(url=url, query="") - raw_text = page.get("text") or page.get("content") or "" - title = page.get("title", "") - paras = [p.strip() for p in raw_text.split(" -") if len(p.strip()) > 60] - result: dict = { - "ok": page.get("ok", True), - "url": url, - "title": title, - "word_count": len(raw_text.split()), - "char_count": len(raw_text), - } - if page.get("error"): - result["error"] = page["error"] - if extract in ("all", "text", "summary"): - result["paragraphs"] = paras[:8] - result["excerpt"] = raw_text[:2500] - if extract in ("all", "links"): - result["links"] = list(dict.fromkeys(_re.findall(r"https?://[^s'"<>]{10,}", raw_text)))[:30] - return result - - -async def _read_multiple_files(paths: list, max_chars_per_file: int = 4000) -> dict: - """Macro: read_file x N in parallelo — legge più file e aggrega i contenuti.""" if not paths: - return {"ok": False, "error": "paths non può essere vuoto"} - - async def _single(path: str) -> "tuple[str, str | None, str | None]": # type: ignore[type-arg] - try: - res = await _read_file(path=path) - content = (res.get("content") or res.get("text") or "")[:max_chars_per_file] - return path, content, None - except Exception as exc: # noqa: BLE001 - return path, None, str(exc)[:200] - - results = await asyncio.gather(*[_single(p) for p in paths]) - files_out = {p: c for p, c, e in results if c is not None} - errors_out = {p: e for p, c, e in results if e is not None} - return { - "ok": len(errors_out) == 0, - "files": files_out, - "errors": errors_out, - "read_count": len(files_out), - "total": len(paths), - } - - -async def _trigger_webhook( - url: str, - payload: "dict | str | None" = None, - method: str = "POST", - headers: "dict | None" = None, - timeout: float = 10.0, -) -> dict: - """Wrapper — delega all'implementazione in tools/trigger_webhook.py.""" - try: - from tools.trigger_webhook import trigger_webhook as _tw - return await _tw(url=url, payload=payload, method=method, headers=headers, timeout=timeout) - except ImportError: - import httpx as _hx - body = None - _hdrs: dict = {"User-Agent": "agente-ai/1.0"} - if headers: - _hdrs.update(headers) - if payload is not None: - import json as _j - body = _j.dumps(payload).encode() if isinstance(payload, dict) else str(payload).encode() - _hdrs.setdefault("Content-Type", "application/json") - async with _hx.AsyncClient(timeout=min(float(timeout), 10.0), follow_redirects=True) as _c: - _m = method.upper() - if _m == "GET": - r = await _c.get(url, headers=_hdrs) - elif _m == "PUT": - r = await _c.put(url, content=body, headers=_hdrs) - else: - r = await _c.post(url, content=body, headers=_hdrs) - return {"ok": r.is_success, "status": r.status_code, "body": r.text[:2000], "error": None} - - -# ─── P24-F2: notion_rw wrapper ──────────────────────────────────────────────── -async def _notion_rw( - action: str, - query: str = "", - page_id: str = "", - parent_id: str = "", - title: str = "", - content: str = "", - max_results: int = 5, -) -> dict: - """Wrapper — delega all'implementazione in tools/notion_tool.py.""" from tools.notion_tool import notion_rw as _nrw # noqa: PLC0415 - return await _nrw( - action=action, query=query, page_id=page_id, - parent_id=parent_id, title=title, content=content, - max_results=max_results, - ) - - -# ─── P24-F3: jina_fetch wrapper ──────────────────────────────────────────────── -async def _jina_fetch( - url: str, - query: str = "", - target_selector: str = "", - remove_selector: str = "", - max_length: int = 12000, -) -> dict: - """Wrapper — delega a tools/jina_reader.py (Jina Reader per SPA e siti JS).""" - from tools.jina_reader import jina_fetch as _jr # noqa: PLC0415 - return await _jr( - url=url, query=query, - target_selector=target_selector, - remove_selector=remove_selector, - max_length=max_length, - ) - -async def _python_analyze(code: str = "", content: str = "", filename: str = "") -> dict: - """P30-B1: Analisi statica Python in-process — zero exec_engine, zero deps esterne. - - Usa ast.parse() + visitor per: - - Syntax check con posizione esatta (riga, colonna, testo) - - Metriche strutturali: funzioni/classi/imports/nesting/righe - - Suggerimenti actionable (funzioni troppo lunghe, nesting alto, ecc.) - - Zero I/O, zero network. Tipicamente <5ms. - - GAP-1: accetta sia 'code' che 'content' — alias per compatibilità frontend. - Il frontend (toolDefsCode.ts) invia 'content', il backend usava solo 'code'. - """ - # GAP-1: alias — frontend può inviare 'content' invece di 'code' - code = code or content - import ast as _ast - - result: dict = {"syntax_ok": False, "errors": [], "complexity": {}, "suggestions": [], "summary": ""} - - if not code or not code.strip(): - result["errors"] = [{"type": "EmptyCode", "message": "Nessun codice fornito (parametro 'code' o 'content' richiesto)", "line": 0}] - result["summary"] = "Codice vuoto" - return result - - # 1. Syntax check - try: - tree = _ast.parse(code, filename=filename) - result["syntax_ok"] = True - except SyntaxError as _e: - result["errors"] = [{ - "type": "SyntaxError", "message": str(_e.msg or _e), - "line": _e.lineno or 0, "col": _e.offset or 0, - "text": (_e.text or "").rstrip(), - }] - result["summary"] = f"SyntaxError alla riga {_e.lineno}: {_e.msg}" - return result - except IndentationError as _e: - result["errors"] = [{ - "type": "IndentationError", "message": str(_e.msg or _e), "line": _e.lineno or 0, - }] - result["summary"] = f"IndentationError alla riga {_e.lineno}" - return result - - # 2. AST visitor per metriche - class _V(_ast.NodeVisitor): - def __init__(self): - self.fns: list[dict] = [] - self.classes: list[dict] = [] - self.imports: list[str] = [] - self.max_nesting = 0 - self._depth = 0 - def _ent(self): self._depth += 1; self.max_nesting = max(self.max_nesting, self._depth) - def _ex(self): self._depth -= 1 - def visit_FunctionDef(self, n): - _ln = (n.end_lineno or n.lineno) - n.lineno + 1 - self.fns.append({"name": n.name, "line": n.lineno, "lines": _ln}) - self._ent(); self.generic_visit(n); self._ex() - visit_AsyncFunctionDef = visit_FunctionDef - def visit_ClassDef(self, n): - self.classes.append({"name": n.name, "line": n.lineno}) - self._ent(); self.generic_visit(n); self._ex() - def visit_For(self, n): self._ent(); self.generic_visit(n); self._ex() - def visit_While(self, n): self._ent(); self.generic_visit(n); self._ex() - def visit_If(self, n): self._ent(); self.generic_visit(n); self._ex() - def visit_With(self, n): self._ent(); self.generic_visit(n); self._ex() - def visit_Try(self, n): self._ent(); self.generic_visit(n); self._ex() - def visit_Import(self, n): - for _a in n.names: self.imports.append(_a.name) - def visit_ImportFrom(self, n): - if n.module: self.imports.append(n.module) - - _v = _V(); _v.visit(tree) - _tot = len(code.splitlines()) - result["complexity"] = { - "total_lines": _tot, - "functions": len(_v.fns), - "classes": len(_v.classes), - "imports": _v.imports[:20], - "max_nesting": _v.max_nesting, - } - - # 3. Suggerimenti actionable - _sug: list[str] = [] - for _f in _v.fns: - if _f["lines"] > 50: - _sug.append(f"Funzione '{_f['name']}' (riga {_f['line']}) ha {_f['lines']} righe — valuta di dividerla") - if _v.max_nesting >= 5: - _sug.append(f"Nesting max {_v.max_nesting} livelli — rischio complessità ciclomatica alta") - if not _v.fns and _tot > 30: - _sug.append("Nessuna funzione definita su codice lungo — struttura in funzioni") - if "import *" in code: - _sug.append("Evita 'import *' — importa esplicitamente solo ciò che serve") - _dup_imports = {_i for _i in _v.imports if _v.imports.count(_i) > 1} - if _dup_imports: - _sug.append(f"Import duplicati rilevati: {', '.join(sorted(_dup_imports))}") - result["suggestions"] = _sug[:5] - - # 4. Summary - _parts = [f"OK — {_tot} righe"] - if _v.fns: _parts.append(f"{len(_v.fns)} funzioni") - if _v.classes:_parts.append(f"{len(_v.classes)} classi") - if _v.imports:_parts.append(f"{len(_v.imports)} import") - if _v.max_nesting: _parts.append(f"nesting max {_v.max_nesting}") - result["summary"] = "Sintassi " + ", ".join(_parts) - return result - +# ── Sub-moduli estratti (split 2026-06-30) ─────────────────────────────────── +from tools.registry_web import ( + _web_search, _read_page, _get_weather, _calculate, _run_python, + _generate_image, _browser_navigate, _browser_session_open, + _browser_session_act, _browser_session_close, _get_news, +) +from tools.registry_fs import ( + _read_file, _write_file, _ts_syntax_check, _apply_patch, _execute_shell, + _web_research, _send_email, _database_query, _call_api, _execute_sql, _create_pdf, +) +from tools.registry_dev import ( + _directory_tree, _file_search, _git_status, _git_clone, _git_diff, + _recall, _list_files, _diff_text, _validate_json, _lint_code_tool, + _git_push, _git_sync_vfs, _git_commit, _npm_install, _npm_run, + _pip_install, _type_check, +) +from tools.registry_scaffold import _scaffold_project, _create_chart +from tools.registry_macro import ( + _write_and_check, _bulk_write_files, _fetch_and_extract, + _read_multiple_files, _trigger_webhook, _notion_rw, _jina_fetch, _python_analyze, +) TOOL_REGISTRY: dict[str, dict] = { "web_search": { @@ -2573,7 +689,49 @@ TOOL_REGISTRY: dict[str, dict] = { "P30-B1: Analisi statica Python in-process — zero exec_engine, <5ms. " "code: stringa Python (obbligatorio). filename: nome file per errori (default ''). " "Ritorna: syntax_ok (bool), errors[] (type/message/line/col), " - "complexity{total_lines/functions/classes/imports/max_nesting}, " + "complexity{total_lines/functions/classes/imports/max_nesting}, + "read_file_range": { + "name": "read_file_range", + "goal": "Legge un range specifico di righe da un file (ottimizzato per file grandi)", + "description": ( + "P42: Estrae solo le righe necessarie. " + "path obbligatorio. start_line (default 1), end_line (default 100). " + "Ritorna content, start_line, end_line, total_lines_read." + ), + "required_inputs": ["path"], + "optional_inputs": {"start_line": 1, "end_line": 100}, + "risk_level": "low", + "fallbacks": ["read_file"], + "_fn": _read_file_range, + }, + "grep_file": { + "name": "grep_file", + "goal": "Cerca un pattern in un file senza caricarlo interamente in RAM", + "description": ( + "P42: Ricerca efficiente di stringhe. " + "path e pattern obbligatori. max_results (default 20). " + "Ritorna results [{line, content}], count, limit_reached." + ), + "required_inputs": ["path", "pattern"], + "optional_inputs": {"max_results": 20}, + "risk_level": "low", + "fallbacks": ["read_file"], + "_fn": _grep_file, + }, + "file_metadata": { + "name": "file_metadata", + "goal": "Ottiene metadati del file (dimensione, numero righe) senza leggere il contenuto", + "description": ( + "P42: Analisi strutturale file. " + "path obbligatorio. Ritorna size_bytes, line_count, modified_at." + ), + "required_inputs": ["path"], + "optional_inputs": {}, + "risk_level": "low", + "fallbacks": [], + "_fn": _file_metadata, + }, +, " "suggestions[] (max 5 actionable), summary (stringa leggibile). " "Usare per: verificare sintassi prima di exec, analizzare qualità codice, " "ottenere metriche strutturali, suggerire refactoring." diff --git a/tools/registry_dev.py b/tools/registry_dev.py new file mode 100644 index 0000000000000000000000000000000000000000..da8c2082c1ca6d921a6e66037a38c59a67de8598 --- /dev/null +++ b/tools/registry_dev.py @@ -0,0 +1,514 @@ +"""registry_dev.py — Tool sviluppo: git, npm, pip, lint, scaffold tools leggeri. + +Estratto da registry.py per ridurre il file principale. + +Funzioni esportate: + _directory_tree, _file_search, _git_status, _git_clone, _git_diff, + _recall, _list_files, _diff_text, _validate_json, _lint_code_tool, + _git_push, _git_sync_vfs, _git_commit, _npm_install, _npm_run, + _pip_install, _type_check +""" +from __future__ import annotations +import httpx +import asyncio +import subprocess +import tempfile +import os +import sys +import logging +_logger = logging.getLogger("tools.registry") + +# ─── S763: 10 tool mancanti (S760 dichiarati mai implementati) ────────────── +# Presenti in planner.py PLANNER_SYSTEM, agent.py _STEP_VISIBILITY/_NARR_QUICK, +# _TOOL_NEEDED_RE ma senza _fn in TOOL_REGISTRY -> KeyError silenzioso al runtime. + +async def _directory_tree(path: str = ".", max_depth: int = 3, show_hidden: bool = False) -> dict: + """S763: Albero filesystem con os.walk.""" + import os as _os + try: + base = _os.path.abspath(path) + if not _os.path.isdir(base): + return {"error": f"Percorso non trovato: {path}"} + _IGNORE = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".next", ".cache"} + lines: list = [f"{base}/"] + count = 0 + for root, dirs, files in _os.walk(base): + depth = root.replace(base, "").count(_os.sep) + if depth >= max_depth: + dirs.clear() + continue + dirs[:] = sorted(d for d in dirs + if (show_hidden or not d.startswith(".")) and d not in _IGNORE) + ind = " " * (depth + 1) + for d in dirs: + lines.append(f"{ind}+-- {d}/") + for fname in sorted(files): + if not show_hidden and fname.startswith("."): + continue + if count >= 200: + lines.append(f"{ind}... (troncato)") + break + lines.append(f"{ind} {fname}") + count += 1 + return {"ok": True, "path": base, "tree": "\n".join(lines), "count": count} + except Exception as e: + return {"error": str(e)[:300]} + + +async def _file_search(pattern: str, path: str = ".", file_glob: str = "*") -> dict: + """S763: grep -rn con fallback Python os.walk+read.""" + import os as _os + try: + proc = await asyncio.create_subprocess_exec( + "grep", "-rn", "--include", file_glob, "--color=never", "-m", "5", + pattern, _os.path.abspath(path), + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, _ = await asyncio.wait_for(proc.communicate(), timeout=15) + lines = [l for l in out.decode("utf-8", errors="replace").splitlines() if l.strip()] + if proc.returncode == 1 and not lines: + return {"ok": True, "pattern": pattern, "matches": [], "count": 0, "note": "Nessun risultato"} + return {"ok": True, "pattern": pattern, "path": path, "matches": lines[:50], "count": len(lines)} + except (FileNotFoundError, asyncio.TimeoutError): + import re as _re2 + matches: list = [] + try: + _pat = _re2.compile(pattern, _re2.IGNORECASE) + for root, _, files in _os.walk(path): + for fname in files: + fpath = _os.path.join(root, fname) + try: + with open(fpath, "r", encoding="utf-8", errors="replace") as fh: + for i, line in enumerate(fh, 1): + if _pat.search(line): + matches.append(f"{fpath}:{i}: {line.rstrip()[:200]}") + if len(matches) >= 50: + break + except Exception: + continue + if len(matches) >= 50: + break + except Exception as ex: + return {"error": str(ex)[:300]} + return {"ok": True, "pattern": pattern, "matches": matches, "count": len(matches)} + except Exception as e: + return {"error": str(e)[:300]} + + +async def _git_status(cwd: str = ".") -> dict: + """S763: branch + status --short + log -5. Read-only.""" + try: + async def _rg(*args: str) -> str: + p = await asyncio.create_subprocess_exec( + "git", *args, cwd=cwd, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, _ = await asyncio.wait_for(p.communicate(), timeout=10) + return out.decode("utf-8", errors="replace").strip() + return { + "ok": True, + "branch": await _rg("rev-parse", "--abbrev-ref", "HEAD"), + "status": await _rg("status", "--short") or "(working tree clean)", + "log": await _rg("log", "--oneline", "-5"), + } + except Exception as e: + return {"error": str(e)[:300]} + + +async def _git_clone(url: str, directory: str = "", depth: int = 0) -> dict: + """S763: git clone [dir]. Timeout 120s. Risk medium.""" + try: + cmd = ["git", "clone"] + if depth > 0: + cmd += ["--depth", str(depth)] + cmd.append(url) + if directory: + cmd.append(directory) + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=120) + combined = (out + err).decode("utf-8", errors="replace")[:1000] + if proc.returncode == 0: + target = directory or url.rstrip("/").split("/")[-1].removesuffix(".git") + return {"ok": True, "directory": target, "output": combined} + return {"ok": False, "error": combined} + except asyncio.TimeoutError: + return {"ok": False, "error": "timeout 120s"} + except Exception as e: + return {"ok": False, "error": str(e)[:300]} + + +async def _git_diff(cwd: str = ".", staged: bool = False) -> dict: + """S763: git diff [--cached]. Stat + diff max 3000 chars. Read-only.""" + try: + extra = ["--cached"] if staged else [] + async def _rg(*a: str) -> str: + p = await asyncio.create_subprocess_exec( + "git", *a, cwd=cwd, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, _ = await asyncio.wait_for(p.communicate(), timeout=15) + return out.decode("utf-8", errors="replace") + stat = await _rg("diff", *extra, "--stat", "--no-color") + diff = await _rg("diff", *extra, "--no-color") + return {"staged": staged, "stat": stat[:1000], "diff": diff[:3000] or "(nessuna modifica)"} + except Exception as e: + return {"error": str(e)[:300]} + + + + +async def _recall(query: str, limit: int = 5) -> dict: + """S-GAP13: cerca in agentMemory (chiave/valore in-process). Fallback su entries recenti.""" + try: + from api.state import _get_mem_manager_async as _gmm + mem = await _gmm() + if mem is None: + return {"results": [], "note": "MemoryManager non disponibile"} + results = [] + try: + raw = await mem.search(query, limit=limit) + results = [{"key": r.get("key",""), "value": r.get("value",""), "score": r.get("score",0)} for r in (raw or [])] + except Exception: + try: + raw = await mem.list(limit=limit * 2) + q_lower = query.lower() + for r in (raw or []): + k = str(r.get("key","")).lower() + v = str(r.get("value","")).lower() + if q_lower in k or q_lower in v: + results.append({"key": r.get("key",""), "value": r.get("value","")}) + if len(results) >= limit: + break + except Exception as _exc: + _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 + return {"results": results, "count": len(results), "query": query} + except Exception as e: + return {"results": [], "error": str(e)[:200]} + + +async def _list_files(path: str = ".", recursive: bool = False, max_items: int = 100) -> dict: + """S-GAP13: elenca file nella directory. os.listdir/os.walk. Max 100 items.""" + import os as _os + try: + path = _os.path.abspath(path) + if not _os.path.exists(path): + return {"ok": False, "error": f"Path non trovato: {path}"} + if not _os.path.isdir(path): + return {"ok": False, "error": f"Non e una directory: {path}"} + items = [] + if recursive: + for root, dirs, files in _os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('node_modules','__pycache__','.git')] + rel_root = _os.path.relpath(root, path) + for f in files: + rel = _os.path.join(rel_root, f) if rel_root != '.' else f + items.append(rel) + if len(items) >= max_items: + break + if len(items) >= max_items: + break + else: + for entry in _os.scandir(path): + items.append(entry.name + ('/' if entry.is_dir() else '')) + if len(items) >= max_items: + break + return {"ok": True, "path": path, "items": items, "count": len(items), "truncated": len(items) >= max_items} + except Exception as e: + return {"ok": False, "error": str(e)[:300]} + + +def _diff_text(text_a: str, text_b: str, context_lines: int = 3) -> dict: + """S-GAP13: confronta due testi con difflib.unified_diff. Restituisce patch testo.""" + import difflib + try: + lines_a = text_a.splitlines(keepends=True) + lines_b = text_b.splitlines(keepends=True) + diff = list(difflib.unified_diff(lines_a, lines_b, fromfile="a", tofile="b", n=context_lines)) + patch = "".join(diff) + added = sum(1 for l in diff if l.startswith('+') and not l.startswith('+++')) + removed = sum(1 for l in diff if l.startswith('-') and not l.startswith('---')) + return {"patch": patch[:4000], "added": added, "removed": removed, "identical": len(diff) == 0} + except Exception as e: + return {"patch": "", "error": str(e)[:200]} + + +def _validate_json(json_str: str, schema: dict | None = None) -> dict: + """S-GAP13: valida JSON (json.loads). Con schema dict usa jsonschema se disponibile.""" + import json + try: + parsed = json.loads(json_str) + result: dict = {"valid": True, "type": type(parsed).__name__} + if schema: + try: + import jsonschema + jsonschema.validate(parsed, schema) + result["schema_valid"] = True + except ImportError: + result["schema_note"] = "jsonschema non installato — validazione struttura skippata" + except Exception as ve: + result["valid"] = False + result["schema_error"] = str(ve)[:400] + return result + except json.JSONDecodeError as e: + return {"valid": False, "error": f"JSON non valido: {e.msg} (riga {e.lineno}, col {e.colno})"} + except Exception as e: + return {"valid": False, "error": str(e)[:200]} + + +async def _lint_code_tool(content: str, language: str = "auto", path: str = "") -> dict: + """S-GAP13: analisi statica codice. Wrapper di api.linter.lint_code. Auto-detect da estensione.""" + try: + if language == "auto" and path: + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + language = {"py": "python", "js": "javascript", "jsx": "javascript", + "ts": "typescript", "tsx": "typescript", "json": "json"}.get(ext, "python") + from api.linter import lint_code as _lc + return await _lc(content=content, language=language, path=path) + except Exception as e: + return {"ok": False, "errors": [], "warnings": [], "error": str(e)[:200]} + +async def _git_push(remote: str = "origin", branch: str = "", cwd: str = ".") -> dict: + """S-GAP12: git push []. Timeout 70s. Risk high.""" + import asyncio as _aio + try: + cmd = ["git", "push", remote] + if branch: + cmd.append(branch) + proc = await _aio.create_subprocess_exec( + *cmd, cwd=cwd, + stdout=_aio.subprocess.PIPE, stderr=_aio.subprocess.PIPE, + ) + out, err = await _aio.wait_for(proc.communicate(), timeout=65) + combined = (out + err).decode("utf-8", errors="replace")[:800] + return {"ok": proc.returncode == 0, "output": combined, "code": proc.returncode} + except _aio.TimeoutError: + return {"ok": False, "error": "git push timeout (65s)"} + except Exception as e: + return {"ok": False, "error": str(e)[:300]} + + +async def _git_sync_vfs( + files: dict, + branch: str = "agent-state", + message: str = "chore(vfs): auto-sync session", + repo: str = "", +) -> dict: + """ + RF-1: git_sync_vfs — Commit atomico VFS→GitHub via Git Data API. + + Flusso: GET HEAD → POST blob×N → POST tree → POST commit → PATCH/POST ref. + Branch inesistente: creato automaticamente da HEAD di main. + Repo: param repo oppure env GITHUB_REPO. + Fail-safe: ritorna error se GITHUB_TOKEN mancante. + """ + import base64 + import httpx as _httpx + import os as _os + + gh_token = _os.environ.get("GITHUB_TOKEN", "") + if not gh_token: + return {"success": False, "error": "GITHUB_TOKEN non configurato"} + if not files: + return {"success": False, "error": "Nessun file da sincronizzare"} + + gh_repo = repo or _os.environ.get("GITHUB_REPO", "") + if not gh_repo: + return {"success": False, "error": "Specifica repo='owner/repo' oppure imposta GITHUB_REPO env"} + + _hdrs = { + "Authorization": f"token {gh_token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + } + base_url = f"https://api.github.com/repos/{gh_repo}" + + try: + async with _httpx.AsyncClient(timeout=30.0, headers=_hdrs) as _c: + # 1. Leggi HEAD branch target (o fallback a main) + base_sha: str | None = None + branch_exists = False + _ref_r = await _c.get(f"{base_url}/git/ref/heads/{branch}") + if _ref_r.status_code == 200: + base_sha = _ref_r.json()["object"]["sha"] + branch_exists = True + else: + _main_r = await _c.get(f"{base_url}/git/ref/heads/main") + if _main_r.status_code == 200: + base_sha = _main_r.json()["object"]["sha"] + else: + return {"success": False, "error": f"Impossibile leggere HEAD: {_main_r.status_code}"} + + # 2. Crea blob per ogni file + tree_items: list[dict] = [] + for fpath, content in files.items(): + if not isinstance(content, str): + content = str(content) + encoded = base64.b64encode(content.encode("utf-8", errors="replace")).decode() + _blob_r = await _c.post(f"{base_url}/git/blobs", json={"content": encoded, "encoding": "base64"}) + if _blob_r.status_code not in (200, 201): + return {"success": False, "error": f"Blob fail [{fpath}]: {_blob_r.status_code}"} + tree_items.append({"path": fpath, "mode": "100644", "type": "blob", "sha": _blob_r.json()["sha"]}) + + # 3. Crea tree + _tree_payload: dict = {"tree": tree_items} + if base_sha: + _tree_payload["base_tree"] = base_sha + _tree_r = await _c.post(f"{base_url}/git/trees", json=_tree_payload) + if _tree_r.status_code not in (200, 201): + return {"success": False, "error": f"Tree fail: {_tree_r.status_code}"} + tree_sha = _tree_r.json()["sha"] + + # 4. Crea commit + _commit_payload: dict = {"message": message, "tree": tree_sha} + if base_sha: + _commit_payload["parents"] = [base_sha] + _commit_r = await _c.post(f"{base_url}/git/commits", json=_commit_payload) + if _commit_r.status_code not in (200, 201): + return {"success": False, "error": f"Commit fail: {_commit_r.status_code}"} + commit_sha = _commit_r.json()["sha"] + + # 5. PATCH ref (o POST se branch nuovo) + if branch_exists: + _ref_upd = await _c.patch(f"{base_url}/git/refs/heads/{branch}", json={"sha": commit_sha}) + else: + _ref_upd = await _c.post(f"{base_url}/git/refs", json={"ref": f"refs/heads/{branch}", "sha": commit_sha}) + if _ref_upd.status_code not in (200, 201): + return {"success": False, "error": f"Ref update fail: {_ref_upd.status_code} — {_ref_upd.text[:200]}"} + + return { + "success": True, + "commit_sha": commit_sha, + "branch": branch, + "files_synced": len(tree_items), + "repo": gh_repo, + "url": f"https://github.com/{gh_repo}/tree/{branch}", + } + except Exception as _e: + return {"success": False, "error": f"git_sync_vfs errore: {str(_e)[:300]}"} + + +async def _git_commit(message: str, cwd: str = ".", push: bool = False, add_all: bool = True) -> dict: + """S763: git add -A + commit -m. push=True per git push. Risk medium.""" + try: + async def _run(cmd: list) -> tuple: + p = await asyncio.create_subprocess_exec( + *cmd, cwd=cwd, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, err = await asyncio.wait_for(p.communicate(), timeout=30) + return p.returncode, (out + err).decode("utf-8", errors="replace")[:500] + if add_all: + rc, out = await _run(["git", "add", "-A"]) + if rc != 0: + return {"ok": False, "step": "git add", "error": out} + rc, out = await _run(["git", "commit", "-m", message]) + if rc != 0: + return {"ok": False, "step": "git commit", "error": out} + result: dict = {"ok": True, "commit_output": out} + if push: + rc_p, out_p = await _run(["git", "push"]) + result["push_ok"] = rc_p == 0 + result["push_output"] = out_p + return result + except Exception as e: + return {"ok": False, "error": str(e)[:300]} + + +async def _npm_install(cwd: str = ".", manager: str = "auto", args: str = "") -> dict: + """S763: npm/pnpm/yarn install. Auto-detecta da lockfile. Timeout 120s.""" + import os as _os + try: + if manager == "auto": + manager = ("pnpm" if _os.path.exists(_os.path.join(cwd, "pnpm-lock.yaml")) + else "yarn" if _os.path.exists(_os.path.join(cwd, "yarn.lock")) + else "npm") + cmd = [manager, "install"] + ([args] if args else []) + proc = await asyncio.create_subprocess_exec( + *cmd, cwd=cwd, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=120) + combined = (out + err).decode("utf-8", errors="replace")[:2000] + return {"ok": proc.returncode == 0, "manager": manager, "output": combined, "code": proc.returncode} + except asyncio.TimeoutError: + return {"ok": False, "error": f"{manager} install timeout (120s)"} + except Exception as e: + return {"ok": False, "error": str(e)[:300]} + + +async def _npm_run(script: str, cwd: str = ".", manager: str = "auto") -> dict: + """S763: npm/pnpm/yarn run \n' + ), + "src/main.tsx": ( + 'import { StrictMode } from "react";\n' + 'import { createRoot } from "react-dom/client";\n' + 'import App from "./App";\n' + 'createRoot(document.getElementById("root")!).render();' + ), + "src/App.tsx": ( + 'export default function App() {\n' + ' return
\n' + '

' + _pn + '

\n' + '

Modifica src/App.tsx per iniziare.

\n' + '
;\n}' + ), + "src/index.css": "body{margin:0;font-family:system-ui,sans-serif}", + "vite.config.ts": ( + 'import { defineConfig } from "vite";\nimport react from "@vitejs/plugin-react";\n' + 'export default defineConfig({ plugins: [react()] });' + ), + }, + "nextjs": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0",' + '"scripts":{"dev":"next dev","build":"next build","start":"next start"},' + '"dependencies":{"next":"^15.1.0","react":"^19","react-dom":"^19"},' + '"devDependencies":{"typescript":"^5","@types/node":"^20","@types/react":"^19",\"@types/react-dom\":\"^19\"}}' + ), + "app/layout.tsx": ( + 'export const metadata = { title: "' + _pn + '" };\n' + 'export default function Layout({ children }: { children: React.ReactNode }) {\n' + ' return {children};\n}' + ), + "app/page.tsx": ( + '"use client";\n' + 'export default function Page() {\n' + ' return

' + _pn + '

;\n}' + ), + "next.config.mjs": "const nextConfig = {};\nexport default nextConfig;", + }, + "fastapi": { + "main.py": ( + 'from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\n\n' + 'app = FastAPI(title="' + _pn + '", version="0.1.0")\n' + 'app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])\n\n' + '@app.get("/health")\nasync def health(): return {"status": "ok"}\n\n' + '@app.get("/")\nasync def root(): return {"message": "Benvenuto in ' + _pn + '"}\n' + ), + "requirements.txt": "fastapi>=0.115.0\nuvicorn[standard]>=0.30.0\nhttpx>=0.27.0\n", + "Dockerfile": ( + 'FROM python:3.11-slim\nWORKDIR /app\n' + 'COPY requirements.txt .\nRUN pip install -r requirements.txt\n' + 'COPY . .\nEXPOSE 8000\nCMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]' + ), + ".gitignore": "__pycache__/\n*.pyc\n.env\n", + }, + "flask": { + "app.py": ( + 'from flask import Flask, jsonify\nfrom flask_cors import CORS\n\n' + 'app = Flask(__name__)\nCORS(app)\n\n' + '@app.get("/health")\ndef health(): return jsonify({"status": "ok"})\n\n' + '@app.get("/")\ndef root(): return jsonify({"message": "Benvenuto in ' + _pn + '"})\n\n' + 'if __name__ == "__main__":\n app.run(debug=True, host="0.0.0.0", port=5000)\n' + ), + "requirements.txt": "flask>=3.0.0\nflask-cors>=4.0.0\ngunicorn>=21.2.0\n", + ".gitignore": "__pycache__/\n*.pyc\n.env\n", + }, + "django": { + "manage.py": ( + '#!/usr/bin/env python\nimport os, sys\n' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE","config.settings")\n' + 'from django.core.management import execute_from_command_line\nexecute_from_command_line(sys.argv)\n' + ), + "requirements.txt": "django>=5.0\ndjangorestframework>=3.15\ndjango-cors-headers>=4.3\ngunicorn>=21.2.0\n", + "config/__init__.py": "", + "config/settings.py": ( + 'from pathlib import Path\nBASE_DIR=Path(__file__).resolve().parent.parent\n' + 'SECRET_KEY="change-me-in-production"\nDEBUG=True\nALLOWED_HOSTS=["*"]\n' + 'INSTALLED_APPS=["django.contrib.contenttypes","django.contrib.auth","rest_framework","corsheaders"]\n' + 'MIDDLEWARE=["corsheaders.middleware.CorsMiddleware","django.middleware.common.CommonMiddleware"]\n' + 'ROOT_URLCONF="config.urls"\nDEFAULT_AUTO_FIELD="django.db.models.BigAutoField"\nCORS_ALLOW_ALL_ORIGINS=True\n' + ), + "config/urls.py": 'from django.urls import path, include\nurlpatterns=[path("api/", include("api.urls"))]\n', + "api/__init__.py": "", + "api/views.py": ( + 'from rest_framework.decorators import api_view\nfrom rest_framework.response import Response\n\n' + '@api_view(["GET"])\n' + 'def hello(request): return Response({"message": "Benvenuto in ' + _pn + '"})\n' + ), + "api/urls.py": 'from django.urls import path\nfrom . import views\nurlpatterns=[path("", views.hello)]\n', + }, + "express": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"start":"node src/index.js","dev":"node --watch src/index.js"},' + '"dependencies":{"express":"^4.19.2"}}' + ), + "src/index.js": ( + 'import express from "express";\n' + 'const app=express(), PORT=process.env.PORT||3000;\n' + 'app.use(express.json());\n' + 'app.get("/health", (_, res) => res.json({ status: "ok" }));\n' + 'app.get("/", (_, res) => res.json({ message: "Benvenuto in ' + _pn + '" }));\n' + 'app.listen(PORT, () => console.log("Server: http://localhost:" + PORT));\n' + ), + ".gitignore": "node_modules/\n.env\n", + }, + "astro": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"dev":"astro dev","build":"astro build","preview":"astro preview"},' + '"dependencies":{"astro":"^4.11.0"}}' + ), + "astro.config.mjs": ( + 'import { defineConfig } from "astro/config";\n' + 'export default defineConfig({});\n' + ), + "src/pages/index.astro": ( + '---\nconst title = "' + _pn + '";\n---\n' + '\n {title}\n' + ' \n

{title}

\n' + '

Modifica src/pages/index.astro per iniziare.

\n' + ' \n\n' + ), + "src/layouts/Layout.astro": ( + '---\nconst { title } = Astro.props;\n---\n' + '\n\n' + ' {title}\n' + ' \n\n' + ), + ".gitignore": "node_modules/\ndist/\n.astro/\n.env\n", + }, + "sveltekit": { + "package.json": ( + '{"name":"' + _safe + '","version":"0.1.0","type":"module",' + '"scripts":{"dev":"vite dev","build":"vite build","preview":"vite preview"},' + '"dependencies":{"@sveltejs/kit":"^2.5.0","svelte":"^4.2.0"},' + '"devDependencies":{"@sveltejs/adapter-auto":"^3.2.0","vite":"^5.3.0"}}' + ), + "svelte.config.js": ( + 'import adapter from "@sveltejs/adapter-auto";\n' + 'export default { kit: { adapter: adapter() } };\n' + ), + "vite.config.js": ( + 'import { sveltekit } from "@sveltejs/kit/vite";\n' + 'import { defineConfig } from "vite";\n' + 'export default defineConfig({ plugins: [sveltekit()] });\n' + ), + "src/routes/+page.svelte": ( + '\n' + '
\n

{title}

\n' + '

Modifica src/routes/+page.svelte per iniziare.

\n' + '
\n' + ), + "src/routes/+layout.svelte": '\n', + ".gitignore": "node_modules/\nbuild/\n.svelte-kit/\n.env\n", + }, + } + + _match = 'react' + for _k in _TEMPLATES: + if _k in _fw or _fw.startswith(_k[:4]): + _match = _k + break + + _tpl = _TEMPLATES[_match] + _created: list[str] = [] + _errs_scaf: list[str] = [] + for _rel, _content in _tpl.items(): + _full = _os.path.join(_base, _rel) + _os.makedirs(_os.path.dirname(_full), exist_ok=True) + try: + with open(_full, 'w', encoding='utf-8') as _f: + _f.write(_content) + _created.append(_rel) + except Exception as _e: + _errs_scaf.append(f'{_rel}: {_e}') + + _steps_map = { + 'react': 'npm install && npm run dev', + 'nextjs': 'npm install && npm run dev', + 'fastapi': 'pip install -r requirements.txt && uvicorn main:app --reload', + 'flask': 'pip install -r requirements.txt && python app.py', + 'django': 'pip install -r requirements.txt && python manage.py runserver', + 'express': 'npm install && npm run dev', + 'astro': 'npm install && npm run dev', + 'sveltekit': 'npm install && npm run dev', + } + _next_step = _steps_map.get(_match, 'installa le dipendenze') + _out = ( + f'Scaffold **{_match}** per **{_pn}** — {len(_created)} file creati:\n' + + '\n'.join(f' {p}' for p in _created) + + f'\n\nDirectory: {_base}' + + f'\n\nProssimi passi: cd {_safe} && {_next_step}' + ) + if _errs_scaf: + _out += f'\n\nErrori: {"; ".join(_errs_scaf)}' + # GAP-X6: restituisce anche il dict files → usato da /api/scaffold_project + # Il frontend li scrive nel VFS con vfsAsync.write() — zero passaggi via agent. + return { + 'success': True, + 'output': _out, + 'files': _tpl, # dict {rel_path: content} — già con project_name interpolato + 'framework': _match, + 'project_name': _pn, + 'project_dir': f'/{_safe}', # percorso VFS suggerito + 'created': _created, + } + +# ─── S-GAP15: create_chart ────────────────────────────────────────────────── +async def _create_chart( + chart_type: str = "bar", + data: dict | None = None, + title: str = "", + x_label: str = "", + y_label: str = "", + labels: list | None = None, + values: list | None = None, +) -> dict: + """ + S-GAP15: Genera un grafico come immagine PNG base64 usando matplotlib (se disponibile), + fallback SVG testuale per ambienti senza display. + chart_type: bar | line | pie | scatter + data: dict {label: value} oppure usa labels/values separati + """ + import base64 + import io + + # Normalizza input + if data and isinstance(data, dict): + _labels = list(data.keys()) + _values = [float(v) for v in data.values()] + else: + _labels = labels or [] + _values = [float(v) for v in (values or [])] + + if not _labels or not _values: + return {"error": "Devi fornire 'data' (dict) oppure 'labels' e 'values' (list)."} + + # Tentativo matplotlib (potrebbe non essere disponibile in tutti gli ambienti) + try: + import matplotlib + matplotlib.use("Agg") # Headless — nessun display necessario + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(8, 5)) + ct = chart_type.lower().strip() + if ct == "bar": + ax.bar(_labels, _values) + elif ct == "line": + ax.plot(_labels, _values, marker="o") + elif ct == "pie": + ax.pie(_values, labels=_labels, autopct="%1.1f%%") + elif ct == "scatter": + ax.scatter(range(len(_values)), _values) + ax.set_xticks(range(len(_labels))) + ax.set_xticklabels(_labels, rotation=45, ha="right") + else: + ax.bar(_labels, _values) # default bar + + if title: ax.set_title(title) + if x_label: ax.set_xlabel(x_label) + if y_label: ax.set_ylabel(y_label) + plt.tight_layout() + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=100) + plt.close(fig) + buf.seek(0) + b64 = base64.b64encode(buf.read()).decode("utf-8") + return { + "type": "image/png", + "format": "base64", + "data": b64, + "chart_type": ct, + "title": title, + "note": f"Grafico {ct} generato con matplotlib. Mostra l'immagine con: ", + } + except ImportError: + pass # matplotlib non disponibile → fallback SVG + + # Fallback SVG testuale (sempre disponibile) + _max_v = max(_values) if _values else 1 + _bar_w = 60 + _gap = 10 + _h = 200 + _svg_w = len(_labels) * (_bar_w + _gap) + 60 + bars = "" + for i, (lbl, val) in enumerate(zip(_labels, _values)): + bh = int((_h - 40) * val / _max_v) if _max_v else 1 + x = 40 + i * (_bar_w + _gap) + y = _h - bh - 20 + bars += f'' + bars += f'{str(lbl)[:10]}' + bars += f'{val}' + title_tag = f'{title}' if title else "" + svg = f'{title_tag}{bars}' + import base64 as _b64 + svg_b64 = _b64.b64encode(svg.encode()).decode() + return { + "type": "image/svg+xml", + "format": "base64", + "data": svg_b64, + "chart_type": "bar_svg_fallback", + "title": title, + "note": "matplotlib non disponibile — grafico SVG testuale (fallback). Installa matplotlib per grafici PNG di qualità.", + } + + + +# ─── P17-F4-REG: trigger_webhook — registra il tool nel registry ──────────── + diff --git a/tools/registry_web.py b/tools/registry_web.py new file mode 100644 index 0000000000000000000000000000000000000000..cce9cb10688d39f937adbad2e0d1a05ccbd86939 --- /dev/null +++ b/tools/registry_web.py @@ -0,0 +1,517 @@ +"""registry_web.py — Tool web: search, meteo, calcolo, Python, immagini, browser. + +Estratto da registry.py per ridurre il file principale. + +Funzioni esportate: + _web_search, _read_page, _get_weather, _calculate, _run_python, + _generate_image, _browser_navigate, _browser_session_open, + _browser_session_act, _browser_session_close, _get_news +""" +from __future__ import annotations +import httpx +import asyncio +import subprocess +import tempfile +import os +import sys +import logging +_logger = logging.getLogger("tools.registry") + +async def _web_search(query: str, max_results: int = 5) -> dict: + """ + S357: Parallelismo completo — tutti e 4 i provider lanciati con asyncio.gather. + Worst case: 10s (timeout singolo provider) invece di 40s+ (4 × 10s sequenziali). + Merge con deduplicazione per URL, priorità Brave > Tavily > Wikipedia > DDG. + """ + import re as _re, html as _html, urllib.parse as _urlparse, urllib.request as _urlreq + _headers = {"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"} + brave_key = os.environ.get("BRAVE_SEARCH_API_KEY", "") + tavily_key = os.environ.get("TAVILY_API_KEY", "") + + async def _brave() -> list: + if not brave_key: + return [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": max_results, "text_decorations": "0"}, + headers={**_headers, "Accept": "application/json", "X-Subscription-Token": brave_key}, + ) + if r.status_code == 200: + return [ + # S602: snippet 300→500 — Brave description[:300] parity con Tavily + {"title": it["title"], "snippet": (it.get("description") or "")[:500], + "url": it["url"], "source": "Brave"} + for it in r.json().get("web", {}).get("results", [])[:max_results] + if it.get("title") and it.get("url") + ] + except Exception as _exc: + _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 + return [] + + async def _tavily() -> list: + if not tavily_key: + return [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.post( + "https://api.tavily.com/search", + json={"api_key": tavily_key, "query": query, + "max_results": max_results, "include_answer": False}, + headers={**_headers, "Content-Type": "application/json"}, + ) + if r.status_code == 200: + return [ + # S600: snippet 300→500 — Tavily restituisce snippet più ricchi + {"title": it["title"], "snippet": (it.get("content") or "")[:500], + "url": it["url"], "source": "Tavily"} + for it in r.json().get("results", [])[:max_results] + if it.get("title") and it.get("url") + ] + except Exception as _exc: + _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 + return [] + + async def _wikipedia() -> list: + try: + wiki_qs = _urlparse.urlencode({ + "action": "query", "list": "search", "srsearch": query, + "format": "json", "utf8": "1", "srlimit": min(max_results, 4), "srnamespace": "0", + }) + wiki_req = _urlreq.Request( + f"https://en.wikipedia.org/w/api.php?{wiki_qs}", + headers={"User-Agent": "agente-ai/3.2"}, + ) + wiki_data = await asyncio.to_thread( + lambda: __import__("json").loads(_urlreq.urlopen(wiki_req, timeout=7).read()) + ) + return [ + { + "title": item.get("title", ""), + "url_trunc": ("https://en.wikipedia.org/wiki/" + item.get("title", "")).replace(" ", "_"), # S600: snippet 300→500 — Wikipedia snippet può essere più lungo + # S600: snippet 300→500 — Wikipedia snippet può essere più lungo + "snippet": _html.unescape(_re.sub(r"<[^>]+>", "", item.get("snippet", "")))[:500], + "source": "Wikipedia", + } + for item in wiki_data.get("query", {}).get("search", [])[:max_results] + ] + except Exception: + return [] + + async def _ddg() -> list: + try: + ddg_qs = _urlparse.urlencode({"q": query, "kl": "it-it"}) + ddg_req = _urlreq.Request( + f"https://html.duckduckgo.com/html/?{ddg_qs}", + headers={ + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "it-IT,it;q=0.9,en;q=0.8", + }, + ) + raw = await asyncio.to_thread( + lambda: _urlreq.urlopen(ddg_req, timeout=10).read().decode("utf-8", errors="replace") + ) + links = _re.findall(r'class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)', raw, _re.S) + snippets = _re.findall(r'class="result__snippet"[^>]*>(.*?)', raw, _re.S) + out = [] + for i, (href, title_html) in enumerate(links[:max_results]): + title = _html.unescape(_re.sub(r"<[^>]+>", "", title_html)).strip() + snippet = _html.unescape(_re.sub(r"<[^>]+>", "", snippets[i] if i < len(snippets) else "")).strip() + if title and href.startswith("http"): + # S600: snippet 300→500 — DDG snippet spesso viene troncato a 300 + out.append({"title": title, "url": href, "snippet": snippet[:500], "source": "DDG"}) # S600 + return out + except Exception: + return [] + + async def _jina() -> list: + """S378: Jina Search — gratuito, no API key, fallback affidabile.""" + try: + import urllib.parse as _up + jina_url = f"https://s.jina.ai/?q={_up.quote(query)}" + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + jina_url, + headers={**_headers, "Accept": "application/json", "X-No-Cache": "true"}, + ) + if r.status_code == 200: + data = r.json().get("data", []) + return [ + { + "title": item.get("title", ""), + "url": item.get("url", ""), + # S602: snippet 300→500 — Jina description/content parity con altri provider + "snippet": (item.get("description") or item.get("content") or "")[:500], + "source": "Jina", + } + for item in data[:max_results] + if item.get("title") and item.get("url") + ] + except Exception as _exc: + _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 + return [] + + async def _hackernews() -> list: + """S763: HackerNews via Algolia — gratuito, no API key, qualità alta su query tech.""" + try: + async with httpx.AsyncClient(timeout=8) as c: + r = await c.get( + "https://hn.algolia.com/api/v1/search", + params={"query": query, "hitsPerPage": min(max_results, 4), "tags": "story"}, + ) + if r.status_code == 200: + return [ + {"title": h.get("title", "")[:300], # S607: 200→300 + "snippet": (h.get("story_text") or "")[:300], # S583: 250→300 + "url": h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}", + "source": "HackerNews"} + for h in r.json().get("hits", []) + if h.get("title") + ] + except Exception as _exc: + _logger.debug("[registry] silenced %s", type(_exc).__name__) # noqa: BLE001 + return [] + + # S357: lancio parallelo — worst case = max timeout singolo (10s), non 4×10s=40s + # S378: Jina aggiunto come 5° provider (gratuito, no API key) + # GAP3-fix: HackerNews aggiunto come 6° provider (tech quality, da web_search.py ora integrato) + _gather_results = await asyncio.gather( + _brave(), _tavily(), _wikipedia(), _ddg(), _jina(), _hackernews(), + return_exceptions=True, + ) + all_lists = [r for r in _gather_results if not isinstance(r, BaseException)] + + # Merge con deduplicazione per URL (priorità: Brave > Tavily > Wikipedia > DDG > Jina) + seen_urls: set = set() + results: list = [] + for res_list in all_lists: + for item in (res_list if isinstance(res_list, list) else []): + url = item.get("url", "") + if url and url not in seen_urls: + seen_urls.add(url) + results.append(item) + if len(results) >= max_results: + break + if len(results) >= max_results: + break + + return {"query": query, "results": results[:max_results]} + +async def _read_page(url: str, query: str = "") -> dict: + """S763: upgrade — usa extract_with_trafilatura (Readability-quality). + Fallback a regex strip se trafilatura non disponibile. + query opzionale per filtrare paragrafi rilevanti. + """ + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c: + r = await c.get( + url, + headers={"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}, + ) + try: + from tools.content_cleaner import extract_with_trafilatura + result = extract_with_trafilatura( + html=r.text, url=url, query=query or None, max_chars=5000, + ) + return { + "url": url, + "content": result["content"], + "status": r.status_code, + "extractor": result.get("extractor", "trafilatura"), + "chars": result.get("chars", 0), + } + except ImportError: + import re as _re + _c = _re.sub(r"<[^>]+>", " ", r.text) + _c = _re.sub(r"\s+", " ", _c).strip() + return {"url": url, "content": _c[:5000], "status": r.status_code, "extractor": "regex"} + except Exception as e: + return {"url": url, "content": "", "error": str(e)[:300]} + +async def _get_weather(city: str) -> dict: + try: + async with httpx.AsyncClient(timeout=8) as c: + # S390-B-H: usa params= invece di f-string per URL encoding corretto. + # f-string non codifica spazi/accenti → API restituisce 0 risultati per "New York", "Reggio Emilia", ecc. + geo = await c.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={"name": city, "count": 1, "language": "it", "format": "json"}, + ) + results = geo.json().get("results", []) + if not results: + return {"error": f"Città '{city}' non trovata"} + loc = results[0] + weather = await c.get( + f"https://api.open-meteo.com/v1/forecast?latitude={loc['latitude']}&longitude={loc['longitude']}" + f"¤t=temperature_2m,weather_code,wind_speed_10m&timezone=auto" + ) + w = weather.json().get("current", {}) + return {"city": loc["name"], "country": loc.get("country", ""), "temp_c": w.get("temperature_2m"), "wind_kmh": w.get("wind_speed_10m"), "code": w.get("weather_code")} + except Exception as e: + return {"error": str(e)} + +async def _calculate(expression: str) -> dict: + try: + import ast, operator + # S390-B-M: aggiunto Mod (%) e FloorDiv (//) agli operator consentiti + allowed = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg, ast.Mod: operator.mod, ast.FloorDiv: operator.floordiv} + def eval_node(node): + if isinstance(node, ast.Constant): return node.value + if isinstance(node, ast.BinOp): return allowed[type(node.op)](eval_node(node.left), eval_node(node.right)) + if isinstance(node, ast.UnaryOp): return allowed[type(node.op)](eval_node(node.operand)) + raise ValueError("Operazione non supportata") + tree = ast.parse(expression, mode='eval') + result = eval_node(tree.body) + return {"expression": expression, "result": result} + except Exception as e: + return {"expression": expression, "error": str(e)} + +async def _run_python(code: str) -> dict: + """S574/S749: run_python con backend-exec microservice (sandbox persistente per sessione). + + Priorità: + 1. backend-exec (EXEC_ENGINE_URL configurato) — sandbox persistente, pip install per sessione, + resource limits 512 MB, TTL cleaner. Usa AGENT_SESSION_ID env come session_id. + 2. Fallback: exec_sandbox locale (S574) — tmpdir effimera, comportamento pre-S749. + + Vantaggio chiave: se lo step precedente ha installato pandas, questo step lo trova. + """ + # S749-D: session_id da ContextVar (impostata da unified_loop per task isolation) + _session_id = _agent_session_id_var.get() + _remote = await _call_exec_engine({ + "code": code, + "language": "python", + "timeout": 15, + "session_id": _session_id, + }) + if _remote is not None: + # Normalizza formato: backend-exec → {exit_code, stdout, stderr} + # exec_sandbox → {returncode, stdout, stderr} + return { + "returncode": _remote.get("exit_code", -1), + "stdout": _remote.get("stdout", ""), + "stderr": _remote.get("stderr", ""), + } + # Fallback locale (S574) + from api.exec_sandbox import run_in_sandbox_async + return await run_in_sandbox_async(code, lang="python", + task_id="tool_run_python", timeout=15.0) # S574: task isolamento per run_python + + + +async def _generate_image(prompt: str, width: int = 512, height: int = 512) -> dict: + """ + Genera immagine AI: FLUX.1-schnell via backend /api/vision/generate (HF Space, gratuito), + fallback Pollinations AI se FLUX non disponibile. + """ + import urllib.parse + + # V001: Prova prima il backend interno FLUX (stesso processo, localhost) + _base_url = os.environ.get("BACKEND_BASE_URL", "http://localhost:7860") + _token = os.environ.get("INTERNAL_TOKEN", "") + try: + async with httpx.AsyncClient(timeout=30.0) as c: + _r = await c.post( + f"{_base_url}/api/vision/generate", + json={"prompt": prompt.strip()[:600], "width": width, "height": height}, + headers={**({"X-Internal-Token": _token} if _token else {})}, + ) + if _r.status_code == 200: + _data = _r.json() + if _data.get("ok") and _data.get("image_url"): + return { + "url": _data["image_url"], + "prompt": prompt, + "width": width, + "height": height, + "ready": True, + "source": "flux", + "note": "Immagine generata con FLUX.1-schnell via backend.", + } + except Exception: + pass # FLUX non raggiungibile → Pollinations fallback + + # Fallback: Pollinations AI (gratuito, nessuna API key) + encoded = urllib.parse.quote(prompt.strip(), safe="") + seed = sum(ord(c) for c in prompt) % 9999 + 1 + url = ( + f"https://image.pollinations.ai/prompt/{encoded}" + f"?width={width}&height={height}&seed={seed}&nologo=true&enhance=true" + ) + return { + "url": url, + "prompt": prompt, + "width": width, + "height": height, + "ready": True, + "source": "pollinations", + "note": "Copia l\'URL nel browser o incollalo in un tag per vedere l\'immagine.", + } + +async def _browser_navigate(url: str, wait_ms: int = 2000, mobile: bool = False) -> dict: + """ + S403 — Limite 1 (criticità 10/10): Browser execution layer. + Naviga a una URL con Playwright headless, restituisce titolo + testo + DOM links/inputs. + Stateless (nessuna sessione persistente) — usa per lettura/scraping rapido. + Playwright è già installato nel backend (browser.py v5 S65). + """ + try: + from api.browser import ( + _safe_url, _LAUNCH_ARGS, _make_context, _DOM_SCRIPT, + GOTO_TIMEOUT, MAX_LINKS, MAX_INPUTS, MAX_TEXT + ) + if not _safe_url(url): + return {"error": "URL non consentita (localhost/intranet bloccato per sicurezza)"} + from playwright.async_api import async_playwright + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS) + ctx = await _make_context(browser, 1280, 800, mobile) + page = await ctx.new_page() + try: + await page.goto(url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT) + await page.wait_for_timeout(wait_ms) + title = await page.title() + dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT)) + text = (dom_raw.get("text") or "")[:2000] if isinstance(dom_raw, dict) else "" + links = (dom_raw.get("links") or [])[:15] if isinstance(dom_raw, dict) else [] + inputs = (dom_raw.get("inputs") or [])[:10] if isinstance(dom_raw, dict) else [] + return { + "url": page.url, + "title": title, + "text": text, + "links": links, + "inputs": inputs, + } + except Exception as e: + # S600: 300→500 — browser inner exception può contenere stack/path + return {"url": url, "error": str(e)[:500]} + finally: + await ctx.close() + await browser.close() + except ImportError: + return {"error": "Playwright non disponibile — usa read_page come alternativa"} + except Exception as e: + # S600: 300→500 — outer exception + return {"error": str(e)[:500]} + + +async def _browser_session_open(url: str, wait_ms: int = 1500, mobile: bool = False) -> dict: + """ + S403 — Apre sessione Playwright persistente (stateful). + Restituisce session_id da usare con browser_session_act e browser_session_close. + Max 2 sessioni simultanee (OOM guard HF free tier). + Usa questa modalità per task multi-step: login → naviga → compila → invia. + """ + try: + from api.browser import ( + _safe_url, _LAUNCH_ARGS, _make_context, _DOM_SCRIPT, + _sessions, SESSION_LIMIT, GOTO_TIMEOUT, MAX_LINKS, MAX_INPUTS, MAX_TEXT, + _close_session + ) + import uuid, time + if not _safe_url(url): + return {"error": "URL non consentita"} + if len(_sessions) >= SESSION_LIMIT: + oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"]) + await _close_session(oldest, "OOM guard from tool") + from playwright.async_api import async_playwright + pw = await async_playwright().start() + browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS) + ctx = await _make_context(browser, 1280, 800, mobile) + page = await ctx.new_page() + await page.goto(url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT) + await page.wait_for_timeout(wait_ms) + title = await page.title() + dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT)) + sid = uuid.uuid4().hex[:16] + _sessions[sid] = { + "pw": pw, "browser": browser, "context": ctx, "page": page, + "created_at": time.time(), "last_used": time.time(), "url": page.url, + } + return { + "session_id": sid, + "url": page.url, + "title": title, + "text": (dom_raw.get("text") or "")[:1500] if isinstance(dom_raw, dict) else "", + "links": (dom_raw.get("links") or [])[:15] if isinstance(dom_raw, dict) else [], + "inputs": (dom_raw.get("inputs") or [])[:10] if isinstance(dom_raw, dict) else [], + } + except ImportError: + return {"error": "Playwright non disponibile"} + except Exception as e: + # S600: 300→500 — registry browser session exception + return {"error": str(e)[:500]} + + +async def _browser_session_act(session_id: str, actions: list, wait_ms: int = 1000) -> dict: + """ + S403 — Esegue azioni su sessione Playwright esistente. + actions: lista di {type, selector?, value?, key?, ms?} + Tipi supportati: click, fill, select, press, hover, wait_for, wait, scroll. + Restituisce DOM aggiornato (titolo + testo + links + inputs). + """ + try: + from api.browser import ( + _sessions, _execute_actions, _DOM_SCRIPT, + MAX_LINKS, MAX_INPUTS, MAX_TEXT + ) + import time + sess = _sessions.get(session_id) + if not sess: + return {"error": f"Sessione {session_id} non trovata o scaduta (TTL 8 min)"} + sess["last_used"] = time.time() + page = sess["page"] + + # Converti lista dict → oggetti con attributo .type, .selector, ecc. + class _A: + def __init__(self, d: dict): + self.type = d.get("type", "") + self.selector = d.get("selector") + self.value = d.get("value") + self.key = d.get("key") + self.ms = d.get("ms") + + await _execute_actions(page, [_A(a) for a in (actions or [])]) + await page.wait_for_timeout(wait_ms) + sess["url"] = page.url + title = await page.title() + dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT)) + return { + "session_id": session_id, + "url": page.url, + "title": title, + "text": (dom_raw.get("text") or "")[:1500] if isinstance(dom_raw, dict) else "", + "links": (dom_raw.get("links") or [])[:15] if isinstance(dom_raw, dict) else [], + "inputs": (dom_raw.get("inputs") or [])[:10] if isinstance(dom_raw, dict) else [], + } + except ImportError: + return {"error": "Playwright non disponibile"} + except Exception as e: + # S601: 300→500 — parity con altri session handler + return {"error": str(e)[:500]} + + +async def _browser_session_close(session_id: str) -> dict: + """S403 — Chiude sessione Playwright e libera memoria (~300 MB/sessione).""" + try: + from api.browser import _sessions, _close_session + if session_id not in _sessions: + return {"ok": True, "note": "Sessione già chiusa o non trovata"} + await _close_session(session_id, "tool call") + return {"ok": True, "session_id": session_id} + except Exception as e: + return {"ok": False, "error": str(e)[:300]} # S589: 200→300 + + +async def _get_news(query: str, max_results: int = 5) -> dict: + """S378: get_news — alias di web_search con query ottimizzata per notizie recenti. + smolagents chiama questo tool per richieste di tipo 'notizie su X'. + """ + news_query = f"notizie recenti {query}" if not any( + kw in query.lower() for kw in ("notizie", "news", "ultime", "latest", "breaking") + ) else query + return await _web_search(news_query, max_results=max_results) + +