diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index d19e2fe46d9897378bf1fb16b499e9478b9ee7da..5cee92c2d923da119f67dff6b6c5cd995bf53100 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,4 +38,4 @@ COPY --chown=user . /home/user/app/ EXPOSE 7860 -CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${WEB_CONCURRENCY:-2}"] +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"] diff --git a/REBUILD_TRIGGER b/REBUILD_TRIGGER new file mode 100644 index 0000000000000000000000000000000000000000..fc13f38f3b6fb446eb5419f850e65c767c44cdf2 --- /dev/null +++ b/REBUILD_TRIGGER @@ -0,0 +1 @@ +rebuild diff --git a/agents/audit_semantic_l2.py b/agents/audit_semantic_l2.py new file mode 100644 index 0000000000000000000000000000000000000000..2dab4e8b60a9ad0b28cf38daf6144a200cd98655 --- /dev/null +++ b/agents/audit_semantic_l2.py @@ -0,0 +1,303 @@ +""" +audit_semantic_l2.py — S303: Audit Semantico L2 (Critico Senior) su Nodo D. + +L1 (goal_verifier.py) valida se la risposta *aderisce* al goal. +L2 (questo file) verifica la *coerenza logica interna* dell'output: + - Nessuna contraddizione auto-referenziale + - Claim verificabili non inventati (anti-hallucination guard) + - Completezza rispetto ai sotto-obiettivi esplicitati nel goal + - Stato outcome: PASS / FAIL / UNKNOWN — mai forzare PASS + +Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py. +Se L1 = FAIL → L2 non viene invocato (risparmio token). +Se L1 = PASS o UNKNOWN → L2 aggiunge una seconda garanzia semantica. + +Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + +_logger = logging.getLogger("agents.audit_l2") + + +class AuditStatus(str, Enum): + PASS = "PASS" + FAIL = "FAIL" + UNKNOWN = "UNKNOWN" + + +@dataclass +class AuditL2Result: + status: AuditStatus + confidence: float = 0.0 # 0.0 – 1.0 + issues: list[str] = field(default_factory=list) + repair_hint: str = "" + engine: str = "heuristic" # "heuristic" | "llm" + + +# ── Pattern anti-hallucination ──────────────────────────────────────────────── +# Claim di azioni che l'agente NON può eseguire da solo senza tool confirmation. +# Copre IT / EN / ES / FR — le 4 lingue attive nel cluster. +_HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [ + # Deploy / publish + (re.compile( + r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|" + r"ho caricato|ho aggiornato il server|ho rilasciato|" + r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|" + r"he desplegado|he publicado|he enviado|he subido|he lanzado|" + r"j'ai déployé|j'ai publié|j'ai envoyé|j'ai poussé|j'ai mis en ligne)\b", + re.I), + "claim di deploy/push/send non verificato da tool"), + + # Stato esterno live + (re.compile( + r"\b(il sito è live|the site is live|ora funziona|it now works|" + r"è online|is online|è andato live|went live|" + r"the app is running|l'app è in esecuzione|" + r"el sitio está en vivo|el sistema funciona ahora|" + r"le site est en ligne|l'application fonctionne maintenant)\b", + re.I), + "claim di stato esterno non verificabile"), + + # Assunzioni sull'utente + (re.compile( + r"\b(l'utente ha|the user has|hai già|you already|" + r"your database (is|has)|il tuo database (è|ha)|" + r"el usuario ya|vous avez déjà)\b", + re.I), + "assunzione su stato dell'utente non verificabile"), + + # Test / CI passati senza prova + (re.compile( + r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|" + r"la CI è verde|CI is green|build successful|build riuscita|" + r"todos los tests pasan|tous les tests passent)\b", + re.I), + "claim di test/CI passati senza esecuzione verificata"), +] + +# ── Pattern contraddizione interna ──────────────────────────────────────────── +_CONTRADICTION_PAIRS: list[tuple[str, str]] = [ + ("errore", "nessun errore"), + ("error", "no error"), + ("fallito", "completato con successo"), + ("failed", "completed successfully"), + ("non trovato", "trovato correttamente"), + ("not found", "found correctly"), + ("timeout", "risposta ricevuta"), + ("timeout", "response received"), + ("impossibile", "funziona"), + ("impossible", "works"), + ("non funziona", "funziona correttamente"), + ("doesn't work", "works correctly"), + ("eccezione", "nessuna eccezione"), + ("exception", "no exception"), + ("crash", "stabile"), + ("crash", "stable"), +] + + +def _check_hallucinations(text: str) -> list[str]: + issues = [] + for pattern, label in _HALLUCINATION_PATTERNS: + if pattern.search(text): + issues.append(f"Possibile hallucination: {label}") + return issues + + +def _check_contradictions(text: str) -> list[str]: + issues = [] + text_lower = text.lower() + for a, b in _CONTRADICTION_PAIRS: + if a in text_lower and b in text_lower: + issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti") + return issues + + +def _check_completeness(goal: str, answer: str) -> list[str]: + """ + Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate + o bullet points) siano almeno menzionati nella risposta. + """ + issues = [] + sub_goals = re.findall( + r"(?:^|\n)\s*(?:\d+\.|[-*•])\s+(.+?)(?:\n|$)", goal + ) + if not sub_goals: + return [] + answer_lower = answer.lower() + missing = [] + for sg in sub_goals[:8]: # max 8 sotto-obiettivi + words = [w for w in sg.lower().split() if len(w) > 4][:4] + if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2): + missing.append(sg.strip()[:60]) + if missing: + issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}") + return issues + + +def _heuristic_audit(goal: str, answer: str) -> AuditL2Result: + """Audit euristico: pattern matching su testo, senza LLM.""" + issues: list[str] = [] + issues.extend(_check_hallucinations(answer)) + issues.extend(_check_contradictions(answer)) + issues.extend(_check_completeness(goal, answer)) + + if not issues: + return AuditL2Result( + status=AuditStatus.PASS, + confidence=0.75, + engine="heuristic", + ) + # Gravi (hallucination o contraddizione) → FAIL; solo completeness → UNKNOWN + has_severe = any( + "hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower() + for i in issues + ) + return AuditL2Result( + status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN, + confidence=0.82 if has_severe else 0.55, + issues=issues, + repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.", + engine="heuristic", + ) + + +_AUDIT_SYSTEM = ( + "Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. " + "Rispondi SOLO con JSON valido, senza markdown. Formato:\n" + '{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,' + '"issues":["..."],"repair_hint":"..."}\n\n' + "Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). " + "UNKNOWN per incertezze moderate. PASS se la risposta è coerente. " + "Mai forzare PASS se ci sono dubbi fondati." +) + + +def _build_audit_prompt(goal: str, answer: str) -> str: + # Tronca intelligentemente: preserva inizio e fine dell'answer + max_ans = 1400 + if len(answer) > max_ans: + half = max_ans // 2 + answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:] + else: + answer_trunc = answer + return ( + f"GOAL ORIGINALE:\n{goal[:500]}\n\n" + f"RISPOSTA AGENTE:\n{answer_trunc}\n\n" + "VERIFICA (rispondi solo con JSON):\n" + "1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n" + "2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n" + "3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n" + ) + + +class SemanticAuditorL2: + """ + S303 — Audit Semantico L2. + Istanziato come singleton. + Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 ≠ FAIL). + """ + + def __init__(self, ai_client: Any = None, timeout_s: float = 12.0): + self.ai_client = ai_client + self.timeout_s = timeout_s + + async def audit(self, goal: str, answer: str) -> AuditL2Result: + """ + Punto di ingresso principale. + 1. Prova audit LLM se ai_client disponibile. + 2. Fallback a audit euristico in caso di errore o timeout. + """ + if not goal or not answer: + return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0, + issues=["goal o answer vuoti"]) + + # Euristico sempre eseguito — base line gratuita + heuristic_result = _heuristic_audit(goal, answer) + + # Se euristico ha già trovato problemi gravi, non invocare LLM per efficienza + if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2: + _logger.debug("[AuditL2] heuristic FAIL con %d issues — skip LLM", len(heuristic_result.issues)) + return heuristic_result + + if self.ai_client is not None: + try: + result = await asyncio.wait_for( + self._llm_audit(goal, answer), + timeout=self.timeout_s + ) + if result: + # Merge: se LLM dice PASS ma euristico ha trovato issue → UNKNOWN + if result.status == AuditStatus.PASS and heuristic_result.issues: + result.status = AuditStatus.UNKNOWN + result.issues = heuristic_result.issues + result.confidence = min(result.confidence, 0.65) + return result + except asyncio.TimeoutError: + _logger.warning("[AuditL2] timeout LLM (%.1fs) — fallback euristico", self.timeout_s) + except Exception as e: + _logger.warning("[AuditL2] errore LLM (%s) — fallback euristico", type(e).__name__) + + return heuristic_result + + async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]: + """Chiamata LLM reale per l'audit semantico.""" + prompt = _build_audit_prompt(goal, answer) + # Preferisce modello veloce/economico (8B) — audit non richiede ragionamento profondo + _model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant" + response = await self.ai_client.chat.completions.create( + model=_model, + messages=[ + {"role": "system", "content": _AUDIT_SYSTEM}, + {"role": "user", "content": prompt}, + ], + max_tokens=256, + temperature=0.0, # deterministico + ) + raw = response.choices[0].message.content or "" + match = re.search(r"\{[\s\S]*?\}", raw) + if not match: + _logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw) + return None + try: + parsed = json.loads(match.group(0)) + except json.JSONDecodeError as _je: + _logger.warning("[AuditL2] JSON decode error: %s", _je) + return None + status_raw = parsed.get("status", "UNKNOWN").upper() + try: + status = AuditStatus(status_raw) + except ValueError: + status = AuditStatus.UNKNOWN + return AuditL2Result( + status=status, + confidence=float(parsed.get("confidence", 0.70)), + issues=parsed.get("issues", []), + repair_hint=parsed.get("repair_hint", ""), + engine="llm", + ) + + +# ── Singleton ───────────────────────────────────────────────────────────────── +_auditor: Optional[SemanticAuditorL2] = None + +def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2: + """ + Ritorna o crea il singleton SemanticAuditorL2. + Se chiamato con ai_client= e il singleton esiste già senza client, + aggiorna il client sul singleton esistente (upgrade lazy). + """ + global _auditor + if _auditor is None: + _auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s) + elif ai_client is not None and _auditor.ai_client is None: + _auditor.ai_client = ai_client # upgrade: inserisce client dopo init + return _auditor diff --git a/agents/executor.py b/agents/executor.py index f42abdead4a2d2967a2a0a9c3d745f8a34becc5c..7ba2b9fbd1117027a647b89815cbedecb80a3495 100644 --- a/agents/executor.py +++ b/agents/executor.py @@ -292,39 +292,15 @@ class Executor: _t0 = _time_mod.monotonic() result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to) _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0) - - # Il tool ha già prodotto il side effect: la persistenza memoria - # è osservabilità e non deve riaprire il retry del tool. - _memory_persisted = True - _memory_error = None if self.memory: - try: - # S577→S600: inputs 100→500 — parity con altri handler - await self.memory.save_episode( - "tool", - f"{tool_name}: {str(inputs)[:500]}", - str(result)[:500], - True, - ) - except Exception as _memory_exc: - _memory_persisted = False - _memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}" - _logger.warning( - "[executor] tool %s completato ma save_episode fallito; " - "nessun retry del side effect: %s", - tool_name, - _memory_error, - ) - response = { - "success": True, - "tool": tool_name, - "output": result, - "attempt": attempt + 1, - "memory_persisted": _memory_persisted, - } - if _memory_error: - response["memory_error"] = _memory_error - return response + # S577→S600: inputs 100→500 — parity con altri handler + await self.memory.save_episode( + "tool", + f"{tool_name}: {str(inputs)[:500]}", + str(result)[:500], + True, + ) + return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1} except asyncio.TimeoutError: # FIX-GAP2: registra il timeout come durata massima per shrink futuro 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/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/html_fast_path.py b/agents/html_fast_path.py deleted file mode 100644 index 202119ce5b252a37e53c332de60948355af71fbd..0000000000000000000000000000000000000000 --- a/agents/html_fast_path.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Classificazione locale del fast path per mini-app HTML a file singolo. - -Il classificatore è deliberatamente conservativo: in caso di dubbio restituisce -False. Non usa LLM, rete o stato globale e quindi non aggiunge latenza misurabile. -""" -from __future__ import annotations - -from dataclasses import dataclass -import re - - -@dataclass(frozen=True) -class HtmlFastPathDecision: - eligible: bool - reason: str - path: str = "index.html" - - -_HTML_RE = re.compile(r"\b(?:html5?|html|pagina\s+web|single[- ]page|landing\s+page)\b", re.I) -_CREATE_RE = re.compile(r"\b(?:crea|genera|scrivi|realizza|implementa|build|create|generate|make)\b", re.I) -_SINGLE_FILE_RE = re.compile( - r"\b(?:un\s+solo\s+file|singolo\s+file|one\s+file|single\s+file|file\s+unico)\b", re.I -) -_PATH_RE = re.compile(r"(? HtmlFastPathDecision: - """Return an eligible decision only for a safe, self-contained HTML request.""" - text = " ".join(str(goal or "").split()) - if not text: - return HtmlFastPathDecision(False, "empty_goal") - if len(text) > 500: - return HtmlFastPathDecision(False, "goal_too_long") - if not _HTML_RE.search(text): - return HtmlFastPathDecision(False, "not_html_goal") - if not _CREATE_RE.search(text): - return HtmlFastPathDecision(False, "not_creation_goal") - if not _SINGLE_FILE_RE.search(text): - return HtmlFastPathDecision(False, "single_file_not_explicit") - if _FORBIDDEN_RE.search(text): - return HtmlFastPathDecision(False, "contains_project_or_sensitive_operation") - if _EXTERNAL_RE.search(text): - return HtmlFastPathDecision(False, "external_dependency_or_network") - - paths = _PATH_RE.findall(text) - path = paths[0] if paths else "index.html" - if "/" in path or path.startswith("."): - return HtmlFastPathDecision(False, "nested_path_not_allowed", path) - return HtmlFastPathDecision(True, "self_contained_single_html", path) - - -__all__ = ["HtmlFastPathDecision", "classify_html_fast_path"] diff --git a/agents/unified_loop.py b/agents/unified_loop.py index 5167027fb97ae2dd342423f834cb07a73a461b2a..4c13dcee13e6ca5a5b1109ec8a2f44fa36a63354 100644 --- a/agents/unified_loop.py +++ b/agents/unified_loop.py @@ -214,46 +214,37 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error) async def _rollback_writes(self, on_step=None) -> None: - """Restore all writes from this run or report an incomplete rollback. - - A None snapshot means the file did not exist and must be removed. - Failed restores remain tracked so a supervisor can retry or block the run. + """ + 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", + "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n", "status": "streaming", })) - - remaining: dict[str, str | None] = {} - rolled = 0 - for path, original in list(self._write_snapshots.items()): - tool_name = "delete_file" if original is None else "write_file" - inputs = {"path": path} if original is None else {"path": path, "content": original} + _rolled = 0 + for path, original in self._write_snapshots.items(): + if original is None: + continue # file non esisteva prima — saltiamo (non eliminiamo) try: - result = await asyncio.wait_for( - self.executor.run_tool(tool_name, inputs), + await asyncio.wait_for( + self.executor.run_tool("write_file", {"path": path, "content": original}), timeout=10.0, ) - payload = result.get("output") if isinstance(result, dict) else None - nested_failed = isinstance(payload, dict) and payload.get("ok") is False - if not isinstance(result, dict) or not result.get("success") or nested_failed: - error = (payload or {}).get("error") if isinstance(payload, dict) else None - raise RuntimeError(error or result.get("error", "rollback tool failed")) - rolled += 1 - except Exception as exc: - remaining[path] = original - _logger.error("GAP-3 rollback fallito per %s: %s", path, str(exc)[:240]) - - total = len(self._write_snapshots) - self._write_snapshots = remaining - _logger.info("GAP-3 rollback: %d/%d file ripristinati", rolled, total) - if remaining: - raise RuntimeError(f"VFS rollback incompleto: {len(remaining)}/{total} file non ripristinati") + _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. @@ -559,29 +550,9 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, # 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. - try: - from agents.html_fast_path import classify_html_fast_path - _html_fast_decision = classify_html_fast_path(state.goal) - except Exception as _html_cls_exc: - _logger.debug("[html-fast-path] classifier unavailable: %s", type(_html_cls_exc).__name__) - _html_fast_decision = None - _html_fast_plan = None - if _html_fast_decision is not None and _html_fast_decision.eligible and not tool_results: - _html_fast_plan = { - "summary": "Piano locale mini-app HTML a file singolo", - "goal": state.goal, - "subtasks": [ - {"id": 1, "description": f"Scrivi {_html_fast_decision.path}: {state.goal}", "tool": "write_file", "requires": []}, - {"id": 2, "description": f"Rileggi {_html_fast_decision.path} e verifica la scrittura", "tool": "read_file", "requires": [1]}, - ], - "complexity": "low", - "source": "local_html_fast_path", - } - _logger.info("[html-fast-path] planner bypass: %s", _html_fast_decision.path) _should_plan = ( self.planner and not tool_results - and _html_fast_plan is None and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) and len(state.goal) > 10 ) @@ -598,7 +569,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, } _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 or _html_fast_plan is not None: + if _should_plan: if on_step: await _maybe_await(on_step({ "loop": 0, "action": "plan", "status": "started", @@ -607,10 +578,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, })) # S640: timeout planner + S-FMT-ORCH fast-fix bypass # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati) - if _html_fast_plan is not None: - plan = _html_fast_plan - _logger.info("[html-fast-path] ARCHITECT bypassato") - elif _fast_fix_plan is not None: + if _fast_fix_plan is not None: plan = _fast_fix_plan _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato") else: @@ -1328,28 +1296,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, {"path": _wf_path, "content": _wf_generated} if rn == "write_file" else {"path": _wf_path, "patch": _wf_generated} ) - # GAP-3: snapshot pre-write; read errors are not "file absent". + # GAP-3: snapshot pre-write — cattura originale per rollback atomico if rn == "write_file" and _wf_path not in self._write_snapshots: - _snap_r = await asyncio.wait_for( - self.executor.run_tool("read_file", {"path": _wf_path}), - timeout=4.0, - ) - _snap_payload = _snap_r.get("output") if isinstance(_snap_r, dict) else None - _snap_failed = isinstance(_snap_payload, dict) and _snap_payload.get("ok") is False - if isinstance(_snap_payload, dict): - _snap_content = _snap_payload.get("content") - _snap_error = str(_snap_payload.get("error", "")) - else: - _snap_content = _snap_payload - _snap_error = str(_snap_r.get("error", "")) if isinstance(_snap_r, dict) else "" - if isinstance(_snap_content, str) and not _snap_failed: - self._write_snapshots[_wf_path] = _snap_content - elif "File non trovato" in _snap_error or "File not found" in _snap_error: - self._write_snapshots[_wf_path] = None - else: - raise RuntimeError( - f"Snapshot VFS non disponibile per {_wf_path}: {_snap_error[:240]}" + 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: @@ -3576,12 +3534,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, } state.errors.append(error_text) - if getattr(self, "_write_snapshots", None): - try: - await self._rollback_writes(on_step) - except Exception as rollback_error: - state.errors.append(str(rollback_error)[:500]) - _logger.error("[unified_loop] rollback inatteso incompleto: %s", rollback_error) previous = state.state_machine.current if previous != AgentState.FAILED: try: @@ -3739,12 +3691,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, async def _finish(result: dict[str, Any]) -> dict[str, Any]: next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED - if next_state == AgentState.FAILED and getattr(self, "_write_snapshots", None): - try: - await self._rollback_writes(on_step) - except Exception as rollback_error: - result.setdefault("errors", []).append(str(rollback_error)[:500]) - result["rollback_incomplete"] = True try: await self._transition_state(state, next_state, on_step) finally: @@ -3831,6 +3777,29 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, except Exception: pass # decision_memory non disponibile — continua normalmente + # B5-ORDER: una spiegazione completa e non operativa è già un intento + # sufficiente. Deve bypassare le guardie di ambiguità, che sono riservate a + # comandi realmente vaghi; le guardie in _is_pure_explanation() proteggono + # file, mutazioni, dati realtime e richieste troppo lunghe. + # Sprint 5 ITEM 13: classify_ms — tempo routing/classificazione goal (sync, <1ms) + _t0_classify = _time.monotonic() + if self._is_pure_explanation(goal): + try: + from api.state import record_timing as _rtcB5 + _rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000) + except Exception: + pass + await self._transition_state(state, AgentState.THINKING, on_step) + _r = await _finish(await self._run_fallback(state, on_step)) + _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) + _r["effective_max_steps"] = state.max_steps + if _sid_token is not None: + try: _sid_var.reset(_sid_token) + except Exception: pass + if self._session_files: + asyncio.ensure_future(self._vfs_git_backup()) + return _r + # P29-B1: gate ambiguità strutturale — _is_goal_ambiguous() era P28-B2 dead code (mai chiamata). # Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing. if _is_goal_ambiguous(goal): @@ -3994,9 +3963,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, return _r_bl - # Sprint 5 ITEM 13: classify_ms — tempo routing/classificazione goal (sync, <1ms) - _t0_classify = _time.monotonic() - # S402: Fast Path — greeting/ack/identità semplice → bypass tutto l'overhead if self._is_simple_query(goal): try: @@ -4046,27 +4012,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, # già — "direct tools + fallback" — ma il codice faceva solo _run_fallback senza tool). # Bug: query meteo/news/cerca non chiamavano mai i tool reali → LLM allucinava i dati # → ResponseVerifier girava su risposta inventata → retry → 20-60s inutili. - # B5: query spiegazione pura → _run_fallback diretta (-20-30s risparmio) - # Scenari: "cos'è X", "spiegami Y", "how does Z work?", "explain W" - # Fail-open: se regex troppo larga → path normale (nessuna perdita) - if self._is_pure_explanation(goal): - try: - from api.state import record_timing as _rtcB5 - _rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000) - except Exception: - pass - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fallback(state, on_step)) - _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) - _r["effective_max_steps"] = state.max_steps - if _sid_token is not None: - try: _sid_var.reset(_sid_token) - except Exception: pass - if self._session_files: - asyncio.ensure_future(self._vfs_git_backup()) - return _r - - # P36: Hybrid Execution Router — Python code analysis fast path. # Se goal contiene keyword analisi + codice Python nel context/goal, # chiama python_analyze direttamente (<5ms) saltando planner+LLM (5-15s). diff --git a/agents/unified_loop_delegate.py b/agents/unified_loop_delegate.py new file mode 100644 index 0000000000000000000000000000000000000000..4bae5909ebe6e22f54cc1e891641f635a531fc2a --- /dev/null +++ b/agents/unified_loop_delegate.py @@ -0,0 +1,192 @@ +"""unified_loop_delegate.py — DelegateMixin: debug riflessivo, replan, delega in-loop. + +Estratto da unified_loop.py per ridurre il file principale. + +Contiene: + _reflective_debug(goal, errors): BGAP-GUARD diagnosi breve da errori tool + _budget_replan_check(state, step): BGAP-1 replan probabilistico su budget critico + _DELEGATE_RESEARCH_RE: regex riconoscimento sub-goal tipo ricerca + _run_in_loop_delegate(sub_goal): GAP-1 micro-agente specializzato in-loop + +Invariante B1: nessun corpo duplicato con unified_loop.py. +MRO garantisce che DelegateMixin._budget_replan_check sovrascriva HelpersMixin +(DelegateMixin precede HelpersMixin nella lista basi di UnifiedAgentLoop). +""" +from __future__ import annotations + +import asyncio +import logging +import re +from typing import Any + +from agents.unified_loop_types import StepCallback, UnifiedLoopState, _maybe_await + +_logger = logging.getLogger("agente_ai") + + +class DelegateMixin: + async def _reflective_debug( + self, goal: str = "", errors: Any = None, **kwargs: Any + ) -> str: + """Reflective debug: analizza errori e propone diagnosi in max 2 frasi. + Chiamato dopo tool failures per arricchire state.context con ipotesi fix. + Fail-open: non blocca mai il loop in caso di errore LLM.""" + try: + _ctx = f"Goal: {str(goal)[:200]}\nErrori: {'; '.join(str(e)[:300] for e in (errors if isinstance(errors, list) else [errors])[:3])}" # S573: 150→300 + _fast = self._get_fast_llm() + _diag = await asyncio.wait_for( + _fast.chat([{"role": "user", "content": f"Diagnosi breve (max 2 frasi):\n{_ctx}"}], max_tokens=300), # S586: 120->180->300 + timeout=5.0, + ) + return (str(_diag) if _diag else "").strip()[:300] + except Exception: + pass # fail-open + return "" + + # ── BGAP-1: Probabilistic Re-planning Trigger ──────────────────────────── + async def _budget_replan_check( + self, state: Any, step_count: int, on_step: Any = None + ) -> str: + """BGAP-1: probabilistic re-planning trigger. + Guards: skip se _n_err < 2 OR _budget_ratio < 0.6. + Usa _get_fast_llm() con max_tokens=120. Fail-open.""" + _n_err = len(state.errors) if getattr(state, 'errors', None) else 0 + if _n_err < 2: + return '' + _budget_ratio = step_count / max(state.max_steps, 1) + if _budget_ratio < 0.6: + return '' + # dedup guard [GAP-1-REPLAN]: skip se già replanned in questo loop + if '[GAP-1-REPLAN]' in (state.context or ''): + return '' + try: + _fast_llm = self._get_fast_llm() + _prompt = ( + f'Task ha avuto {_n_err} errori e usato {_budget_ratio:.0%} del budget. ' + f'Suggerisci UN approccio alternativo in max 2 frasi. Goal: {state.goal[:500]}' # S597: 200->300->500 + ) + _hint = await asyncio.wait_for( + _fast_llm.chat([{'role': 'user', 'content': _prompt}], max_tokens=120), + timeout=5.0, + ) + return (str(_hint) if _hint else '').strip()[:200] + except Exception: + pass # fail-open totale + return '' + + # ── 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], + ) + # S-PARTIAL: emetti evento SSE partial_output al frontend PRIMA di restituire + # così l'utente vede il chip "⚠ output parziale — riprendo" in tempo reale + if on_step: + await _maybe_await(on_step({ + "event": "partial_output", + "action": "partial_output", + "visibility": "progress", + "partial": True, + "steps_done": len(_partial_steps), + "partial_files": _partial_files, + "partial_output": _partial_out, + "output": _partial_out, + "explanation": f"Output parziale dopo {timeout:.0f}s — l'agente sta recuperando", + "status": "warning", + })) + 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. diff --git a/agents/unified_loop_fallback.py b/agents/unified_loop_fallback.py new file mode 100644 index 0000000000000000000000000000000000000000..379fb5524c571daf07874090902d199987a4b3b7 --- /dev/null +++ b/agents/unified_loop_fallback.py @@ -0,0 +1,2954 @@ +"""unified_loop_fallback.py — FallbackMixin: loop LLM principale (_run_fallback). + +Estratto da unified_loop.py per ridurre il file principale da 3954 a ~640 righe. + +Contiene: + _run_fallback(state, on_step, ...): loop LLM multi-step con planner, executor, + verifier, goal_verifier, self-healing, + browser vision, repair loop Python/HTML. + +Dipendenze via MRO (self.*): + DirectToolsMixin — _run_direct_tools, _needs_tools, _is_simple_query + PromptBuilderMixin — _build_messages, _compress_goal, _SYSTEM_IDENTITY + LLMSelectionMixin — _get_llm_for_goal, _get_fast_llm, _sanitize_agent_output + HelpersMixin — _run_fast_path, _proactive_reflect + DelegateMixin — _budget_replan_check, _run_in_loop_delegate + RoutingMixin — _extract_written_files, _CODE_RE + VFSMixin — _rollback_writes + +Invariante B1: nessun corpo duplicato con unified_loop.py. +""" +from __future__ import annotations +from .fallback_utils import _is_refusal, _s759_bjac, _avg10 +from .fallback_healer import StrategicHealer + +import asyncio +import logging +import os +import re +from typing import Any + +from agents.unified_loop_types import ( + StepCallback, + UnifiedLoopState, + _maybe_await, + _LANG_INSTRUCTIONS, + _ANALYTICAL_VERBS_RE, + _TASK_VERBS_RE, + _is_goal_ambiguous, + _is_borderline_ambiguous, + _BORDERLINE_FIX_RE, + _BORDERLINE_HELP_RE, + _BORDERLINE_MAKE_RE, + _detect_user_lang, +) + +_logger = logging.getLogger("agente_ai") + +# QF-2: costanti timeout — replicate da unified_loop.py (evita circular import) +LLM_TIMEOUT: float = float(os.getenv('LLM_CALL_TIMEOUT', '60')) +TOOL_TIMEOUT: float = float(os.getenv('TOOL_CALL_TIMEOUT', '25')) + + +class FallbackMixin: + 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-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2) + # GAP-RUN-NAMEERROR FIX: define exec_errors from exec_warn + exec_errors = [w for w in exec_warn if isinstance(w, str) and w.startswith('⚠')] + if exec_errors and getattr(self, '_strategic_healer', None): + try: + _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str)) + _sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str) + if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None): + exec_warn.insert(0, _sh_decision.strategy_prompt) + _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn") + if _sh_decision and getattr(_sh_decision, 'should_stop', False): + _logger.info("GAP-4: StrategicHealer → should_stop, interruzione loop") + # [GAP-4-FIXSYN] break rimosso: era dentro async def _run_subtask fuori da loop + # GAP-4-FIX: Re-implement stop logic via state flag + if hasattr(state, 'should_stop'): state.should_stop = True + return # Interrompe l'esecuzione del fallback corrente + # SyntaxError a compile-time — strategia gia iniettata in exec_warn sopra. + except Exception as _sh_loop_err: + _logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err) + # GAP-SELFHEAL v2: delegated to StrategicHealer + StrategicHealer.analyze_errors(exec_errors, exec_warn) + 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), # S586: 250->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 + ) + + + + # 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() + errors = state.errors # S576: alias for comprehension + _clf_result = _clf_fn([str(e)[:500] for e in errors[-3:]]) # S576+S592: errors window -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'): + + 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"]) + # GAP-1 guards (mirrors _budget_replan_check): skip se _n_err < 2 o _budget_ratio < 0.6 + _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 = 500), # S586: 350->500 + 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 + + + # S303: Audit Semantico L2 — verifica coerenza logica interna dell'output. + # Eseguito DOPO GoalVerifier L1, solo se L1 ≠ FAIL (risparmio token). + # Rileva: hallucination claims (deploy/push/send non verificati), + # contraddizioni interne (errore + completato), sotto-obiettivi mancanti. + # Silent failure totale — non blocca mai la risposta al client. + _l2_should_run = ( + answer + and not answer.startswith('[LLM') + and len(answer.split()) > 15 + ) + try: + _l1_was_fail = ( + getattr(_gvr, 'verification_status', None) is not None # type: ignore[name-defined] + and str(getattr(_gvr, 'verification_status', '')).endswith('FAIL') + ) + except NameError: + _l1_was_fail = False # _gvr non definito — L1 non era attivo (goal non-code) + if _l2_should_run and not _l1_was_fail: + try: + from agents.audit_semantic_l2 import get_auditor as _get_auditor_l2 + _auditor_l2 = _get_auditor_l2( + ai_client=self._get_verifier_llm(), # cross-model (P25-B4) + timeout_s=12.0, + ) + _l2_result = await asyncio.wait_for( + _auditor_l2.audit(state.goal, answer), + timeout=13.0, + ) + # Telemetria + try: + from api.state import increment_stat as _inc_l2 + _inc_l2(f"audit_l2_{_l2_result.status.value.lower()}") + except Exception as _exc: + _logger.debug("[S303] telemetry silenced: %s", type(_exc).__name__) + _logger.info( + "[S303] AuditL2 %s (conf=%.2f engine=%s) issues=%d", + _l2_result.status.value, + _l2_result.confidence, + _l2_result.engine, + len(_l2_result.issues), + ) + if _l2_result.status.value == "FAIL" and _l2_result.issues: + # Notifica UI — step visibile nel pannello avanzamento + if on_step: + await _maybe_await(on_step({ + "action": "audit_l2", + "status": "warning", + "visibility": "progress", + "title": "⚠️ Verifica coerenza risposta", + "explanation": _l2_result.issues[0][:120], + })) + # Appende nota discreta — non modifica il codice, solo avvisa + if answer: + _l2_note_parts = ["\n\n> ⚠️ **Nota di coerenza**:"] + for _iss in _l2_result.issues[:2]: + _l2_note_parts.append(f" {_iss}") + if _l2_result.repair_hint: + _l2_note_parts.append(f" \n> 💡 {_l2_result.repair_hint}") + answer += "".join(_l2_note_parts) + except asyncio.TimeoutError: + try: + from api.state import increment_stat as _inc_l2t + _inc_l2t("audit_l2_timeout") + except Exception as _exc: + _logger.debug("[S303] timeout counter silenced: %s", type(_exc).__name__) + except Exception as _l2_exc: + _logger.debug("[S303] AuditL2 silenced: %s", type(_l2_exc).__name__) # 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 \n'}, + {"path": "package.json", + "content": '{{\n "name": "{name}",\n "private": true,\n "scripts": {{"dev": "vite", "build": "vite build"}},\n "dependencies": {{"react": "^18.3.0", "react-dom": "^18.3.0"}},\n "devDependencies": {{"@vitejs/plugin-react": "^4.0.0", "vite": "^5.0.0", "typescript": "^5.0.0"}}\n}}\n'}, + ], + "python": [ + {"path": "README.md", + "content": "# {name}\n\nPython app.\n"}, + {"path": "main.py", + "content": 'def main():\n print("Hello from {name}")\n\nif __name__ == "__main__":\n main()\n'}, + {"path": "requirements.txt", + "content": "# Aggiungi dipendenze qui\n"}, + ], + "node": [ + {"path": "README.md", + "content": "# {name}\n\nNode.js app.\n"}, + {"path": "index.js", + "content": 'const http = require("http");\nhttp.createServer((_, res) => res.end("Hello from {name}\\n")).listen(process.env.PORT || 3000);\nconsole.log("Running {name}");\n'}, + {"path": "package.json", + "content": '{{\n "name": "{name}",\n "private": true,\n "scripts": {{"start": "node index.js"}}\n}}\n'}, + ], + "fastapi": [ + {"path": "README.md", + "content": "# {name}\n\nFastAPI app.\n"}, + {"path": "main.py", + "content": 'from fastapi import FastAPI\napp = FastAPI(title="{name}")\n\n@app.get("/")\ndef root():\n return {{"name": "{name}", "ok": True}}\n'}, + {"path": "requirements.txt", + "content": "fastapi>=0.110.0\nuvicorn[standard]>=0.29.0\n"}, + {"path": "Dockerfile", + "content": 'FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install -r requirements.txt\nCOPY . .\nCMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]\n'}, + ], +} + +@router.post("/api/scaffold_project") +async def scaffold_project(body: ScaffoldRequest) -> JSONResponse: + """ + Restituisce file da creare nel VFS per un nuovo progetto. + Il frontend applica i file al VFS locale (nessuna scrittura server-side). + """ + import datetime + date_str = datetime.date.today().isoformat() + template = _TEMPLATES.get(body.template, _TEMPLATES["blank"]) + + files = [] + for f in template: + content = (f["content"] + .replace("{name}", body.name) + .replace("{date}", date_str) + .replace("{goal}", body.goal or "—")) + files.append({"path": f["path"], "content": content}) + + _logger.info("[scaffold] template=%s name=%s files=%d", body.template, body.name, len(files)) + + return JSONResponse({ + "ok": True, + "project": body.name, + "template": body.template, + "files": files, + "created_at": time.time(), + }) diff --git a/api/self_healing.py b/api/self_healing.py new file mode 100644 index 0000000000000000000000000000000000000000..04b7cd40bdcb022beca020cbd86e9d7872fe0dfb --- /dev/null +++ b/api/self_healing.py @@ -0,0 +1,252 @@ +""" +backend/api/self_healing.py — Self-Healing Deploy System (S766-GRID-5) + +Sistema di auto-guarigione per il daemon di Railway: +- Heartbeat Monitoring: Controlla se il daemon è vivo ogni 30 secondi +- Failover Automatico: Sposta il traffico su un altro profilo se uno è down +- Auto-Restart: Riavvia il daemon se si rileva un crash +- Incident Logging: Registra tutti gli incidenti per analisi post-mortem +""" + +import os +import asyncio +import logging +import time +from typing import Optional, Dict +from datetime import datetime, timedelta +import httpx + +_logger = logging.getLogger("self_healing") + +# ── Configurazione ───────────────────────────────────────────────────────── +HEARTBEAT_INTERVAL_S = int(os.getenv("HEARTBEAT_INTERVAL", "30")) +HEARTBEAT_TIMEOUT_S = int(os.getenv("HEARTBEAT_TIMEOUT", "10")) +FAILURE_THRESHOLD = 3 # Numero di fallimenti prima di failover +INCIDENT_LOG_TABLE = "self_healing_incidents" + +DAEMON_URLS = { + "D": "https://ai-production-4c06.up.railway.app", + "B": "https://backend-b-production-5794.up.railway.app", +} + + +class HeartbeatMonitor: + """Monitora la salute del daemon.""" + + def __init__(self): + self.failure_count = {} + self.last_heartbeat = {} + self.is_running = False + self._task: Optional[asyncio.Task] = None + + async def start(self): + """Avvia il monitoraggio del heartbeat.""" + if self.is_running: + _logger.warning("HeartbeatMonitor already running") + return + + self.is_running = True + self._task = asyncio.create_task(self._heartbeat_loop()) + _logger.info("HeartbeatMonitor started") + + async def stop(self): + """Ferma il monitoraggio del heartbeat.""" + self.is_running = False + if self._task: + self._task.cancel() + _logger.info("HeartbeatMonitor stopped") + + async def _heartbeat_loop(self): + """Loop principale del heartbeat.""" + while self.is_running: + try: + await asyncio.sleep(HEARTBEAT_INTERVAL_S) + await self._check_all_daemons() + except asyncio.CancelledError: + break + except Exception as exc: + _logger.error(f"Heartbeat loop error: {exc}") + + async def _check_all_daemons(self): + """Controlla la salute di tutti i daemon.""" + for profile, url in DAEMON_URLS.items(): + await self._check_daemon(profile, url) + + async def _check_daemon(self, profile: str, url: str): + """Controlla la salute di un singolo daemon.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{url}/api/health", + timeout=HEARTBEAT_TIMEOUT_S, + ) + + if response.status_code == 200: + self.failure_count[profile] = 0 + self.last_heartbeat[profile] = time.time() + _logger.debug(f"Daemon {profile} healthy") + else: + await self._handle_failure(profile, f"HTTP {response.status_code}") + + except asyncio.TimeoutError: + await self._handle_failure(profile, "Timeout") + except Exception as exc: + await self._handle_failure(profile, str(exc)) + + async def _handle_failure(self, profile: str, reason: str): + """Gestisce il fallimento di un daemon.""" + self.failure_count[profile] = self.failure_count.get(profile, 0) + 1 + failures = self.failure_count[profile] + + _logger.warning(f"Daemon {profile} failure #{failures}: {reason}") + + if failures >= FAILURE_THRESHOLD: + await self._trigger_failover(profile, reason) + + async def _trigger_failover(self, failed_profile: str, reason: str): + """Attiva il failover a un altro profilo e persiste lo stato su Supabase.""" + _logger.critical(f"FAILOVER TRIGGERED: {failed_profile} ({reason})") + + # Registra l'incidente via incident_registry centralizzato + await self._log_incident( + profile=failed_profile, + event="failover_triggered", + reason=reason, + ) + + # Determina il profilo di fallback + fallback_profile = self._get_fallback_profile(failed_profile) + if fallback_profile: + _logger.info(f"Failing over to profile {fallback_profile}") + # Persiste il profilo attivo su agent_memory (chiave: daemon_active_profile) + # così il routing layer e i client sanno quale daemon usare dopo il restart. + try: + import json as _json + from api.state import _sb + if _sb: + _now = int(time.time() * 1000) + _payload = _json.dumps({ + "profile": fallback_profile, + "switched_from": failed_profile, + "switched_at": _now, + "reason": reason, + }) + await asyncio.to_thread( + lambda: _sb.table("agent_memory").upsert( + {"key": "daemon_active_profile", + "category": "system", + "value": _payload, + "created_at": _now, + "updated_at": _now}, + on_conflict="key", + ).execute() + ) + _logger.info(f"Failover state persisted → active={fallback_profile}") + except Exception as _fe: + _logger.warning(f"Failover state persist failed: {_fe}") + + def _get_fallback_profile(self, failed_profile: str) -> Optional[str]: + """Determina il profilo di fallback.""" + fallback_map = { + "D": "B", + "B": "D", + } + return fallback_map.get(failed_profile) + + async def _log_incident(self, profile: str, event: str, reason: str): + """Registra un incidente via incident_registry centralizzato (agent_memory table).""" + try: + from api.incident_registry import log_incident + await log_incident( + task_id=f"heartbeat:{profile}", + goal=f"daemon_health:{profile}", + error=f"{event}: {reason}", + source="heartbeat_monitor", + ) + except Exception as exc: + _logger.warning(f"_log_incident failed: {exc}") + _logger.info(f"Incident logged: {profile} - {event} - {reason}") + + +class SelfHealingVerifier: + """Verifica la validità delle azioni proposte dall'agente.""" + + def __init__(self): + self.verification_cache = {} + + async def verify_action(self, action: Dict) -> Dict: + """ + Verifica un'azione prima dell'esecuzione. + Ritorna: {"valid": bool, "reason": str, "suggested_fix": str} + """ + action_type = action.get("type", "unknown") + + if action_type == "deploy": + return await self._verify_deploy(action) + elif action_type == "code_execution": + return await self._verify_code_execution(action) + elif action_type == "database_write": + return await self._verify_database_write(action) + else: + return {"valid": True, "reason": "Unknown action type, allowing"} + + async def _verify_deploy(self, action: Dict) -> Dict: + """Verifica un'azione di deploy.""" + # Controlla se il codice ha errori di sintassi + code = action.get("code", "") + if not code: + return {"valid": False, "reason": "No code provided"} + + # Controlla se il codice contiene pattern pericolosi + dangerous_patterns = ["rm -rf", "DROP TABLE", "DELETE FROM"] + for pattern in dangerous_patterns: + if pattern in code: + return { + "valid": False, + "reason": f"Dangerous pattern detected: {pattern}", + } + + return {"valid": True, "reason": "Deploy action verified"} + + async def _verify_code_execution(self, action: Dict) -> Dict: + """Verifica un'azione di esecuzione di codice.""" + # Simile a _verify_deploy + return {"valid": True, "reason": "Code execution verified"} + + async def _verify_database_write(self, action: Dict) -> Dict: + """Verifica un'azione di scrittura su database.""" + table = action.get("table", "") + if not table: + return {"valid": False, "reason": "No table specified"} + + # Controlla se la tabella è in una lista di tabelle "critiche" + critical_tables = ["users", "admin", "secrets"] + if table in critical_tables: + return { + "valid": False, + "reason": f"Cannot write to critical table: {table}", + "suggested_fix": "Use a staging table instead", + } + + return {"valid": True, "reason": "Database write verified"} + + +# ── Singleton globale ────────────────────────────────────────────────────── +_heartbeat_monitor_instance: Optional[HeartbeatMonitor] = None +_verifier_instance: Optional[SelfHealingVerifier] = None + + +def get_heartbeat_monitor() -> HeartbeatMonitor: + """Restituisce l'istanza globale del HeartbeatMonitor.""" + global _heartbeat_monitor_instance + if _heartbeat_monitor_instance is None: + _heartbeat_monitor_instance = HeartbeatMonitor() + return _heartbeat_monitor_instance + + +def get_self_healing_verifier() -> SelfHealingVerifier: + """Restituisce l'istanza globale del SelfHealingVerifier.""" + global _verifier_instance + if _verifier_instance is None: + _verifier_instance = SelfHealingVerifier() + return _verifier_instance diff --git a/api/state.py b/api/state.py index e6233b588d6378066091aa77f077e35480173586..9ec81421ba58b5ac62cde2f0e00975803b94b9cc 100644 --- a/api/state.py +++ b/api/state.py @@ -139,11 +139,6 @@ _CHECKPOINT_TTL_MS = 2 * 60 * 60 * 1000 _CHECKPOINT_MAX = 100 _AGENT_TASK_TTL_MS = 2 * 60 * 60 * 1000 _AGENT_TASK_MAX = 200 -# All statuses that represent a finished task and can therefore expire from the -# bounded in-memory store. Keep this set aligned with API lifecycle writers. -_AGENT_TASK_TERMINAL_STATES: frozenset[str] = frozenset({ - 'SUCCESS', 'COMPLETED', 'ERROR', 'CANCELLED', 'RATE_LIMITED', -}) # ── Telemetry & Health ──────────────────────────────────────────────────────── _ai_health_cache: dict = {"data": None, "at": 0.0} @@ -249,7 +244,7 @@ def _prune_checkpoints() -> None: def _prune_agent_tasks() -> None: now = int(time.time() * 1000) expired = [k for k, v in list(_agent_tasks.items()) - if v.get('status') in _AGENT_TASK_TERMINAL_STATES + if v.get('status') in ('SUCCESS', 'ERROR', 'CANCELLED') and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS] for k in expired: _agent_tasks.pop(k, None) @@ -324,7 +319,6 @@ class AgentTaskIn(BaseModel): context: list[dict] = [] max_steps: int = 8 taskId: Optional[str] = None - idempotency_key: Optional[str] = None project_context: str = "" learning_hints: list[str] = [] session_id: Optional[str] = None diff --git a/api/state_sync.py b/api/state_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..251c49f3cf2c4b718037bf9ade68563cb11e7947 --- /dev/null +++ b/api/state_sync.py @@ -0,0 +1,153 @@ +import os, json, asyncio, httpx, time +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import List, Dict, Any, Optional +import logging + +_logger = logging.getLogger("api.state_sync") +router = APIRouter(prefix="/api/sync", tags=["sync"]) + +# ── Helper Redis (Upstash REST) ────────────────────────────────────────────── +async def _rcmd(cmd: list) -> Optional[dict]: + url = os.getenv("UPSTASH_REDIS_REST_URL") + tok = os.getenv("UPSTASH_REDIS_REST_TOKEN") + if not url or not tok: return None + try: + async with httpx.AsyncClient() as client: + r = await client.post(url, headers={"Authorization": f"Bearer {tok}"}, json=cmd, timeout=5.0) + return r.json() + except Exception as e: + _logger.error("[sync] redis error: %s", e) + return None + +# ── Polling interval per cross-node sync (secondi) ────────────────────────── +_PUBSUB_POLL_INTERVAL = float(os.getenv("SYNC_POLL_INTERVAL", "3")) + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, List[WebSocket]] = {} + self.room_states: Dict[str, Any] = {} + # Cursore per polling: ultimo timestamp letto per ogni room + self._last_seen: Dict[str, int] = {} + self._pubsub_task: Optional[asyncio.Task] = None + + async def connect(self, websocket: WebSocket, room_id: str): + await websocket.accept() + if room_id not in self.active_connections: + self.active_connections[room_id] = [] + self.active_connections[room_id].append(websocket) + + # S901: Recupero stato globale da Redis + res = await _rcmd(["GET", f"sync:state:{room_id}"]) + if res and res.get("result"): + try: + state = json.loads(res["result"]) + except json.JSONDecodeError: + state = {} + await websocket.send_json({"type": "SYNC_STATE", "payload": state}) + + def disconnect(self, websocket: WebSocket, room_id: str): + if room_id in self.active_connections: + if websocket in self.active_connections[room_id]: + self.active_connections[room_id].remove(websocket) + if not self.active_connections[room_id]: + del self.active_connections[room_id] + + async def broadcast(self, message: dict, room_id: str, exclude: WebSocket = None, remote: bool = False): + """ + Broadcast locale e remoto (via Redis). + remote=True indica che il messaggio arriva da Pub/Sub, quindi non ri-pubblicare. + """ + if room_id not in self.active_connections: + return + + # Aggiornamento stato locale/globale + if message.get("type") == "UPDATE_STATE": + self.room_states[room_id] = message.get("payload") + if not remote: + # Persistenza stato su Redis (TTL 1h) + await _rcmd(["SET", f"sync:state:{room_id}", json.dumps(message.get("payload")), "EX", "3600"]) + + # Pubblicazione su Redis per altri nodi (lista circolare con timestamp) + if not remote: + envelope = { + "ts": int(time.time() * 1000), + "room": room_id, + "msg": message, + } + await _rcmd(["LPUSH", f"sync:events:{room_id}", json.dumps(envelope)]) + # Mantieni solo gli ultimi 50 eventi per room (evita memory leak Redis) + await _rcmd(["LTRIM", f"sync:events:{room_id}", "0", "49"]) + + # Invio ai client connessi a QUESTA istanza + for connection in self.active_connections[room_id]: + if connection != exclude: + try: + await connection.send_json(message) + except Exception as _sj_err: + _logger.debug("[sync] send_json silenced (WS dead?): %s", type(_sj_err).__name__) + + async def start_pubsub_listener(self): + """ + S901 Fix: Polling leggero su Redis per sincronizzazione cross-nodo. + Upstash REST non supporta SUBSCRIBE persistente; usiamo LRANGE + cursore timestamp. + Intervallo configurabile via SYNC_POLL_INTERVAL (default: 3s). + """ + _logger.info("[sync] pubsub polling listener avviato (interval=%.1fs)", _PUBSUB_POLL_INTERVAL) + while True: + try: + # Itera su tutte le room attive + for room_id in list(self.active_connections.keys()): + if not self.active_connections.get(room_id): + continue + + res = await _rcmd(["LRANGE", f"sync:events:{room_id}", "0", "9"]) + if not res or not res.get("result"): + continue + + events: list = res["result"] + last_seen = self._last_seen.get(room_id, 0) + new_events = [] + + for raw in events: + try: + envelope = json.loads(raw) + ts = envelope.get("ts", 0) + if ts > last_seen: + new_events.append(envelope) + except Exception: + pass + + if new_events: + # Ordina per timestamp crescente + new_events.sort(key=lambda e: e.get("ts", 0)) + for envelope in new_events: + msg = envelope.get("msg", {}) + # Broadcast locale senza ri-pubblicare su Redis (remote=True) + await self.broadcast(msg, room_id, remote=True) + # Aggiorna cursore + self._last_seen[room_id] = new_events[-1].get("ts", last_seen) + + except Exception as e: + _logger.error("[sync] pubsub polling error: %s", e) + + await asyncio.sleep(_PUBSUB_POLL_INTERVAL) + +manager = ConnectionManager() + +@router.websocket("/ws/{room_id}") +async def websocket_endpoint(websocket: WebSocket, room_id: str): + await manager.connect(websocket, room_id) + + # Avvia il listener di polling se non già attivo + if manager._pubsub_task is None or manager._pubsub_task.done(): + manager._pubsub_task = asyncio.create_task(manager.start_pubsub_listener()) + + try: + while True: + data = await websocket.receive_json() + await manager.broadcast(data, room_id, exclude=websocket) + except WebSocketDisconnect: + manager.disconnect(websocket, room_id) + except Exception as e: + _logger.error(f"WebSocket error in room {room_id}: {e}") + manager.disconnect(websocket, room_id) diff --git a/api/telegram_callbacks.py b/api/telegram_callbacks.py new file mode 100644 index 0000000000000000000000000000000000000000..a78a3dd97565cfd15d650f17d0208a3472d54f35 --- /dev/null +++ b/api/telegram_callbacks.py @@ -0,0 +1,358 @@ +"""backend/api/telegram_callbacks.py — Gestione callback_query e inline_query Telegram. + +Handler: + _handle_inline — inline query (@bot testo in chat) + _handle_callback — callback_data dei bottoni inline +""" +from __future__ import annotations +import asyncio, html, logging, os, time + +from .telegram_tg_client import ( + _get_bot_token, _tg_reply, _tg_send, _tg_edit, + _tg_typing, _tg_answer_callback, _fmt_elapsed, + _log_tg_exc, _BACK_KB, +) +from .telegram_keyboards import ( + _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL, +) +from .telegram_cmd_ai import ( + _cmd_do, _cmd_autofix, _cmd_score, _cmd_bench, _cmd_improve, + _cmd_scan_now, _cmd_telemetry, _cmd_coord, _cmd_git, +) +from .telegram_cmd_monitoring import ( + _cmd_help, _cmd_status, _cmd_tasks, _cmd_check, + _cmd_logs, +) + +_logger = logging.getLogger("api.telegram_webhook") + +# ── callback_query handler — inline buttons da notify_task_done ─────────────── + +async def _handle_inline(iq: dict, token: str) -> None: + """Gestisce inline query: @ARJagent_ap_bot in qualsiasi chat. + Abilita tramite @BotFather → /setinline → ARJagent_ap_bot. + """ + bot_token = token or _get_bot_token() + if not bot_token: return + iq_id = iq.get("id","") + query = (iq.get("query") or "").strip() + q60 = query[:60] + if not query: + results = [ + {"type":"article","id":"help","title":"🤖 Comandi Agente AI", + "description":"Lista tutti i comandi", + "input_message_content":{"message_text": + "🤖 Agente AI\n/do <goal> — task streaming\n/ask <q> — risposta veloce\n" + "/autofix — fix errori auto\n/status — stato\n\nagente-ai.pages.dev","parse_mode":"HTML"}}, + {"type":"article","id":"do","title":"🚀 Nuovo Task AI", + "description":"/do — lancia task complesso", + "input_message_content":{"message_text":"/do "}}, + {"type":"article","id":"ask","title":"🧠 Domanda AI", + "description":"/ask ", + "input_message_content":{"message_text":"/ask "}}, + ] + else: + results = [ + {"type":"article","id":"ask","title":f"🧠 Chiedi: {q60}", + "description":f"/ask {q60}", + "input_message_content":{"message_text":f"/ask {query}"}}, + {"type":"article","id":"do","title":f"🚀 Task: {q60}", + "description":f"/do {q60}", + "input_message_content":{"message_text":f"/do {query}"}}, + {"type":"article","id":"fix","title":f"🔧 AutoFix: {q60}", + "description":f"/autofix {q60}", + "input_message_content":{"message_text":f"/autofix {query}"}}, + ] + try: + import httpx as _hx + async with _hx.AsyncClient(timeout=5.0) as c: + await c.post(f"https://api.telegram.org/bot{bot_token}/answerInlineQuery", + json={"inline_query_id":iq_id,"results":results,"cache_time":30,"is_personal":True}) + except Exception as exc: + _logger.debug("inline answer error: %s", exc) + + +async def _handle_callback(callback_query: dict, token: str) -> None: + """Gestisce i callback_data dei bottoni inline (notify_task_done, menu).""" + cq_id = callback_query.get("id", "") + chat_id = callback_query.get("message", {}).get("chat", {}).get("id", 0) + data = (callback_query.get("data") or "").strip() + + # Risponde subito per chiudere il loading Telegram + await _tg_answer_callback(cq_id, token=token) + + if not chat_id or not data: + return + + # ── task_sum: — riepilogo task da Supabase ────────────────────────── + if data.startswith("task_sum:"): + tid = data[9:].strip() + try: + from api.state import _sb + if _sb: + res = await asyncio.to_thread( + lambda: _sb.table("agent_tasks") + .select("task_id,goal,status,created_at") + .eq("task_id", tid) + .limit(1) + .execute() + ) + rows = res.data or [] + if rows: + r = rows[0] + st = r.get("status", "?") + gol = html.escape((r.get("goal") or "")[:200]) + em = {"SUCCESS":"✅","ERROR":"❌","RUNNING":"⚙️","QUEUED":"⏳"}.get(st,"•") + ca = r.get("created_at", 0) + age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "?" + await _tg_reply( + chat_id, + "📋 Riepilogo task\n\n" + "ID: " + html.escape(tid) + "\n" + "Stato: " + em + " " + st + "\n" + "Goal: " + gol + "\n" + "Avviato: " + age, + token=token, + ) + return + await _tg_reply(chat_id, "⚠️ Task " + html.escape(tid) + " non trovato in Supabase.", token=token) + except Exception as exc: + await _tg_reply(chat_id, "❌ Errore: " + html.escape(str(exc)[:200]), token=token) + + # ── task_wins: — quick wins (non persisti — riepilogo generico) ───── + elif data.startswith("task_wins:"): + tid = data[10:].strip() + await _tg_reply( + chat_id, + "💡 Quick Wins" + html.escape(tid) + "\n\n" + "I quick wins vengono inclusi nella notifica del task.\n" + "Avvia /tasks per vedere i task recenti o usa il pannello web per dettagli completi.\n\n" + "Apri Dashboard →", + token=token, + ) + + # ── agent — nuovo task (quick-pick) ────────────────────────────────────── + elif data == "agent": + await _tg_reply( + chat_id, + "🚀 Avvia Task — scegli un template o scrivi il tuo obiettivo:", + token=token, keyboard=_QUICK_PICK_KB, + ) + + # ── tgw_status / tgw_tasks / tgw_health / tgw_help — menu comandi ──────── + elif data == "tgw_chart": + await _tg_reply(chat_id, + "📈 Grafici\n\nHeatmap commit, burndown sprint e molto altro nella Dashboard:", + token=token, keyboard=_WEBAPP_KB) + elif data == "tgw_ask": + await _tg_reply(chat_id, + "🧠 Chiedi all'AI\n\n" + "Scrivi la domanda direttamente o usa:\n" + "/ask <domanda>\n\n" + "Esempio: /ask come ottimizzare una query PostgreSQL?", + token=token, keyboard=_BACK_KB) + elif data == "tgw_bench": + asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_autofix": + asyncio.create_task(_cmd_autofix(chat_id,"")).add_done_callback(_log_tg_exc) + elif data == "tgw_logs": + asyncio.create_task(_cmd_logs(chat_id,"WARNING")).add_done_callback(_log_tg_exc) + elif data == "tgw_bench_fix": + # Legge gap dall'ultimo run (cache) e chiede all'agente un fix mirato + cached = _BENCH_CACHE.get(chat_id, {}) + goal = ( + "Analizza i gap del benchmark-extended (bench.yml) più recente. " + "Identifica le categorie con score più basso e genera patch mirate " + "(prompt rules, retry logic, tool selection) per migliorare ogni area debole. " + "Priorità: agentic → coding → reasoning. Fai push delle modifiche su GitHub." + ) + asyncio.create_task(_cmd_do(chat_id, goal)).add_done_callback(_log_tg_exc) + elif data == "tgw_bench_run": + # Rilancia bench + asyncio.create_task(_cmd_bench(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_score": + asyncio.create_task(_cmd_score(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_telemetry": + asyncio.create_task(_cmd_telemetry(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_improve": + asyncio.create_task(_cmd_improve(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_status": + await _cmd_status(chat_id) + elif data == "tgw_tasks": + await _cmd_tasks(chat_id) + elif data == "tgw_health": + await _cmd_scan_now(chat_id) + elif data == "tgw_check": + asyncio.create_task(_cmd_check(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_help": + await _cmd_help(chat_id) + elif data == "tgw_coord": + asyncio.create_task(_cmd_coord(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_git": + asyncio.create_task(_cmd_git(chat_id)).add_done_callback(_log_tg_exc) + + # ── Sub-menu dispatchers (Reply Keyboard → inline sub-menu) ───────────────── + elif data in ("menu_task",): + await _tg_reply(chat_id, "🚀 Task AI — scegli un'azione:", token=token, keyboard=_TASK_MENU_KB) + elif data == "menu_status": + await _cmd_status(chat_id) + await _tg_reply(chat_id, "📊 Stato Sistema — altre opzioni:", token=token, keyboard=_STATUS_MENU_KB) + elif data == "menu_perf": + await _tg_reply(chat_id, "📈 Performance — scegli:", token=token, keyboard=_PERF_MENU_KB) + elif data == "menu_health": + asyncio.create_task(_cmd_scan_now(chat_id)).add_done_callback(_log_tg_exc) + elif data == "menu_dev": + await _tg_reply(chat_id, "🛠 Dev Tools — scegli:", token=token, keyboard=_DEV_MENU_KB) + elif data == "tgw_home": + asyncio.create_task(_cmd_help(chat_id)).add_done_callback(_log_tg_exc) + + # ── Nuovi callback task/utility ──────────────────────────────────────────── + elif data == "tgw_do": + await _tg_reply(chat_id, + "🚀 Nuovo Task AI\n\n" + "Scrivi il tuo obiettivo nella chat — elaboro tutto come task AI.\n\n" + "Esempi:\n" + " analizza bug in backend/api/state.py\n" + " genera test per providers.py\n" + " ottimizza le query Supabase più lente\n\n" + "Oppure: /do <goal>", + token=token, keyboard=_BACK_KB) + elif data == "tgw_briefing": + asyncio.create_task(_cmd_riepilogo(chat_id)).add_done_callback(_log_tg_exc) + elif data == "tgw_nota": + await _tg_reply(chat_id, + "📝 Salva Nota\n\n" + "Usa: /nota <testo da ricordare>\n\n" + "Esempio: /nota domani rivedere il deploy HF Space", + token=token, keyboard=_BACK_KB) + elif data == "tgw_cerca": + await _tg_reply(chat_id, + "🔍 Cerca sul web\n\n" + "Usa: /cerca <query>\n\n" + "Esempio: /cerca best practices FastAPI async Python", + token=token, keyboard=_BACK_KB) + elif data == "tgw_meteo": + await _tg_reply(chat_id, + "🌤 Meteo\n\n" + "Usa: /meteo <città>\n\n" + "Esempio: /meteo Milano", + token=token, keyboard=_BACK_KB) + elif data == "tgw_providers": + try: + from api.providers import _heartbeat_state as _hs_pv + providers = (_hs_pv or {}).get("providers") or [] + if providers: + lines = ["🔌 Provider AI\n"] + best = next((p for p in providers if p.get("ok")), None) + for p in providers: + ok = p.get("ok", False) + icon = "✅" if ok else ("🔑" if "401" in str(p.get("error","")) else ("💳" if "429" in str(p.get("error","")) else "❌")) + ms = p.get("latency_ms") + ms_str = f"{ms}ms" if ms else "–" + name = p.get("name","?") + star = " ⭐" if (best and p.get("name") == best.get("name")) else "" + lines.append(f"{icon} {html.escape(name)}{star} — {ms_str}") + if not ok and p.get("error"): + lines.append(f" {html.escape(str(p['error'])[:80])}") + import time as _tpv + last = (_hs_pv or {}).get("last_run_at",0) + age = f"{int(_tpv.time()-last)}s fa" if last else "?" + lines.append(f"\n⏱ Heartbeat {age}") + await _tg_reply(chat_id, "\n".join(lines), token=token, keyboard=_HEALTH_MENU_KB) + else: + await _tg_reply(chat_id, + "⚠️ Dati provider non ancora disponibili (~90s al primo heartbeat).", + token=token, keyboard=_BACK_KB) + except Exception as exc: + await _tg_reply(chat_id, + "❌ Errore: " + html.escape(str(exc)[:200]) + "", + token=token, keyboard=_BACK_KB) + elif data == "tgw_snap": + try: + from .integrity_manager import handle_snap_cmd as _hi_snap + asyncio.create_task(_hi_snap(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc) + except Exception: + await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB) + elif data == "tgw_verify": + try: + from .integrity_manager import handle_verify_cmd as _hi_verify + asyncio.create_task(_hi_verify(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc) + except Exception: + await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB) + elif data == "tgw_heal": + try: + from .integrity_manager import handle_heal_cmd as _hi_heal + asyncio.create_task(_hi_heal(chat_id, _tg_reply, None)).add_done_callback(_log_tg_exc) + except Exception: + await _tg_reply(chat_id, "⚠️ Integrity manager non disponibile.", token=token, keyboard=_BACK_KB) + elif data == "tgw_ping": + ry = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app") + try: + async with httpx.AsyncClient(timeout=8.0) as _hxc: + r = await _hxc.get(f"{ry}/health") + j = r.json() + ok = "✅" if j.get("status")=="ok" else "⚠️" + await _tg_reply(chat_id, + f"🏓 Pong! {ok}\nv{j.get('version','?')} — Railway live", + token=token, keyboard=_DEV_MENU_KB) + except Exception as exc: + await _tg_reply(chat_id, + "❌ Railway non raggiungibile\n"+html.escape(str(exc)[:150])+"", + token=token, keyboard=_BACK_KB) + + # ── Quick-pick task templates (MX-QUICKPICK) ───────────────────────────── + elif data == "qp_bug": + asyncio.create_task(_cmd_do(chat_id, + "Leggi i log di Railway/HF degli ultimi 30 minuti. Identifica gli errori principali, " + "trova la root cause reale e suggerisci un fix concreto con codice.")).add_done_callback(_log_tg_exc) + elif data == "qp_db": + asyncio.create_task(_cmd_do(chat_id, + "Analizza le query Supabase più lente del progetto (agent_tasks, agent_logs, semantic_memory). " + "Identifica query N+1, indici mancanti, e proponi le ottimizzazioni con SQL concreto.")).add_done_callback(_log_tg_exc) + elif data == "qp_autofix": + asyncio.create_task(_cmd_autofix(chat_id, "")).add_done_callback(_log_tg_exc) + elif data == "qp_commits": + asyncio.create_task(_cmd_do(chat_id, + "Leggi gli ultimi 10 commit su Baida98/AI via GitHub API e produci un riepilogo in italiano: " + "cosa è stato fatto, da chi, e qual è lo stato attuale del progetto.")).add_done_callback(_log_tg_exc) + elif data == "qp_docs": + asyncio.create_task(_cmd_do(chat_id, + "Genera la documentazione degli endpoint API principali del backend Railway: " + "/api/telegram, /api/agent, /health. Per ognuno: metodo HTTP, parametri, risposta attesa, esempi.")).add_done_callback(_log_tg_exc) + elif data == "qp_tests": + asyncio.create_task(_cmd_do(chat_id, + "Genera test pytest per i moduli critici del backend: " + "api/telegram_webhook.py, agents/unified_loop.py, providers. " + "Priorità: test di integrazione per i path più usati e i casi di errore.")).add_done_callback(_log_tg_exc) + elif data == "qp_custom": + await _tg_reply(chat_id, + "✍️ Scrivi il tuo obiettivo\n" + "Scrivi liberamente nella chat — lo eseguo come task AI.\n" + "Esempi:\n" + " ottimizza le query Supabase lente\n" + " analizza providers.py e suggerisci fix", + token=token, keyboard=_BACK_KB) + # ── tgw_retry — rilancia l'ultimo task ─────────────────────────────────── + elif data == "tgw_retry": + last = _LAST_GOAL.get(chat_id) + if last: + asyncio.create_task(_cmd_do(chat_id, last)).add_done_callback(_log_tg_exc) + else: + await _tg_reply(chat_id, + "⚠️ Nessun task precedente da ripetere. Avvia un nuovo task:", + token=token, keyboard=_QUICK_PICK_KB) + + # ── m — menu principale ─────────────────────────────────────────────────── + elif data == "m": + await _tg_reply( + chat_id, + "🤖 Menu principale\n\nScegli un'azione:", + token=token, + keyboard=_MAIN_KB, + ) + + # ── fallback — command non gestito ─────────────────────────────────────── + else: + _logger.debug("callback_query unhandled: %s", data) + + diff --git a/api/telegram_cmd_ai.py b/api/telegram_cmd_ai.py new file mode 100644 index 0000000000000000000000000000000000000000..449a23145d4b12a174f056f1c74a0b376d77be30 --- /dev/null +++ b/api/telegram_cmd_ai.py @@ -0,0 +1,1154 @@ +"""backend/api/telegram_cmd_ai.py — Comandi Telegram AI e operativi. + +Comandi: + _cmd_do, _cmd_autofix, _cmd_nota, _cmd_cerca, _cmd_meteo, + _cmd_riepilogo, _cmd_score, _cmd_bench, _cmd_improve +""" +from __future__ import annotations +import asyncio, html, logging, os, re, time + +from .telegram_tg_client import ( + _get_bot_token, _tg_reply, _tg_send, _tg_edit, + _tg_typing, _tg_react, _tg_photo, _fmt_elapsed, + _log_tg_exc, _BACK_KB, +) +from .telegram_keyboards import ( + _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL, + _BENCH_ACTION_KB, +) + +_logger = logging.getLogger("api.telegram_webhook") + +async def _cmd_do(chat_id: int, goal: str) -> None: + """Lancia task AI con streaming progressivo via editMessageText. + + Flusso: invia msg iniziale → salva message_id → on_step accumula token + → edit throttled ogni 1.5s → flush finale con output + keyboard. + Rate-limit sicuro: max ~40 edit/min totali, Telegram consente 20 edit/min/chat. + """ + if not goal.strip(): + await _tg_reply(chat_id, "⚠️ Usa il menu per scegliere un task:", keyboard=_QUICK_PICK_KB) + return + + _LAST_GOAL[chat_id] = goal # salva per retry + await _tg_typing(chat_id) + msg_id = await _tg_send( + chat_id, + "🚀 Avvio task…\n\n" + "🎯 " + html.escape(goal[:200]) + "\n\n" + "⏳ Sto elaborando, un momento…", + ) + + _buf: list[str] = [] + _last_edit: list[float] = [0.0] # list per mutabilità in closure + _EDIT_INTERVAL = 1.5 + _MAX_LEN = 3600 + + async def _flush(final: bool = False) -> None: + if not msg_id: + return + content = "".join(_buf).strip() + if not content: + return + now = time.monotonic() + if not final and (now - _last_edit[0]) < _EDIT_INTERVAL: + return + prefix = "🧠 Risposta AI\nGoal: " + html.escape(goal[:80]) + "\n\n" + suffix = "" if final else "\n⏳" + body = html.escape(content[: _MAX_LEN - len(prefix) - len(suffix)]) + await _tg_edit(chat_id, msg_id, prefix + body + suffix) + _last_edit[0] = time.monotonic() + + async def _on_step(event: dict) -> None: + if event.get("action") == "text_chunk": + tok = event.get("token", "") + if tok: + _buf.append(tok) + await _flush(final=False) + + try: + from agents.unified_loop import UnifiedAgentLoop + from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner + client = _get_ai_client() + memory = await _get_mem_manager_async() + executor = _get_executor() + planner = _get_planner() + try: + from agents.critic import Critic + from agents.response_verifier import ResponseVerifier + critic = Critic(llm_client=client) + verifier = ResponseVerifier() + except Exception: + critic = verifier = None + loop = UnifiedAgentLoop( + llm_client=client, critic=critic, verifier=verifier, + memory=memory, executor=executor, planner=planner, + ) + result = await asyncio.wait_for( + loop.run(goal=goal, context="", max_steps=8, on_step=_on_step), + timeout=120.0, + ) + output = result.get("output", "") if isinstance(result, dict) else str(result) + await _flush(final=True) + if msg_id and output: + _out_str = str(output) + _out_esc = html.escape(_out_str[:3000]) + # Usa blockquote espandibile per output lunghi (Bot API 7.4+, Jul 2024) + _out_body = ( + f"
{_out_esc}
" + if len(_out_str) > 400 else _out_esc + ) + await _tg_edit( + chat_id, msg_id, + "✅ Fatto!\n\n" + "🎯 " + html.escape(goal[:100]) + "\n\n" + + _out_body, + keyboard=_after_task_kb(chat_id), + ) + await _tg_react(chat_id, msg_id, "🎉") + try: + from .telegram_notify import notify_task_done + task_id = result.get("task_id", "tgw-do") if isinstance(result, dict) else "tgw-do" + await notify_task_done(str(task_id), goal, str(output)) + except Exception: + if not msg_id: + _fb_esc = html.escape(str(output)[:800]) + _fb_body = ( + f"
{_fb_esc}
" + if len(str(output)) > 400 else _fb_esc + ) + await _tg_reply( + chat_id, + "✅ Fatto!\n\n" + "🎯 " + html.escape(goal[:80]) + "\n\n" + + _fb_body, + ) + except asyncio.TimeoutError: + await _flush(final=True) + err = ("⏱ Timeout — task >120s.\nGoal: " + + html.escape(goal[:120]) + "\n\nUsa il pannello web.") + if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB) + else: await _tg_reply(chat_id, err) + except Exception as exc: + await _flush(final=True) + err = "❌ Errore\n" + html.escape(str(exc)[:300]) + "" + if msg_id: await _tg_edit(chat_id, msg_id, err, keyboard=_MAIN_KB) + else: await _tg_reply(chat_id, err) + + +async def _cmd_autofix(chat_id: int, hint: str = "") -> None: + """Comando /autofix — legge errori dai log backend, genera patch via AI, pusha su GitHub. + + Flusso: + 1. GET /api/telegram/logs?level=ERROR — raccoglie ultimi errori + 2. AI loop con streaming (on_step) — analizza e genera patch + 3. Parsa blocco ```autofix\nFILE: path\n---\ncontent``` dall'output AI + 4. Git Data API blob->tree->commit->PATCH ref — push automatico + 5. Riporta commit SHA al chat + link GitHub + + Env vars Railway: GITHUB_TOKEN (gia' presente), GITHUB_REPO, GITHUB_BRANCH. + """ + import httpx, base64 as _b64, json as _json + await _tg_typing(chat_id, "upload_document") + + msg_id = await _tg_send( + chat_id, + "🔧 AutoFix avviato\n\n" + + (f"Hint: {html.escape(hint[:100])}\n\n" if hint else "") + + "⏳ Step 1/4 — Lettura log errori…", + ) + + async def _edit(text: str, final: bool = False) -> None: + if msg_id: + await _tg_edit(chat_id, msg_id, text, keyboard=_MAIN_KB if final else None) + + # ── Step 1: leggi errori dal log endpoint ───────────────────────────────────────── + railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app").rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as c: + resp = await c.get(f"{railway_url}/api/telegram/logs", + params={"level": "ERROR", "n": 30}) + log_data = resp.json() if resp.status_code == 200 else {} + except Exception as e: + log_data = {} + _logger.warning("autofix: log fetch: %s", e) + + records = log_data.get("records", []) + + if not records and not hint: + await _edit( + "✅ AutoFix\n\n" + "🟢 Nessun errore nei log recenti!\n\n" + "Sistema stabile. Usa /autofix <descrizione bug> " + "per fix su un errore specifico.", + final=True, + ) + return + + log_lines = "\n".join( + f"[{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')}" + for r in records[:15] + ) if records else f"(nessun errore nei log — hint: {hint})" + + log_preview = "\n".join( + f"• [{r.get('level','?')}] {r.get('logger','?')}: {r.get('msg','')[:80]}" + for r in records[:5] + ) or f"hint: {html.escape(hint[:100])}" + + await _edit( + "🔧 AutoFix\n\n" + "📋 Errori trovati:\n" + log_preview + + "\n\n⏳ Step 2/4 — Analisi AI in corso…", + ) + + # ── Step 2: AI loop genera patch ───────────────────────────────────────────────────── + BT3 = "```" # triple backtick — non usare literal per evitare syntax issues + goal = ( + "Sei un senior engineer. Analizza questi errori dal log backend Python " + "e genera un patch preciso per risolvere il problema principale.\n\n" + "LOG ERRORI:\n" + log_lines[:2000] + + (f"\n\nHINT UTENTE: {hint}" if hint else "") + + "\n\n" + "FORMATO RISPOSTA RICHIESTO (tassativo):\n" + + BT3 + "autofix\n" + + "FILE: backend/api/.py\n" + + "---\n" + + "\n" + + BT3 + "\n\n" + "Regole:\n" + "1. Scrivi SOLO il blocco con FILE: e contenuto completo (non un diff)\n" + "2. Per piu' file includi un blocco per file\n" + "3. Se non riesci a determinare il file, scrivi FILE: UNKNOWN e spiega" + ) + context = f"Backend: {railway_url} Repo: {os.getenv('GITHUB_REPO','Baida98/AI')}" + + _buf: list[str] = [] + _last: list[float] = [0.0] + + async def _on_step(event: dict) -> None: + if event.get("action") == "text_chunk": + tok = event.get("token", "") + if tok: + _buf.append(tok) + now = time.monotonic() + if now - _last[0] > 2.0: + preview = "".join(_buf).strip()[-300:] + await _edit( + "🔧 AutoFix — Step 2/4 AI analizza…\n\n" + "" + html.escape(preview) + "\n\n⏳" + ) + _last[0] = now + + try: + from agents.unified_loop import UnifiedAgentLoop + from api.state import _get_ai_client, _get_mem_manager_async, _get_executor, _get_planner + client = _get_ai_client() + memory = await _get_mem_manager_async() + executor = _get_executor() + planner = _get_planner() + try: + from agents.critic import Critic + from agents.response_verifier import ResponseVerifier + critic = Critic(llm_client=client) + verifier = ResponseVerifier() + except Exception: + critic = verifier = None + loop = UnifiedAgentLoop( + llm_client=client, critic=critic, verifier=verifier, + memory=memory, executor=executor, planner=planner, + ) + result = await asyncio.wait_for( + loop.run(goal=goal, context=context, max_steps=5, on_step=_on_step), + timeout=150.0, + ) + ai_output = result.get("output", "") if isinstance(result, dict) else str(result) + except asyncio.TimeoutError: + await _edit("⏱ AutoFix timeout — AI non ha risposto in 150s.\n\n" + "Riprova con un hint più specifico.", final=True) + return + except Exception as exc: + await _edit("❌ AutoFix — errore AI\n" + + html.escape(str(exc)[:300]) + "", final=True) + return + + # ── Step 3: parsa blocco autofix dall'output AI ─────────────────────────────────── + await _edit( + "🔧 AutoFix\n\n⏳ Step 3/4 — Parsing patch…\n\n" + "" + html.escape(ai_output.strip()[-300:]) + "" + ) + + import re as _re + BLOCK_RE = _re.compile( + r"```autofix\s*\nFILE:\s*(\S+)\s*\n---\s*\n([\s\S]*?)```", + _re.MULTILINE, + ) + patches: list[tuple[str, str]] = [ + (m.group(1).strip(), m.group(2)) + for m in BLOCK_RE.finditer(ai_output) + if m.group(1).strip() not in ("", "UNKNOWN") and m.group(2).strip() + ] + + if not patches: + await _edit( + "⚠️ AutoFix — nessuna patch parsata\n\n" + "Output AI:\n" + html.escape(ai_output.strip()[:600]) + "\n\n" + "L'AI non ha prodotto un blocco autofix valido.\n" + "Prova: /autofix descrizione precisa del bug", + final=True, + ) + return + + # ── Step 4: push su GitHub via Git Data API ───────────────────────────────────────── + gh_token = os.getenv("GITHUB_TOKEN", "") + gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI") + gh_branch = os.getenv("GITHUB_BRANCH", "main") + + if not gh_token: + await _edit( + "❌ AutoFix — GITHUB_TOKEN non configurato\n\n" + "Aggiungi GITHUB_TOKEN nelle Railway env vars.", + final=True, + ) + return + + await _edit( + "🔧 AutoFix\n\n" + f"⏳ Step 4/4 — Push {len(patches)} file su GitHub…\n\n" + + "\n".join(f"• {p[0]}" for p in patches[:6]) + ) + + gh_hdr = { + "Authorization": f"token {gh_token}", + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + "User-Agent": "agente-ai-autofix", + } + gh_base = f"https://api.github.com/repos/{gh_repo}" + + try: + async with httpx.AsyncClient(timeout=20.0) as c: + r = await c.get(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr) + r.raise_for_status() + h_sha = r.json()["object"]["sha"] + + r = await c.get(f"{gh_base}/git/commits/{h_sha}", headers=gh_hdr) + r.raise_for_status() + t_sha = r.json()["tree"]["sha"] + + tree_items = [] + for fpath, content in patches: + b64 = _b64.b64encode(content.encode()).decode() + r = await c.post(f"{gh_base}/git/blobs", headers=gh_hdr, + json={"content": b64, "encoding": "base64"}) + r.raise_for_status() + tree_items.append({"path": fpath, "mode": "100644", + "type": "blob", "sha": r.json()["sha"]}) + + r = await c.post(f"{gh_base}/git/trees", headers=gh_hdr, + json={"base_tree": t_sha, "tree": tree_items}) + r.raise_for_status() + new_t = r.json()["sha"] + + err_summary = ("; ".join(rc.get("msg","")[:60] for rc in records[:2]) + or hint[:60] or "autofix via /autofix command") + commit_msg = ( + f"fix(autofix): {err_summary}\n\n" + f"Generato da /autofix — {len(patches)} file patchati\n" + + "Patch: " + ", ".join(p[0] for p in patches) + ) + r = await c.post(f"{gh_base}/git/commits", headers=gh_hdr, + json={"message": commit_msg, "tree": new_t, "parents": [h_sha]}) + r.raise_for_status() + c_sha = r.json()["sha"] + + r = await c.patch(f"{gh_base}/git/refs/heads/{gh_branch}", headers=gh_hdr, + json={"sha": c_sha, "force": False}) + r.raise_for_status() + + files_list = "\n".join(f"• {p[0]}" for p in patches[:6]) + await _edit( + "✅ AutoFix completato!\n\n" + f"Commit: {c_sha[:10]}\n" + f"Branch: {gh_branch}\n" + f"File patchati:\n{files_list}\n\n" + "U0001f680 Railway deploy: avviato automaticamente\n" + f"U0001f517 Vedi commit", + final=True, + ) + _logger.info("autofix: pushed %s — %d files", c_sha[:10], len(patches)) + + except Exception as exc: + await _edit( + "❌ AutoFix — push GitHub fallito\n" + + html.escape(str(exc)[:400]) + "\n\n" + "Verifica GITHUB_TOKEN nelle Railway env vars.", + final=True, + ) + _logger.error("autofix: push failed: %s", exc) + + +async def _cmd_nota(chat_id: int, text: str) -> None: + """Salva nota rapida nella memoria persistente dell'agente.""" + goal = ( + f"Salva questa nota nella tua memoria persistente usando il tool remember: " + f"«{text}». Poi confermami con '✅ Nota salvata' e ripeti il testo della nota." + ) + await _cmd_do(chat_id, goal) + + +async def _cmd_cerca(chat_id: int, query: str) -> None: + """Ricerca web + sintesi AI in italiano.""" + goal = ( + f"Cerca su web: {query}. " + "Usa web_search e sintetizza i risultati più rilevanti in 4-5 bullet points concisi " + "in italiano. Per ogni punto includi la fonte (dominio) tra parentesi." + ) + await _cmd_do(chat_id, goal) + + +async def _cmd_meteo(chat_id: int, city: str) -> None: + """Meteo per città specifica via tool get_weather.""" + goal = ( + f"Usa il tool get_weather per {city} e dimmi: temperatura attuale, " + "condizioni meteo, umidità, previsioni per le prossime ore. Risposta concisa in italiano." + ) + await _cmd_do(chat_id, goal) + + +async def _cmd_riepilogo(chat_id: int) -> None: + """Briefing completo: task recenti + deploy + score + prossimi passi. + + GAP-TGB: usa benchmark_handler.get_smart_summary per briefing strutturato + con analisi gap da ultimo report. Fallback: agent-loop se import fallisce. + """ + try: + from .benchmark_handler import get_smart_summary as _gsummary + text = await _gsummary(chat_id) + await _tg_reply(chat_id, text, keyboard=_BENCH_ACTION_KB) + except Exception as _exc: + _logger.debug("benchmark_handler fallback: %s", _exc) + goal = ( + "Dammi un briefing completo dello stato attuale come assistente personale. " + "Struttura ESATTA con questi header ## obbligatori:\n" + "## 📋 Task recenti (usa recall per recuperare gli ultimi 5)\n" + "## 🚀 Deploy status (CF Pages + Railway + HF Space — dati reali via tool)\n" + "## 📊 Score AI (ultimo benchmark disponibile)\n" + "## ⚠️ Problemi aperti (errori, timeout, regressioni)\n" + "## ✅ Prossimi passi (3 priorità concrete)\n" + "Usa i tool per dati reali — mai inventare status." + ) + await _cmd_do(chat_id, goal) + + +async def _cmd_scan_now(chat_id: int) -> None: + """Health-check via health_full() — AI + Supabase + Telegram.""" + await _tg_reply(chat_id, "🔍 Scan in corso…") + try: + from .providers import health_full + h = await health_full() + ai = h.get("ai", {}) + sb = h.get("supabase", {}) + tg = h.get("telegram", {}) + bk = h.get("backend", {}) + ai_ok = "✅" if ai.get("ok") else "❌" + sb_ok = "✅" if sb.get("ok") else "❌" + tg_ok = "✅" if tg.get("ok") else "❌" + over = "✅" if h.get("ok") else "⚠️" + sb_msg = "ok" if sb.get("ok") else str(sb.get("error", "?"))[:40] + tg_msg = ("@" + str(tg.get("username", ""))) if tg.get("username") else str(tg.get("error", "?"))[:40] + avail = str(ai.get("available", 0)) + "/" + str(ai.get("total", 0)) + best = str(ai.get("best", "?")) + ntasks = str(bk.get("active_tasks", 0)) + ms_str = str(h.get("elapsed_ms", "?")) + st_str = str(h.get("status", "?")) + parts = [ + over + " Health Check — " + st_str, + "", + ai_ok + " AI — " + avail + " provider | best: " + best, + sb_ok + " Supabase — " + sb_msg, + tg_ok + " Telegram — " + tg_msg, + "📦 Task attivi: " + ntasks + " 🕒 " + ms_str + "ms", + ] + await _tg_reply(chat_id, "\n".join(parts), keyboard=_MAIN_KB) + except Exception as exc: + await _tg_reply( + chat_id, + "❌ Scan errore\n" + html.escape(str(exc)[:300]) + "", + ) + + +async def _cmd_coord(chat_id: int) -> None: + """🔗 /coord — sessioni agent-coord attive e file claimati (sola lettura). + + Legge la tabella Supabase 'agent_tasks' dove le sessioni agent-coord.mjs + persistono con goal='__session__'. TTL = 5 minuti senza heartbeat. + Non modifica nulla — pura lettura. Non interferisce con il lavoro agente. + """ + import httpx as _hx_c, time as _tc, json as _jc + SB_URL = os.getenv("SUPABASE_URL", "").rstrip("/") + SB_KEY = os.getenv("SUPABASE_KEY", "") + if not SB_KEY: + await _tg_reply(chat_id, "⚠️ SUPABASE_KEY non configurata su Railway.") + return + await _tg_reply(chat_id, "🔗 Agent Coord — lettura sessioni…") + try: + async with _hx_c.AsyncClient(timeout=8.0) as _c: + r = await _c.get( + f"{SB_URL}/rest/v1/agent_tasks", + params={"goal": "ilike.*__session__*", "select": "goal,context,updated_at"}, + headers={ + "apikey": SB_KEY, + "Authorization": f"Bearer {SB_KEY}", + "Content-Type": "application/json", + }, + ) + if not r.is_success: + await _tg_reply(chat_id, f"❌ Supabase {r.status_code}: {r.text[:200]}") + return + rows = r.json() or [] + except Exception as exc: + await _tg_reply(chat_id, + "❌ " + html.escape(str(exc)[:200]) + "") + return + + SESSION_TTL_MS = 5 * 60 * 1000 # 5 minuti senza heartbeat = morta + now_ms = int(_tc.time() * 1000) + active, stale = [], [] + for row in rows: + try: + ctx = _jc.loads(row.get("context") or "{}") + except Exception: + continue + hb = int(ctx.get("lastHeartbeat", 0)) + age_ms = now_ms - hb + entry = { + "name": ctx.get("sessionName", "?"), + "sprint": ctx.get("sprint", "—"), + "files": ctx.get("claimedFiles", []), + "age_ms": age_ms, + } + (active if age_ms < SESSION_TTL_MS else stale).append(entry) + + if not rows: + await _tg_reply(chat_id, + "🔗 Agent Coord\n\n" + "ℹ️ Nessuna sessione registrata.\nTutti i file sono liberi.", + keyboard=_BACK_KB) + return + + def _age_str(ms: int) -> str: + s = ms // 1000 + return f"{s // 60}m{s % 60:02d}s" if s >= 60 else f"{s}s" + + out = ["🔗 Agent Coord", ""] + if active: + out.append(f"✅ Attive ({len(active)})") + for s in active: + fnames = ", ".join( + "" + html.escape(f.split("/")[-1]) + "" + for f in s["files"] + ) or "nessun file" + out.append( + f" {html.escape(s['name'])} " + f"[{html.escape(s['sprint'])}] · {_age_str(s['age_ms'])} fa" + ) + out.append(f" 📎 {fnames}") + else: + out.append("✅ Nessuna sessione attiva — file liberi") + if stale: + out.append("") + out.append(f"⚫ Inattive >5min ({len(stale)})") + for s in stale: + out.append(f" {html.escape(s['name'])} · {_age_str(s['age_ms'])} fa") + + await _tg_reply(chat_id, "\n".join(out), keyboard=_BACK_KB) + + +async def _cmd_git(chat_id: int, n: int = 5) -> None: + """🔀 /git [n] — ultimi N commit su main con SHA, messaggio, autore, eta e link diff. + + Chiama GitHub API (GITHUB_TOKEN Railway). Default: ultimi 5 commit. + Max: 10. Timeout 6s. Silent fail se GITHUB_TOKEN assente. + """ + import httpx as _hx_git2, datetime as _dt2 + _gh_token = os.getenv("GITHUB_TOKEN", "").strip() + _gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip() + if not _gh_token: + await _tg_reply(chat_id, + "⚠️ GITHUB_TOKEN non configurato su Railway — /git non disponibile.") + return + n = max(1, min(n, 10)) + await _tg_reply(chat_id, f"🔀 Git log — ultimi {n} commit…") + try: + async with _hx_git2.AsyncClient(timeout=6.0) as _gc: + _gr = await _gc.get( + f"https://api.github.com/repos/{_gh_repo}/commits", + headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"}, + params={"sha": "main", "per_page": str(n)}, + ) + if _gr.status_code != 200: + await _tg_reply(chat_id, + f"❌ GitHub API {_gr.status_code}: {html.escape(_gr.text[:200])}") + return + commits = _gr.json() or [] + except Exception as exc: + await _tg_reply(chat_id, + "❌ " + html.escape(str(exc)[:200]) + "") + return + + if not commits: + await _tg_reply(chat_id, "ℹ️ Nessun commit trovato.", keyboard=_BACK_KB) + return + + _now_utc = _dt2.datetime.now(_dt2.timezone.utc) + + def _age(iso: str) -> str: + try: + _ts = _dt2.datetime.fromisoformat(iso.replace("Z", "+00:00")) + _secs = int((_now_utc - _ts).total_seconds()) + if _secs < 3600: return f"{_secs // 60}m fa" + if _secs < 86400: return f"{_secs // 3600}h fa" + if _secs < 604800: return f"{_secs // 86400}g fa" + return _ts.strftime("%d/%m") + except Exception: + return "?" + + lines = [f"🔀 Git log — main ({_gh_repo})\n"] + for c in commits: + _sha = (c.get("sha") or "")[:7] + _commit = c.get("commit") or {} + _msg_raw = (_commit.get("message") or "").split("\n")[0][:52] + _msg = html.escape(_msg_raw) + _author = html.escape((_commit.get("author") or {}).get("name", "?")[:18]) + _date = (_commit.get("committer") or {}).get("date", "") + _when = _age(_date) + _url = f"https://github.com/{_gh_repo}/commit/{c.get('sha','')}" + lines.append( + f"{html.escape(_sha)} {_msg}\n" + f" {_author} · {_when} " + f'diff →' + ) + + await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB) + +async def _cmd_telemetry(chat_id: int) -> None: + """📡 Metriche runtime live: /api/telemetry + /debug/timing da Railway.""" + import httpx as _hx_t + rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/") + await _tg_reply(chat_id, "⏳ Telemetria — interrogo Railway…") + try: + async with _hx_t.AsyncClient(timeout=8.0) as _c: + tel_r, tim_r = await asyncio.gather( + _c.get(f"{rw_url}/api/telemetry"), + _c.get(f"{rw_url}/debug/timing"), + return_exceptions=True, + ) + except Exception as e: + await _tg_reply(chat_id, f"❌ Railway non raggiungibile\n{html.escape(str(e)[:120])}", keyboard=_BACK_KB) + return + + tel_d = tel_r.json() if not isinstance(tel_r, Exception) and tel_r.status_code == 200 else {} + tim_d = tim_r.json() if not isinstance(tim_r, Exception) and tim_r.status_code == 200 else {} + + msg = "📡 Telemetria Runtime\n\n" + timing = tel_d.get("timing", {}) + if timing: + msg += "⏱ Latenze per fase (avg / p90 / n):\n" + for phase, v in list(timing.items())[:10]: + avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0) + icon = "🟢" if avg_v < 3000 else "🟡" if avg_v < 10000 else "🔴" + msg += f"{icon} {phase[:18]:<18} {avg_v:>7}ms p90:{p90_v:>7}ms ×{n_v}\n" + msg += "\n" + else: + msg += "⚠️ Nessun dato timing — backend idle o prima run.\n" + repair = tel_d.get("repair", tim_d.get("repair_stats", {})) + if repair: + msg += "\n🔧 Quality & Repair:\n" + for k, v in list(repair.items())[:10]: + msg += f" {k[:22]:<22} {v:>6}\n" + msg += "\n" + ts_d = tim_d.get("timing_stats", {}) + if ts_d: + msg += "\n📊 Breakdown /debug/timing:\n" + for label, v in list(ts_d.items())[:8]: + avg_v = v.get("avg") or 0; icon = "🟢" if avg_v < 3000 else "🟡" if avg_v < 10000 else "🔴" + msg += f"{icon} {label[:18]:<18} {avg_v:>8}ms ×{v.get('count',0)}\n" + msg += "" + if len(msg) > 4000: + msg = msg[:4000] + "…" + await _tg_reply(chat_id, msg, keyboard=_BACK_KB) + +async def _cmd_score(chat_id: int) -> None: + """🏆 Score card dettagliata — chart + ranking 4 competitor + nodes + gaps + runtime telemetry.""" + import httpx as _hx_sc, base64 as _b64_sc, json as _j_sc, urllib.parse as _ul_sc + gh_token = os.getenv("GITHUB_TOKEN", "").strip() + rw_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/") + await _tg_reply(chat_id, "⏳ Score — carico report + metriche runtime…") + + report: dict | None = None + if gh_token: + try: + async with _hx_sc.AsyncClient(timeout=8.0) as _c: + _r = await _c.get( + "https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main", + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r.status_code == 200: + report = _j_sc.loads(_b64_sc.b64decode(_r.json()["content"]).decode()) + except Exception as _e: + _logger.warning("cmd_score fetch: %s", _e) + + if not report: + await _tg_reply(chat_id, + "❌ Score — benchmark-report.json non trovato.\n" + "Avvia prima /bench per generare i dati.", keyboard=_BENCH_ACTION_KB) + return + + s = report.get("summary", {}) + tasks = report.get("tasks", []) + gaps = report.get("gapCards", []) + nodes = report.get("orchestrationNodeMap", {}) + ts = (report.get("timestamp") or "")[:16].replace("T", " ") + seed = report.get("seed", "?") + ver = report.get("version", "?") + replay = report.get("replayCli", f"node benchmark-extended.mjs --seed {seed}") + mft = s.get("mft") + gap_cnt = s.get("gapCount", len(gaps)) + verdict = s.get("verdict", "").replace("_", " ") + avg_ai = s.get("avgScore", 0) + avg_rpl = s.get("avgReplit", 57.9) + avg_cur = s.get("avgCursor", 64.1) + avg_dev = s.get("avgDevin", 70.1) + avg_mns = s.get("avgManus", 71.2) + w_rpl = s.get("wins_replit", "?") + w_dev = s.get("wins_devin", "?") + w_mns = s.get("wins_manus", "?") + + # Runtime telemetria da Railway (best-effort) + rt_timing: dict = {} + rt_repair: dict = {} + try: + async with _hx_sc.AsyncClient(timeout=5.0) as _c: + _tr = await _c.get(f"{rw_url}/api/telemetry") + if _tr.status_code == 200: + _td = _tr.json() + rt_timing = _td.get("timing", {}) + rt_repair = _td.get("repair", {}) + except Exception: + pass + + # ── Chart per categoria ─────────────────────────────────────── + cat_map: dict = {} + for t in tasks: + cat = (t.get("cat") or "other").replace("_", " ")[:14] + cat_map.setdefault(cat, []).append(float(t.get("score", 0))) + labels_ = list(cat_map.keys()) + scores_ = [round(sum(v)/len(v)) for v in cat_map.values()] + colors_ = ["#4CAF50" if sc >= avg_rpl else "#FF9800" if sc >= 50 else "#F44336" for sc in scores_] + chart_cfg = { + "type": "horizontalBar", + "data": {"labels": labels_, "datasets": [ + {"label": "Agente AI", "data": scores_, "backgroundColor": colors_, "borderWidth": 1}, + {"label": f"Replit {avg_rpl}", + "data": [avg_rpl]*len(labels_), "type": "line", + "borderColor": "#2196F3", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2}, + {"label": f"Devin {avg_dev}", + "data": [avg_dev]*len(labels_), "type": "line", + "borderColor": "#FF9800", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2}, + {"label": f"Manus {avg_mns}", + "data": [avg_mns]*len(labels_), "type": "line", + "borderColor": "#9C27B0", "borderDash": [5,3], "pointRadius": 0, "fill": False, "borderWidth": 2}, + ]}, + "options": { + "title": {"display": True, "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Devin {avg_dev}% | Manus {avg_mns}%"}, + "scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]}, + "legend": {"display": True, "position": "bottom"}, + "plugins": {"datalabels": {"display": False}}, + }, + } + chart_url = ("https://quickchart.io/chart?c=" + + _ul_sc.quote(_j_sc.dumps(chart_cfg, separators=(",",":"))) + + "&width=760&height=440&backgroundColor=white") + + # ── Caption messaggio 1 (foto, max 1024) ───────────────────── + bar_g = "█" * round(avg_ai/10) + "░" * (10 - round(avg_ai/10)) + d_rpl = round(avg_ai - avg_rpl); s_rpl = ("+" if d_rpl >= 0 else "") + str(d_rpl) + d_dev = round(avg_ai - avg_dev); s_dev = ("+" if d_dev >= 0 else "") + str(d_dev) + d_mns = round(avg_ai - avg_mns); s_mns = ("+" if d_mns >= 0 else "") + str(d_mns) + d_cur = round(avg_ai - avg_cur); s_cur = ("+" if d_cur >= 0 else "") + str(d_cur) + caption = f"🏆 Score — {ts} UTC v{ver}\n" + caption += f"{bar_g} {avg_ai}% {verdict}\n\n" + caption += f"{'Modello':<10} {'Score':>5} {'Δ':>4} Wins\n" + caption += f"{'Agente AI':<10} {str(avg_ai)+'%':>5} {'─':>4} ─\n" + caption += f"{'Replit':<10} {str(avg_rpl)+'%':>5} {s_rpl:>4} {w_rpl}/10\n" + caption += f"{'Cursor':<10} {str(avg_cur)+'%':>5} {s_cur:>4} ─\n" + caption += f"{'Devin':<10} {str(avg_dev)+'%':>5} {s_dev:>4} {w_dev}/10\n" + caption += f"{'Manus':<10} {str(avg_mns)+'%':>5} {s_mns:>4} {w_mns}/10\n\n" + for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1])/len(x[1])): + sc = round(sum(vals)/len(vals)) + bar = "█" * round(sc/10) + "░" * (10 - round(sc/10)) + d = round(sc - avg_rpl) + vs = ("+" if d >= 0 else "") + str(d) + icon = "✅" if sc >= avg_rpl else "⚠️" if sc >= 50 else "❌" + line = f"{cat[:12]:<12} {bar} {sc:>3}% {vs:>4} {icon}\n" + if len(caption) + len(line) < 1010: + caption += line + caption += f"\n🎲 Seed {seed} MFT {mft}s Gaps: {gap_cnt}" + await _tg_photo(chat_id, chart_url, caption=caption[:1024], keyboard=_BENCH_ACTION_KB) + + # ── Messaggio 2 — dettaglio completo ───────────────────────── + det = "📊 Score — Dettaglio\n\n" + + # Orchestration nodes + NODE_ICONS = {"planner":"🧠","executor":"⚙️","reasoner":"🔬", + "recovery_manager":"🛡","robustness_layer":"🔒","memory_module":"💾"} + if nodes: + det += "⚡ Orchestration Nodes:\n" + for nk, nv in nodes.items(): + sr = str(nv.get("success_rate", "?")) + lat = nv.get("avg_latency_s") + ntsk = nv.get("tasks", "") + try: + icon = "✅" if float(sr.rstrip("%")) >= 60 else "⚠️" if float(sr.rstrip("%")) >= 30 else "❌" + except Exception: + icon = "❓" + lat_s = f" {lat}s" if lat is not None else "" + tsk_s = f" ×{ntsk}" if ntsk else "" + det += f"{NODE_ICONS.get(nk,'•')} {nk[:20]:<20} {icon} {sr:>5}{lat_s}{tsk_s}\n" + det += "\n" + + # Runtime telemetria (live da Railway) + if rt_timing: + det += "\n⏱ Runtime Latenze (live):\n" + for phase, v in list(rt_timing.items())[:8]: + avg_v = v.get("avg", 0); p90_v = v.get("p90", 0); n_v = v.get("n", 0) + icon = "🟢" if avg_v < 3000 else "🟡" if avg_v < 10000 else "🔴" + det += f"{icon} {phase[:16]:<16} avg:{avg_v:>7}ms p90:{p90_v:>7}ms ×{n_v}\n" + det += "\n" + if rt_repair: + det += "🔧 Quality counters:\n" + for k, v in list(rt_repair.items())[:6]: + det += f" {k[:20]:<20} {v}\n" + det += "\n" + else: + det += "ℹ️ Telemetria runtime non disponibile (Railway idle)\n" + + # Top 3 best + Top 3 worst + sorted_tasks = sorted([t for t in tasks if t.get("score") is not None], key=lambda t: -t["score"]) + if sorted_tasks: + det += "\n🥇 Migliori task:\n" + for t in sorted_tasks[:3]: + ref_r = (t.get("ref") or {}).get("replit", avg_rpl) + d_ = round(t["score"] - ref_r) + ds_ = ("+" if d_ >= 0 else "") + str(d_) + ms_ = f" {round(t['agentMs']/1000)}s" if t.get("agentMs") else "" + det += f" {(t.get('id') or '?'):>3} {t['score']}% ({ds_} vsRpl){ms_}\n" + det += f" {(t.get('label') or '')[:60]}\n" + det += "\n⚠️ Task critici:\n" + for t in sorted_tasks[-3:]: + ref_r = (t.get("ref") or {}).get("replit", avg_rpl) + ref_m = (t.get("ref") or {}).get("manus", avg_mns) + d_ = round(t["score"] - ref_r) + dm_ = round(t["score"] - ref_m) + ds_ = ("+" if d_ >= 0 else "") + str(d_) + dms_ = ("+" if dm_ >= 0 else "") + str(dm_) + det += f" {(t.get('id') or '?'):>3} {t['score']}% vsRpl {ds_} vsManus {dms_}\n" + det += f" {(t.get('label') or '')[:60]}\n" + + # Gap cards top 3 + if gaps: + det += "\n🔥 Gap da correggere:\n" + for g in gaps[:3]: + gicon = "🔴" if g.get("gravita") == "critical" else "🟡" + det += f"{gicon} {(g.get('modulo_coinvolto') or '').replace('_',' ')} — {(g.get('causa_radice') or '')[:55]}\n" + det += f" Fix: {(g.get('fix_immediato') or '')[:65]}\n" + det += f" Atteso: {(g.get('beneficio_atteso') or '')[:40]}\n" + + det += f"\n{replay}" + det += f"\n🌐 Dashboard →" + + _TG_MAX = 4000 + if len(det) <= _TG_MAX: + await _tg_reply(chat_id, det, keyboard=_BENCH_ACTION_KB) + else: + await _tg_reply(chat_id, det[:_TG_MAX]) + await _tg_reply(chat_id, det[_TG_MAX:_TG_MAX*2][:_TG_MAX], keyboard=_BENCH_ACTION_KB) + + +async def _cmd_bench(chat_id: int, mode: str = "default") -> None: + """📊 Benchmark via bench.yml (benchmark-extended.mjs) + quickchart.io. + + GAP-TGB: workflow_dispatch su bench.yml — usa benchmark-extended.mjs + (20 categorie, seed canonico 1337, tutte le fix v5). + Risultati inviati via Telegram da ab-bench.mjs --notify al completamento. + """ + gh_token = os.getenv("GITHUB_TOKEN", "").strip() + + # ── Tenta fetch ultimo run completato da GitHub Actions artifact ───────── + last_report: dict | None = None + if gh_token: + try: + import httpx as _hx + async with _hx.AsyncClient(timeout=8.0) as _c: + _r = await _c.get( + "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/runs" + "?status=completed&per_page=1", + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r.status_code == 200: + _runs = _r.json().get("workflow_runs", []) + if _runs: + last_report = { + "run_id": _runs[0]["id"], + "run_url": _runs[0]["html_url"], + "conclusion":_runs[0].get("conclusion","?"), + "updated": _runs[0].get("updated_at",""), + } + except Exception as _exc: + _logger.debug("bench fetch last run: %s", _exc) + + # ── Trigger nuovo run via workflow_dispatch ─────────────────────────────── + run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml" + if gh_token: + try: + import httpx as _hx + async with _hx.AsyncClient(timeout=10.0) as _c: + _r = await _c.post( + "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches", + json={"ref": "main", "inputs": { + "mode": mode, + "run_improve": "false", + "force_update_baseline": "false", + }}, + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r.status_code == 204: + _logger.info("[bench] workflow_dispatch OK (mode=%s)", mode) + # Attendi 2s e leggi il run ID appena creato + await asyncio.sleep(2.0) + async with _hx.AsyncClient(timeout=8.0) as _c2: + _r2 = await _c2.get( + "https://api.github.com/repos/Baida98/AI/actions/workflows/" + "bench.yml/runs?per_page=1", + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r2.status_code == 200: + _rr = _r2.json().get("workflow_runs", []) + if _rr: + run_url = _rr[0]["html_url"] + else: + _logger.warning("[bench] workflow_dispatch status=%d", _r.status_code) + except Exception as _exc: + _logger.warning("[bench] workflow_dispatch error: %s", _exc) + + # ── Costruisci messaggio con quickchart dell'ultimo run (se disponibile) ── + _BENCH_CACHE[chat_id] = {"mode": mode, "run_url": run_url} + + # ── Fetch benchmark-report.json dal repo per quickchart reale ────────────── + bench_report: dict | None = None + if gh_token: + try: + import httpx as _hx, base64 as _b64, json as _json + async with _hx.AsyncClient(timeout=8.0) as _c: + _br = await _c.get( + "https://api.github.com/repos/Baida98/AI/contents/benchmark-report.json?ref=main", + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _br.status_code == 200: + _content = _b64.b64decode(_br.json()["content"]).decode() + bench_report = _json.loads(_content) + except Exception as _exc: + _logger.debug("bench fetch benchmark-report.json: %s", _exc) + + chart_url: str | None = None + + def _build_quickchart(report: dict) -> str: + """Costruisce URL quickchart.io da benchmark-report.json.""" + import json as _j, urllib.parse as _ul + tasks = report.get("tasks", []) + summary = report.get("summary", {}) + avg_ai = summary.get("avgScore", 0) + avg_rpl = summary.get("avgReplit", 57.9) + avg_mns = summary.get("avgManus", 71.2) + cat_map: dict[str, list[float]] = {} + for t in tasks: + cat = (t.get("cat") or "other").replace("_", " ")[:14] + cat_map.setdefault(cat, []).append(t.get("score", 0)) + if not cat_map: + return "" + labels = list(cat_map.keys()) + scores = [round(sum(v)/len(v)) for v in cat_map.values()] + colors = ["#4CAF50" if s >= avg_rpl else "#FF9800" if s >= 50 else "#F44336" for s in scores] + cfg = { + "type": "horizontalBar", + "data": { + "labels": labels, + "datasets": [ + {"label": "Agente AI", "data": scores, + "backgroundColor": colors, "borderColor": colors, "borderWidth": 1}, + {"label": f"Replit {avg_rpl}", + "data": [avg_rpl]*len(labels), + "type": "line", "borderColor": "#2196F3", "borderDash": [5,3], + "pointRadius": 0, "fill": False, "borderWidth": 2}, + {"label": f"Manus {avg_mns}", + "data": [avg_mns]*len(labels), + "type": "line", "borderColor": "#9C27B0", "borderDash": [5,3], + "pointRadius": 0, "fill": False, "borderWidth": 2}, + ], + }, + "options": { + "title": {"display": True, + "text": f"Agente AI {avg_ai}% | Replit {avg_rpl}% | Manus {avg_mns}%"}, + "scales": {"xAxes": [{"ticks": {"min": 0, "max": 100, "stepSize": 20}}]}, + "legend": {"display": True, "position": "bottom"}, + "plugins": {"datalabels": {"display": False}}, + }, + } + return ("https://quickchart.io/chart?c=" + + _ul.quote(_j.dumps(cfg, separators=(",",":"))) + + "&width=720&height=420&backgroundColor=white") + + if bench_report: + chart_url = _build_quickchart(bench_report) + + summary = (bench_report or {}).get("summary", {}) + avg_ai = summary.get("avgScore") + avg_rpl = summary.get("avgReplit") + avg_mns = summary.get("avgManus") + # ── Tabella ASCII con barre per caption Telegram ────────────────────────── + def _text_table_bench(report: dict, rpl: float) -> str: + tasks = report.get("tasks", []) + cat_map: dict[str, list[float]] = {} + for t in tasks: + cat = (t.get("cat") or "other").replace("_", " ")[:12] + cat_map.setdefault(cat, []).append(float(t.get("score", 0))) + if not cat_map: + return "" + rows = [] + for cat, vals in sorted(cat_map.items(), key=lambda x: -sum(x[1]) / len(x[1])): + sc = round(sum(vals) / len(vals)) + bar = "█" * round(sc / 10) + "░" * (10 - round(sc / 10)) + delta_rpl = sc - rpl + vs = ("+" if delta_rpl >= 0 else "") + str(round(delta_rpl)) + "vsRpl" + rows.append(f"{cat:<12} {bar} {sc:>3}% {vs}") + return "\n".join(rows) + + text_table = "" + if bench_report and avg_rpl is not None: + text_table = _text_table_bench(bench_report, float(avg_rpl)) + + score_line = "" + if avg_ai is not None: + score_line = ( + f"\n📈 Score: AI {avg_ai}%" + + (f" | Replit {avg_rpl}%" if avg_rpl else "") + + (f" | Manus {avg_mns}%" if avg_mns else "") + + "\n" + ) + + def _build_bench_caption(header: str) -> str: + tbl = ("\n" + text_table + "") if text_table else "" + link = f'\n🔗 GitHub Actions' + full = header + score_line + tbl + link + if len(full) > 1020 and text_table: + avail = max(0, 1020 - len(header) - len(score_line) - len(link) - 14) + tbl = "\n" + text_table[:avail] + "…" + full = header + score_line + tbl + link + return full[:1024] + + if last_report: + _conclusion = last_report.get("conclusion", "?") + _em = "✅" if _conclusion == "success" else ("❌" if _conclusion == "failure" else "⚠️") + _upd = last_report.get("updated", "")[:16].replace("T", " ") + header = f"📊 Benchmark avviato — {_em} {_conclusion}\n🕐 {_upd} UTC" + else: + header = "📊 Benchmark avviato (benchmark-extended.mjs)" + + caption = _build_bench_caption(header) + + if chart_url: + await _tg_photo(chat_id, chart_url, caption=caption, keyboard=_BENCH_ACTION_KB) + else: + await _tg_reply(chat_id, caption, keyboard=_BENCH_ACTION_KB) + + + +# ── _cmd_improve — ciclo miglioramento: bench → gap → patch ───────────────── + +async def _cmd_improve(chat_id: int) -> None: + """⚙️ Ciclo di miglioramento: bench.yml con --improve → gap → patch automatica. + + Triggera bench.yml con run_improve=true (benchmark-extended.mjs --improve). + Il ciclo: few-shot retry sui task falliti → identifica pattern di miglioramento + → genera regole → propone patch via notify Telegram. + """ + gh_token = os.getenv("GITHUB_TOKEN", "").strip() + + await _tg_reply(chat_id, + "⚙️ Ciclo miglioramento avviato\n\n" + "• Avvio benchmark-extended.mjs --improve\n" + "• Identifica gap · genera regole migliorate · propone patch\n" + "Riceverai notifica Telegram al completamento (~10-25 min).", + keyboard=_BACK_KB) + + run_url = "https://github.com/Baida98/AI/actions/workflows/bench.yml" + if not gh_token: + await _tg_reply(chat_id, + "⚠️ GITHUB_TOKEN non configurato — impossibile avviare workflow.", + keyboard=_BACK_KB) + return + + try: + import httpx as _hx + async with _hx.AsyncClient(timeout=10.0) as _c: + _r = await _c.post( + "https://api.github.com/repos/Baida98/AI/actions/workflows/bench.yml/dispatches", + json={"ref": "main", "inputs": { + "mode": "default", + "run_improve": "true", + "force_update_baseline": "false", + }}, + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r.status_code == 204: + _logger.info("[improve] workflow_dispatch OK") + await asyncio.sleep(2.0) + async with _hx.AsyncClient(timeout=8.0) as _c2: + _r2 = await _c2.get( + "https://api.github.com/repos/Baida98/AI/actions/workflows/" + "bench.yml/runs?per_page=1", + headers={"Authorization": f"Bearer {gh_token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgenteAI-Bot"}, + ) + if _r2.status_code == 200: + _rr = _r2.json().get("workflow_runs", []) + if _rr: + run_url = _rr[0]["html_url"] + await _tg_reply(chat_id, + f"✅ Ciclo miglioramento avviato\n" + f'🔗 Segui su GitHub Actions', + keyboard=_BENCH_ACTION_KB) + else: + await _tg_reply(chat_id, + f"⚠️ GitHub API status {_r.status_code} — riprova tra poco.", + keyboard=_BACK_KB) + except Exception as _exc: + _logger.warning("[improve] error: %s", _exc) + await _tg_reply(chat_id, + f"❌ Errore: {html.escape(str(_exc)[:150])}", + keyboard=_BACK_KB) + + diff --git a/api/telegram_cmd_monitoring.py b/api/telegram_cmd_monitoring.py new file mode 100644 index 0000000000000000000000000000000000000000..b487e130713fee778134d63616ff803bff5f2655 --- /dev/null +++ b/api/telegram_cmd_monitoring.py @@ -0,0 +1,457 @@ +"""backend/api/telegram_cmd_monitoring.py — Comandi Telegram di monitoraggio e stato. + +Comandi: + _cmd_help, _cmd_logs, _cmd_status, _cmd_commit_summary, + _cmd_check, _cmd_tasks, _cmd_git, _cmd_coord, + _cmd_scan_now, _cmd_telemetry +""" +from __future__ import annotations +import asyncio, html, logging, os, re, time + +from .telegram_tg_client import ( + _get_bot_token, _tg_reply, _tg_send, _tg_edit, + _tg_typing, _tg_react, _tg_photo, _fmt_elapsed, + _BACK_KB, +) +from .telegram_keyboards import ( + _MAIN_KB, _QUICK_PICK_KB, _after_task_kb, _BENCH_CACHE, _LAST_GOAL, +) + +_logger = logging.getLogger("api.telegram_webhook") + +# ── Command handlers ────────────────────────────────────────────────────────── + +async def _cmd_help(chat_id: int) -> None: + """Menu principale: un messaggio pulito + inline keyboard essenziale.""" + await _tg_typing(chat_id) + welcome = ( + "🤖 Agente AI\n" + "Assistente autonomo per lo sviluppo software\n\n" + "📝 Come usarmi:\n" + "Scrivi qualsiasi obiettivo — lo eseguo autonomamente:\n" + " • analizza i bug in providers.py\n" + " • ottimizza le query Supabase più lente\n" + " • fai autofix degli errori nel log\n\n" + "📌 Comandi rapidi:\n" + " /avvia — lancia un task AI\n" + " /stato — vedi cosa sta facendo\n" + " /salute — controllo sistema\n" + " /chiedi — domanda veloce all\'AI\n\n" + "⬇️ O scegli dal menu:" + ) + await _tg_reply(chat_id, welcome, keyboard=_MAIN_KB) + + +async def _cmd_logs(chat_id: int, level: str = "WARNING") -> None: + """Mostra ultimi log dal backend Railway filtrando per livello.""" + import httpx as _hx + railway_url = os.getenv("RAILWAY_URL", "https://ai-production-4c06.up.railway.app").rstrip("/") + await _tg_reply(chat_id, + f"📋 Log Railway{level.upper()}\n⏳ Fetching…") + try: + async with _hx.AsyncClient(timeout=10.0) as c: + r = await c.get(f"{railway_url}/api/telegram/logs", + params={"level": level.upper(), "n": 20}) + data = r.json() if r.status_code == 200 else {} + except Exception as exc: + await _tg_reply(chat_id, + "❌ Log non disponibili\n" + html.escape(str(exc)[:200]) + "\n" + "Controlla Railway dashboard.", keyboard=_BACK_KB) + return + records = data.get("records", []) + if not records: + msg = ("✅ Nessun " + level.upper() + " nei log!\nSistema stabile." + if level.upper() in ("WARNING","ERROR") + else "📋 Log vuoti — nessun record disponibile") + await _tg_reply(chat_id, msg, keyboard=_BACK_KB) + return + import datetime as _dt + lines = [f"📋 Log ({data.get('count',0)} rec — {level.upper()})\n"] + for rec in records[:15]: + ts = _dt.datetime.fromtimestamp(rec.get("ts",0), tz=_dt.timezone.utc).strftime("%H:%M:%S") + lvl = rec.get("level","?") + lgr = rec.get("logger","").split(".")[-1][:18] + msg = html.escape(str(rec.get("msg",""))[:100]) + icon = "🔴" if lvl=="ERROR" else "🟡" if lvl=="WARNING" else "⚪" + lines.append(f"{icon} {ts} [{lgr}] {msg}") + await _tg_reply(chat_id, "\n".join(lines), keyboard=_BACK_KB) + + +async def _cmd_status(chat_id: int) -> None: + await _tg_typing(chat_id) + try: + from api.state import _agent_tasks, _loop_registry # noqa: F401 + total = len(_agent_tasks) + running = sum(1 for t in _agent_tasks.values() if t.get("status") == "RUNNING") + success = sum(1 for t in _agent_tasks.values() if t.get("status") == "SUCCESS") + error = sum(1 for t in _agent_tasks.values() if t.get("status") == "ERROR") + queued = sum(1 for t in _agent_tasks.values() if t.get("status") == "QUEUED") + + # Supabase fallback: se in-memory è vuoto (restart backend) legge dal DB + sb_line = "" + if total == 0: + try: + from api.state import _sb + if _sb: + res = await asyncio.to_thread( + lambda: _sb.table("agent_tasks") + .select("status") + .order("created_at", desc=True) + .limit(50) + .execute() + ) + rows = res.data or [] + if rows: + db_run = sum(1 for r in rows if r.get("status") == "RUNNING") + db_done = sum(1 for r in rows if r.get("status") == "SUCCESS") + db_err = sum(1 for r in rows if r.get("status") == "ERROR") + sb_line = ( + "\n📦 Supabase (ultimi 50): " + + str(db_run) + " in corso / " + + str(db_done) + " ok / " + + str(db_err) + " err" + + " (backend riavviato)" + ) + except Exception as _exc: + _logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001 + + from api.scheduler import _tasks as sched_tasks, _loop_task + sched_ok = _loop_task is not None and not _loop_task.done() + sched_pending = sum(1 for t in sched_tasks.values() if t.get("status") == "pending") + sched_label = "✅ attivo" if sched_ok else "❌ fermo" + + ts_now = time.strftime("%Y-%m-%d %H:%M:%S") + railway_url = os.getenv("RAILWAY_URL","https://ai-production-4c06.up.railway.app") + ry_line = "" + try: + import httpx as _hx + async with _hx.AsyncClient(timeout=4.0) as c: + rv = await c.get(f"{railway_url}/api/info") + if rv.status_code == 200: + rj = rv.json() + ry_line = ("\n🚂 Railway: v" + rj.get("version","?") + + " — " + rj.get("sprint","")) + except Exception: + pass + + # ── NEW-1: HEAD git + ultimo commit ────────────────────────────────── + # Chiama GitHub API con GITHUB_TOKEN (Railway env) — timeout 4s, silent fail. + # Mostra: sha corto + prima riga commit message + età ("3h fa"). + git_line = "" + try: + import httpx as _hx_g, datetime as _dt + _gh_token = os.getenv("GITHUB_TOKEN", "").strip() + _gh_repo = os.getenv("GITHUB_REPO", "Baida98/AI").strip() + if _gh_token and _gh_repo: + async with _hx_g.AsyncClient(timeout=4.0) as _gc: + _gr = await _gc.get( + f"https://api.github.com/repos/{_gh_repo}/commits/main", + headers={"Authorization": f"Bearer {_gh_token}", "User-Agent": "agente-ai"}, + params={"per_page": 1}, + ) + if _gr.status_code == 200: + _cj = _gr.json() + _sha = (_cj.get("sha") or "")[:7] + _cmsg = ((_cj.get("commit") or {}).get("message") or "").split("\n")[0][:45] + _date = ((_cj.get("commit") or {}).get("committer") or {}).get("date", "") + _age = "" + if _date: + _ts = _dt.datetime.fromisoformat(_date.replace("Z", "+00:00")) + _secs = int((_dt.datetime.now(_dt.timezone.utc) - _ts).total_seconds()) + if _secs < 3600: _age = f"{_secs // 60}m fa" + elif _secs < 86400: _age = f"{_secs // 3600}h fa" + else: _age = f"{_secs // 86400}g fa" + git_line = ( + f"\n🔀 HEAD: {html.escape(_sha)}" + f" {html.escape(_cmsg)} ({_age})" + ) + except Exception: + pass + + # ── NEW-2: task live — goal + step corrente dal loop_registry ──────── + # Per il primo task RUNNING: mostra goal + azione corrente (dal buffer SSE) + # + tempo trascorso. Zero overhead se non c'è task in corso. + live_line = "" + try: + _running_list = [t for t in _agent_tasks.values() if t.get("status") == "RUNNING"] + if _running_list: + import json as _lj + _rt = _running_list[0] + _tid = _rt.get("id") or _rt.get("task_id") or "" + _goal_s = html.escape((_rt.get("goal") or "")[:38]) + _elapsed = "" + _ca = _rt.get("created_at", 0) + if isinstance(_ca, int) and _ca > 0: + _es = int(time.time() * 1000 - _ca) // 1000 + _elapsed = f" · {_es // 60}m{_es % 60:02d}s" if _es >= 60 else f" · {_es}s" + # Legge ultimo evento SSE dal buffer (action/type corrente) + _act = "" + _ebuf = (_loop_registry.get(_tid) or {}).get("event_buffer", []) + for _ev in reversed(_ebuf[-30:]): + try: + _raw = _ev[6:] if _ev.startswith("data: ") else _ev + _ed = _lj.loads(_raw) + _a = _ed.get("action") or _ed.get("type") or "" + if _a and _a not in ("ping", "connected", "keepalive"): + _act = f" → {html.escape(str(_a)[:20])}" + break + except Exception: + pass + live_line = f"\n⚙️ Live: {_goal_s}{_act}{_elapsed}" + except Exception: + pass + + # ── NEW-3: coord mini — sessioni agent-coord attive ────────────────── + # 1 riga: chi sta lavorando, su quale sprint, su quali file. + # Timeout aggressivo 3s — /status deve essere veloce. + coord_line = "" + try: + import httpx as _hx_c2, json as _jc2, time as _tc2 + _SB_URL2 = os.getenv("SUPABASE_URL", "").rstrip("/") + _SB_KEY2 = os.getenv("SUPABASE_KEY", "") + if _SB_KEY2: + async with _hx_c2.AsyncClient(timeout=3.0) as _cc: + _cr = await _cc.get( + f"{_SB_URL2}/rest/v1/agent_tasks", + params={"goal": "ilike.*__session__*", "select": "context"}, + headers={"apikey": _SB_KEY2, "Authorization": f"Bearer {_SB_KEY2}"}, + ) + if _cr.is_success: + _now_ms = int(_tc2.time() * 1000) + _active = [] + for _row in (_cr.json() or []): + try: + _ctx = _jc2.loads(_row.get("context") or "{}") + if _now_ms - int(_ctx.get("lastHeartbeat", 0)) < 300_000: + _active.append(_ctx) + except Exception: + pass + if _active: + _s = _active[0] + _files = [f.split("/")[-1] for f in _s.get("claimedFiles", [])] + _fstr = ", ".join(_files[:3]) or "—" + _extra = f" (+{len(_active)-1})" if len(_active) > 1 else "" + coord_line = ( + f"\n🔗 Coord: {html.escape(_s.get('sessionName','?'))}" + f" [{html.escape(_s.get('sprint','—'))}]" + f" · {html.escape(_fstr)}{_extra}" + ) + else: + coord_line = "\n🔗 Coord: nessuna sessione attiva" + except Exception: + pass + + # ── Icona salute sistema ────────────────────────────────────────────── + if running > 0: + _sys_icon, _sys_label = "⚙️", f"{running} task in esecuzione" + elif error > 0 and success == 0 and total > 0: + _sys_icon, _sys_label = "🔴", "ultimi task terminati con errore" + elif total == 0: + _sys_icon, _sys_label = "💤", "nessun task recente" + else: + _sys_icon, _sys_label = "✅", "tutto operativo" + + parts = [f"📊 Sistema {_sys_icon} — {_sys_label}", ""] + if running or queued: + parts.append(f"⚙️ In esecuzione: {running} · In coda: {queued}") + if success or error or total: + parts.append(f"✅ Completati: {success} · ❌ Errori: {error} · Totale: {total}") + for _extra_line in [sb_line, ry_line, git_line, live_line, coord_line]: + if _extra_line: + parts.append(_extra_line) + parts += [ + "", + f"🗓 Scheduler: {sched_label}" + (f" · {sched_pending} in coda" if sched_pending else ""), + "", + f"🕐 {ts_now} UTC", + ] + await _tg_reply(chat_id, "\n".join(p for p in parts if p is not None), + keyboard=_MAIN_KB) + except Exception as exc: + await _tg_reply(chat_id, "⚠️ Errore lettura stato: " + html.escape(str(exc)[:200])) + + +async def _cmd_commit_summary(chat_id: int) -> None: + """Riepilogo humanizzato degli ultimi commit via GitHub + AI leggera.""" + import json as _json + GH_API = "https://api.github.com/repos/Baida98/AI/commits?per_page=8" + SKIP_RE = re.compile(r"^(🔒|🔓|acquire push lock|release push lock)", re.I) + try: + import httpx + async with httpx.AsyncClient(timeout=10) as cli: + r = await cli.get(GH_API, headers={"Authorization": f"Bearer {_gh_token}", + "Accept": "application/vnd.github+json"}) + commits = r.json() if r.status_code == 200 else [] + except Exception: + commits = [] + + # Filtra commit di lock/chore puro + commits = [c for c in commits + if not SKIP_RE.match((c.get("commit", {}).get("message") or "").split("\n")[0])][:6] + + if not commits: + await _tg_reply(chat_id, "📭 Nessun commit recente trovato.", keyboard=_BACK_KB) + return + + # Costruisci breve sintesi statica (no AI) con traduzione tipo + _TYPE_IT = { + "feat": "Nuova funzione", "fix": "Correzione bug", "docs": "Documentazione", + "refactor": "Refactor", "chore": "Manutenzione", "test": "Test", + "perf": "Performance", "ux": "Esperienza utente", "style": "Stile", + "ci": "CI/CD", "build": "Build", + } + _TYPE_ICON = { + "feat": "✨", "fix": "🔧", "docs": "📄", "refactor": "♻️", + "chore": "🔩", "test": "🧪", "perf": "⚡", "ux": "🎨", + "ci": "⚙️", "build": "📦", + } + lines = ["🔀 Ultimi commit\n"] + for c in commits: + raw_msg = (c.get("commit", {}).get("message") or "").split("\n")[0] + sha = (c.get("sha") or "")[:7] + date = (c.get("commit", {}).get("author", {}).get("date") or "") + time_s = date[11:16] if len(date) >= 16 else "??:??" + # Estrai tipo e corpo + m = re.match(r"^(feat|fix|docs|refactor|chore|test|perf|ux|style|ci|build)(?:\([^)]+\))?:\s*(.+)$", raw_msg) + if m: + tipo, corpo = m.group(1), m.group(2) + icon = _TYPE_ICON.get(tipo, "📌") + tipo_it = _TYPE_IT.get(tipo, tipo) + # Humanizza la descrizione: rimuovi jargon tecnico comune + corpo_h = corpo.replace("_", " ").replace("-", " ") + corpo_h = re.sub(r"(impl|add|implement|update|refactor|fix|use|remove|clean)", "", corpo_h, flags=re.I).strip() + corpo_h = corpo_h[:60] or corpo[:60] + lines.append(f"{icon} {tipo_it} — {html.escape(corpo_h)}\n {time_s} · {sha}") + else: + lines.append(f"📌 {html.escape(raw_msg[:65])}\n {time_s} · {sha}") + + await _tg_reply( + chat_id, + "\n\n".join(lines), + keyboard={ + "inline_keyboard": [ + [{"text": "🔄 Aggiorna", "callback_data": "qp_commits"}, + {"text": "🚀 Nuovo Task", "callback_data": "agent"}], + [{"text": "🏠 Menu", "callback_data": "tgw_help"}], + ] + } + ) + + +async def _cmd_check(chat_id: int) -> None: + """🔍 Health check live: Railway, HF Space A+B, versione vs GH HEAD.""" + import time as _time + await _tg_typing(chat_id) + await _tg_reply(chat_id, "🔍 Check infrastruttura...", keyboard=None) + + async def _probe(label: str, url: str, timeout: int = 7) -> str: + t0 = _time.monotonic() + try: + async with httpx.AsyncClient(timeout=timeout) as cli: + r = await cli.get(url) + ms = round((_time.monotonic() - t0) * 1000) + icon = "✅" if r.status_code == 200 else "⚠️" + return f"{icon} {label}{r.status_code} {ms}ms" + except Exception as exc: + ms = round((_time.monotonic() - t0) * 1000) + return f"❌ {label}{str(exc)[:55]} {ms}ms" + + probes = await asyncio.gather( + _probe("Railway backend", "https://ai-production-4c06.up.railway.app/health"), + _probe("HF Space A", "https://arjanit98-terminal.hf.space/api/version"), + _probe("HF Space B", "https://baida00-ai-backend-collab.hf.space/api/version"), + ) + + # GH HEAD vs HF Space version + gh_sha, hf_build = "?", "?" + try: + async with httpx.AsyncClient(timeout=5) as cli: + ref_r = await cli.get( + "https://api.github.com/repos/Baida98/AI/git/refs/heads/main", + headers={"Authorization": f"Bearer {_gh_token}", "Accept": "application/vnd.github+json"}, + ) + if ref_r.status_code == 200: + gh_sha = (ref_r.json().get("object", {}).get("sha") or "?")[:10] + except Exception: + pass + try: + async with httpx.AsyncClient(timeout=5) as cli: + ver_r = await cli.get("https://arjanit98-terminal.hf.space/api/version") + if ver_r.status_code == 200: + d = ver_r.json() + hf_build = f"{d.get('version','?')} ({d.get('build_date','?')})" + except Exception: + pass + + sync_icon = "✅" if gh_sha != "?" and gh_sha[:8] in hf_build else "⚠️" + lines_out = ["🔍 Infrastructure Check"] + lines_out.extend(probes) + lines_out.append(f"GH HEAD: {gh_sha}") + lines_out.append(f"HF build: {hf_build}") + lines_out.append(f"{sync_icon} GH↔HF {'in sync' if sync_icon=='✅' else 'OUT OF SYNC — usa 🔄 Sync HF'}") + await _tg_reply(chat_id, "\n".join(lines_out), keyboard=_BACK_KB) + + +async def _cmd_tasks(chat_id: int) -> None: + await _tg_typing(chat_id) + STATUS_EMOJI = { + "SUCCESS": "✅", "ERROR": "❌", "RUNNING": "⚙️", + "QUEUED": "⏳", "CANCELLED": "🚫", + } + try: + from api.state import _agent_tasks + mem_tasks = sorted( + _agent_tasks.values(), + key=lambda t: t.get("created_at", 0), + reverse=True, + )[:8] + + # Supabase fallback se memoria vuota + if not mem_tasks: + try: + from api.state import _sb + if _sb: + res = await asyncio.to_thread( + lambda: _sb.table("agent_tasks") + .select("task_id,goal,status,created_at") + .order("created_at", desc=True) + .limit(8) + .execute() + ) + rows = res.data or [] + if rows: + lines = ["📋 Ultimi task (Supabase) ⚠️ backend riavviato\n"] + for r in rows: + em = STATUS_EMOJI.get(r.get("status", ""), "•") + gol = html.escape((r.get("goal") or "")[:55]) + tid = html.escape(str(r.get("task_id") or "")[:8]) + ts = r.get("created_at", 0) + age = _fmt_elapsed(ts) if isinstance(ts, int) and ts > 1_000_000 else "?" + lines.append(em + " " + tid + " " + gol + " " + age + "") + return await _tg_reply(chat_id, "\n".join(lines)) + except Exception as _exc: + _logger.debug("[telegram_webhook] silenced %s", type(_exc).__name__) # noqa: BLE001 + return await _tg_reply(chat_id, "📋 Nessun task recente in memoria.") + + _ST_LABEL = { + "SUCCESS": "Fatto", "ERROR": "Fallito ⚠️", "RUNNING": "In corso…", + "QUEUED": "In coda", "CANCELLED": "Annullato", + } + _ST_FLAIR = { + "SUCCESS": "✅", "ERROR": "❌", "RUNNING": "⚙️", + "QUEUED": "⏳", "CANCELLED": "🚫", + } + lines = ["Cosa ha fatto l'agente:\n"] + for t in mem_tasks: + st = t.get("status", "?") + em = _ST_FLAIR.get(st, "•") + gol = html.escape((t.get("goal") or "—")[:70]) + ca = t.get("created_at", 0) + age = _fmt_elapsed(ca) if isinstance(ca, int) and ca > 1_000_000 else "poco fa" + lbl = _ST_LABEL.get(st, st) + lines.append(f"{em} {gol}\n {lbl} · {age}") + await _tg_reply(chat_id, "\n\n".join(lines), keyboard=_BACK_KB) + except Exception as exc: + await _tg_reply(chat_id, "⚠️ " + html.escape(str(exc)[:200])) + + diff --git a/api/telegram_keyboards.py b/api/telegram_keyboards.py new file mode 100644 index 0000000000000000000000000000000000000000..44e1f1d7ad42f09cf979e05432e4953021dc2c98 --- /dev/null +++ b/api/telegram_keyboards.py @@ -0,0 +1,122 @@ +"""backend/api/telegram_keyboards.py — Inline keyboards, menu e costanti UI Telegram. + +Costanti: + _QUICK_PICK_KB — quick-pick task templates + _MAIN_KB — tastiera principale (reply keyboard) + _LAST_GOAL — dict chat_id → ultimo goal (per retry) + _BENCH_CACHE — cache benchmark + +Funzioni: + _after_task_kb(chat_id) — keyboard post-task con retry +""" +from __future__ import annotations +# ── Quick-pick task templates (MX-QUICKPICK) ────────────────────────────────── +_QUICK_PICK_KB = { + "inline_keyboard": [ + [{"text": "🔍 Analizza bug", "callback_data": "qp_bug"}, + {"text": "⚡ Ottimizza DB", "callback_data": "qp_db"}], + [{"text": "🔧 AutoFix log", "callback_data": "qp_autofix"}, + {"text": "🔀 Riassumi commit", "callback_data": "qp_commits"}], + [{"text": "📝 Genera docs", "callback_data": "qp_docs"}, + {"text": "🧪 Genera test", "callback_data": "qp_tests"}], + [{"text": "✍️ Scrivi obiettivo...", "callback_data": "qp_custom"}], + [{"text": "🏠 Menu", "callback_data": "tgw_help"}], + ] +} + +# ── After-task keyboard (retry + navigazione) ───────────────────────────────── +# Ultimo goal per chat_id — usato da 🔁 Rifai +_LAST_GOAL: dict[int, str] = {} + +def _after_task_kb(chat_id: int) -> dict: + """Keyboard mostrata dopo ogni task completato.""" + return { + "inline_keyboard": [ + [{"text": "🔁 Rifai", "callback_data": "tgw_retry"}, + {"text": "📋 Attività", "callback_data": "tgw_tasks"}], + [{"text": "🚀 Nuovo Task", "callback_data": "agent"}, + {"text": "🏠 Menu", "callback_data": "tgw_help"}], + ] + } + + + +# ── Main reply keyboard ───────────────────────────────────────────────────── +_MAIN_KB = { + "inline_keyboard": [ + [{"text": "🚀 Nuovo Task", "callback_data": "agent"}, + {"text": "📋 Attività", "callback_data": "tgw_tasks"}], + [{"text": "🩺 Salute", "callback_data": "tgw_health"}, + {"text": "🔧 AutoFix", "callback_data": "tgw_autofix"}], + [{"text": "💬 Chiedi all'AI","callback_data": "tgw_ask"}, + {"text": "📈 Stato", "callback_data": "tgw_status"}], + [{"text": "🌐 Dashboard →", "url": "https://agente-ai.pages.dev"}], + ] +} + +# ── Bench cache + keyboard (GAP-TGB) ───────────────────────────────────────── +# Salva l'ultimo run bench per chat_id → usato dai callback tgw_bench_fix/run +_BENCH_CACHE: dict[int, dict] = {} + +_BENCH_ACTION_KB = { + "inline_keyboard": [ + [{"text": "🔧 Applica Fix", "callback_data": "tgw_bench_fix"}, + {"text": "🔄 Riesegui", "callback_data": "tgw_bench_run"}], + [{"text": "⚙️ Migliora", "callback_data": "tgw_improve"}], + [{"text": "🏠 Menu", "callback_data": "tgw_help"}], + ] +} + +# ── ReplyKeyboardRemove — rimuove tastiera persistente da versioni precedenti ── +_REPLY_KB_REMOVE = {"remove_keyboard": True} + +# ── Sub-menu inline keyboards ───────────────────────────────────────────────── +_TASK_MENU_KB = { + "inline_keyboard": [ + [{"text": "🤖 Nuovo Task", "callback_data": "tgw_do"}, + {"text": "🔧 AutoFix", "callback_data": "tgw_autofix"}], + [{"text": "⚙️ Migliora AI", "callback_data": "tgw_improve"}, + {"text": "📋 Task recenti", "callback_data": "tgw_tasks"}], + [{"text": "🧠 Briefing", "callback_data": "tgw_briefing"}, + {"text": "📝 Salva Nota", "callback_data": "tgw_nota"}], + [{"text": "🔍 Cerca web", "callback_data": "tgw_cerca"}, + {"text": "🌤 Meteo", "callback_data": "tgw_meteo"}], + ] +} +_STATUS_MENU_KB = { + "inline_keyboard": [ + [{"text": "📊 Daemon+Task", "callback_data": "tgw_status"}, + {"text": "🔌 Provider AI", "callback_data": "tgw_providers"}], + [{"text": "🔗 Coord sessioni", "callback_data": "tgw_coord"}, + {"text": "🔀 Git log", "callback_data": "tgw_git"}], + [{"text": "🌐 Dashboard", "url": "https://agente-ai.pages.dev"}], + ] +} +_PERF_MENU_KB = { + "inline_keyboard": [ + [{"text": "📊 Benchmark", "callback_data": "tgw_bench"}, + {"text": "🏆 Score", "callback_data": "tgw_score"}], + [{"text": "📡 Telemetria", "callback_data": "tgw_telemetry"}, + {"text": "⚙️ Migliora", "callback_data": "tgw_improve"}], + [{"text": "🔧 Fix gap bench", "callback_data": "tgw_bench_fix"}], + ] +} +_HEALTH_MENU_KB = { + "inline_keyboard": [ + [{"text": "🔍 Scan completo", "callback_data": "tgw_health"}, + {"text": "📝 Log errori", "callback_data": "tgw_logs"}], + [{"text": "📊 Status", "callback_data": "tgw_status"}, + {"text": "🔌 Provider AI", "callback_data": "tgw_providers"}], + ] +} +_DEV_MENU_KB = { + "inline_keyboard": [ + [{"text": "📸 Snapshot", "callback_data": "tgw_snap"}, + {"text": "✅ Verify", "callback_data": "tgw_verify"}], + [{"text": "💊 Heal", "callback_data": "tgw_heal"}, + {"text": "📝 Log", "callback_data": "tgw_logs"}], + [{"text": "🔀 Git commits", "callback_data": "tgw_git"}, + {"text": "🏓 Ping", "callback_data": "tgw_ping"}], + ] +} + diff --git a/api/telegram_tg_client.py b/api/telegram_tg_client.py new file mode 100644 index 0000000000000000000000000000000000000000..071ec9f9a67922d15db2e6e1b7bb87a5541ffcee --- /dev/null +++ b/api/telegram_tg_client.py @@ -0,0 +1,264 @@ +"""backend/api/telegram_tg_client.py — Telegram Bot API helpers. + +Livello più basso dell'integrazione Telegram: funzioni pure per inviare +messaggi, modificarli, reazioni, typing indicator, foto. +Non ha dipendenze da altri moduli interni — importabile ovunque. + +Esportati: + _get_bot_token, _log_tg_exc, _fmt_elapsed + _tg_reply, _tg_send, _tg_edit, _tg_photo, _tg_typing, _tg_react, _tg_answer_callback +""" +from __future__ import annotations +import asyncio, html, logging, os, time +import httpx # top-level — era lazy in 20+ funzioni + +_logger = logging.getLogger("api.telegram_webhook") # unico logger (duplicato rimosso) + + +def _log_tg_exc(task: "asyncio.Task[None]") -> None: + """Gap-2.6: log exceptions from fire-and-forget tasks.""" + try: + exc = task.exception() + if exc: + _logger.warning("tg_webhook bg task error: %s: %s", type(exc).__name__, exc) + except (asyncio.CancelledError, asyncio.InvalidStateError): + pass + + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _get_bot_token() -> str: + return os.getenv("TELEGRAM_BOT_TOKEN", "").strip() + + +async def _tg_reply(chat_id: str | int, text: str, token: str | None = None, + keyboard: dict | None = None) -> None: + """Invia risposta al chat_id con HTML + opzionale inline keyboard.""" + bot_token = token or _get_bot_token() + if not bot_token: + return + payload: dict = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "link_preview_options": {"is_disabled": True}, + } + if keyboard: + payload["reply_markup"] = keyboard + try: + import httpx + async with httpx.AsyncClient(timeout=8.0) as c: + await c.post( + f"https://api.telegram.org/bot{bot_token}/sendMessage", + json=payload, + ) + except Exception as exc: + _logger.warning("tg_reply error: %s", exc) + + +async def _tg_answer_callback(callback_query_id: str, text: str = "", token: str | None = None) -> None: + """Risponde a un callback_query (obbligatorio per chiudere il loading sui buttons).""" + bot_token = token or _get_bot_token() + if not bot_token: + return + try: + import httpx + async with httpx.AsyncClient(timeout=5.0) as c: + await c.post( + f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery", + json={"callback_query_id": callback_query_id, "text": text, "show_alert": False}, + ) + except Exception as exc: + _logger.debug("answer_callback error: %s", exc) + + +async def _tg_send(chat_id: str | int, text: str, token: str | None = None, + keyboard: dict | None = None) -> str | None: + """Invia messaggio e ritorna il message_id (per editMessageText streaming).""" + bot_token = token or _get_bot_token() + if not bot_token: + return None + payload: dict = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "link_preview_options": {"is_disabled": True}, + } + if keyboard: + payload["reply_markup"] = keyboard + try: + import httpx + async with httpx.AsyncClient(timeout=8.0) as c: + r = await c.post( + f"https://api.telegram.org/bot{bot_token}/sendMessage", + json=payload, + ) + j = r.json() + return str(j.get("result", {}).get("message_id", "")) if j.get("ok") else None + except Exception as exc: + _logger.warning("tg_send error: %s", exc) + return None + + +async def _tg_edit(chat_id: str | int, message_id: str, text: str, + token: str | None = None, keyboard: dict | None = None) -> bool: + """Aggiorna messaggio esistente — streaming live via editMessageText. + Ritorna True se successo. Rate-limit: max 20 edit/min per chat Telegram.""" + bot_token = token or _get_bot_token() + if not bot_token or not message_id: + return False + payload: dict = { + "chat_id": chat_id, + "message_id": int(message_id), + "text": text[:4000], + "parse_mode": "HTML", + "link_preview_options": {"is_disabled": True}, + } + if keyboard: + payload["reply_markup"] = keyboard + try: + import httpx + async with httpx.AsyncClient(timeout=8.0) as c: + r = await c.post( + f"https://api.telegram.org/bot{bot_token}/editMessageText", + json=payload, + ) + return r.json().get("ok", False) + except Exception as exc: + _logger.debug("tg_edit error: %s", exc) + return False + + +async def _tg_photo( + chat_id: str | int, + photo_url: str, + caption: str = "", + token: str | None = None, + keyboard: dict | None = None, +) -> None: + """Invia foto/chart via sendPhoto Telegram. + + Strategia anti URL-lungo: + 1. POST a quickchart.io → scarica PNG bytes → multipart sendPhoto (no limite URL). + 2. Fallback: invia URL direttamente (funziona se URL < ~2000 chars). + """ + bot_token = token or _get_bot_token() + if not bot_token: + return + caption_safe = (caption or "")[:1024] + + import httpx as _hx_p, json as _j_p, urllib.parse as _ul_p, re as _re_p + + png_bytes: bytes | None = None + if "quickchart.io/chart" in photo_url: + try: + m = _re_p.search(r"[?&]c=([^&]+)", photo_url) + if m: + cfg_dict = _j_p.loads(_ul_p.unquote(m.group(1))) + async with _hx_p.AsyncClient(timeout=20.0) as c: + qr = await c.post( + "https://quickchart.io/chart", + json={"chart": cfg_dict, "width": 720, "height": 420, + "backgroundColor": "white", "format": "png"}, + ) + if qr.status_code == 200 and qr.headers.get("content-type", "").startswith("image/"): + png_bytes = qr.content + _logger.debug("tg_photo: quickchart POST ok, %d bytes", len(png_bytes)) + except Exception as exc: + _logger.debug("tg_photo: quickchart POST fallback: %s", exc) + + try: + import httpx as _hx_s + async with _hx_s.AsyncClient(timeout=15.0) as c: + if png_bytes: + import json as _j_s + data: dict = {"chat_id": str(chat_id), "parse_mode": "HTML"} + if caption_safe: + data["caption"] = caption_safe + if keyboard: + data["reply_markup"] = _j_s.dumps(keyboard) + files = {"photo": ("chart.png", png_bytes, "image/png")} + await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", + data=data, files=files) + else: + payload: dict = {"chat_id": chat_id, "photo": photo_url, "parse_mode": "HTML"} + if caption_safe: + payload["caption"] = caption_safe + if keyboard: + payload["reply_markup"] = keyboard + await c.post(f"https://api.telegram.org/bot{bot_token}/sendPhoto", json=payload) + except Exception as exc: + _logger.warning("tg_photo error: %s", exc) + + +async def _tg_typing(chat_id: str | int, action: str = "typing", token: str | None = None) -> None: + """Invia sendChatAction — mostra '⌨️ digitando…' prima di operazioni pesanti. + + Dura 5 secondi o fino al prossimo messaggio del bot. + Azioni: typing, upload_photo, upload_document, find_location, record_video_note. + """ + bot_token = token or _get_bot_token() + if not bot_token: + return + try: + async with httpx.AsyncClient(timeout=3.0) as c: + await c.post( + f"https://api.telegram.org/bot{bot_token}/sendChatAction", + json={"chat_id": chat_id, "action": action}, + ) + except Exception: + pass + + +async def _tg_react( + chat_id: str | int, + message_id: int | str, + emoji: str = "👍", + token: str | None = None, +) -> None: + """Aggiunge reazione emoji a un messaggio (Bot API 7.1+, Feb 2024). + + Emoji supportate: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 🎉 🤩 🏆 ✅ 💯 ⚡ 🚀 🎯 + """ + bot_token = token or _get_bot_token() + if not bot_token or not message_id: + return + try: + async with httpx.AsyncClient(timeout=3.0) as c: + await c.post( + f"https://api.telegram.org/bot{bot_token}/setMessageReaction", + json={ + "chat_id": chat_id, + "message_id": int(message_id), + "reaction": [{"type": "emoji", "emoji": emoji}], + "is_big": False, + }, + ) + except Exception: + pass + + +def _fmt_elapsed(created_at_ms: int) -> str: + """Formatta elapsed time da un timestamp ms → stringa leggibile.""" + diff = int(time.time() * 1000) - created_at_ms + s = diff // 1000 + if s < 60: + return f"{s}s fa" + if s < 3600: + return f"{s // 60}m{s % 60:02d}s fa" + return f"{s // 3600}h{(s % 3600) // 60:02d}m fa" + +_WEBAPP_KB = { + "inline_keyboard": [ + [{"text": "🚀 Apri Dashboard", "web_app": {"url": "https://agente-ai.pages.dev"}}], + [{"text": "🏠 Menu", "callback_data": "tgw_help"}], + ] +} +_BACK_KB = { + "inline_keyboard": [ + [{"text": "🏠 Menu", "callback_data": "tgw_help"}, + {"text": "📊 Stato", "callback_data": "tgw_status"}], + ] +} + diff --git a/api/token_rotator.py b/api/token_rotator.py new file mode 100644 index 0000000000000000000000000000000000000000..f9b26579b04f2a546d7d03464ddf59e8e37afe15 --- /dev/null +++ b/api/token_rotator.py @@ -0,0 +1,57 @@ +"""backend/api/token_rotator.py — Rotazione token API multi-istanza. + + CRIT-A: Rotazione atomica via Redis (REDIS_URL) con fallback in-memory. + REDIS_URL = stringa connessione Upstash Redis (redis://default:...@host:port). + Nota: diversa da UPSTASH_REDIS_REST_URL usata dal client REST in backend/redis.py. + + Fix 2026-07-19: aggiunto metodo async rotate() mancante — execution_fabric.py lo chiamava + su ogni quota/rate-limit error causando AttributeError silenzioso (swallowed da except). + """ +import os +import logging + +_logger = logging.getLogger("api.token_rotator") + +_REDIS_URL = os.getenv("REDIS_URL", "") +_REDIS_KEY = "token_rotator:current_index" + + +class TokenRotator: + def __init__(self) -> None: + self._local_index: int = 0 + + async def rotate(self) -> None: + """Forza rotazione al token successivo (es. su quota/rate-limit error). + + Usa Redis INCR atomico se REDIS_URL e' configurato — garantisce sincronizzazione + tra piu' istanze HuggingFace Spaces. Fallback silente in-memory se Redis non disponibile. + + REDIS_URL = stringa connessione Upstash Redis: + redis://default:@.upstash.io: + Disponibile su Upstash Dashboard -> Database -> "Redis connection string". + """ + if _REDIS_URL: + try: + import redis.asyncio as _aioredis + async with _aioredis.from_url(_REDIS_URL, decode_responses=True) as rc: + idx = await rc.incr(_REDIS_KEY) + _logger.info("TokenRotator: rotate() via Redis — idx=%d", idx) + return + except Exception as exc: + _logger.warning( + "TokenRotator: Redis rotate() fallita (%s) — in-memory fallback", exc + ) + # Fallback in-memory (non sincronizzato multi-istanza) + self._local_index += 1 + _logger.info("TokenRotator: rotate() in-memory — idx=%d", self._local_index) + + def get_current_index(self, total_tokens: int) -> int: + """Indice corrente in-memory (non sincronizzato multi-istanza). + Fallback locale quando Redis non e' disponibile. + Legge il valore corrente — usa rotate() per avanzare.""" + if total_tokens <= 0: + return 0 + return self._local_index % total_tokens + + +rotator = TokenRotator() diff --git a/api/tool_cache.py b/api/tool_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..2d6585fd97cafef0638cc28dc10e717a25b29566 --- /dev/null +++ b/api/tool_cache.py @@ -0,0 +1,39 @@ +import hashlib +import json +import time +from typing import Any, Optional +from .redis import get_redis_client + +class ToolCache: + """Sistema di caching per i risultati dei tool call (Pilastro 3: Stabilità).""" + + def __init__(self, ttl: int = 3600): + self.redis = get_redis_client() + self.ttl = ttl + + def _generate_key(self, tool_name: str, args: dict) -> str: + """Genera una chiave univoca basata sul nome del tool e gli argomenti deterministici.""" + args_str = json.dumps(args, sort_keys=True) + hash_val = hashlib.sha256(args_str.encode()).hexdigest() + return f"tool_cache:{tool_name}:{hash_val}" + + async def get(self, tool_name: str, args: dict) -> Optional[Any]: + """Recupera un risultato dalla cache se presente e non scaduto.""" + key = self._generate_key(tool_name, args) + try: + data = await self.redis.get(key) + if data: + return json.loads(data) + except Exception: + pass + return None + + async def set(self, tool_name: str, args: dict, result: Any): + """Salva un risultato nella cache con il TTL configurato.""" + key = self._generate_key(tool_name, args) + try: + await self.redis.set(key, json.dumps(result), ex=self.ttl) + except Exception: + pass + +tool_cache = ToolCache() diff --git a/api/tool_engine.py b/api/tool_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..e4dbf857f55f160bb2ead1fad6f9aa56ed4a9343 --- /dev/null +++ b/api/tool_engine.py @@ -0,0 +1,312 @@ +""" +backend/api/tool_engine.py — Tool Engine (ARCH-I4.3) + +Integra il Workflow Engine nel flusso di esecuzione dei tool. +Flusso evoluto: + Brain (Planner) → WorkflowPlan(steps=[tool_call,...]) + → WorkflowEngine → ToolEngine.execute(tool, payload) + → CapabilityResolver → ExecutionFabric → Worker + +I tool sono ora capability di prima classe nel Catalog: + - ogni tool si registra con un ToolDescriptor + - il Brain richiede capability=tool_name (non hardcoda il provider) + - il Resolver trova il Worker che espone quel tool + +HTTP: + POST /api/tools/register — registra un tool descriptor + POST /api/tools/execute — esegue un tool (sync, auth MACHINE) + GET /api/tools/ — lista tool registrati + GET /api/tools/{tool_name}/schema — schema input/output del tool + +ADR: S4 S5 S9 S19 S20 S21 S27 +""" +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from .auth_guard import AuthRole, require_role + +_logger = logging.getLogger("api.tool_engine") + +# ── Guards ───────────────────────────────────────────────────────────────────── +try: + from .capability_catalog import catalog as _catalog, CapabilityDescriptor as _CapDesc + _CATALOG_AVAILABLE = True +except Exception: + _catalog = None; _CapDesc = None; _CATALOG_AVAILABLE = False # type: ignore + +try: + from .capability_resolver import resolver as _resolver, ResolveRequest as _RReq + _RESOLVER_AVAILABLE = True +except Exception: + _resolver = None; _RReq = None; _RESOLVER_AVAILABLE = False # type: ignore + +try: + from .kernel import kernel as _kernel + _KERNEL_AVAILABLE = True +except Exception: + _kernel = None; _KERNEL_AVAILABLE = False # type: ignore + +try: + from .workflow_engine import engine as _workflow_engine, SubmitWorkflowRequest as _SWReq + _WORKFLOW_ENGINE_AVAILABLE = True +except Exception: + _workflow_engine = None; _SWReq = None; _WORKFLOW_ENGINE_AVAILABLE = False # type: ignore + +# ── Models ────────────────────────────────────────────────────────────────────── + +class ToolDescriptor(BaseModel): + name: str = Field(..., description="Nome tool = capability nel Catalog") + version: str = Field("1.0.0") + description: str = Field("") + provider_id: str = Field(..., description="Worker/provider che implementa il tool") + provider_name: str = Field("") + input_schema: dict[str, Any] = Field(default_factory=dict, + description="JSON Schema per il payload input") + output_schema: dict[str, Any] = Field(default_factory=dict, + description="JSON Schema per il payload output") + requires_gpu: bool = Field(False) + sla_ms: float = Field(5000.0) + cost_unit: float = Field(0.0) + region: str = Field("global") + tags: list[str] = Field(default_factory=list) + idempotent: bool = Field(False, description="True se sicuro retry senza side-effect") + enabled: bool = Field(True) + registered_at: float = Field(default_factory=time.time) + + +class ToolExecuteRequest(BaseModel): + tool_name: str + payload: dict[str, Any] = Field(default_factory=dict) + session_id: str | None = None + correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + timeout_s: float = Field(30.0) + use_workflow: bool = Field(False, + description="Se True, esegue via WorkflowEngine (async)") + + +class ToolExecuteResult(BaseModel): + tool_name: str + status: str # "ok" | "error" | "queued" | "timeout" + result: Any = None + error: str | None = None + provider_id: str | None = None + execution_id: str | None = None # WorkflowExecution ID se use_workflow=True + latency_ms: float = 0.0 + correlation_id: str = "" + executed_at: float = Field(default_factory=time.time) + + +class RegisterToolRequest(BaseModel): + descriptor: ToolDescriptor + force: bool = False + + +# ── ToolRegistry ──────────────────────────────────────────────────────────────── + +class ToolRegistry: + """ + Registro dei tool disponibili. Ogni tool è anche una capability nel Catalog. + Il Brain (Planner) usa tool_name come capability — il Resolver trova il Worker. + """ + + def __init__(self) -> None: + self._tools: dict[str, ToolDescriptor] = {} + self._lock = asyncio.Lock() + + async def register(self, desc: ToolDescriptor, force: bool = False) -> bool: + async with self._lock: + if desc.name in self._tools and not force: + raise ValueError(f"Tool '{desc.name}' già registrato. Usa force=True.") + self._tools[desc.name] = desc + # Registra come capability nel Catalog + if _CATALOG_AVAILABLE and _catalog is not None and _CapDesc is not None: + await _catalog.register([_CapDesc( + name = desc.name, + version = desc.version, + provider_id = desc.provider_id, + provider_name = desc.provider_name or desc.provider_id, + description = desc.description, + requires_gpu = desc.requires_gpu, + sla_ms = desc.sla_ms, + cost_unit = desc.cost_unit, + region = desc.region, + always_on = desc.enabled, + tags = ["tool"] + desc.tags, + metadata = {"input_schema": desc.input_schema, + "output_schema": desc.output_schema, + "idempotent": desc.idempotent}, + )]) + _logger.info("[tool-engine] registered tool=%s provider=%s", desc.name, desc.provider_id) + return True + + def get(self, name: str) -> ToolDescriptor | None: + return self._tools.get(name) + + def list_tools(self) -> list[dict]: + return [ + {"name": d.name, "version": d.version, "provider_id": d.provider_id, + "sla_ms": d.sla_ms, "enabled": d.enabled, "tags": d.tags, + "idempotent": d.idempotent, "requires_gpu": d.requires_gpu} + for d in self._tools.values() + ] + + +# ── ToolExecutor ───────────────────────────────────────────────────────────────── + +class ToolExecutor: + """ + Esegue un tool tramite il Kernel (che chiama Resolver → Fabric → Worker). + Due modalità: + sync (use_workflow=False) → await Kernel.submit_task(), ritorna subito + async (use_workflow=True) → WorkflowEngine.submit() → WorkflowExecution + """ + + def __init__(self, registry: ToolRegistry) -> None: + self._registry = registry + + async def execute(self, req: ToolExecuteRequest) -> ToolExecuteResult: + t0 = time.time() + desc = self._registry.get(req.tool_name) + if desc is None: + return ToolExecuteResult( + tool_name=req.tool_name, status="error", + error=f"Tool '{req.tool_name}' non registrato", + correlation_id=req.correlation_id) + + if not desc.enabled: + return ToolExecuteResult( + tool_name=req.tool_name, status="error", + error=f"Tool '{req.tool_name}' disabilitato", + correlation_id=req.correlation_id) + + # Verifica che la capability sia risolvibile + if _RESOLVER_AVAILABLE and _resolver is not None and not _resolver.can_resolve(req.tool_name): + _logger.warning("[tool-engine] capability '%s' not resolvable", req.tool_name) + + if req.use_workflow and _WORKFLOW_ENGINE_AVAILABLE and _workflow_engine is not None: + return await self._execute_via_workflow(req, desc, t0) + return await self._execute_via_kernel(req, desc, t0) + + async def _execute_via_kernel(self, req: ToolExecuteRequest, + desc: ToolDescriptor, t0: float) -> ToolExecuteResult: + """Esecuzione sincrona via Kernel.submit_task().""" + try: + payload = {**req.payload, "capability": req.tool_name} + if _KERNEL_AVAILABLE and _kernel is not None: + result = await asyncio.wait_for( + _kernel.submit_task( + payload = payload, + session_id = req.session_id, + correlation_id = req.correlation_id, + timeout_s = int(req.timeout_s), + ), + timeout=req.timeout_s + 5, + ) + return ToolExecuteResult( + tool_name=req.tool_name, status="ok", + result=result.model_dump() if hasattr(result, "model_dump") else str(result), + provider_id=desc.provider_id, + latency_ms=(time.time()-t0)*1000, + correlation_id=req.correlation_id) + else: + return ToolExecuteResult( + tool_name=req.tool_name, status="queued", + result={"payload": payload, "kernel": "unavailable"}, + provider_id=desc.provider_id, + latency_ms=(time.time()-t0)*1000, + correlation_id=req.correlation_id) + except asyncio.TimeoutError: + return ToolExecuteResult( + tool_name=req.tool_name, status="timeout", + error=f"Timeout {req.timeout_s}s", latency_ms=(time.time()-t0)*1000, + correlation_id=req.correlation_id) + except Exception as exc: + _logger.error("[tool-engine] execute error tool=%s: %s", req.tool_name, exc) + return ToolExecuteResult( + tool_name=req.tool_name, status="error", error=str(exc), + latency_ms=(time.time()-t0)*1000, correlation_id=req.correlation_id) + + async def _execute_via_workflow(self, req: ToolExecuteRequest, + desc: ToolDescriptor, t0: float) -> ToolExecuteResult: + """Esecuzione asincrona via WorkflowEngine (per tool long-running).""" + try: + from .brain_planner import WorkflowPlan, PlanStep + step = PlanStep( + capability = req.tool_name, + description = f"Tool execution: {req.tool_name}", + payload = req.payload, + timeout_s = int(req.timeout_s), + ) + plan = WorkflowPlan( + goal = f"Execute tool {req.tool_name}", + strategy = "sequential", + steps = [step], + metadata = {"tool_execute": True, "correlation_id": req.correlation_id}, + ) + execution = await _workflow_engine.submit(_SWReq(plan=plan, + session_id=req.session_id, + correlation_id=req.correlation_id)) + return ToolExecuteResult( + tool_name=req.tool_name, status="queued", + execution_id=execution.execution_id, + provider_id=desc.provider_id, + latency_ms=(time.time()-t0)*1000, + correlation_id=req.correlation_id) + except Exception as exc: + return ToolExecuteResult( + tool_name=req.tool_name, status="error", error=str(exc), + latency_ms=(time.time()-t0)*1000, correlation_id=req.correlation_id) + + +# ── Singletons ────────────────────────────────────────────────────────────────── +tool_registry = ToolRegistry() +tool_executor = ToolExecutor(tool_registry) + +# ── HTTP Router ────────────────────────────────────────────────────────────────── +router = APIRouter( + prefix="/api/tools", + tags=["tool-engine"], + dependencies=[Depends(require_role(AuthRole.MACHINE))], +) + + +@router.post("/register", summary="Registra un tool descriptor nel Tool Engine") +async def route_register(req: RegisterToolRequest) -> dict: + try: + ok = await tool_registry.register(req.descriptor, force=req.force) + return {"registered": ok, "tool_name": req.descriptor.name} + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@router.post("/execute", summary="Esegui un tool tramite Kernel/Fabric") +async def route_execute(req: ToolExecuteRequest) -> ToolExecuteResult: + return await tool_executor.execute(req) + + +@router.get("/", summary="Lista tutti i tool registrati") +async def route_list() -> dict: + tools = tool_registry.list_tools() + return {"count": len(tools), "tools": tools} + + +@router.get("/{tool_name}/schema", summary="Schema input/output di un tool") +async def route_schema(tool_name: str) -> dict: + desc = tool_registry.get(tool_name) + if not desc: + raise HTTPException(404, f"Tool '{tool_name}' non trovato") + return { + "tool_name": desc.name, + "version": desc.version, + "input_schema": desc.input_schema, + "output_schema": desc.output_schema, + "idempotent": desc.idempotent, + } diff --git a/api/vault_stateless.py b/api/vault_stateless.py new file mode 100644 index 0000000000000000000000000000000000000000..edf06b4b263a093f6bded5d1f47ca598aaace378 --- /dev/null +++ b/api/vault_stateless.py @@ -0,0 +1,128 @@ +"""backend/api/vault_stateless.py — P0-01: Stateless Vault su Supabase. +Migrazione dal file locale vault_secrets.json a Supabase 'managed_tokens' o tabella dedicata. +Per ora usiamo la tabella 'managed_tokens' con provider='vault' per semplicità di deploy. +""" +import os, json as _json_v, logging, time, asyncio +from typing import Optional +from fastapi import APIRouter, HTTPException, Request, Depends +from pydantic import BaseModel +from .auth_guard import require_role, AuthRole +from .state import _sb + +try: + from cryptography.fernet import Fernet as _Fernet, InvalidToken as _InvalidToken + _HAS_FERNET = True +except ImportError: + _HAS_FERNET = False + +router = APIRouter() +_vault_logger = logging.getLogger('agente_ai.vault') + +# ── Crittografia ────────────────────────────────────────────────────────────── +_raw_vault_key = os.getenv('VAULT_KEY', '') +_VAULT_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '') + +def _get_fernet() -> Optional['_Fernet']: + if not _HAS_FERNET or not _raw_vault_key: + return None + try: + import base64, hashlib + # GAP-SEC-5: Derivazione chiave robusta + key_32 = base64.urlsafe_b64encode(hashlib.sha256(_raw_vault_key.encode()).digest()) + return _Fernet(key_32) + except Exception: + return None + +def _vault_encrypt(plaintext: str) -> str: + f = _get_fernet() + if not f: return plaintext # fallback insicuro se non configurato + return f.encrypt(plaintext.encode()).decode() + +def _vault_decrypt(ciphertext: str) -> str: + f = _get_fernet() + if not f: return ciphertext + try: + return f.decrypt(ciphertext.encode()).decode() + except Exception: + return ciphertext # fallback plaintext/legacy + +# ── Auth Gate ──────────────────────────────────────────────────────────────── +async def _require_vault_auth( + request: Request, + role: AuthRole = Depends(require_role(AuthRole.MACHINE)), +) -> None: + if _VAULT_ADMIN_TOKEN: + authorization = request.headers.get('authorization') + if authorization != f'Bearer {_VAULT_ADMIN_TOKEN}': + raise HTTPException(status_code=401, detail='Vault: Bearer token non valido') + +# ── DB Ops (Supabase) ──────────────────────────────────────────────────────── +async def _sb_load_vault(user_id: str = "system") -> dict: + if not _sb: return {} + try: + res = await asyncio.to_thread( + lambda: _sb.table('managed_tokens') + .select('access_token, raw_meta') + .eq('user_id', user_id) + .eq('provider', 'vault_v2') + .execute() + ) + if not res.data: return {} + # Vault v2 salva un singolo JSON cifrato in access_token + enc_data = res.data[0].get('access_token', '') + if not enc_data: return {} + dec_data = _vault_decrypt(enc_data) + return _json_v.loads(dec_data) + except Exception as e: + _vault_logger.error("Vault load error: %s", e) + return {} + +async def _sb_save_vault(data: dict, user_id: str = "system") -> None: + if not _sb: return + enc_data = _vault_encrypt(_json_v.dumps(data)) + payload = { + 'user_id': user_id, + 'provider': 'vault_v2', + 'access_token': enc_data, + 'refresh_token': '', + 'expires_at': 0, + 'scope': 'vault', + 'raw_meta': _json_v.dumps({'count': len(data), 'updated_at': int(time.time())}), + 'updated_at': int(time.time() * 1000) + } + await asyncio.to_thread( + lambda: _sb.table('managed_tokens').upsert(payload, on_conflict='user_id,provider').execute() + ) + +# ── Endpoints ──────────────────────────────────────────────────────────────── +@router.get('/api/vault/status') +async def vault_status(_auth: None = Depends(_require_vault_auth)): + data = await _sb_load_vault() + return {'keys': list(data.keys()), 'count': len(data), 'storage': 'supabase'} + +@router.post('/api/vault') +async def vault_save(req: dict, _auth: None = Depends(_require_vault_auth)): + key = req.get('key', '').lower().strip() + val = req.get('value', '').strip() + if not key or not val: raise HTTPException(400, "Key/Value required") + data = await _sb_load_vault() + data[key] = val + await _sb_save_vault(data) + return {'ok': True, 'key': key} + +@router.get('/api/vault/token/{key}') +async def vault_get_token(key: str, _auth: None = Depends(_require_vault_auth)): + data = await _sb_load_vault() + if key not in data: raise HTTPException(404, "Not found") + return {'key': key, 'value': data[key]} + +@router.delete('/api/vault/{key}') +async def vault_delete(key: str, _auth: None = Depends(_require_vault_auth)): # GAP-4-FIX: endpoint DELETE mancante dopo migrazione vault_stateless + data = await _sb_load_vault() + if key not in data: + raise HTTPException(404, f"Chiave '{key}' non trovata") + del data[key] + await _sb_save_vault(data) + _vault_logger.info('Vault delete key=%s', key) + return {'deleted': key} + diff --git a/api/webhook.py b/api/webhook.py index 2fe8b02b0f1b86f17ebc015d1e23419203f2459f..52c005d73d80b5b9ddd9d5ae7094f2ade2e29af7 100644 --- a/api/webhook.py +++ b/api/webhook.py @@ -1,27 +1,6 @@ """backend/api/webhook.py — Webhook inbound + Public REST API (S354).""" import os, asyncio from typing import Optional - -# Public agent execution is deliberately bounded: each agent run fans out to -# several provider calls, so unbounded HTTP concurrency can exhaust a 1-worker -# Space and cause the peer to reset connections. The limit is configurable but -# capped to keep deployments safe. -_PUBLIC_AGENT_CONCURRENCY = max(1, min(int(os.getenv("PUBLIC_AGENT_CONCURRENCY", "3")), 8)) -_PUBLIC_AGENT_QUEUE_TIMEOUT = max(5.0, float(os.getenv("PUBLIC_AGENT_QUEUE_TIMEOUT", "90"))) -_PUBLIC_AGENT_TIMEOUT = max(15.0, float(os.getenv("PUBLIC_AGENT_TIMEOUT", "90"))) -_public_agent_slots = asyncio.Semaphore(_PUBLIC_AGENT_CONCURRENCY) -_public_agent_client = None -_public_agent_client_lock = asyncio.Lock() - -async def _get_public_agent_client(): - """Create AIClient once per worker, off the event loop, and reuse it.""" - global _public_agent_client - if _public_agent_client is None: - async with _public_agent_client_lock: - if _public_agent_client is None: - from models.ai_client import AIClient - _public_agent_client = await asyncio.to_thread(AIClient, None, True) - return _public_agent_client from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ValidationError, field_validator from .state import _get_mem_manager, _get_executor, _get_planner @@ -292,7 +271,7 @@ async def inbound_webhook(webhook_token: str, request: Request): loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=lambda _s: None, allow_tools=not _task_policy.forbid_tools), - timeout=min(float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), _PUBLIC_AGENT_TIMEOUT), + timeout=float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), ) except asyncio.TimeoutError: asyncio.ensure_future(_tg_error(_wh_task_id, body.goal, "Timeout: task terminato dopo 120s")) @@ -327,7 +306,7 @@ async def public_chat(payload: PublicChatPayload, request: Request): S292 — API REST pubblica autenticata per integrazioni esterne. Auth: Authorization: Bearer """ - _expected = (os.getenv('PUBLIC_API_TOKEN') or os.getenv('INTERNAL_TOKEN')).strip() + _expected = (os.getenv('PUBLIC_API_TOKEN') or os.getenv('INTERNAL_TOKEN', '')).strip() if not _expected: raise HTTPException( status_code=503, @@ -345,16 +324,10 @@ async def public_chat(payload: PublicChatPayload, request: Request): 'conversation_id': payload.conversation_id, 'steps': 0, } - acquired = False try: - try: - await asyncio.wait_for(_public_agent_slots.acquire(), timeout=_PUBLIC_AGENT_QUEUE_TIMEOUT) - acquired = True - except asyncio.TimeoutError as exc: - raise HTTPException(429, detail="Server occupato: riprova tra poco.", headers={"Retry-After": "5"}) from exc - from agents.unified_loop import UnifiedAgentLoop - client = await _get_public_agent_client() + from models.ai_client import AIClient + client = AIClient() try: from agents.critic import Critic from agents.response_verifier import ResponseVerifier @@ -374,7 +347,7 @@ async def public_chat(payload: PublicChatPayload, request: Request): loop.run(goal=payload.message, context='', max_steps=payload.max_steps, on_step=lambda _s: None, allow_tools=not _task_policy.forbid_tools), - timeout=min(float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), _PUBLIC_AGENT_TIMEOUT), + timeout=float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), ) except asyncio.TimeoutError: asyncio.ensure_future(_tg_error(_pc_task_id, payload.message, "Timeout dopo 120s")) @@ -395,6 +368,3 @@ async def public_chat(payload: PublicChatPayload, request: Request): raise except Exception as exc: raise HTTPException(status_code=500, detail=f'Errore agente: {exc}') - finally: - if acquired: - _public_agent_slots.release() diff --git a/api/whoami.py b/api/whoami.py new file mode 100644 index 0000000000000000000000000000000000000000..71648587d3d693e5d68c8ad8f5447570a7fc3b3f --- /dev/null +++ b/api/whoami.py @@ -0,0 +1,34 @@ +""" +backend/api/whoami.py — GET /api/whoami-v2 + +Risponde con identità sessione + feature flags del backend. +Chiamato dal frontend al boot per: health-check, versione, env. +""" +import os, time, logging +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +router = APIRouter() +_logger = logging.getLogger("api.whoami") + +_BOOT_AT = time.time() + +@router.get("/api/whoami-v2") +async def whoami_v2() -> JSONResponse: + """Identità backend + feature flags. Risposta in <5ms (nessun I/O).""" + uptime_s = round(time.time() - _BOOT_AT) + return JSONResponse({ + "ok": True, + "version": os.getenv("APP_VERSION", "dev"), + "env": os.getenv("APP_ENV", "production"), + "hf_space": os.getenv("SPACE_ID", ""), + "uptime_s": uptime_s, + "features": { + "dashboard_snapshot": True, # INT-2 + "benchmark_live": True, # /api/debug/benchmark + "scheduler": True, + "incident_registry": True, + "abort": True, # /api/agent/abort + "benchmarks_hub": True, # /api/debug/benchmark/advanced|fabric|extended + }, + }) diff --git a/api/workflow_engine.py b/api/workflow_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..799cbd6c0f74d1ca8ff42411b3297650895deb3e --- /dev/null +++ b/api/workflow_engine.py @@ -0,0 +1,418 @@ +""" +backend/api/workflow_engine.py — Workflow Engine (ARCH-I4.2) + +Coordina l'esecuzione di WorkflowPlan persistenti prodotti dal BrainPlanner. +Il Workflow Engine è il collante tra Planner e Executor: riceve un piano, +esegue gli step nell'ordine corretto (sequenziale, parallelo, DAG), +gestisce retry, timeout e stato persistente. + +Flusso: + BrainPlanner.plan(goal) → WorkflowPlan + → WorkflowEngine.submit(plan) → WorkflowExecution + → Engine esegue step via Kernel.submit_task() + → BrainExecutor aggiorna stato (IDLE→RUNNING→DONE|FAILED) + +Invarianti ADR: + S1: stateless — stato in Redis/memoria, non nel Workflow Engine + S3: solo Queue e Database contengono stato + S10: ogni comunicazione è asincrona + S16: retry con backoff esponenziale centralizzato + S27: ogni step tracciato via correlation_id +""" +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from enum import Enum +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from .auth_guard import AuthRole, require_role + +_logger = logging.getLogger("api.workflow_engine") + +# ── Guards ───────────────────────────────────────────────────────────────────── +try: + from .brain_planner import WorkflowPlan, PlanStep + _PLANNER_AVAILABLE = True +except Exception: + WorkflowPlan = None # type: ignore[assignment,misc] + PlanStep = None # type: ignore[assignment,misc] + _PLANNER_AVAILABLE = False + +try: + from .kernel import kernel as _kernel + _KERNEL_AVAILABLE = True +except Exception: + _kernel = None # type: ignore[assignment] + _KERNEL_AVAILABLE = False + +try: + from .event_bus import publish as _publish_event + _EVENT_BUS_AVAILABLE = True +except Exception: + async def _publish_event(*_a, **_kw): pass # type: ignore[misc] + _EVENT_BUS_AVAILABLE = False + +# ── Enums ────────────────────────────────────────────────────────────────────── + +class StepStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + SKIPPED = "skipped" # step opzionale saltato dopo fallimento + +class WorkflowStatus(str, Enum): + QUEUED = "queued" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + CANCELLED = "cancelled" + PARTIAL = "partial" # alcuni step falliti (opzionali), piano comunque completato + +# ── Models ────────────────────────────────────────────────────────────────────── + +class StepExecution(BaseModel): + step_id: str + capability: str + status: StepStatus = StepStatus.PENDING + task_id: str | None = None # task_id Kernel + result: Any = None + error: str | None = None + started_at: float | None = None + finished_at: float | None = None + retries: int = 0 + correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + + +class WorkflowExecution(BaseModel): + execution_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + plan_id: str + goal: str + strategy: str = "sequential" + status: WorkflowStatus = WorkflowStatus.QUEUED + steps: list[StepExecution] = Field(default_factory=list) + created_at: float = Field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class SubmitWorkflowRequest(BaseModel): + plan_id: str | None = Field(None, description="ID di un piano esistente nel Planner") + plan: Any = Field(None, description="WorkflowPlan inline (alternativo a plan_id)") + session_id: str | None = None + correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + + +class CancelWorkflowRequest(BaseModel): + execution_id: str + reason: str = "" + + +# ── WorkflowEngine singleton ─────────────────────────────────────────────────── + +class WorkflowEngine: + """ + Orchestratore di WorkflowPlan. + + Strategie di esecuzione: + sequential → step in ordine, stop al primo fallimento non-optional + parallel → tutti gli step in asyncio.gather(), risultati aggregati + dag → topological sort su depends_on, esecuzione a wave + + Retry: + Ogni step ha retry_max. Backoff: 2^retry secondi (max 30s). + + Persistenza: + In-memory per ora. ARCH-I4.2 Phase 2: Redis TTL + Supabase event_store. + """ + + def __init__(self) -> None: + self._executions: dict[str, WorkflowExecution] = {} + self._tasks: dict[str, asyncio.Task] = {} + self._lock = asyncio.Lock() + + # ── Submit ──────────────────────────────────────────────────────────────── + + async def submit(self, req: SubmitWorkflowRequest) -> WorkflowExecution: + """Accetta un WorkflowPlan e avvia l'esecuzione in background.""" + plan = await self._resolve_plan(req) + if plan is None: + raise ValueError("Nessun piano trovato (plan_id o plan inline richiesto)") + + steps_exec = [ + StepExecution( + step_id = s.step_id, + capability = s.capability, + metadata = s.metadata if hasattr(s, "metadata") else {}, + ) + for s in plan.steps + ] if hasattr(plan, "steps") else [] + + execution = WorkflowExecution( + plan_id = plan.plan_id if hasattr(plan, "plan_id") else str(uuid.uuid4()), + goal = plan.goal if hasattr(plan, "goal") else "", + strategy = plan.strategy if hasattr(plan, "strategy") else "sequential", + steps = steps_exec, + metadata = {"session_id": req.session_id, "correlation_id": req.correlation_id}, + ) + + async with self._lock: + self._executions[execution.execution_id] = execution + + task = asyncio.create_task(self._run(execution, plan)) + self._tasks[execution.execution_id] = task + task.add_done_callback(lambda t: self._on_done(execution.execution_id, t)) + + _logger.info("[workflow] submitted execution=%s plan=%s strategy=%s steps=%d", + execution.execution_id, execution.plan_id, + execution.strategy, len(steps_exec)) + return execution + + # ── Cancel ──────────────────────────────────────────────────────────────── + + async def cancel(self, req: CancelWorkflowRequest) -> dict: + task = self._tasks.get(req.execution_id) + if task and not task.done(): + task.cancel() + exec_obj = self._executions.get(req.execution_id) + if exec_obj: + exec_obj.status = WorkflowStatus.CANCELLED + exec_obj.finished_at = time.time() + for s in exec_obj.steps: + if s.status == StepStatus.PENDING: + s.status = StepStatus.SKIPPED + return {"cancelled": True, "execution_id": req.execution_id, "reason": req.reason} + + # ── Run ─────────────────────────────────────────────────────────────────── + + async def _run(self, execution: WorkflowExecution, plan: Any) -> None: + execution.status = WorkflowStatus.RUNNING + execution.started_at = time.time() + await _publish_event("workflow.started", { + "execution_id": execution.execution_id, "plan_id": execution.plan_id + }) + + try: + if execution.strategy == "parallel": + await self._run_parallel(execution, plan) + elif execution.strategy == "dag": + await self._run_dag(execution, plan) + else: + await self._run_sequential(execution, plan) + + failed = [s for s in execution.steps if s.status == StepStatus.FAILED] + if not failed: + execution.status = WorkflowStatus.DONE + elif all(self._is_optional(s.step_id, plan) for s in failed): + execution.status = WorkflowStatus.PARTIAL + else: + execution.status = WorkflowStatus.FAILED + except asyncio.CancelledError: + execution.status = WorkflowStatus.CANCELLED + except Exception as exc: + _logger.error("[workflow] run error execution=%s: %s", execution.execution_id, exc) + execution.status = WorkflowStatus.FAILED + finally: + execution.finished_at = time.time() + await _publish_event("workflow.finished", { + "execution_id": execution.execution_id, + "status": execution.status.value, + }) + _logger.info("[workflow] finished execution=%s status=%s elapsed=%.1fs", + execution.execution_id, execution.status.value, + execution.finished_at - (execution.started_at or execution.finished_at)) + + async def _run_sequential(self, execution: WorkflowExecution, plan: Any) -> None: + for step_exec in execution.steps: + step_plan = self._get_plan_step(step_exec.step_id, plan) + success = await self._execute_step(step_exec, step_plan) + if not success and not self._is_optional(step_exec.step_id, plan): + # Marca i restanti come SKIPPED + for s in execution.steps: + if s.status == StepStatus.PENDING: + s.status = StepStatus.SKIPPED + return + + async def _run_parallel(self, execution: WorkflowExecution, plan: Any) -> None: + await asyncio.gather(*[ + self._execute_step(s, self._get_plan_step(s.step_id, plan)) + for s in execution.steps + ], return_exceptions=True) + + async def _run_dag(self, execution: WorkflowExecution, plan: Any) -> None: + """Esegue step rispettando depends_on — wave by wave.""" + completed: set[str] = set() + step_map = {s.step_id: s for s in execution.steps} + plan_map = {} + if plan and hasattr(plan, "steps"): + plan_map = {s.step_id: s for s in plan.steps} + + max_waves = len(execution.steps) + 1 + for _ in range(max_waves): + ready = [ + s for s in execution.steps + if s.status == StepStatus.PENDING + and all(dep in completed for dep in (plan_map.get(s.step_id).depends_on + if s.step_id in plan_map else [])) + ] + if not ready: + break + results = await asyncio.gather(*[ + self._execute_step(s, plan_map.get(s.step_id)) + for s in ready + ], return_exceptions=True) + for s in ready: + if s.status == StepStatus.DONE: + completed.add(s.step_id) + + # ── Step execution ──────────────────────────────────────────────────────── + + async def _execute_step(self, step_exec: StepExecution, step_plan: Any) -> bool: + """Esegue un singolo step con retry. Ritorna True se success.""" + payload = step_plan.payload if step_plan and hasattr(step_plan, "payload") else {} + retry_max = step_plan.retry_max if step_plan and hasattr(step_plan, "retry_max") else 2 + timeout_s = step_plan.timeout_s if step_plan and hasattr(step_plan, "timeout_s") else 60 + + step_exec.status = StepStatus.RUNNING + step_exec.started_at = time.time() + + for attempt in range(retry_max + 1): + step_exec.retries = attempt + try: + if _KERNEL_AVAILABLE and _kernel is not None: + full_payload = {**payload, "capability": step_exec.capability} + result = await asyncio.wait_for( + _kernel.submit_task( + payload = full_payload, + correlation_id = step_exec.correlation_id, + ), + timeout=timeout_s, + ) + step_exec.task_id = getattr(result, "task_id", None) + step_exec.result = result.model_dump() if hasattr(result, "model_dump") else str(result) + else: + step_exec.result = {"status": "queued", "kernel": "unavailable"} + + step_exec.status = StepStatus.DONE + step_exec.finished_at = time.time() + _logger.info("[workflow] step DONE step_id=%s cap=%s attempt=%d", + step_exec.step_id, step_exec.capability, attempt) + return True + except asyncio.TimeoutError: + step_exec.error = f"Timeout {timeout_s}s (attempt {attempt+1}/{retry_max+1})" + except Exception as exc: + step_exec.error = str(exc) + + if attempt < retry_max: + backoff = min(2 ** attempt, 30) + _logger.warning("[workflow] step retry step_id=%s attempt=%d backoff=%ds", + step_exec.step_id, attempt+1, backoff) + await asyncio.sleep(backoff) + + step_exec.status = StepStatus.FAILED + step_exec.finished_at = time.time() + _logger.error("[workflow] step FAILED step_id=%s cap=%s error=%s", + step_exec.step_id, step_exec.capability, step_exec.error) + return False + + # ── Helpers ─────────────────────────────────────────────────────────────── + + async def _resolve_plan(self, req: SubmitWorkflowRequest) -> Any: + if req.plan is not None: + return req.plan + if req.plan_id: + try: + from .brain_planner import planner as _planner + return _planner.get_plan(req.plan_id) + except Exception: + pass + return None + + def _get_plan_step(self, step_id: str, plan: Any) -> Any: + if plan and hasattr(plan, "steps"): + for s in plan.steps: + if s.step_id == step_id: + return s + return None + + def _is_optional(self, step_id: str, plan: Any) -> bool: + s = self._get_plan_step(step_id, plan) + return s.optional if s and hasattr(s, "optional") else False + + def _on_done(self, execution_id: str, task: asyncio.Task) -> None: + if task.cancelled(): + _logger.info("[workflow] execution %s cancelled", execution_id) + elif task.exception(): + _logger.error("[workflow] execution %s exception: %s", execution_id, task.exception()) + + # ── Query ───────────────────────────────────────────────────────────────── + + def get_execution(self, execution_id: str) -> WorkflowExecution | None: + return self._executions.get(execution_id) + + def list_executions(self, limit: int = 20) -> list[WorkflowExecution]: + all_exec = sorted(self._executions.values(), key=lambda e: e.created_at, reverse=True) + return all_exec[:limit] + + def status(self) -> dict: + statuses = [e.status.value for e in self._executions.values()] + return { + "total_executions": len(self._executions), + "running": statuses.count("running"), + "done": statuses.count("done"), + "failed": statuses.count("failed"), + "partial": statuses.count("partial"), + "cancelled": statuses.count("cancelled"), + "kernel_available": _KERNEL_AVAILABLE, + "planner_available": _PLANNER_AVAILABLE, + } + + +# ── Singleton ──────────────────────────────────────────────────────────────────── +engine = WorkflowEngine() + +# ── HTTP Router ────────────────────────────────────────────────────────────────── +router = APIRouter( + prefix="/api/workflow", + tags=["workflow-engine"], + dependencies=[Depends(require_role(AuthRole.MACHINE))], +) + + +@router.post("/submit", summary="Avvia un WorkflowPlan") +async def route_submit(req: SubmitWorkflowRequest) -> WorkflowExecution: + try: + return await engine.submit(req) + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@router.post("/cancel", summary="Cancella un workflow in esecuzione") +async def route_cancel(req: CancelWorkflowRequest) -> dict: + return await engine.cancel(req) + + +@router.get("/executions/{execution_id}", summary="Stato di una WorkflowExecution") +async def route_get_execution(execution_id: str) -> WorkflowExecution: + ex = engine.get_execution(execution_id) + if not ex: + raise HTTPException(404, f"Execution '{execution_id}' non trovata") + return ex + + +@router.get("/executions", summary="Lista ultime WorkflowExecution") +async def route_list_executions(limit: int = 20) -> dict: + executions = engine.list_executions(limit) + return {"count": len(executions), "executions": [e.model_dump() for e in executions]} + + +@router.get("/status", summary="Stato del Workflow Engine") +async def route_status() -> dict: + return engine.status() diff --git a/benchmark-extended.mjs b/benchmark-extended.mjs index 96408f4416c77e2a67a90e3707d11598e6923aac..1077f521d4e2339470fa03fa07649feb7e52f50e 100644 --- a/benchmark-extended.mjs +++ b/benchmark-extended.mjs @@ -68,34 +68,6 @@ const F_GAP = _A.includes("--gap-analysis"); const _multi = _A.indexOf("--multi"); const MULTI = _multi !== -1 ? Math.max(2, Math.min(10, parseInt(_A[_multi+1])||3)) : 1; const F_JUDGE = !_A.includes("--no-judge"); // semantic judge abilitato di default -const JUDGE_TELEMETRY = { - requested: 0, - succeeded: 0, - failed: 0, - cacheHits: 0, - provider: process.env.GROQ_API_KEY ? "groq" : process.env.CEREBRAS_API_KEY ? "cerebras" : null, - model: process.env.GROQ_API_KEY ? "openai/gpt-oss-120b" : process.env.CEREBRAS_API_KEY ? "llama-3.3-70b" : null, - lastFailure: null, - failureReasons: {}, - attempts: 0, - retries: 0, - recoveredRetries: 0, -}; -const recordJudgeFailure = (reason) => { - JUDGE_TELEMETRY.failed++; - JUDGE_TELEMETRY.lastFailure = reason; - JUDGE_TELEMETRY.failureReasons[reason] = (JUDGE_TELEMETRY.failureReasons[reason] ?? 0) + 1; -}; -const classifyJudgeHttpFailure = (status, raw, isGroq) => { - const text = String(raw || "").toLowerCase(); - if (status === 401 || status === 403) return "JUDGE_AUTH_REJECTED"; - if (status === 429 || /rate.?limit|too many requests/.test(text)) return "JUDGE_RATE_LIMIT"; - if (status >= 500) return "JUDGE_PROVIDER_5XX"; - if (isGroq && status === 400 && /json.?schema|response_format|strict/.test(text)) return "GROQ_SCHEMA_REJECTED"; - if (isGroq && status === 400 && /reasoning_effort|include_reasoning|max_completion_tokens|parameter/.test(text)) return "GROQ_PARAMETER_REJECTED"; - if (status >= 400 && status < 500) return "JUDGE_REQUEST_REJECTED"; - return `HTTP_${status}`; -}; const _categoriesArg = _A.find(a=>a.startsWith("--categories=")); const TARGET_CATEGORIES = _categoriesArg ? new Set(_categoriesArg.slice("--categories=".length).split(",").map(c=>c.trim()).filter(Boolean)) @@ -108,9 +80,6 @@ const G="\x1b[32m",R="\x1b[31m",Y="\x1b[33m",B="\x1b[34m", // ── Constants ───────────────────────────────────────────────────────────────── const _baseUrlArg = _A.find(a=>a.startsWith("--base-url=")); const BASE_URL = (_baseUrlArg ? _baseUrlArg.slice("--base-url=".length) : (process.env.BENCHMARK_BASE_URL ?? process.env.BACKEND_URL ?? "https://baida07-terminal.hf.space")).replace(/\/+$/, ""); -const TEST_TRANSPORT_TIMEOUT_MS = process.env.BENCHMARK_TEST_TRANSPORT_TIMEOUT_MS && process.env.NODE_ENV === "test" - ? Math.max(50, Number(process.env.BENCHMARK_TEST_TRANSPORT_TIMEOUT_MS) || 0) - : null; const TASK_DIR = "/tmp/bench-ext/tasks"; const RUNNER_DIR = dirname(fileURLToPath(import.meta.url)); let TSC_BIN = ""; @@ -350,13 +319,6 @@ FSH.research_synthesis = "3. Ragionamento: [come hai derivato la risposta]\n" + "4. Confidence: [alta/media/bassa + perché]"; -FSH.data_analysis = -"ANALISI NUMERICA VERIFICABILE:\n" + -"1. Usa esclusivamente i record JSON forniti nel task; non chiedere altro contesto.\n" + -"2. Calcola internamente somma, conteggio e media; individua massimo e outlier.\n" + -"3. Restituisci soltanto quattro bullet nominati Media, Picco, Anomalia e Trend.\n" + -"4. Per Media, Picco e Anomalia inserisci sempre sia il mese sia il valore numerico richiesto."; - FSH.feature = "FEATURE TYPESCRIPT — implementazione completa:\n" + "1. **Interfaccia** (tipi + contratto pubblico)\n" + @@ -460,27 +422,6 @@ async function repairCodeCorrectIfNeeded(task, agent, timeoutMs) { if(!repaired.failed&&checked?.buildPassed&&checked?.testsPassed)return{agent:repaired,retried:true,reason}; return{agent:{...agent,repairFailureReason,repairFallbackEmpty:!repairedOutput.trim()},retried:true,reason:`${reason}:${repairFailureReason}`}; } -async function repairDataAnalysisIfNeeded(task, agent, timeoutMs) { - if (task.category !== "data_analysis" || agent.failed) return {agent,retried:false,reason:null}; - let first; - try { first = await task.verify(agent.output || ""); } catch { return {agent,retried:false,reason:null}; } - const analysisChecks = first.analysisChecks ?? {}; - const allNumericChecksPassed = ["avgOk", "peakOk", "anomOk"].every((key) => analysisChecks[key] === true); - if (allNumericChecksPassed) return {agent,retried:false,reason:null}; - const detail = String(first.detail || "validator non superato"); - const repairGoal = `${task.prompt}\n\n---\nDATA_ANALYSIS REPAIR OBBLIGATORIO\nLa risposta precedente non ha superato il controllo deterministico: ${detail}.\nNon chiedere ulteriori dati e ignora ogni contesto non presente nel JSON qui sopra. Calcola di nuovo dai record forniti: (1) somma tutti i valori e dividi per il numero di mesi; (2) individua il valore massimo e il suo mese; (3) individua il valore fuori scala e il suo mese.\nRestituisci esclusivamente queste quattro righe, senza introduzione, spiegazioni o Markdown aggiuntivo:\n- **Media: N**\n- **Picco: MESE (N)**\n- **Anomalia: MESE (N)**\n- **Trend: testo breve**\nUsa i nomi mese presenti nel JSON e valori numerici effettivamente calcolati. Non copiare esempi o placeholder.`; - if (!F_JSON) process.stdout.write(` ⟳ repair data_analysis (${detail.slice(0,60)})... `); - const repaired = await callAgent(repairGoal, Math.min(timeoutMs, 120000), {maxSteps:4}); - let checked = null; - if (!repaired.failed) { - try { checked = await task.verify(repaired.output || ""); } catch {} - } - if (!F_JSON) process.stdout.write(`${checked?.testsPassed ? "pass" : "fail"}\n`); - if (!repaired.failed && checked?.buildPassed && checked?.testsPassed) { - return {agent:repaired,retried:true,reason:detail}; - } - return {agent:{...agent,dataAnalysisRepairFailure:String(repaired.failureReason || detail)},retried:true,reason:detail}; -} async function repairFeatureIfNeeded(task, agent, timeoutMs) { if (task.category !== "feature" || agent.failed) return {agent, retried:false, reason:null}; let first; try { first=await task.verify(agent.output||""); } catch { return {agent,retried:false,reason:null}; } @@ -812,101 +753,44 @@ const _JUDGE_CACHE = new Map(); async function judgeWithLLM(question, response, rubric) { if (!F_JUDGE) return null; const apiKey = process.env.GROQ_API_KEY || process.env.CEREBRAS_API_KEY; - if (!apiKey) { - JUDGE_TELEMETRY.lastFailure = "JUDGE_API_KEY_MISSING"; - return null; - } + if (!apiKey) return null; const cacheKey = question.slice(0,40) + response.slice(0,40); - if (_JUDGE_CACHE.has(cacheKey)) { - JUDGE_TELEMETRY.cacheHits++; - return _JUDGE_CACHE.get(cacheKey); - } + if (_JUDGE_CACHE.has(cacheKey)) return _JUDGE_CACHE.get(cacheKey); const isGroq = !!process.env.GROQ_API_KEY; const endpoint = isGroq - ? (process.env.BENCHMARK_GROQ_BASE_URL || "https://api.groq.com/openai/v1/chat/completions") + ? "https://api.groq.com/openai/v1/chat/completions" : "https://api.cerebras.ai/v1/chat/completions"; const model = isGroq ? "openai/gpt-oss-120b" : "llama-3.3-70b"; const dims = Object.keys(rubric); const rubricText = dims.map(d => `- ${d} (0.0-1.0): ${rubric[d]}`).join("\n"); - const scoreSchema = { - type: "object", - properties: Object.fromEntries(dims.map(d => [d, { type: "number", minimum: 0, maximum: 1 }])), - required: dims, - additionalProperties: false, - }; - const request = { - model, - messages: [ - { role: "system", content: "Strict benchmark evaluator. Return only the requested numeric JSON object." }, - { role: "user", content: `TASK: ${question.slice(0,350)}\n\nRESPONSE: ${response.slice(0,1200)}\n\nScore each 0.0-1.0:\n${rubricText}` } - ], - temperature: 0, - max_completion_tokens: 256, - }; - if (isGroq) { - request.reasoning_effort = "low"; - request.include_reasoning = false; - request.response_format = { - type: "json_schema", - json_schema: { name: "benchmark_scores", strict: true, schema: scoreSchema }, - }; - } - JUDGE_TELEMETRY.requested++; - const executeJudge = async (payload, isFallback = false) => { - JUDGE_TELEMETRY.attempts++; - let r; - try { - r = await fetch(endpoint, { - method: "POST", - headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(12_000), - }); - } catch (error) { - recordJudgeFailure(error?.name === "AbortError" ? "JUDGE_TIMEOUT" : "JUDGE_REQUEST_ERROR"); - return null; - } - if (!r.ok) { - const raw = await r.text().catch(() => ""); - const reason = classifyJudgeHttpFailure(r.status, raw, isGroq); - recordJudgeFailure(reason); - const recoverable = isGroq && !isFallback && (reason === "GROQ_SCHEMA_REJECTED" || reason === "GROQ_PARAMETER_REJECTED"); - if (!recoverable) return null; - JUDGE_TELEMETRY.retries++; - const fallback = { ...payload, response_format: { type: "json_object" } }; - delete fallback.reasoning_effort; - delete fallback.include_reasoning; - const recovered = await executeJudge(fallback, true); - if (recovered) JUDGE_TELEMETRY.recoveredRetries++; - return recovered; - } - let data; - try { data = await r.json(); } - catch { recordJudgeFailure("JUDGE_INVALID_JSON_RESPONSE"); return null; } + try { + const r = await fetch(endpoint, { + method: "POST", + headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: "Strict benchmark evaluator. Return ONLY valid JSON, no prose." }, + { role: "user", content: `TASK: ${question.slice(0,350)}\n\nRESPONSE: ${response.slice(0,1200)}\n\nScore each 0.0-1.0:\n${rubricText}\n\nJSON only: {${dims.map(d=>`"${d}":0.0`).join(",")}}` } + ], + temperature: 0.05, + max_tokens: 120, + }), + signal: AbortSignal.timeout(9000), + }); + if (!r.ok) return null; + const data = await r.json(); const content = data.choices?.[0]?.message?.content ?? ""; const m = content.match(/\{[^}]+\}/); - if (!m) { - recordJudgeFailure("JUDGE_EMPTY_OR_NON_JSON"); - return null; - } - let scores; - try { scores = JSON.parse(m[0]); } - catch { recordJudgeFailure("JUDGE_INVALID_SCORE_JSON"); return null; } + if (!m) return null; + const scores = JSON.parse(m[0]); for (const d of dims) { - if (typeof scores[d] !== "number") { - recordJudgeFailure("JUDGE_SCHEMA_MISMATCH"); - return null; - } + if (typeof scores[d] !== "number") return null; scores[d] = Math.max(0, Math.min(1, +scores[d].toFixed(2))); } + _JUDGE_CACHE.set(cacheKey, scores); return scores; - }; - const scores = await executeJudge(request); - if (!scores) return null; - JUDGE_TELEMETRY.succeeded++; - JUDGE_TELEMETRY.lastFailure = null; - _JUDGE_CACHE.set(cacheKey, scores); - return scores; + } catch { return null; } } function tDir(id){ @@ -1036,17 +920,6 @@ async function callAgent(goal,timeoutMs=90000,options={}){ terminalStatus=String(body.status||"UNKNOWN"); return terminalStatus; }; - const readStatusAfterAbort=async()=>{ - if(!taskId)return null; - statusChecks++; - try{ - const response=await fetch(`${BASE_URL}/api/agent/tasks/${encodeURIComponent(taskId)}/status`,{method:"GET",headers:taskHeaders,signal:AbortSignal.timeout(5000)}); - if(!response.ok)return null; - const body=await response.json(); - terminalStatus=String(body.status||"UNKNOWN"); - return terminalStatus; - }catch{return null;} - }; try{ const created=await fetch(`${BASE_URL}/api/agent/tasks`,{ method:"POST",headers, @@ -1151,13 +1024,10 @@ async function callAgent(goal,timeoutMs=90000,options={}){ if(done&&!failed&&!sawText&&!out)return{ok:false,output:"",engine,...telemetry(),toolCalls,durationMs:Date.now()-t0,failed:true,failureReason:"NO_OUTPUT"}; if(done&&options.earlyComplete)await cancelTask(); }catch(e){ - const timedOut=e.name==="AbortError"; - if(timedOut)await readStatusAfterAbort(); - if(!["SUCCESS","ERROR","CANCELLED"].includes(String(terminalStatus)))await cancelTask(); + await cancelTask(); const partial=normalizeAgentOutput(out||""); const salvageFeature=options.earlyComplete === "typescript"&&partial.length>=120&&/(?:interface|type|class|function|const)\b/.test(partial)&&/(?:subscribe|getState|async|await|try|catch)/.test(partial); - const timeoutReason=terminalStatus?`TIMEOUT_${terminalStatus}`:"TIMEOUT"; - return{ok:salvageFeature,output:partial,engine,...telemetry(),toolCalls,durationMs:Date.now()-t0,failed:!salvageFeature,failureReason:timedOut?(salvageFeature?`PARTIAL_${timeoutReason}`:timeoutReason):String(e.message||e),partial:salvageFeature}; + return{ok:salvageFeature,output:partial,engine,...telemetry(),toolCalls,durationMs:Date.now()-t0,failed:!salvageFeature,failureReason:e.name==="AbortError"?(salvageFeature?"PARTIAL_TIMEOUT":"TIMEOUT"):String(e.message||e),partial:salvageFeature}; }finally{clearTimeout(timer);} const finalOutput=normalizeAgentOutput(out); return{ok:finalOutput.length>30&&!failed,output:finalOutput,engine,...telemetry(),toolCalls,durationMs:Date.now()-t0,failed,failureReason}; @@ -1899,14 +1769,14 @@ async function makeReasoning(rng,seed){ async function makeDataAnalysis(rng){ // Time series con anomalia iniettata e ground truth calcolato - const requestedN=rng.int(9,14); - const months=["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"].slice(0,requestedN); + const n=rng.int(9,14); + const months=["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"].slice(0,n); const vals=months.map(()=>rng.int(80,320)); - const aIdx=rng.int(2,vals.length-2); vals[aIdx]=rng.int(5,15); - const avg=+(vals.reduce((a,b)=>a+b,0)/vals.length).toFixed(1); + const aIdx=rng.int(2,n-2); vals[aIdx]=rng.int(5,15); + const avg=+(vals.reduce((a,b)=>a+b,0)/n).toFixed(1); const maxV=Math.max(...vals),maxM=months[vals.indexOf(maxV)],anomM=months[aIdx]; const data=months.map((m,i)=>({mese:m,vendite:vals[i]})); - return{id:"DA",category:"data_analysis",label:`Time series ${data.length}m anomalia:${anomM}`, + return{id:"DA",category:"data_analysis",label:`Time series ${n}m anomalia:${anomM}`, hfSource:"local-ground-truth",targetMs:50000,ref:REF.data_analysis, prompt:`Analizza vendite mensili:\n\n${JSON.stringify(data,null,2)}\n\n` + `RISPONDI ESATTAMENTE CON QUESTO FORMATO (copia la struttura, sostituisci i valori):\n- **Media: N** ← calcola la media reale (es. Media: ${avg})\n- **Picco: MESE (N)** ← mese col valore massimo (es. Picco: ${maxM} (${maxV}))\n- **Anomalia: MESE (N)** ← mese col valore anomalo fuori scala (es. Anomalia: ${anomM} (${vals[aIdx]}))\n- **Trend: testo breve**\n\nNon aggiungere testo prima dei 4 bullet. Usa i nomi mese: ${months.slice(0,3).join("/")}...`, @@ -1948,7 +1818,6 @@ async function makeDataAnalysis(rng){ const score=Object.values(ok).filter(Boolean).length/3; return{buildPassed:score>=0.33,testsPassed:score>=0.66, accuracy:score,structure:/\*\*/.test(o)?0.9:0.4,completeness:score>=0.66?0.9:0.5,precision:score, - analysisChecks:ok, detail:`avg:${ok.avgOk}(got:${avgGot}exp:${avg}) peak:${ok.peakOk} anom:${ok.anomOk}`}; },isNonCoding:true}; } @@ -2522,12 +2391,10 @@ async function runOneSeed(seed,opts={}){ const t0=Date.now(); // Il target resta una metrica di punteggio; non è un hard-stop di trasporto. // I fallback gratuiti possono richiedere più tempo per il primo chunk su task coding. - const transportTimeout = TEST_TRANSPORT_TIMEOUT_MS ?? ((task.category === "feature" || task.category === "research_synthesis") ? 150000 : Math.max(task.targetMs||65000,180000)); + const transportTimeout = (task.category === "feature" || task.category === "research_synthesis") ? 150000 : Math.max(task.targetMs||65000,180000); let agent=await callAgentWithRetry(task,transportTimeout); const repair=await repairSecurityIfNeeded(task,agent,Math.min(transportTimeout,120000)); agent=repair.agent; - const dataAnalysisRepair=await repairDataAnalysisIfNeeded(task,agent,transportTimeout); - agent=dataAnalysisRepair.agent; const featureRepair=await repairFeatureIfNeeded(task,agent,transportTimeout); agent=featureRepair.agent; const codeRepair=await repairCodeCorrectIfNeeded(task,agent,transportTimeout); @@ -2665,7 +2532,6 @@ async function runOneSeed(seed,opts={}){ runtime_input:{profile:"fixed-realistic-chat-v1",persona:BENCHMARK_PERSONA,negative_constraints:true,context_messages:BENCHMARK_CONTEXT.length}, runner_prerequisites:{typescript_required:requiresTypeScript,typescript_bin:requiresTypeScript?TSC_BIN:null}, sse_recovery:{resume:"Last-Event-ID",deduplicate_replayed_event_ids:true,max_reconnects:1,status_endpoint:"/api/agent/tasks/{taskId}/status",cancel_on_nonterminal_incomplete:true}, - semantic_judge:{enabled:F_JUDGE,provider:JUDGE_TELEMETRY.provider,model:JUDGE_TELEMETRY.model,requested:JUDGE_TELEMETRY.requested,succeeded:JUDGE_TELEMETRY.succeeded,failed:JUDGE_TELEMETRY.failed,attempts:JUDGE_TELEMETRY.attempts,retries:JUDGE_TELEMETRY.retries,recovered_retries:JUDGE_TELEMETRY.recoveredRetries,cache_hits:JUDGE_TELEMETRY.cacheHits,last_failure:JUDGE_TELEMETRY.lastFailure,failure_reasons:JUDGE_TELEMETRY.failureReasons,coverage_complete:JUDGE_TELEMETRY.requested===JUDGE_TELEMETRY.succeeded}, canonical_seed:"1337 (stesse domande per tutti gli agenti — usa --rotate per seed diverso)", coding:"enterprise: Acc(35%)+Stab(20%)+Auto(15%)+Perf(10%)+Spd(10%)+Cost(5%)+Tool(5%)", nonCoding:"content: Acc(40%)+Struct(20%)+Comp(15%)+Prec(10%)+Auto(5%)+Spd(5%)+Cost(5%)", diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/config/supabase_profiles.py b/config/supabase_profiles.py new file mode 100644 index 0000000000000000000000000000000000000000..40114643ca9348a1635f97b6aa2dcd6c137ab7cf --- /dev/null +++ b/config/supabase_profiles.py @@ -0,0 +1,132 @@ +""" +backend/config/supabase_profiles.py — Multi-profile Supabase configuration for load distribution. + +5 Supabase profiles for distributed load: +- Profile A: Infrastructure Nodes + Oracle Config +- Profile B: AI Providers Fleet + Reasoning Tasks +- Profile C: Agent Memory + Conversations +- Profile D: Execution Logs + Health Monitoring +- Profile E: Backup + Failover + Analytics +""" + +import os +from typing import Dict, Any, Optional + +# ── Supabase Profile A: Infrastructure Nodes (Oracle, Railway, etc.) ──────── +SUPABASE_A = { + "url": os.getenv("SUPABASE_URL_A", "https://zwdoplodbdsxfrddoxmo.supabase.com"), + "key": os.getenv("SUPABASE_KEY_A", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3ZG9wbG9kYmRzeGZyZGRveG1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjA3Mjk5MiwiZXhwIjoyMDk3NjQ4OTkyfQ.WS4cpsvtO-FGkXukCqpFF6qXfQb38VUs5rtiLwa0khk"), + "anon_key": os.getenv("SUPABASE_ANON_KEY_A", "sb_secret_2zmKHh4fWrorWISuWkkQyw_m_ThinWL"), + "db_password": os.getenv("SUPABASE_DB_PASSWORD_A", "QtcKIidImsX0qzd9"), + "purpose": "infrastructure_nodes", + "tables": ["infrastructure_nodes", "oracle_config"], +} + +# ── Supabase Profile B: AI Providers Fleet + Reasoning Tasks ──────────────── +SUPABASE_B = { + "url": os.getenv("SUPABASE_URL_B", "https://sluvxtpbtxevrcooaqou.supabase.co"), + "key": os.getenv("SUPABASE_KEY_B", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNsdXZ4dHBidHhldnJjb29hcW91Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjA3Mjk5MiwiZXhwIjoyMDk3NjQ4OTkyfQ.WS4cpsvtO-FGkXukCqpFF6qXfQb38VUs5rtiLwa0khk"), + "anon_key": os.getenv("SUPABASE_ANON_KEY_B", "sb_secret_OHtVs6Vw4b5UTbm_QUnh4A_zqx9dadY"), + "db_password": os.getenv("SUPABASE_DB_PASSWORD_B", "cQILsihuJk9aVu6m"), + "purpose": "ai_providers_and_reasoning", + "tables": ["ai_providers", "reasoning_tasks", "ai_provider_status"], +} + +# ── Supabase Profile C: Agent Memory + Conversations ──────────────────────── +SUPABASE_C = { + "url": os.getenv("SUPABASE_URL_C", "https://rsiphwzlokhsxnkcvzos.supabase.co"), + "key": os.getenv("SUPABASE_KEY_C", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJzaXBod3psb2toc3hua2N2em9zIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjI1OTEyOSwiZXhwIjoyMDk3ODM1MTI5fQ.LxO8ltt-t8SpHf-ZIi1ogcEtcQcPvW1Vyu2W14i1zzM"), + "anon_key": os.getenv("SUPABASE_ANON_KEY_C", "sb_secret_11jeLuJnZBA1IKCAm1ZjiA_QxIyU5u6"), + "db_password": os.getenv("SUPABASE_DB_PASSWORD_C", "tegpih-jodKi6-hyjgov"), + "purpose": "agent_memory_and_conversations", + "tables": ["agent_memory", "conversations", "conv_messages", "semantic_memory"], +} + +# ── Supabase Profile D: Execution Logs + Health Monitoring ────────────────── +SUPABASE_D = { + "url": os.getenv("SUPABASE_URL_D", "https://cyotadpvgaxfdwaovstv.supabase.co"), + "key": os.getenv("SUPABASE_KEY_D", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImN5b3RhZHB2Z2F4ZmR3YW92c3R2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjY1NjQ1OSwiZXhwIjoyMDk4MjMyNDU5fQ.FuLweX2H6RKJmC7PbXdctmgzVIcqspq9FSxwFzFwB9o"), + "anon_key": os.getenv("SUPABASE_ANON_KEY_D", "sb_secret_aZGjt7d8KWsvIm5-JP4qrg_XrNPBAZE"), + "db_password": os.getenv("SUPABASE_DB_PASSWORD_D", "6fRlt97lQrcavo3m"), + "purpose": "execution_logs_and_monitoring", + "tables": ["agent_tasks", "agent_checkpoints", "agent_locks", "oci_search_status"], +} + +# ── Supabase Profile E: Backup + Failover + Analytics ────────────────────── +SUPABASE_E = { + "url": os.getenv("SUPABASE_URL_E", "https://vcgtvfimdctozxpvizvw.supabase.co"), + "key": os.getenv("SUPABASE_KEY_E", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZjZ3R2ZmltZGN0b3p4cHZpenZ3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MzI2NzUxMCwiZXhwIjoyMDk4ODQzNTEwfQ.7g6KkCx260P4EUmKhnmxekZwb8ZDPZ8Z28pauadr61U"), + "anon_key": os.getenv("SUPABASE_ANON_KEY_E", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZjZ3R2ZmltZGN0b3p4cHZpttenZ3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMyNjc1MTAsImV4cCI6MjA5ODg0MzUxMH0.3b4Y5bc_9KqEpT38GXnEIft7gw0-brkhcJHG0jlh9Gw"), + "db_password": os.getenv("SUPABASE_DB_PASSWORD_E", "vcgtvfimdctozxpvizvw"), + "purpose": "backup_failover_analytics", + "tables": ["agent_tasks_backup", "analytics_events", "health_metrics"], +} + +# ── Profile Registry ────────────────────────────────────────────────────────── +SUPABASE_PROFILES = { + "A": SUPABASE_A, + "B": SUPABASE_B, + "C": SUPABASE_C, + "D": SUPABASE_D, + "E": SUPABASE_E, +} + +# ── Table to Profile Mapping ────────────────────────────────────────────────── +TABLE_TO_PROFILE = { + # Profile A: Infrastructure + "infrastructure_nodes": "A", + "oracle_config": "A", + + # Profile B: AI Providers + "ai_providers": "B", + "reasoning_tasks": "B", + "ai_provider_status": "B", + + # Profile C: Memory + "agent_memory": "C", + "conversations": "C", + "conv_messages": "C", + "semantic_memory": "C", + + # Profile D: Execution + "agent_tasks": "D", + "agent_checkpoints": "D", + "agent_locks": "D", + "oci_search_status": "D", + + # Profile E: Backup + "agent_tasks_backup": "E", + "analytics_events": "E", + "health_metrics": "E", +} + +def get_profile_for_table(table_name: str) -> str: + """Get the Supabase profile for a given table.""" + return TABLE_TO_PROFILE.get(table_name, "A") # Default to Profile A + +def get_supabase_config(profile: str) -> Optional[Dict[str, Any]]: + """Get Supabase configuration for a specific profile.""" + return SUPABASE_PROFILES.get(profile) + +def is_profile_configured(profile: str) -> bool: + """Check if a Supabase profile is configured.""" + config = get_supabase_config(profile) + if not config: + return False + return bool(config.get("url") and config.get("key")) + +def get_all_configured_profiles() -> list: + """Get list of all configured Supabase profiles.""" + return [p for p in SUPABASE_PROFILES.keys() if is_profile_configured(p)] + +# ── Perfect Sync Configuration ──────────────────────────────────────────────── +PERFECT_SYNC_CONFIG = { + "infrastructure_nodes_profile": "A", + "ai_providers_profile": "B", + "agent_memory_profile": "C", + "execution_profile": "D", + "backup_profile": "E", + "enable_multi_write": True, # Write to primary + backup profile + "enable_failover": True, # Automatic failover if primary unavailable + "sync_interval_seconds": 30, # Periodic sync interval +} diff --git a/cors_policy.py b/cors_policy.py deleted file mode 100644 index b0c6bf0400db32e4e6bf6a93b3108034171042ec..0000000000000000000000000000000000000000 --- a/cors_policy.py +++ /dev/null @@ -1,61 +0,0 @@ -"""CORS policy shared by the FastAPI entrypoint. - -The backend authenticates browser calls with explicit bearer/internal headers, -not with ambient browser cookies. Credentials therefore remain disabled unless -an explicit future policy requires them. -""" -from __future__ import annotations - -import os -from urllib.parse import urlsplit - -PUBLIC_FRONTEND_ORIGIN = "https://agente-ai.pages.dev" -_DEVELOPMENT_ORIGINS = frozenset( - { - "http://localhost:3000", - "http://localhost:5173", - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - } -) -_DEVELOPMENT_ENVS = frozenset({"dev", "development", "local", "test"}) - - -def _is_valid_origin(origin: str) -> bool: - """Return True only for an exact HTTP(S) origin without path or wildcard.""" - if not origin or origin == "*" or any(char.isspace() for char in origin): - return False - parsed = urlsplit(origin) - return bool( - parsed.scheme in {"http", "https"} - and parsed.netloc - and not parsed.username - and not parsed.password - and not parsed.path - and not parsed.query - and not parsed.fragment - ) - - -def allowed_origins(raw: str | None = None, environment: str | None = None) -> list[str]: - """Build the exact CORS allow-list from environment and safe defaults. - - ``CORS_ALLOWED_ORIGINS`` is a comma-separated list of exact origins. An - invalid value, including ``*``, is ignored rather than widening access. - Local development origins are enabled only for an explicit development or - test environment. The production default is the canonical Pages origin. - """ - configured = raw if raw is not None else os.getenv("CORS_ALLOWED_ORIGINS", "") - origins = [ - origin.strip().rstrip("/") - for origin in configured.split(",") - if origin.strip() - ] - valid = {origin for origin in origins if _is_valid_origin(origin)} - - env_name = (environment if environment is not None else os.getenv("APP_ENV", os.getenv("ENVIRONMENT", ""))).strip().lower() - if not valid: - valid.add(PUBLIC_FRONTEND_ORIGIN) - if env_name in _DEVELOPMENT_ENVS: - valid.update(_DEVELOPMENT_ORIGINS) - return sorted(valid) diff --git a/main.py b/main.py index 7da9a1101442f940af4639e14b2b05f1c0485f99..f495a9990aa863d52f3d89481a2a11877b5e0638 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from api.version import RUNTIME_VERSION -from cors_policy import allowed_origins # Configurazione Logging logging.basicConfig( @@ -26,14 +25,13 @@ app = FastAPI( version=RUNTIME_VERSION, ) -# CORS: whitelist esatta e fail-closed. Il frontend usa bearer/internal headers, -# non cookie cross-origin; le credenziali browser restano quindi disabilitate. +# CORS app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins(), - allow_credentials=False, - allow_methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allow_headers=["Authorization", "Content-Type", "X-Internal-Token", "X-Requested-With"], + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], ) # ── P17-F1: RLS Fix & Auto-Migration ────────────────────────────────────────── @@ -90,24 +88,7 @@ async def _run_auto_migration(): TO service_role USING (true) WITH CHECK (true); - -- 5. Public dashboard snapshot used by /api/public/status - CREATE TABLE IF NOT EXISTS public.public_dashboard_snapshot ( - singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), - service_status text NOT NULL DEFAULT 'operational', - active_sessions integer NOT NULL DEFAULT 0, - queued_tasks integer NOT NULL DEFAULT 0, - in_progress_tasks integer NOT NULL DEFAULT 0, - app_version text, - updated_at timestamptz NOT NULL DEFAULT now() - ); - ALTER TABLE public.public_dashboard_snapshot ENABLE ROW LEVEL SECURITY; - GRANT SELECT ON public.public_dashboard_snapshot TO anon, authenticated; - GRANT SELECT, INSERT, UPDATE, DELETE ON public.public_dashboard_snapshot TO service_role; - DROP POLICY IF EXISTS "public_dashboard_snapshot_read" ON public.public_dashboard_snapshot; - CREATE POLICY "public_dashboard_snapshot_read" ON public.public_dashboard_snapshot - FOR SELECT TO anon, authenticated USING (true); - - -- 6. Healthcheck function + -- 5. Healthcheck function CREATE OR REPLACE FUNCTION public.health_check() RETURNS jsonb AS $$ BEGIN @@ -122,7 +103,6 @@ async def _run_auto_migration(): try: conn = psycopg2.connect(f"postgresql://postgres:{db_pass}@{db_host}:{port}/postgres?sslmode=require", connect_timeout=5) cur = conn.cursor() - cur.execute("SET statement_timeout = '5000ms'") cur.execute(sql) conn.commit() cur.close() @@ -167,7 +147,6 @@ _ROUTER_MAP = { "private_state": "private_state", "auth": "auth_managed", "public_status": "public_status", - "public_snapshot_diagnostics": "public_snapshot_diagnostics", "me_tasks": "me_tasks", "admin_state": "admin_state", # ── Aggiunti ROUTER-COMPLETE (29 moduli orfani rimontati) ───────────────── @@ -196,7 +175,6 @@ _ROUTER_MAP = { "session_manager": "session_manager", "structured_log": "structured_log", "telemetry": "telemetry", - "performance": "performance_rum", "terminal": "terminal", "vision": "vision", "web": "web", @@ -246,20 +224,6 @@ async def run_cli_task(task_description: str): sys.exit(1) # ── Startup ─────────────────────────────────────────────────────────────────── -async def _snapshot_heartbeat() -> None: - """Keep the public dashboard snapshot fresh without affecting request handling.""" - from api.public_snapshot import refresh_public_dashboard_snapshot - - while True: - await asyncio.sleep(30) - try: - await asyncio.wait_for(refresh_public_dashboard_snapshot(), timeout=10.0) - except asyncio.CancelledError: - raise - except Exception as exc: - _logger.debug("Public snapshot heartbeat failed (non-blocking): %s", exc) - - @app.on_event("startup") async def startup_event(): _logger.info("Server starting up...") @@ -269,26 +233,7 @@ async def startup_event(): _logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.") except Exception as e: _logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}") - # Schema e RLS sono gestiti dalle migrazioni versionate Supabase. - # Non avviare auto-migrazioni in background: il boot deve restare - # deterministico e non può dichiarare successo prima della migrazione. - try: - from api.public_snapshot import write_public_dashboard_snapshot - snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=12.0) - if snapshot_ok: - _logger.info("✅ BOOT: public dashboard snapshot writer completato.") - else: - _logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.") - except Exception as e: - _logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}") - try: - from api.background_tasks import spawn_background_task - from api.telemetry import telemetry_alert_loop - spawn_background_task(telemetry_alert_loop(), name="telemetry-alert-loop") - spawn_background_task(_snapshot_heartbeat(), name="public-snapshot-heartbeat") - _logger.info("✅ BOOT: telemetry e snapshot loops supervisionati.") - except Exception as e: - _logger.warning(f"⚠️ BOOT: avvio telemetry/snapshot loops fallito (non bloccante): {e}") + asyncio.create_task(_run_auto_migration()) try: from api.providers import start_heartbeat start_heartbeat() @@ -297,24 +242,9 @@ async def startup_event(): _logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}") if not any(arg in sys.argv for arg in ["--task", "-t"]): try: - from api.background_tasks import spawn_background_task from api.job_queue import start_job_queue_consumer - spawn_background_task( - start_job_queue_consumer(), name="job-queue-consumer-supervisor" - ) - except Exception as e: - _logger.warning("⚠️ BOOT: avvio job queue fallito (non bloccante): %s", e) - - -@app.on_event("shutdown") -async def shutdown_event(): - """Cancel and await all registered background tasks before loop shutdown.""" - try: - from api.background_tasks import shutdown_background_tasks - await shutdown_background_tasks() - _logger.info("✅ SHUTDOWN: background tasks supervisionate arrestate.") - except Exception as e: - _logger.warning("⚠️ SHUTDOWN: arresto background tasks incompleto: %s", e) + asyncio.create_task(start_job_queue_consumer()) + except Exception: pass # ── SPA Hosting ─────────────────────────────────────────────────────────────── _STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static') diff --git a/memory/create_oracle_memories_table.sql b/memory/create_oracle_memories_table.sql new file mode 100644 index 0000000000000000000000000000000000000000..ff0855cc52a34869e62c7def2f287dd7ba697b6a --- /dev/null +++ b/memory/create_oracle_memories_table.sql @@ -0,0 +1,27 @@ +-- backend/memory/create_oracle_memories_table.sql +-- Run once on the Oracle instance (Oracle 12c+ required for FETCH NEXT syntax). +-- +-- Changes vs prototype: +-- + INDEX on user_id to avoid full-scan on every list_memories() call. +-- + NVARCHAR2 / NCLOB commented out as alternatives for multi-byte content. + +CREATE TABLE memories ( + user_id VARCHAR2(255) NOT NULL, + memory_id VARCHAR2(36) NOT NULL, -- UUID format (36 chars) + data CLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT pk_memories PRIMARY KEY (user_id, memory_id) +); + +-- Speeds up list_memories() WHERE user_id = :x (avoids PK full-scan) +CREATE INDEX idx_memories_user ON memories (user_id); + +-- Auto-update updated_at on every UPDATE +CREATE OR REPLACE TRIGGER trg_memories_updated_at +BEFORE UPDATE ON memories +FOR EACH ROW +BEGIN + :NEW.updated_at := CURRENT_TIMESTAMP; +END; +/ diff --git a/memory/distiller.py b/memory/distiller.py new file mode 100644 index 0000000000000000000000000000000000000000..eb84e1e598428f0a8dc36d611eed9b152f4d67e8 --- /dev/null +++ b/memory/distiller.py @@ -0,0 +1,123 @@ +""" +distiller.py — S-SESTO-SENSO-V2: Memory Cognitive Compressive. +Sintetizza i log grezzi delle sessioni in lezioni apprese e concetti chiave. + +C1-FIX (2026-07-01): chiamata LLM reale implementata — il distillatore produceva +solo placeholder vuoto (lessons: [], patterns: [], facts: []) invece di estrarre +conoscenza reale. Ora chiama ai_client quando disponibile; fallback euristico +se il client è None o se la chiamata fallisce. +""" +import logging +import json +import re +from typing import List, Dict, Any, Optional +from datetime import datetime, timezone + +_logger = logging.getLogger("agente_ai.memory.distiller") + +_DISTILL_SYSTEM = ( + "Sei un Memory Distiller per un agente AI avanzato. " + "Analizza la sessione e restituisci SOLO un oggetto JSON valido, " + "senza markdown, senza spiegazioni. Formato esatto:\n" + '{"lessons":["..."],"patterns":["..."],"facts":["..."],' + '"completed":true|false,"completion_note":"..."}' +) + +def _build_distill_prompt(goal: str, cleaned_msgs: List[Dict]) -> str: + transcript = "\n".join( + f"[{m['role'].upper()}] {m['content'][:300]}" for m in cleaned_msgs[-20:] + ) + return ( + f"OBIETTIVO: {goal}\n\n" + f"TRASCRIZIONE (ultimi {len(cleaned_msgs[-20:])} messaggi):\n{transcript}\n\n" + "Estrai lessons (errori + soluzioni), patterns (architetture fragili o ricorrenti), " + "facts (info tecniche stabili). Rispondi SOLO con il JSON." + ) + +def _heuristic_distill(messages: List[Dict], goal: str) -> Dict[str, Any]: + """Fallback euristico — estrae pattern semplici dal testo.""" + errors_found = [] + facts_found = [] + combined = " ".join(m.get("content", "") for m in messages).lower() + if "errore" in combined or "error" in combined or "exception" in combined: + errors_found.append("Errori rilevati nella sessione — dettagli nel transcript.") + if "completato" in combined or "done" in combined or "success" in combined: + facts_found.append("Task marcato come completato nel transcript.") + return { + "timestamp": datetime.now(timezone.utc).isoformat(), + "goal": goal, + "message_count": len(messages), + "lessons": errors_found, + "patterns": [], + "facts": facts_found, + "completed": "completato" in combined or "done" in combined, + "completion_note": "Distillazione euristica (LLM non disponibile).", + "source": "heuristic", + } + +class MemoryDistiller: + def __init__(self, ai_client=None): + self.ai_client = ai_client + + async def distill_session(self, messages: List[Dict[str, Any]], goal: str) -> Dict[str, Any]: + """ + C1-FIX: chiama l'LLM reale per estrarre lezioni/pattern/fatti. + Fallback euristico se ai_client è None o la chiamata fallisce. + """ + if not messages: + return {} + + # 1. Pre-processing: tronca contenuti pesanti + cleaned_msgs = [] + for msg in messages: + content = msg.get("content", "") + if len(content) > 2000: + content = content[:1000] + "... [TRUNCATED] ..." + content[-400:] + cleaned_msgs.append({"role": msg.get("role", "user"), "content": content}) + + # 2. Tentativo LLM reale + if self.ai_client is not None: + try: + result = await self._distill_with_llm(cleaned_msgs, goal) + if result: + return result + except Exception as e: + _logger.warning("[Distiller] LLM call failed (%s) — fallback euristico", e) + + # 3. Fallback euristico + return _heuristic_distill(cleaned_msgs, goal) + + async def _distill_with_llm( + self, cleaned_msgs: List[Dict], goal: str + ) -> Optional[Dict[str, Any]]: + """Chiamata LLM reale via ai_client (qualsiasi provider con interfaccia openai-compat).""" + prompt = _build_distill_prompt(goal, cleaned_msgs) + response = await self.ai_client.chat.completions.create( + model=getattr(self.ai_client, "_distill_model", "llama-3.1-8b-instant"), + messages=[ + {"role": "system", "content": _DISTILL_SYSTEM}, + {"role": "user", "content": prompt}, + ], + max_tokens=600, + temperature=0.1, + ) + raw = response.choices[0].message.content or "" + # Estrai JSON dalla risposta (può avere testo attorno) + match = re.search(r"\{[\s\S]*\}", raw) + if not match: + _logger.warning("[Distiller] risposta LLM non contiene JSON valido: %.100s", raw) + return None + parsed = json.loads(match.group(0)) + parsed.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + parsed.setdefault("goal", goal) + parsed.setdefault("message_count", len(cleaned_msgs)) + parsed["source"] = "llm" + _logger.info( + "[Distiller] distillazione LLM completata: %d lessons, %d facts | goal: %.50s", + len(parsed.get("lessons", [])), len(parsed.get("facts", [])), goal, + ) + return parsed + + def compress_for_long_term(self, distilled_data: Dict[str, Any]) -> str: + """Converte i dati distillati in stringa ottimizzata per pgvector/semantic memory.""" + return json.dumps(distilled_data, ensure_ascii=False) diff --git a/memory/evolutionary.py b/memory/evolutionary.py new file mode 100644 index 0000000000000000000000000000000000000000..bbf030420c364d84e4aab113ee4ecef867acbd36 --- /dev/null +++ b/memory/evolutionary.py @@ -0,0 +1,93 @@ +""" +backend/memory/evolutionary.py — Evolutionary Memory (S960) +Distilla preferenze utente, stili di codice e regole operative dalle sessioni. +Alimenta il layer 'Reflection' con conoscenze di alto livello (Long-term Evolution). +""" +import json +import logging +import os +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Any + +_logger = logging.getLogger("memory.evolutionary") + +# Directory per i dati evolutivi (Sync con reflection.py) +_DATA_DIR = os.getenv('CHROMA_DATA_DIR') or ('/data' if Path('/data').exists() else '.') +EVO_PATH = Path(_DATA_DIR) / 'evolutionary_rules.json' + +class EvolutionaryMemory: + def __init__(self, ai_client=None): + self.ai_client = ai_client + self.rules: Dict[str, Any] = { + "user_preferences": {}, # es. "language": "python", "style": "functional" + "operational_rules": [], # es. "Usa sempre pnpm invece di npm" + "domain_knowledge": {}, # es. "path/to/project": "description" + "last_updated": None + } + self._load() + + def _load(self): + if EVO_PATH.exists(): + try: + self.rules = json.loads(EVO_PATH.read_text()) + except Exception as e: + _logger.error(f"[S960] Load error: {e}") + + def _save(self): + try: + EVO_PATH.write_text(json.dumps(self.rules, indent=2, ensure_ascii=False)) + except Exception as e: + _logger.error(f"[S960] Save error: {e}") + + async def distill_and_evolve(self, session_summary: Dict[str, Any]): + """ + Prende un sommario distillato (dal MemoryDistiller) e aggiorna le regole evolutive. + """ + # 1. Estrazione euristica (in attesa di LLM integration) + # Se il sommario contiene fatti chiave, li integriamo + facts = session_summary.get("facts", []) + for fact in facts: + if ":" in fact: + k, v = fact.split(":", 1) + self.rules["domain_knowledge"][k.strip()] = v.strip() + + # 2. Rilevamento preferenze (es. linguaggi usati con successo) + lessons = session_summary.get("lessons", []) + for lesson in lessons: + if lesson.get("type") == "success": + # Esempio: "Usato FastAPI con successo" -> preferenza per FastAPI + pass + + self.rules["last_updated"] = datetime.now().isoformat() + self._save() + _logger.info("[S960] Memoria evolutiva aggiornata.") + + def get_evolutionary_context(self) -> str: + """ + Ritorna una stringa formattata da iniettare nel System Prompt. + """ + if not self.rules["user_preferences"] and not self.rules["operational_rules"] and not self.rules["domain_knowledge"]: + return "" + + context = "\n[MEMORIA EVOLUTIVA - REGOLE APPRESE]\n" + + if self.rules["user_preferences"]: + context += "Preferenze Utente:\n" + for k, v in self.rules["user_preferences"].items(): + context += f"- {k}: {v}\n" + + if self.rules["operational_rules"]: + context += "Regole Operative:\n" + for rule in self.rules["operational_rules"]: + context += f"- {rule}\n" + + if self.rules["domain_knowledge"]: + context += "Conoscenza Dominio:\n" + for k, v in self.rules["domain_knowledge"].items(): + context += f"- {k}: {v}\n" + + return context + +# Singleton +evo_memory = EvolutionaryMemory() diff --git a/memory/memory_backend.py b/memory/memory_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..2b4ba158fc94232f2d6910cb96ce3bb1b0e696ad --- /dev/null +++ b/memory/memory_backend.py @@ -0,0 +1,77 @@ +""" +backend/memory/memory_backend.py — Abstract interface for memory backends. + +Defines the MemoryBackend contract implemented by both the Supabase adapter +and the OracleMemoryAdapter. All concrete backends must implement every method. +""" +from __future__ import annotations + +import abc +from typing import Any, Dict, List, Optional + + +class MemoryBackend(abc.ABC): + """Abstract base class for all memory storage backends.""" + + # is_connected lets callers guard operations without catching ConnectionError. + is_connected: bool = False + + @abc.abstractmethod + async def connect(self, config: Dict[str, Any]) -> None: + """Establish a connection to the backend.""" + + @abc.abstractmethod + async def disconnect(self) -> None: + """Close the connection to the backend.""" + + @abc.abstractmethod + async def add_memory( + self, + user_id: str, + memory_data: Dict[str, Any], + memory_id: Optional[str] = None, + ) -> str: + """ + Insert a new memory entry for *user_id*. + + Args: + user_id: Owning user's identifier. + memory_data: Arbitrary JSON-serialisable payload. + memory_id: Optional caller-supplied ID (must be honoured when provided + so that primary and secondary backends stay in sync). + + Returns: + The memory_id that was stored (either caller-supplied or generated). + """ + + @abc.abstractmethod + async def get_memory( + self, user_id: str, memory_id: str + ) -> Optional[Dict[str, Any]]: + """Return the memory payload or None if not found.""" + + @abc.abstractmethod + async def update_memory( + self, user_id: str, memory_id: str, new_data: Dict[str, Any] + ) -> bool: + """ + Replace the payload of an existing memory entry. + + Returns: + True if a row was updated, False if the entry was not found. + """ + + @abc.abstractmethod + async def delete_memory(self, user_id: str, memory_id: str) -> bool: + """ + Remove a memory entry. + + Returns: + True if a row was deleted, False if the entry was not found. + """ + + @abc.abstractmethod + async def list_memories( + self, user_id: str, limit: int = 100, offset: int = 0 + ) -> List[Dict[str, Any]]: + """Return a paginated list of memory payloads for *user_id*.""" diff --git a/memory/oracle_adapter.py b/memory/oracle_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..06aca1a62206a09284ba5a4224b4b7d382bbd314 --- /dev/null +++ b/memory/oracle_adapter.py @@ -0,0 +1,223 @@ +""" +backend/memory/oracle_adapter.py — Oracle Database MemoryBackend adapter. + +Implements MemoryBackend for Oracle using python-oracledb (thin mode, pooled). + +Bug fixes vs the original prototype: + BUG-1 add_memory accepts an external memory_id so IDs stay consistent with + the Supabase primary backend (no more key divergence on dual-write). + BUG-2 uuid4() instead of hash() — hash() is non-deterministic across + processes (PYTHONHASHSEED), causing silent duplicate-key collisions + on Railway restarts. + BUG-3 is_connected flag guards every operation; the flag is only set True + after a successful pool creation, so callers can check it cheaply. + BUG-5 oracledb.create_pool() instead of a single connection — safe under + concurrent asyncio tasks and survives individual connection drops. + BUG-6 Removed isinstance(self.connection, str) branch — that was a + test-artifact that leaked mock awareness into production code. + BUG-7 time.time() instead of asyncio.get_event_loop().time() — the latter + is deprecated since Python 3.10 and raises DeprecationWarning on 3.12. +""" +from __future__ import annotations + +import asyncio +import json +import uuid +from typing import Any, Dict, List, Optional + +import oracledb + +from .memory_backend import MemoryBackend + + +class OracleMemoryAdapter(MemoryBackend): + """MemoryBackend implementation for Oracle Database (thin mode, connection pool).""" + + def __init__(self) -> None: + self._pool: Optional[oracledb.ConnectionPool] = None + self.is_connected: bool = False + + # ── Lifecycle ──────────────────────────────────────────────────────────────── + + async def connect(self, config: Dict[str, Any]) -> None: + """Create a connection pool. config keys: user, password, dsn.""" + try: + self._pool = await asyncio.to_thread( + oracledb.create_pool, + user=config["user"], + password=config["password"], + dsn=config["dsn"], + min=1, + max=5, + increment=1, + ) + self.is_connected = True + except oracledb.Error as exc: + self.is_connected = False + raise ConnectionError(f"OracleMemoryAdapter: pool creation failed — {exc}") from exc + + async def disconnect(self) -> None: + """Close the connection pool gracefully.""" + if self._pool and self.is_connected: + await asyncio.to_thread(self._pool.close) + self.is_connected = False + self._pool = None + + def _require_pool(self) -> None: + if not self.is_connected or self._pool is None: + raise ConnectionError( + "OracleMemoryAdapter: not connected — call connect() first." + ) + + # ── Helpers ────────────────────────────────────────────────────────────────── + + async def _acquire(self): # type: ignore[return] + return await asyncio.to_thread(self._pool.acquire) + + async def _release(self, conn) -> None: # type: ignore[type-arg] + await asyncio.to_thread(self._pool.release, conn) + + # ── CRUD ───────────────────────────────────────────────────────────────────── + + async def add_memory( + self, + user_id: str, + memory_data: Dict[str, Any], + memory_id: Optional[str] = None, # BUG-1 fix + ) -> str: + self._require_pool() + if memory_id is None: + memory_id = str(uuid.uuid4()) # BUG-2 fix + data_json = json.dumps(memory_data) + conn = cursor = None + try: + conn = await self._acquire() + cursor = await asyncio.to_thread(conn.cursor) + await asyncio.to_thread( + cursor.execute, + "INSERT INTO memories (user_id, memory_id, data)" + " VALUES (:user_id, :memory_id, :data)", + user_id=user_id, + memory_id=memory_id, + data=data_json, + ) + await asyncio.to_thread(conn.commit) + return memory_id + except oracledb.Error as exc: + if conn: + await asyncio.to_thread(conn.rollback) + raise RuntimeError(f"OracleMemoryAdapter.add_memory failed: {exc}") from exc + finally: + if cursor: + await asyncio.to_thread(cursor.close) + if conn: + await self._release(conn) + + async def get_memory( + self, user_id: str, memory_id: str + ) -> Optional[Dict[str, Any]]: + self._require_pool() + conn = cursor = None + try: + conn = await self._acquire() + cursor = await asyncio.to_thread(conn.cursor) + await asyncio.to_thread( + cursor.execute, + "SELECT data FROM memories" + " WHERE user_id = :user_id AND memory_id = :memory_id", + user_id=user_id, + memory_id=memory_id, + ) + row = await asyncio.to_thread(cursor.fetchone) + return json.loads(row[0]) if row else None + except oracledb.Error as exc: + raise RuntimeError(f"OracleMemoryAdapter.get_memory failed: {exc}") from exc + finally: + if cursor: + await asyncio.to_thread(cursor.close) + if conn: + await self._release(conn) + + async def update_memory( + self, user_id: str, memory_id: str, new_data: Dict[str, Any] + ) -> bool: + self._require_pool() + data_json = json.dumps(new_data) + conn = cursor = None + try: + conn = await self._acquire() + cursor = await asyncio.to_thread(conn.cursor) + await asyncio.to_thread( + cursor.execute, + "UPDATE memories SET data = :data" + " WHERE user_id = :user_id AND memory_id = :memory_id", + data=data_json, + user_id=user_id, + memory_id=memory_id, + ) + rowcount = cursor.rowcount + await asyncio.to_thread(conn.commit) + return rowcount > 0 + except oracledb.Error as exc: + if conn: + await asyncio.to_thread(conn.rollback) + raise RuntimeError(f"OracleMemoryAdapter.update_memory failed: {exc}") from exc + finally: + if cursor: + await asyncio.to_thread(cursor.close) + if conn: + await self._release(conn) + + async def delete_memory(self, user_id: str, memory_id: str) -> bool: + self._require_pool() + conn = cursor = None + try: + conn = await self._acquire() + cursor = await asyncio.to_thread(conn.cursor) + await asyncio.to_thread( + cursor.execute, + "DELETE FROM memories" + " WHERE user_id = :user_id AND memory_id = :memory_id", + user_id=user_id, + memory_id=memory_id, + ) + rowcount = cursor.rowcount + await asyncio.to_thread(conn.commit) + return rowcount > 0 + except oracledb.Error as exc: + if conn: + await asyncio.to_thread(conn.rollback) + raise RuntimeError(f"OracleMemoryAdapter.delete_memory failed: {exc}") from exc + finally: + if cursor: + await asyncio.to_thread(cursor.close) + if conn: + await self._release(conn) + + async def list_memories( + self, user_id: str, limit: int = 100, offset: int = 0 + ) -> List[Dict[str, Any]]: + self._require_pool() + conn = cursor = None + try: + conn = await self._acquire() + cursor = await asyncio.to_thread(conn.cursor) + await asyncio.to_thread( + cursor.execute, + """SELECT data FROM memories + WHERE user_id = :user_id + ORDER BY memory_id + OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY""", + user_id=user_id, + offset=offset, + limit=limit, + ) + rows = await asyncio.to_thread(cursor.fetchall) + return [json.loads(row[0]) for row in rows] + except oracledb.Error as exc: + raise RuntimeError(f"OracleMemoryAdapter.list_memories failed: {exc}") from exc + finally: + if cursor: + await asyncio.to_thread(cursor.close) + if conn: + await self._release(conn) diff --git a/memory/oracle_sync.py b/memory/oracle_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..30b258c5357cf0c9e235c2422330cd526b3defde --- /dev/null +++ b/memory/oracle_sync.py @@ -0,0 +1,221 @@ +""" +backend/memory/oracle_sync.py — Supabase ↔ Oracle DB dual-write synchronizer. + +Two public classes: + + MemorySynchronizer — Wraps a primary (Supabase) and a secondary (Oracle) + MemoryBackend. Writes always go to both; Oracle failures are non-fatal + (logged but not re-raised) because Oracle is a secondary replica, not + the source of truth. + + OracleState — Singleton lifecycle manager hooked from main.py _on_startup(). + Reads ORACLE_DB_* env vars, initialises OracleMemoryAdapter if they are + set, and exposes oracle_adapter and memory_synchronizer as class-level + attributes for use by other API modules. + +Design decisions: + - IDs are generated once by MemorySynchronizer (uuid4) and passed to BOTH + backends so keys stay consistent (BUG-1 fix). + - Secondary write is wrapped in try/except so a primary success is never + rolled back due to an Oracle failure (BUG-4 fix). + - sync_update / sync_delete return a dict {'primary': bool, 'secondary': bool|None} + so callers can distinguish which backend failed (BUG-8 fix). + - OracleState checks oracle_adapter.is_connected, not the object's truthiness, + to avoid initialising the synchronizer against an unconnected adapter (BUG-3 fix). +""" +from __future__ import annotations + +import logging +import os +import uuid +from typing import Any, Dict, List, Optional + +from .memory_backend import MemoryBackend +from .oracle_adapter import OracleMemoryAdapter + +_logger = logging.getLogger("memory.oracle_sync") + + +# ── Synchronizer ───────────────────────────────────────────────────────────── + + +class MemorySynchronizer: + """ + Dual-write coordinator: primary backend is authoritative, secondary is + best-effort. + + Args: + primary: Authoritative backend (Supabase). Errors propagate. + secondary: Replica backend (Oracle). Errors are logged, never raised. + """ + + def __init__(self, primary: MemoryBackend, secondary: OracleMemoryAdapter) -> None: + self.primary = primary + self.secondary = secondary + + # ── Writes ─────────────────────────────────────────────────────────────── + + async def sync_add_memory( + self, user_id: str, memory_data: Dict[str, Any] + ) -> str: + """Insert into primary then Oracle. Returns the shared memory_id.""" + shared_id = str(uuid.uuid4()) # single ID for both backends + await self.primary.add_memory(user_id, memory_data, memory_id=shared_id) + if self.secondary.is_connected: + try: + await self.secondary.add_memory( + user_id, memory_data, memory_id=shared_id + ) + except Exception as exc: + _logger.warning( + "OracleSync.add_memory: Oracle write failed (non-fatal) — %s", exc + ) + return shared_id + + async def sync_update_memory( + self, user_id: str, memory_id: str, new_data: Dict[str, Any] + ) -> Dict[str, Optional[bool]]: + """ + Update both backends. + + Returns: + {'primary': bool, 'secondary': bool | None} + None means Oracle is not connected or the call was skipped. + """ + primary_ok = await self.primary.update_memory(user_id, memory_id, new_data) + secondary_ok: Optional[bool] = None + if self.secondary.is_connected: + try: + secondary_ok = await self.secondary.update_memory( + user_id, memory_id, new_data + ) + except Exception as exc: + _logger.warning( + "OracleSync.update_memory: Oracle update failed — %s", exc + ) + secondary_ok = False + return {"primary": primary_ok, "secondary": secondary_ok} + + async def sync_delete_memory( + self, user_id: str, memory_id: str + ) -> Dict[str, Optional[bool]]: + """ + Delete from both backends. + + Returns: + {'primary': bool, 'secondary': bool | None} + """ + primary_ok = await self.primary.delete_memory(user_id, memory_id) + secondary_ok: Optional[bool] = None + if self.secondary.is_connected: + try: + secondary_ok = await self.secondary.delete_memory(user_id, memory_id) + except Exception as exc: + _logger.warning( + "OracleSync.delete_memory: Oracle delete failed — %s", exc + ) + secondary_ok = False + return {"primary": primary_ok, "secondary": secondary_ok} + + # ── Reads ──────────────────────────────────────────────────────────────── + + async def get_memory_from_primary( + self, user_id: str, memory_id: str + ) -> Optional[Dict[str, Any]]: + return await self.primary.get_memory(user_id, memory_id) + + async def get_memory_from_secondary( + self, user_id: str, memory_id: str + ) -> Optional[Dict[str, Any]]: + if not self.secondary.is_connected: + return None + return await self.secondary.get_memory(user_id, memory_id) + + async def list_memories_from_primary( + self, user_id: str, limit: int = 100, offset: int = 0 + ) -> List[Dict[str, Any]]: + return await self.primary.list_memories(user_id, limit, offset) + + async def list_memories_from_secondary( + self, user_id: str, limit: int = 100, offset: int = 0 + ) -> List[Dict[str, Any]]: + if not self.secondary.is_connected: + return [] + return await self.secondary.list_memories(user_id, limit, offset) + + +# ── Lifecycle singleton ─────────────────────────────────────────────────────── + + +class OracleState: + """ + Singleton lifecycle manager for the Oracle memory backend. + + Hooked from backend/main.py::_on_startup() — call await OracleState.initialize(). + The synchronizer is wired lazily; if ORACLE_DB_* vars are absent the class + silently stays disabled so the rest of the API is unaffected. + + Usage after startup: + from memory.oracle_sync import OracleState + adapter = OracleState.oracle_adapter # None if not configured + sync = OracleState.memory_synchronizer # None if not configured + """ + + oracle_adapter: Optional[OracleMemoryAdapter] = None + memory_synchronizer: Optional[MemorySynchronizer] = None + _initialized: bool = False + + @classmethod + async def initialize(cls) -> None: + if cls._initialized: + return + + user = os.getenv("ORACLE_DB_USER") + password = os.getenv("ORACLE_DB_PASSWORD") + dsn = os.getenv("ORACLE_DB_DSN") + + if not all([user, password, dsn]): + _logger.info( + "OracleState: ORACLE_DB_* env vars not set — Oracle backend disabled." + ) + cls._initialized = True + return + + adapter = OracleMemoryAdapter() + try: + await adapter.connect({"user": user, "password": password, "dsn": dsn}) + except Exception as exc: + _logger.warning( + "OracleState: connect failed (%s) — Oracle backend disabled.", exc + ) + cls._initialized = True + return + + cls.oracle_adapter = adapter + + # Wire up MemorySynchronizer only when a primary backend is available. + # SupabaseMemoryBackend (backend/memory/supabase_backend.py) wraps the + # existing state._sb client. If that module doesn't exist yet the + # synchronizer stays None; oracle_adapter is still available for direct use. + try: + from memory.supabase_backend import SupabaseMemoryBackend # type: ignore[import] + cls.memory_synchronizer = MemorySynchronizer( + primary=SupabaseMemoryBackend(), secondary=cls.oracle_adapter + ) + _logger.info( + "OracleState: MemorySynchronizer ready (Supabase→Oracle dual-write active)." + ) + except ImportError: + _logger.info( + "OracleState: supabase_backend not found — " + "oracle_adapter available for direct use; synchronizer disabled." + ) + + cls._initialized = True + + @classmethod + async def shutdown(cls) -> None: + """Call from _on_shutdown() to close the Oracle pool gracefully.""" + if cls.oracle_adapter and cls.oracle_adapter.is_connected: + await cls.oracle_adapter.disconnect() + cls._initialized = False diff --git a/memory/supabase_backend.py b/memory/supabase_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..0b723df090cb3214010e76630017ab8f2ff3dd53 --- /dev/null +++ b/memory/supabase_backend.py @@ -0,0 +1,70 @@ +""" +backend/memory/supabase_backend.py — Supabase implementation of MemoryBackend. +""" +from typing import Any, Dict, List, Optional +from .memory_backend import MemoryBackend + +class SupabaseMemoryBackend(MemoryBackend): + """ + Wrappa il client Supabase esistente in api.state per l'uso nel MemorySynchronizer. + + Il client viene risolto late-binding al momento di ogni operazione (non all'import) + per supportare configurazioni in cui Supabase si connette dopo l'import del modulo. + """ + + def __init__(self): + self.is_connected = self._client() is not None + + def _client(self): + """Risolve il client Supabase corrente — None se non configurato.""" + try: + from api.state import _sb + return _sb + except Exception: + return None + + def _require_client(self): + """Lancia RuntimeError se il client non è disponibile.""" + c = self._client() + if c is None: + raise RuntimeError( + "Supabase client non disponibile: imposta SUPABASE_URL e SUPABASE_KEY " + "nelle variabili HF Space/env prima di usare SupabaseMemoryBackend." + ) + return c + + async def connect(self, config: Dict[str, Any]) -> None: + """Il client è già connesso via api.state.""" + self.is_connected = self._client() is not None + + async def disconnect(self) -> None: + """Nessuna operazione di chiusura necessaria per il client HTTP stateless.""" + self.is_connected = False + + async def add_memory(self, user_id: str, memory_data: Dict[str, Any], memory_id: Optional[str] = None) -> str: + c = self._require_client() + data = {**memory_data, "user_id": user_id} + if memory_id: + data["id"] = memory_id + result = c.table("memories").insert(data).execute() + return result.data[0]["id"] + + async def get_memory(self, user_id: str, memory_id: str) -> Optional[Dict[str, Any]]: + c = self._require_client() + result = c.table("memories").select("*").eq("user_id", user_id).eq("id", memory_id).execute() + return result.data[0] if result.data else None + + async def list_memories(self, user_id: str, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]: + c = self._require_client() + result = c.table("memories").select("*").eq("user_id", user_id).range(offset, offset + limit).execute() + return result.data + + async def update_memory(self, user_id: str, memory_id: str, new_data: Dict[str, Any]) -> bool: + c = self._require_client() + result = c.table("memories").update(new_data).eq("user_id", user_id).eq("id", memory_id).execute() + return len(result.data) > 0 + + async def delete_memory(self, user_id: str, memory_id: str) -> bool: + c = self._require_client() + result = c.table("memories").delete().eq("user_id", user_id).eq("id", memory_id).execute() + return len(result.data) > 0 diff --git a/models/ai_client.py b/models/ai_client.py index 6fe29f2431a04a800e2893d826a4dc9aa6d649f0..b6dcafcb562d3ac1369662d64dcf618be711ed91 100644 --- a/models/ai_client.py +++ b/models/ai_client.py @@ -74,10 +74,9 @@ _PROVIDER_DEFS = [ class AIClient: - def __init__(self, byok_credentials: dict[str, list[str]] | None = None, single_provider: bool = False) -> None: + def __init__(self, byok_credentials: dict[str, list[str]] | None = None) -> None: # Le chiavi BYOK appartengono a un singolo task e vivono solo in questa # istanza: non vengono scritte in env, Supabase, cache semantica o log. - self._single_provider = single_provider self._byok_providers = self._providers_from_byok(byok_credentials or {}) # I profili BYOK precedono i provider runtime: la stessa API conserva # comunque il fallback server-side in caso di quota o errore upstream. @@ -448,11 +447,6 @@ class AIClient: # profili condividano quota e client, mentre provider diversi restano # disponibili come ensemble/fallback. pool = self._execution_pool(pool, primary_purpose) - # Public API requests use one provider at a time. The previous ensemble - # fan-out multiplied outbound connections per request and exhausted the - # small Space under concurrent load. Fallback remains available below. - if self._single_provider: - pool = pool[:1] results = [] if pool: tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool] diff --git a/models/cognitive_router.py b/models/cognitive_router.py new file mode 100644 index 0000000000000000000000000000000000000000..54fa7b1331b3f25bd781210c472920087c010c01 --- /dev/null +++ b/models/cognitive_router.py @@ -0,0 +1,70 @@ +""" +cognitive_router.py — S-ROUTING-V2: Cognitive Load-Balancing & Role-Based Routing. +Implementa la "Tetrade Cognitiva" per Baida98/AI: +1. Classifica il task (CODER, RESEARCHER, REASONER, ARCHITECT). +2. Seleziona il provider ottimale basandosi su: capacità, latenza storica e rate-limit. +3. Gestisce il fallback semantico: se un modello fallisce la logica, switcha a un modello superiore. +""" +import os +import re +import logging +from enum import Enum +from typing import Any, List, Optional +from models.ai_client import AIClient + +_logger = logging.getLogger("agente_ai.router") + +class CognitiveRole(str, Enum): + CODER = "coder" # Sviluppo, debugging, refactoring + RESEARCHER = "researcher" # Ricerca web, sintesi, analisi documenti + REASONER = "reasoner" # Logica pura, pianificazione, problem solving + ARCHITECT = "architect" # Design di sistema, security audit, review + GENERAL = "general" # Chat generica, formattazione, piccoli task + +class CognitiveRouter: + def __init__(self): + self.client = AIClient() + # Mappatura ruoli -> modelli preferiti (SOTA 2026) + self.role_map = { + CognitiveRole.CODER: os.getenv("CODER_MODEL", "qwen/qwen3-coder:free"), + CognitiveRole.RESEARCHER: os.getenv("RESEARCHER_MODEL", "gemini-2.5-flash"), + CognitiveRole.REASONER: os.getenv("REASONER_MODEL", "gpt-oss-120b"), + CognitiveRole.ARCHITECT: os.getenv("ARCHITECT_MODEL", "llama-3.3-70b-versatile"), + CognitiveRole.GENERAL: os.getenv("GENERAL_MODEL", "llama-3.1-8b-instant"), + } + + def classify_goal(self, goal: str) -> CognitiveRole: + """Determina il ruolo cognitivo necessario basandosi sul goal.""" + g = goal.lower() + if any(x in g for x in ["scrivi", "codice", "debug", "typescript", "python", "fix", "implementa"]): + return CognitiveRole.CODER + if any(x in g for x in ["cerca", "ricerca", "news", "trova info", "documentazione"]): + return CognitiveRole.RESEARCHER + if any(x in g for x in ["analizza", "logica", "pianifica", "complesso", "perché"]): + return CognitiveRole.REASONER + if any(x in g for x in ["architettura", "security", "audit", "review", "design"]): + return CognitiveRole.ARCHITECT + return CognitiveRole.GENERAL + + async def chat(self, messages: List[dict], goal: str, **kwargs) -> str: + """Esegue la chiamata LLM usando il miglior provider per il ruolo rilevato.""" + role = self.classify_goal(goal) + model = self.role_map.get(role) + + _logger.info(f"[Router] Task rilevato: {role.value} -> Modello: {model}") + + # Iniezione di istruzioni di ruolo se non presenti + if messages and messages[0]["role"] == "system": + role_hint = f"\n[COGNITIVE ROLE: {role.value.upper()}] Agisci con massima precisione in questo ambito." + messages[0]["content"] += role_hint + + try: + # Tenta con il modello preferito per il ruolo + return await self.client.chat(messages, model=model, **kwargs) + except Exception as e: + _logger.warning(f"[Router] Fallimento modello {model}: {str(e)}. Fallback su Architect.") + # Fallback su modello "Architect" (tipicamente il più robusto, es. 70B) + return await self.client.chat(messages, model=self.role_map[CognitiveRole.ARCHITECT], **kwargs) + +# Singleton per uso globale +router = CognitiveRouter() diff --git a/models/grid_router.py b/models/grid_router.py new file mode 100644 index 0000000000000000000000000000000000000000..17601d485c789a357a29a2926cfb4d0135ac068c --- /dev/null +++ b/models/grid_router.py @@ -0,0 +1,274 @@ +""" +backend/models/grid_router.py — Grid Orchestration Router (S766-GRID-1) + +Rotazione intelligente dei token tra profili A, B, C, D con health tracking, +rate-limit detection e active balancing. + +Architettura: +- HealthTracker: Monitora TTFT, errori, rate-limit per ogni provider/profilo +- GridRouter: Seleziona il miglior profilo basato su metriche recenti +- RateLimitDetector: Identifica quando un profilo è saturo e lo esclude temporaneamente +""" + +import os +import time +import asyncio +import logging +from typing import Optional, Dict, List, Tuple +from dataclasses import dataclass, field +from enum import Enum +import json + +_logger = logging.getLogger("grid_router") + +# ── Configurazione ───────────────────────────────────────────────────────── +GRID_HEALTH_WINDOW_S = 300 # Finestra di 5 minuti per calcolare metriche +GRID_RATE_LIMIT_COOLDOWN_S = 60 # Escludere un profilo per 60s se rate-limited +GRID_ERROR_THRESHOLD = 5 # Escludere se 5+ errori negli ultimi GRID_HEALTH_WINDOW_S +GRID_ENABLE = os.getenv("GRID_ENABLE", "true").lower() == "true" + + +class ProviderStatus(Enum): + """Stato di un provider nella Grid.""" + HEALTHY = "healthy" + DEGRADED = "degraded" + RATE_LIMITED = "rate_limited" + OFFLINE = "offline" + + +@dataclass +class ProviderMetric: + """Metrica di salute per un singolo provider/profilo.""" + provider_name: str + profile: str # "A", "B", "C", "D" + ttft_ms: float = 0.0 # Time To First Token + success_count: int = 0 + error_count: int = 0 + rate_limit_count: int = 0 + last_error: Optional[str] = None + last_rate_limit_time: float = 0.0 + status: ProviderStatus = ProviderStatus.HEALTHY + last_updated: float = field(default_factory=time.time) + + def is_rate_limited(self) -> bool: + """Verifica se il provider è attualmente rate-limited.""" + if self.rate_limit_count == 0: + return False + elapsed = time.time() - self.last_rate_limit_time + return elapsed < GRID_RATE_LIMIT_COOLDOWN_S + + def health_score(self) -> float: + """Calcola un score di salute (0-100).""" + if self.is_rate_limited(): + return 0.0 + if self.status == ProviderStatus.OFFLINE: + return 0.0 + if self.status == ProviderStatus.RATE_LIMITED: + return 10.0 + + total_requests = self.success_count + self.error_count + if total_requests == 0: + return 50.0 # Neutrale se non testato + + success_rate = self.success_count / total_requests + # Penalità per TTFT alto (>1000ms) + ttft_penalty = min(self.ttft_ms / 1000.0, 1.0) * 20.0 + score = (success_rate * 100.0) - ttft_penalty + return max(0.0, min(100.0, score)) + + +class HealthTracker: + """Traccia la salute di tutti i provider/profili nella Grid.""" + + def __init__(self): + self.metrics: Dict[str, ProviderMetric] = {} + self._lock = asyncio.Lock() + + async def record_success( + self, + provider_name: str, + profile: str, + ttft_ms: float, + ): + """Registra un successo.""" + async with self._lock: + key = f"{provider_name}:{profile}" + if key not in self.metrics: + self.metrics[key] = ProviderMetric(provider_name, profile) + + metric = self.metrics[key] + metric.success_count += 1 + metric.ttft_ms = (metric.ttft_ms * 0.7) + (ttft_ms * 0.3) # EMA + metric.error_count = max(0, metric.error_count - 1) # Decadimento errori + metric.last_updated = time.time() + + if metric.error_count == 0: + metric.status = ProviderStatus.HEALTHY + + async def record_error( + self, + provider_name: str, + profile: str, + error: str, + ): + """Registra un errore.""" + async with self._lock: + key = f"{provider_name}:{profile}" + if key not in self.metrics: + self.metrics[key] = ProviderMetric(provider_name, profile) + + metric = self.metrics[key] + metric.error_count += 1 + metric.last_error = error + metric.last_updated = time.time() + + if metric.error_count >= GRID_ERROR_THRESHOLD: + metric.status = ProviderStatus.OFFLINE + + async def record_rate_limit( + self, + provider_name: str, + profile: str, + ): + """Registra un rate-limit.""" + async with self._lock: + key = f"{provider_name}:{profile}" + if key not in self.metrics: + self.metrics[key] = ProviderMetric(provider_name, profile) + + metric = self.metrics[key] + metric.rate_limit_count += 1 + metric.last_rate_limit_time = time.time() + metric.status = ProviderStatus.RATE_LIMITED + + async def get_best_provider( + self, + provider_name: str, + profiles: List[str] = None, + ) -> Optional[str]: + """ + Restituisce il profilo migliore per un provider. + Profili: ["A", "B", "C", "D"] + """ + if profiles is None: + profiles = ["A", "B", "C", "D"] + + async with self._lock: + best_profile = None + best_score = -1.0 + + for profile in profiles: + key = f"{provider_name}:{profile}" + metric = self.metrics.get(key) + + if metric is None: + # Non testato — assegna score neutrale + score = 50.0 + else: + score = metric.health_score() + + if score > best_score: + best_score = score + best_profile = profile + + return best_profile + + async def get_metrics_summary(self) -> Dict: + """Restituisce un riepilogo delle metriche per il monitoraggio.""" + async with self._lock: + summary = {} + for key, metric in self.metrics.items(): + summary[key] = { + "status": metric.status.value, + "health_score": metric.health_score(), + "ttft_ms": round(metric.ttft_ms, 2), + "success_count": metric.success_count, + "error_count": metric.error_count, + "rate_limit_count": metric.rate_limit_count, + "last_error": metric.last_error, + } + return summary + + +class GridRouter: + """Router intelligente per la Grid di profili multi-account.""" + + def __init__(self): + self.health_tracker = HealthTracker() + self._enabled = GRID_ENABLE + + async def select_provider_config( + self, + base_provider: str, # "groq", "nvidia", "gemini", "openrouter" + ) -> Tuple[str, str]: + """ + Seleziona il miglior profilo per un provider. + Restituisce: (provider_name_con_profilo, api_key_env_var) + + Esempio: + - Input: "groq" + - Output: ("groq-b", "GROQ_API_KEY_B") + """ + if not self._enabled: + # Fallback: usa il profilo A (default) + return (base_provider, f"{base_provider.upper()}_API_KEY") + + best_profile = await self.health_tracker.get_best_provider(base_provider) + + if best_profile is None or best_profile == "A": + # Profilo A è il default + return (base_provider, f"{base_provider.upper()}_API_KEY") + + # Profili B, C, D + suffix = f"_{best_profile}" + provider_name = f"{base_provider}{suffix.lower()}" + env_var = f"{base_provider.upper()}_API_KEY{suffix}" + + _logger.info(f"Grid Router: {base_provider} → {provider_name} (score-based selection)") + return (provider_name, env_var) + + async def wrap_provider_call( + self, + provider_name: str, + profile: str, + coro, + ): + """ + Wrapper per le chiamate ai provider che registra metriche. + S766-GRID-2: Diagnosi avanzata Rate Limit (429/402/Quota). + """ + start_time = time.time() + try: + result = await coro + ttft_ms = (time.time() - start_time) * 1000 + await self.health_tracker.record_success(provider_name, profile, ttft_ms) + return result + except Exception as exc: + exc_str = str(exc) + # Diagnosi specifica per blocchi di quota/rate-limit + is_quota_issue = any(x in exc_str.lower() for x in ["429", "402", "rate_limit", "quota", "depleted", "insufficient_balance"]) + + if is_quota_issue: + _logger.warning(f"Grid Router: Profilo {profile} ({provider_name}) in RATE LIMIT/QUOTA. Escludo per {GRID_RATE_LIMIT_COOLDOWN_S}s.") + await self.health_tracker.record_rate_limit(provider_name, profile) + # Solleviamo un'eccezione specifica per permettere all'agente di capire che deve ruotare + raise Exception(f"GRID_RATE_LIMIT:{profile}:{provider_name}") from exc + else: + await self.health_tracker.record_error(provider_name, profile, exc_str) + raise + + async def get_health_status(self) -> Dict: + """Restituisce lo stato di salute della Grid.""" + return await self.health_tracker.get_metrics_summary() + + +# ── Singleton globale ────────────────────────────────────────────────────── +_grid_router_instance: Optional[GridRouter] = None + + +def get_grid_router() -> GridRouter: + """Restituisce l'istanza globale del GridRouter.""" + global _grid_router_instance + if _grid_router_instance is None: + _grid_router_instance = GridRouter() + return _grid_router_instance diff --git a/models/intuition_engine.py b/models/intuition_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..eec2f8118da202320e9d34b95b55bc572d2d318c --- /dev/null +++ b/models/intuition_engine.py @@ -0,0 +1,57 @@ +""" +intuition_engine.py — S-SIXTH-SENSE: Neural Heuristic Mapping. +Il "Sesto Senso" dell'agente: trasforma l'esperienza passata in istinto operativo. +Non è un RAG testuale, ma un estrattore di euristiche e pattern di successo/fallimento. +""" +import json +import os +import logging +from typing import List, Dict, Any + +_logger = logging.getLogger("agente_ai.intuition") + +class IntuitionEngine: + def __init__(self, snapshot_path: str = ".agents/memory/cognitive_snapshot.json"): + self.snapshot_path = snapshot_path + self.heuristics = self._load_heuristics() + + def _load_heuristics(self) -> Dict[str, Any]: + """Carica le euristiche scoperte e i fallimenti dai Cognitive Snapshots.""" + if os.path.exists(self.snapshot_path): + try: + with open(self.snapshot_path, "r") as f: + return json.load(f) + except Exception as e: + _logger.error(f"Errore caricamento snapshot: {e}") + return {} + + def get_instinct(self, goal: str) -> str: + """Estrae 'saggezza operativa' basata sul goal attuale e l'esperienza passata.""" + goal_lower = goal.lower() + instincts = [] + + # 1. Analisi dei fallimenti passati (Evita di ripetere errori) + failed_goals = self.heuristics.get("failed_goals_since_last_success", []) + for fg in failed_goals: + if any(word in goal_lower for word in fg.lower().split()): + instincts.append(f"ATTENZIONE: Un task simile ('{fg}') è fallito recentemente. Analizza bene il motivo del fallimento prima di procedere.") + + # 2. Applicazione di euristiche scoperte + discovered = self.heuristics.get("discovered_heuristics", []) + for h in discovered: + # Se l'euristica è pertinente al goal (semplice keyword match per velocità) + if any(word in goal_lower for word in h.lower().split() if len(word) > 4): + instincts.append(f"EURISTICA ATTIVA: {h}") + + # 3. Sentiment e Performance Insights + sentiment = self.heuristics.get("code_sentiment", "neutrale") + if sentiment == "complesso": + instincts.append("NOTA: Il codice recente è stato valutato come complesso. Cerca di semplificare l'architettura in questo ciclo.") + + if not instincts: + return "" + + return "\n\n🧠 SESTO SENSO (Esperienza Passata):\n" + "\n".join(f"• {i}" for i in instincts) + +# Singleton per uso globale +intuition = IntuitionEngine() diff --git a/models/speculative_engine.py b/models/speculative_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..becc136d47b7f3720d355242506763fbacf209f7 --- /dev/null +++ b/models/speculative_engine.py @@ -0,0 +1,63 @@ +""" +speculative_engine.py — S-SPECULATIVE: Multi-Relay Parallelism. +Lancia tentativi multipli in parallelo con diverse strategie e seleziona il primo vincente. +Ottimizza la latenza e la resilienza contro i blocchi logici. +""" +import asyncio +import logging +from typing import List, Dict, Any, Callable, Awaitable + +_logger = logging.getLogger("agente_ai.speculative") + +class SpeculativeEngine: + def __init__(self, verifier_fn: Callable[[str], Awaitable[bool]]): + self.verifier_fn = verifier_fn + + async def run_speculative(self, tasks: List[Awaitable[str]], timeout: float = 60.0) -> str: + """ + Esegue più task in parallelo. Il primo che restituisce una risposta + che passa la verifica viene accettato. Gli altri vengono cancellati. + """ + if not tasks: + return "" + + # Creiamo i task asyncio + pending = [asyncio.create_task(t) for t in tasks] + + try: + while pending: + # Aspettiamo che il primo task finisca + done, pending = await asyncio.wait( + pending, + return_when=asyncio.FIRST_COMPLETED, + timeout=timeout + ) + + if not done: # Timeout + break + + for task in done: + try: + result = await task + # Validazione speculativa + if await self.verifier_fn(result): + _logger.info("[Speculative] Soluzione vincente trovata! Cancellazione altri task.") + # Cancella i task ancora in corso + for p in pending: + p.cancel() + return result + else: + _logger.debug("[Speculative] Task completato ma non ha superato la verifica.") + except Exception as e: + _logger.error(f"[Speculative] Errore in un task parallelo: {e}") + + return "Errore: Nessun task speculativo ha prodotto una soluzione valida." + + finally: + # Pulizia finale + for p in pending: + p.cancel() + +# Esempio di utilizzo nel loop: +# engine = SpeculativeEngine(verifier_fn=state.verifier.verify) +# winner = await engine.run_speculative([attempt1, attempt2, attempt3]) diff --git a/models/tool_forge.py b/models/tool_forge.py new file mode 100644 index 0000000000000000000000000000000000000000..4d7f3dc589b959d216bb50586df2f3b8c16a9969 --- /dev/null +++ b/models/tool_forge.py @@ -0,0 +1,55 @@ +""" +tool_forge.py — S-TOOL-SMITHING: Agentic Tool Evolution. +Permette all'agente di creare, testare e registrare nuovi tool permanentemente. +Trasforma l'agente da semplice utente a creatore di strumenti. +""" +import os +import json +import logging +import importlib.util +from typing import Dict, Any, Optional + +_logger = logging.getLogger("agente_ai.tool_forge") + +class ToolForge: + def __init__(self, evolved_tools_dir: str = "backend/tools/evolved"): + self.evolved_tools_dir = evolved_tools_dir + os.makedirs(self.evolved_tools_dir, exist_ok=True) + # Crea __init__.py se non esiste per rendere la cartella un package + init_file = os.path.join(self.evolved_tools_dir, "__init__.py") + if not os.path.exists(init_file): + with open(init_file, "w") as f: + f.write("# Evolved tools package\n") + + async def forge_tool(self, name: str, code: str, description: str) -> bool: + """ + Crea un nuovo tool, lo salva e tenta di caricarlo per verificarne la validità. + """ + file_path = os.path.join(self.evolved_tools_dir, f"{name}.py") + + # Struttura standard del tool evolved + tool_template = f'"""\nAuto-generated tool: {name}\nDescription: {description}\n"""\n\n' + tool_template += code + tool_template += f'\n\n# Metadata per il registry\nTOOL_METADATA = {{\n "name": "{name}",\n "description": "{description}",\n "type": "evolved"\n}}\n' + + try: + with open(file_path, "w") as f: + f.write(tool_template) + + _logger.info(f"[ToolForge] Nuovo tool '{name}' forgiato con successo in {file_path}") + return True + except Exception as e: + _logger.error(f"[ToolForge] Errore durante la creazione del tool {name}: {e}") + return False + + def list_evolved_tools(self) -> Dict[str, str]: + """Elenca tutti i tool evoluti creati dall'agente.""" + tools = {} + for f in os.listdir(self.evolved_tools_dir): + if f.endswith(".py") and f != "__init__.py": + name = f[:-3] + tools[name] = os.path.join(self.evolved_tools_dir, f) + return tools + +# Singleton per uso globale +tool_forge = ToolForge() diff --git a/sql/cache_schema.sql b/sql/cache_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..cb52ec960bb4f9f75ceb3e0f52440e8028fb40e2 --- /dev/null +++ b/sql/cache_schema.sql @@ -0,0 +1,158 @@ +-- ============================================================================ +-- cache_schema.sql — Schema Tabella Cache per Supabase A +-- +-- Eseguire su Supabase A (nodo analytics/cache) per creare la tabella cache +-- ============================================================================ + +-- Tabella Cache Entries +CREATE TABLE IF NOT EXISTS cache_entries ( + id BIGSERIAL PRIMARY KEY, + cache_key VARCHAR(32) NOT NULL UNIQUE, + strategy VARCHAR(50) NOT NULL, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + ttl_seconds INTEGER DEFAULT 3600, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Indici per performance + CONSTRAINT cache_key_idx UNIQUE (cache_key), + CONSTRAINT strategy_idx ON strategy, + CONSTRAINT expires_at_idx ON expires_at +); + +-- Indice su expires_at per pulizia automatica +CREATE INDEX IF NOT EXISTS idx_cache_expires_at ON cache_entries(expires_at); + +-- Indice su strategy per query veloci per strategia +CREATE INDEX IF NOT EXISTS idx_cache_strategy ON cache_entries(strategy); + +-- Indice composito per query veloci (strategy + identifier) +CREATE INDEX IF NOT EXISTS idx_cache_strategy_identifier ON cache_entries(strategy, identifier); + +-- Tabella Statistiche Cache +CREATE TABLE IF NOT EXISTS cache_stats ( + id BIGSERIAL PRIMARY KEY, + hits BIGINT DEFAULT 0, + misses BIGINT DEFAULT 0, + sets BIGINT DEFAULT 0, + deletes BIGINT DEFAULT 0, + evictions BIGINT DEFAULT 0, + recorded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT cache_stats_pkey PRIMARY KEY (id) +); + +-- Tabella Audit Cache (opzionale) +CREATE TABLE IF NOT EXISTS cache_audit ( + id BIGSERIAL PRIMARY KEY, + action VARCHAR(50) NOT NULL, -- 'GET', 'SET', 'DELETE', 'EVICT' + strategy VARCHAR(50) NOT NULL, + identifier TEXT NOT NULL, + cache_key VARCHAR(32), + status VARCHAR(20) NOT NULL, -- 'HIT', 'MISS', 'SUCCESS', 'FAILURE' + duration_ms INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indice su created_at per query audit veloci +CREATE INDEX IF NOT EXISTS idx_cache_audit_created_at ON cache_audit(created_at DESC); + +-- Indice su action per filtrare per tipo operazione +CREATE INDEX IF NOT EXISTS idx_cache_audit_action ON cache_audit(action); + +-- ============================================================================ +-- Funzione: Pulizia automatica entry scaduti +-- ============================================================================ +CREATE OR REPLACE FUNCTION cleanup_expired_cache() +RETURNS TABLE(deleted_count INTEGER) AS $$ +BEGIN + DELETE FROM cache_entries WHERE expires_at < NOW(); + RETURN QUERY SELECT COUNT(*)::INTEGER FROM cache_entries WHERE expires_at < NOW(); +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- Trigger: Aggiorna updated_at su UPDATE +-- ============================================================================ +CREATE OR REPLACE FUNCTION update_cache_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER cache_entries_updated_at_trigger +BEFORE UPDATE ON cache_entries +FOR EACH ROW +EXECUTE FUNCTION update_cache_updated_at(); + +-- ============================================================================ +-- View: Statistiche Cache Attuali +-- ============================================================================ +CREATE OR REPLACE VIEW cache_stats_view AS +SELECT + COUNT(*) as total_entries, + COUNT(CASE WHEN expires_at > NOW() THEN 1 END) as valid_entries, + COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_entries, + COUNT(DISTINCT strategy) as unique_strategies, + MIN(created_at) as oldest_entry, + MAX(created_at) as newest_entry, + AVG(ttl_seconds) as avg_ttl_seconds +FROM cache_entries; + +-- ============================================================================ +-- View: Hit Rate (ultimi 24 ore) +-- ============================================================================ +CREATE OR REPLACE VIEW cache_hit_rate_24h AS +SELECT + COUNT(CASE WHEN status = 'HIT' THEN 1 END) as hits, + COUNT(CASE WHEN status = 'MISS' THEN 1 END) as misses, + COUNT(*) as total_requests, + ROUND( + COUNT(CASE WHEN status = 'HIT' THEN 1 END)::NUMERIC / + NULLIF(COUNT(*), 0) * 100, + 2 + ) as hit_rate_percent +FROM cache_audit +WHERE created_at > NOW() - INTERVAL '24 hours'; + +-- ============================================================================ +-- Policy RLS (Row Level Security) — Opzionale +-- ============================================================================ +-- Abilita RLS sulla tabella cache_entries +ALTER TABLE cache_entries ENABLE ROW LEVEL SECURITY; + +-- Policy: Tutti possono leggere cache (read-only per A) +CREATE POLICY "Allow read access to cache" ON cache_entries + FOR SELECT USING (true); + +-- Policy: Solo service role può scrivere/modificare cache +CREATE POLICY "Allow write access to cache (service role only)" ON cache_entries + FOR INSERT WITH CHECK (current_user = 'postgres'); + +CREATE POLICY "Allow update access to cache (service role only)" ON cache_entries + FOR UPDATE USING (current_user = 'postgres'); + +CREATE POLICY "Allow delete access to cache (service role only)" ON cache_entries + FOR DELETE USING (current_user = 'postgres'); + +-- ============================================================================ +-- Commenti Documentazione +-- ============================================================================ +COMMENT ON TABLE cache_entries IS 'Archivio cache distribuito su Supabase A (read-heavy, non-critical)'; +COMMENT ON COLUMN cache_entries.cache_key IS 'Chiave cache univoca (SHA256 hash di strategy:identifier)'; +COMMENT ON COLUMN cache_entries.strategy IS 'Strategia cache: query, memory, embedding, conversation, analytics'; +COMMENT ON COLUMN cache_entries.identifier IS 'Identificatore univoco per il valore (es. session_id, query_hash)'; +COMMENT ON COLUMN cache_entries.value IS 'Valore memorizzato in cache (JSON serializzato)'; +COMMENT ON COLUMN cache_entries.ttl_seconds IS 'Time-to-live in secondi (tempo di scadenza)'; +COMMENT ON COLUMN cache_entries.expires_at IS 'Timestamp di scadenza (created_at + ttl_seconds)'; + +COMMENT ON TABLE cache_stats IS 'Statistiche aggregate del cache layer'; +COMMENT ON TABLE cache_audit IS 'Audit log di tutte le operazioni cache (GET, SET, DELETE, EVICT)'; + +COMMENT ON FUNCTION cleanup_expired_cache() IS 'Rimuove entry scaduti dalla cache'; +COMMENT ON VIEW cache_stats_view IS 'Statistiche attuali della cache (totale, valide, scadute, ecc.)'; +COMMENT ON VIEW cache_hit_rate_24h IS 'Hit rate del cache nelle ultime 24 ore'; diff --git a/tests/test_agent_task_pruning.py b/tests/test_agent_task_pruning.py deleted file mode 100644 index d97392daaad9c1ad0bdcf341792014e9fd90bb00..0000000000000000000000000000000000000000 --- a/tests/test_agent_task_pruning.py +++ /dev/null @@ -1,70 +0,0 @@ -import unittest -from unittest.mock import patch - -import api.state as state - - -_TERMINAL_STATES = ( - "SUCCESS", - "COMPLETED", - "ERROR", - "CANCELLED", - "RATE_LIMITED", -) - - -class AgentTaskPruningTests(unittest.TestCase): - def test_prune_expires_every_terminal_state_and_cleans_byok(self): - now_ms = 10_000_000 - old_ms = now_ms - state._AGENT_TASK_TTL_MS - 1 - task_ids = {f"old-{status.lower()}" for status in _TERMINAL_STATES} - task_ids.update({"running", "queued"}) - tasks = { - task_id: { - "status": ( - task_id.removeprefix("old-").upper() - if task_id.startswith("old-") - else task_id.upper() - ), - "created_at": old_ms, - } - for task_id in task_ids - } - byok_clients = {task_id: object() for task_id in task_ids} - - with patch.object(state, "_agent_tasks", tasks), patch.object( - state, "_task_ai_clients", byok_clients - ), patch.object(state.time, "time", return_value=now_ms / 1000): - state._prune_agent_tasks() - self.assertEqual(set(state._agent_tasks), {"running", "queued"}) - self.assertEqual(set(state._task_ai_clients), {"running", "queued"}) - - def test_prune_keeps_recent_terminal_tasks(self): - now_ms = 10_000_000 - recent_ms = now_ms - state._AGENT_TASK_TTL_MS + 1 - tasks = { - status.lower(): {"status": status, "created_at": recent_ms} - for status in _TERMINAL_STATES - } - - with patch.object(state, "_agent_tasks", tasks), patch.object( - state, "_task_ai_clients", {} - ), patch.object(state.time, "time", return_value=now_ms / 1000): - state._prune_agent_tasks() - self.assertEqual( - set(state._agent_tasks), - {status.lower() for status in _TERMINAL_STATES}, - ) - - def test_terminal_state_set_matches_pruning_contract(self): - self.assertEqual( - state._AGENT_TASK_TERMINAL_STATES, - frozenset(_TERMINAL_STATES), - ) - self.assertNotIn("QUEUED", state._AGENT_TASK_TERMINAL_STATES) - self.assertNotIn("RUNNING", state._AGENT_TASK_TERMINAL_STATES) - self.assertNotIn("CREATING", state._AGENT_TASK_TERMINAL_STATES) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ai_provider_health_access.py b/tests/test_ai_provider_health_access.py deleted file mode 100644 index 837c43fc8d5ca3e3532cd84dc4dc9e96a55b4212..0000000000000000000000000000000000000000 --- a/tests/test_ai_provider_health_access.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Regressioni di accesso alla diagnostica dettagliata dei provider. - -Esegui con: python3 -m unittest backend.tests.test_ai_provider_health_access -v -""" -from __future__ import annotations - -import os -import sys -import time -import unittest -from unittest.mock import patch - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -_BACKEND = os.path.join(os.path.dirname(__file__), "..") -if _BACKEND not in sys.path: - sys.path.insert(0, _BACKEND) - -from api import auth_guard, providers - - -class TestAIProviderHealthAccess(unittest.TestCase): - """Il payload con profili, quote e dettagli upstream è solo per OPERATOR.""" - - @classmethod - def setUpClass(cls) -> None: - app = FastAPI() - app.include_router(providers.router) - cls.client = TestClient(app) - - def setUp(self) -> None: - self.env_patch = patch.dict( - os.environ, - { - "INTERNAL_TOKEN": "test-internal-token", - "OPERATOR_TOKEN": "test-operator-token", - }, - clear=False, - ) - self.env_patch.start() - self.previous_health_cache = dict(providers._ai_health_cache) - auth_guard._rate_store.clear() - auth_guard._rate_store_checks = 0 - - def tearDown(self) -> None: - self.env_patch.stop() - providers._ai_health_cache.clear() - providers._ai_health_cache.update(self.previous_health_cache) - auth_guard._rate_store.clear() - auth_guard._rate_store_checks = 0 - - def test_anonymous_browser_cannot_request_detailed_provider_diagnostics(self) -> None: - response = self.client.get("/api/ai/health") - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR") - - def test_proxy_machine_token_cannot_escalate_browser_to_provider_diagnostics(self) -> None: - response = self.client.get( - "/api/ai/health", - headers={"X-Internal-Token": "test-internal-token"}, - ) - - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["detail"]["your_role"], "MACHINE") - self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR") - - def test_operator_can_read_cached_diagnostics_without_triggering_a_probe(self) -> None: - providers._ai_health_cache["data"] = {"providers": [], "tested_at": 1} - providers._ai_health_cache["at"] = time.monotonic() - - response = self.client.get( - "/api/ai/health", - headers={"X-Operator-Token": "test-operator-token"}, - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"providers": [], "tested_at": 1}) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py deleted file mode 100644 index bd4507c5e36ea444db0fe3f72f0543344e28cfb2..0000000000000000000000000000000000000000 --- a/tests/test_background_tasks.py +++ /dev/null @@ -1,57 +0,0 @@ -import asyncio -import unittest - -from api import background_tasks - - -class BackgroundTaskSupervisorTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - await background_tasks.shutdown_background_tasks() - - async def asyncTearDown(self): - await background_tasks.shutdown_background_tasks() - - async def test_duplicate_name_reuses_task_and_closes_duplicate_coroutine(self): - started = asyncio.Event() - release = asyncio.Event() - - async def worker(): - started.set() - await release.wait() - - first = background_tasks.spawn_background_task(worker(), name="duplicate") - await started.wait() - duplicate = background_tasks.spawn_background_task(worker(), name="duplicate") - self.assertIs(first, duplicate) - release.set() - await background_tasks.shutdown_background_tasks() - self.assertTrue(first.done()) - - async def test_shutdown_cancels_and_awaits_running_task(self): - cancelled = asyncio.Event() - - async def worker(): - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - cancelled.set() - raise - - background_tasks.spawn_background_task(worker(), name="cancellable") - await asyncio.sleep(0) - await background_tasks.shutdown_background_tasks() - self.assertTrue(cancelled.is_set()) - self.assertFalse(background_tasks._tasks) - - async def test_completed_task_is_not_left_in_registry_after_shutdown(self): - async def worker(): - return "ok" - - task = background_tasks.spawn_background_task(worker(), name="completed") - await task - await background_tasks.shutdown_background_tasks() - self.assertFalse(background_tasks._tasks) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_calc_extraction_gap.py b/tests/test_calc_extraction_gap.py new file mode 100644 index 0000000000000000000000000000000000000000..022804f5ea72284cbe931099ac8a2033c89206ef --- /dev/null +++ b/tests/test_calc_extraction_gap.py @@ -0,0 +1,134 @@ +""" +test_calc_extraction_gap.py — Regression tests per GAP-CALC-1 e GAP-CALC-2 + +Trovati testando direttamente le regex/estrattori del layer di calcolo +deterministico in agents/unified_loop_tools.py (DirectToolsMixin), invocando +_extract_calc_expr / _SIMPLE_MATH_RE / _is_simple_query con frasi realistiche +che un utente scriverebbe all'agente. + +Copre: + GAP-CALC-1: _CALC_INTENT_RE/_SIMPLE_MATH_RE riconoscevano l'intent di calcolo + per frasi come "quanto vale X", "quanto valgono X", "risolvi X", + "risolvimi X", "dammi il valore di X", "how much is X", "what is X" — ma + _CALC_EXPR_RE (usata da _extract_calc_expr) non le copriva, quindi + _extract_calc_expr() ritornava '' e il calcolo saltava il tool + deterministico `calculate`, finendo sull'LLM generico (rischio di risposta + sbagliata su un calcolo esatto). + GAP-CALC-2: _SIMPLE_MATH_RE non includeva la virgola nel char class, quindi + "calcola 2,5 + 3,5" (notazione decimale italiana, supportata da + _extract_calc_expr che converte virgola→punto) non veniva classificata + come query semplice: saltava il fast-path deterministico + (_run_fast_path → calculate tool, <50ms) e finiva sul percorso LLM + standard nonostante il calcolo fosse triviale ed esatto. + +Dipendenze: solo stdlib. Non richiede server avviato, non richiede LLM/client +reale — UnifiedAgentLoop viene istanziata con llm_client=None perché i metodi +testati sono puri (regex + stringhe), nessuna chiamata di rete. +""" + +from __future__ import annotations + +import os +import sys +import unittest + +_BACKEND = os.path.join(os.path.dirname(__file__), "..") +if _BACKEND not in sys.path: + sys.path.insert(0, _BACKEND) + + +def _make_loop(): + from agents.unified_loop import UnifiedAgentLoop + return UnifiedAgentLoop(llm_client=None) + + +class TestCalcExtractionAlignedWithIntent(unittest.TestCase): + """GAP-CALC-1: ogni frase riconosciuta da _CALC_INTENT_RE come intent di + calcolo deve produrre un'espressione non vuota da _extract_calc_expr().""" + + def setUp(self): + self.loop = _make_loop() + + def test_quanto_vale(self): + self.assertEqual(self.loop._extract_calc_expr("quanto vale (3+4)*2"), "(3+4)*2") + + def test_quanto_valgono(self): + self.assertEqual(self.loop._extract_calc_expr("quanto valgono 5*5"), "5*5") + + def test_quanto_e_accentato(self): + self.assertEqual(self.loop._extract_calc_expr("quanto è 7+8"), "7+8") + + def test_risolvi(self): + # "^" deve anche essere convertito in "**" per compatibilità col tool calculate. + self.assertEqual(self.loop._extract_calc_expr("risolvi 2^10"), "2**10") + + def test_risolvimi(self): + self.assertEqual(self.loop._extract_calc_expr("risolvimi 100/4"), "100/4") + + def test_dammi_il_valore_di(self): + self.assertEqual(self.loop._extract_calc_expr("dammi il valore di 9*9"), "9*9") + + def test_how_much_is(self): + self.assertEqual(self.loop._extract_calc_expr("how much is 3+3"), "3+3") + + def test_what_is(self): + self.assertEqual(self.loop._extract_calc_expr("what is 4+4"), "4+4") + + def test_regressione_frasi_gia_supportate(self): + """Le frasi già supportate prima del fix continuano a funzionare.""" + self.assertEqual(self.loop._extract_calc_expr("calcola 2+2"), "2+2") + self.assertEqual(self.loop._extract_calc_expr("quanto fa 15*3?"), "15*3") + + +class TestSimpleMathAcceptsDecimalComma(unittest.TestCase): + """GAP-CALC-2: notazione decimale italiana con virgola deve attivare il + fast-path deterministico (_SIMPLE_MATH_RE match → is_simple_query True).""" + + def setUp(self): + self.loop = _make_loop() + + def test_comma_decimal_matches_simple_math_re(self): + self.assertTrue(self.loop._SIMPLE_MATH_RE.match("calcola 2,5 + 3,5")) + + def test_comma_decimal_is_simple_query(self): + self.assertTrue(self.loop._is_simple_query("calcola 2,5 + 3,5")) + + def test_comma_decimal_still_extracts_dot_normalized(self): + # _extract_calc_expr converte già la virgola in punto (comportamento pre-esistente). + self.assertEqual(self.loop._extract_calc_expr("calcola 2,5 + 3,5"), "2.5 + 3.5") + + def test_regression_dot_decimal_unaffected(self): + self.assertTrue(self.loop._SIMPLE_MATH_RE.match("calcola 2.5 + 3.5")) + self.assertTrue(self.loop._is_simple_query("calcola 2+2")) + + +class TestCalculateToolProducesExactResult(unittest.TestCase): + """Verifica end-to-end (senza LLM): l'espressione estratta da una frase + GAP-CALC-1 produce il risultato numerico corretto tramite il tool + deterministico `calculate`, non un'approssimazione LLM.""" + + def _run(self, coro): + # NB: usa get_event_loop().run_until_complete() (non asyncio.run()) per + # coerenza con tests/test_scaffold_project.py — asyncio.run() chiude il + # event loop di default al termine, rompendo i test successivi nello + # stesso processo pytest che si aspettano un loop persistente riutilizzabile. + import asyncio + return asyncio.get_event_loop().run_until_complete(coro) + + def test_quanto_vale_produces_exact_result(self): + from tools.registry import TOOL_REGISTRY + loop = _make_loop() + expr = loop._extract_calc_expr("quanto vale (3+4)*2") + result = self._run(TOOL_REGISTRY["calculate"]["_fn"](expression=expr)) + self.assertEqual(result.get("result"), 14) + + def test_risolvi_exponent_produces_exact_result(self): + from tools.registry import TOOL_REGISTRY + loop = _make_loop() + expr = loop._extract_calc_expr("risolvi 2^10") + result = self._run(TOOL_REGISTRY["calculate"]["_fn"](expression=expr)) + self.assertEqual(result.get("result"), 1024) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_cors_policy.py b/tests/test_cors_policy.py deleted file mode 100644 index 981ce0a6f8b7ba066147a87d56f740399f195732..0000000000000000000000000000000000000000 --- a/tests/test_cors_policy.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -import unittest - -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.testclient import TestClient - -from cors_policy import PUBLIC_FRONTEND_ORIGIN, allowed_origins - - -class CorsPolicyTests(unittest.TestCase): - def test_production_defaults_to_canonical_frontend_only(self) -> None: - self.assertEqual(allowed_origins(raw="", environment="production"), [PUBLIC_FRONTEND_ORIGIN]) - - def test_custom_origins_are_exact_and_wildcards_are_ignored(self) -> None: - origins = allowed_origins( - raw=" https://agente-ai.pages.dev/ , * , https://evil.example/path , http://localhost:5173 ", - environment="production", - ) - self.assertEqual(origins, ["http://localhost:5173", PUBLIC_FRONTEND_ORIGIN]) - - def test_local_origins_are_available_only_in_development(self) -> None: - production = allowed_origins(raw="", environment="production") - development = allowed_origins(raw="", environment="development") - self.assertNotIn("http://localhost:5173", production) - self.assertIn("http://localhost:5173", development) - - def test_invalid_configuration_fails_closed_to_public_default(self) -> None: - self.assertEqual(allowed_origins(raw="*", environment="production"), [PUBLIC_FRONTEND_ORIGIN]) - - def test_middleware_rejects_untrusted_origin_and_does_not_allow_credentials(self) -> None: - app = FastAPI() - app.add_middleware( - CORSMiddleware, - allow_origins=allowed_origins(raw=PUBLIC_FRONTEND_ORIGIN, environment="production"), - allow_credentials=False, - allow_methods=["GET", "OPTIONS"], - allow_headers=["Authorization", "Content-Type"], - ) - - @app.get("/health") - def health() -> dict[str, str]: - return {"status": "ok"} - - client = TestClient(app) - trusted = client.get("/health", headers={"Origin": PUBLIC_FRONTEND_ORIGIN}) - untrusted = client.get("/health", headers={"Origin": "https://audit-origin.invalid"}) - preflight = client.options( - "/health", - headers={ - "Origin": "https://audit-origin.invalid", - "Access-Control-Request-Method": "GET", - "Access-Control-Request-Headers": "authorization", - }, - ) - - self.assertEqual(trusted.headers.get("access-control-allow-origin"), PUBLIC_FRONTEND_ORIGIN) - self.assertNotIn("access-control-allow-credentials", trusted.headers) - self.assertNotIn("access-control-allow-origin", untrusted.headers) - self.assertNotEqual(preflight.headers.get("access-control-allow-origin"), "https://audit-origin.invalid") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_direct_file_conversion.py b/tests/test_direct_file_conversion.py index 1670c9621fb6f232f437196b7d1e886067d90800..a82017b803cccde43428fc64cd913ac6b329bea4 100644 --- a/tests/test_direct_file_conversion.py +++ b/tests/test_direct_file_conversion.py @@ -106,33 +106,6 @@ Non usare rete, shell, servizi esterni o provider aggiuntivi.""" } -def test_textual_checkout_plan_does_not_trigger_direct_image_generation(): - writes = {} - _install_tool_stubs(writes) - from agents.unified_loop_tools import DirectToolsMixin - - class Harness(DirectToolsMixin): - def _max_tokens_for_goal(self, _goal): - return 4096 - - events = [] - - async def on_step(event): - events.append(event) - - output, called, succeeded, errors = asyncio.run( - Harness()._run_direct_tools( - "Crea un breve piano per verificare un errore intermittente nel checkout e indica il primo dato da raccogliere.", - on_step=on_step, - ) - ) - - assert "E2E_IMAGE_OK" not in output - assert not any(event.get("action") == "file_written" for event in events) - assert not any(path.startswith("generated-image-") for path in writes) - assert (called, succeeded, errors) == (0, 0, 0) - - def test_direct_image_returns_renderable_terminal_markdown_without_llm(): writes = {} _install_tool_stubs(writes) diff --git a/tests/test_executor_side_effect_retry.py b/tests/test_executor_side_effect_retry.py deleted file mode 100644 index ed1305ffeec2c7cd6f926977f2e2ee235d9b12cd..0000000000000000000000000000000000000000 --- a/tests/test_executor_side_effect_retry.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest.mock import patch - -from agents import executor as executor_module -from agents.executor import Executor - - -class FailingMemory: - async def save_episode(self, *_args, **_kwargs) -> None: - raise RuntimeError("memory unavailable") - - -class RecordingMemory: - def __init__(self) -> None: - self.calls = 0 - - async def save_episode(self, *_args, **_kwargs) -> None: - self.calls += 1 - - -class ExecutorSideEffectRetryTests(unittest.IsolatedAsyncioTestCase): - async def test_memory_failure_does_not_retry_completed_side_effect(self) -> None: - calls = 0 - - async def non_idempotent_tool(**_inputs): - nonlocal calls - calls += 1 - return {"created_id": "resource-1"} - - with patch.dict( - executor_module.TOOL_REGISTRY, - {"non_idempotent_tool": {"required_inputs": [], "fallbacks": [], "_fn": non_idempotent_tool}}, - clear=False, - ): - result = await Executor(llm_client=object(), memory=FailingMemory(), max_retries=2).run_tool( - "non_idempotent_tool", {}, timeout=2, worker_hint="test", - ) - - self.assertTrue(result["success"]) - self.assertEqual(result["attempt"], 1) - self.assertEqual(calls, 1) - self.assertFalse(result["memory_persisted"]) - self.assertIn("memory unavailable", result["memory_error"]) - - async def test_tool_failure_still_retries_before_any_side_effect_result(self) -> None: - calls = 0 - - async def flaky_tool(**_inputs): - nonlocal calls - calls += 1 - if calls == 1: - raise RuntimeError("transient tool failure") - return {"ok": True} - - with patch.dict( - executor_module.TOOL_REGISTRY, - {"flaky_tool": {"required_inputs": [], "fallbacks": [], "_fn": flaky_tool}}, - clear=False, - ): - result = await Executor(llm_client=object(), memory=None, max_retries=2).run_tool( - "flaky_tool", {}, timeout=2, worker_hint="test", - ) - - self.assertTrue(result["success"]) - self.assertEqual(result["attempt"], 2) - self.assertEqual(calls, 2) - - async def test_successful_memory_persistence_is_reported(self) -> None: - memory = RecordingMemory() - - async def safe_tool(**_inputs): - return "done" - - with patch.dict( - executor_module.TOOL_REGISTRY, - {"safe_tool": {"required_inputs": [], "fallbacks": [], "_fn": safe_tool}}, - clear=False, - ): - result = await Executor(llm_client=object(), memory=memory, max_retries=0).run_tool( - "safe_tool", {}, timeout=2, worker_hint="test", - ) - - self.assertTrue(result["success"]) - self.assertTrue(result["memory_persisted"]) - self.assertEqual(memory.calls, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_health_full_contract.py b/tests/test_health_full_contract.py new file mode 100644 index 0000000000000000000000000000000000000000..e27397dbfa29d55cf8011a7f862590c4ab34a3a8 --- /dev/null +++ b/tests/test_health_full_contract.py @@ -0,0 +1,88 @@ +"""Regression tests for the aggregated health endpoint contract. + +These tests inspect the source AST instead of importing the backend, so they stay +fast and do not require provider credentials or optional runtime dependencies. +""" +from __future__ import annotations + +import ast +from pathlib import Path +import unittest + + +PROVIDERS_PATH = Path(__file__).resolve().parents[1] / "api" / "providers.py" + + +def _module_tree() -> ast.Module: + return ast.parse(PROVIDERS_PATH.read_text(encoding="utf-8")) + + +def _async_function(name: str) -> ast.AsyncFunctionDef: + for node in _module_tree().body: + if isinstance(node, ast.AsyncFunctionDef) and node.name == name: + return node + raise AssertionError(f"async function {name!r} not found") + + +class TestHealthFullContract(unittest.TestCase): + def test_auth_ping_route_is_registered(self) -> None: + function = _async_function("auth_ping") + routes = { + decorator.func.attr: ast.literal_eval(decorator.args[0]) + for decorator in function.decorator_list + if ( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and decorator.func.attr in {"get", "post"} + and decorator.args + ) + } + self.assertEqual(routes.get("get"), "/api/auth/ping") + + def test_health_gather_result_count_matches_targets(self) -> None: + function = _async_function("health_full") + matches: list[tuple[list[str], int]] = [] + for node in ast.walk(function): + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + value = node.value + if not ( + isinstance(target, ast.Tuple) + and isinstance(value, ast.Await) + and isinstance(value.value, ast.Call) + and isinstance(value.value.func, ast.Attribute) + and value.value.func.attr == "gather" + ): + continue + names = [item.id for item in target.elts if isinstance(item, ast.Name)] + matches.append((names, len(value.value.args))) + + self.assertEqual(len(matches), 1, "expected one asyncio.gather assignment") + names, argument_count = matches[0] + self.assertEqual(len(names), argument_count) + self.assertIn("c_tg", names) + + def test_health_body_keeps_canonical_and_legacy_fields(self) -> None: + function = _async_function("health_full") + body_assignment = next( + node + for node in ast.walk(function) + if ( + isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "body" for target in node.targets) + and isinstance(node.value, ast.Dict) + ) + ) + keys = { + ast.literal_eval(key) + for key in body_assignment.value.keys + if key is not None and isinstance(key, ast.Constant) + } + self.assertTrue( + {"checks", "summary", "ai", "supabase", "telegram", "backend"}.issubset(keys) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_html_fast_path.py b/tests/test_html_fast_path.py deleted file mode 100644 index 0fab10e2b214aa10db5564156cf12c42c617f26b..0000000000000000000000000000000000000000 --- a/tests/test_html_fast_path.py +++ /dev/null @@ -1,36 +0,0 @@ -from agents.html_fast_path import classify_html_fast_path - - -def test_accepts_self_contained_single_html(): - decision = classify_html_fast_path( - "Crea una mini-app lista spesa in un solo file index.html con aggiunta e filtro" - ) - assert decision.eligible is True - assert decision.path == "index.html" - assert decision.reason == "self_contained_single_html" - - -def test_accepts_explicit_html_file_without_network(): - decision = classify_html_fast_path( - "Genera una pagina web HTML in un solo file todo.html con CSS e JavaScript inline" - ) - assert decision.eligible is True - assert decision.path == "todo.html" - - -def test_rejects_deploy_and_external_dependencies(): - assert not classify_html_fast_path( - "Crea una mini-app HTML in un solo file index.html e pubblicala su GitHub" - ).eligible - assert not classify_html_fast_path( - "Crea una pagina HTML single-file che usa fetch https://api.example.com" - ).eligible - - -def test_rejects_multi_file_or_sensitive_tasks(): - assert not classify_html_fast_path( - "Crea una app React multi-file con backend API" - ).eligible - assert not classify_html_fast_path( - "Crea una pagina HTML in un solo file con login e database" - ).eligible diff --git a/tests/test_model_watch_adapter.py b/tests/test_model_watch_adapter.py index 50143204a620236f313fd56d9546681824607852..8a833024116a9224524475d5a01cb373c0804f2d 100644 --- a/tests/test_model_watch_adapter.py +++ b/tests/test_model_watch_adapter.py @@ -171,14 +171,14 @@ class GeminiModelsAdapterTests(unittest.IsolatedAsyncioTestCase): provider="gemini", profile=profile, base_url="https://generativelanguage.googleapis.com/v1beta", - api_key="x", + api_key="gemini-secret-not-logged", default_model=default_model, auth_mode="query_key", ) async def test_native_models_payload_is_parsed_and_prefix_removed(self): async def handler(request): - self.assertEqual(request.url.params.get("key"), "x") + self.assertEqual(request.url.params.get("key"), "gemini-secret-not-logged") return httpx.Response(200, json={"models": [ {"name": "models/gemini-3.6-flash"}, {"name": "models/gemini-3.5-flash"}, diff --git a/tests/test_oci_service.py b/tests/test_oci_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9c68418162b54afd39af86620625f63fc0b68530 --- /dev/null +++ b/tests/test_oci_service.py @@ -0,0 +1,140 @@ +"""Regression tests for the managed OCI Ampere A1 search service.""" +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +import unittest +from unittest.mock import AsyncMock, patch + +from api.oci_service import OCIComputeClient, OCIRequestError, OCISearchService, OCITarget + + +ROOT = Path(__file__).resolve().parents[2] +MIGRATION = ROOT / "supabase" / "migrations" / "20260716_oci_search_service.sql" +TELEGRAM = ROOT / "backend" / "api" / "telegram_webhook.py" + + +class FakeClient: + def __init__(self) -> None: + self.targets: list[OCITarget] = [] + + def validate(self) -> None: + return None + + def ensure_instance(self, target: OCITarget) -> dict: + self.targets.append(target) + if len(self.targets) < 3: + return {"outcome": "capacity", "error": "OutOfCapacity"} + return {"outcome": "found", "instance_id": "ocid1.instance.test", "instance_ip": "203.0.113.7"} + + +class FakeService(OCISearchService): + def __init__(self, client: FakeClient) -> None: + super().__init__(lambda: client) + self.row = { + "id": "search-1", + "desired_state": "searching", + "status": "searching", + "attempts": 0, + "attempts_24": 0, + "attempts_12": 0, + } + self.events_seen: list[str] = [] + self.notification: tuple[OCITarget, dict] | None = None + + async def latest(self) -> dict: + return dict(self.row) + + async def _update(self, search_id: str, patch: dict): + self.row.update(patch) + return dict(self.row) + + async def _event(self, search_id: str, event: str, message: str, **kwargs) -> None: + self.events_seen.append(event) + + async def _claim_lease(self, search_id: str, seconds: int) -> bool: + return True + + async def _notify_found(self, target: OCITarget, result: dict) -> None: + self.notification = (target, result) + + +class TestOCIService(unittest.IsolatedAsyncioTestCase): + async def test_weighted_targets_and_stop_on_success(self) -> None: + client = FakeClient() + service = FakeService(client) + with patch.dict(os.environ, {"OCI_SEARCH_INTERVAL_SECONDS": "20"}), patch( + "api.oci_service.asyncio.sleep", new=AsyncMock() + ): + await asyncio.wait_for(service._run("search-1"), timeout=1) + + self.assertEqual( + [(target.ocpu, target.memory_gb) for target in client.targets], + [(4, 24), (4, 24), (2, 12)], + ) + self.assertEqual(service.row["status"], "found") + self.assertEqual(service.row["desired_state"], "stopped") + self.assertEqual(service.row["attempts"], 3) + self.assertEqual(service.row["attempts_24"], 2) + self.assertEqual(service.row["attempts_12"], 1) + self.assertIn("instance_found", service.events_seen) + self.assertIsNotNone(service.notification) + + async def test_cancelled_search_propagates_cancellation(self) -> None: + class BlockingClient(FakeClient): + def ensure_instance(self, target: OCITarget) -> dict: + raise asyncio.CancelledError + + service = FakeService(BlockingClient()) + with self.assertRaises(asyncio.CancelledError): + await service._run("search-1") + + +class TestOCIContracts(unittest.TestCase): + def test_list_instances_filters_shape_client_side(self) -> None: + client = OCIComputeClient() + client.compartment = "ocid1.compartment.test" + with patch.object( + client, + "get", + return_value=[ + {"id": "a1", "shape": "VM.Standard.A1.Flex", "lifecycleState": "RUNNING"}, + {"id": "x86", "shape": "VM.Standard.E4.Flex", "lifecycleState": "RUNNING"}, + ], + ) as get: + rows = client.list_a1_instances() + self.assertEqual([row["id"] for row in rows], ["a1"]) + self.assertNotIn("shape=", get.call_args.args[0]) + + def test_public_ip_failure_is_non_blocking(self) -> None: + client = OCIComputeClient() + client.compartment = "ocid1.compartment.test" + with patch.object(client, "get", side_effect=OCIRequestError(404, "NotFound", "pending")): + self.assertEqual(client.public_ip("ocid1.instance.test"), "") + + def test_capacity_error_classification(self) -> None: + self.assertTrue(OCIRequestError(500, "OutOfCapacity", "No capacity").is_capacity_error) + self.assertTrue(OCIRequestError(500, "InternalError", "temporary").is_capacity_error) + self.assertFalse(OCIRequestError(401, "NotAuthenticated", "bad signature").is_capacity_error) + + def test_migration_has_rls_realtime_and_service_role_lease(self) -> None: + sql = MIGRATION.read_text(encoding="utf-8") + self.assertIn("ALTER TABLE public.oci_search_status ENABLE ROW LEVEL SECURITY", sql) + self.assertIn("ALTER PUBLICATION supabase_realtime ADD TABLE public.oci_search_status", sql) + self.assertIn("GRANT EXECUTE ON FUNCTION public.claim_oci_search_lease", sql) + self.assertIn("TO service_role", sql) + self.assertIn("REVOKE ALL ON FUNCTION public.claim_oci_search_lease", sql) + + def test_both_telegram_dispatchers_expose_oci_commands(self) -> None: + source = TELEGRAM.read_text(encoding="utf-8") + self.assertGreaterEqual(source.count('"/cerca_arm"'), 2) + for alias in ("/oci_status", "/oci_start", "/oci_stop", "/oci_log"): + self.assertIn(alias, source) + self.assertIn("await _cmd_oci(chat_id, text, token)", source) + self.assertIn('os.getenv("TELEGRAM_CHAT_ID"', source) + self.assertIn("Comando OCI non autorizzato", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_snapshot_writer.py b/tests/test_public_snapshot_writer.py deleted file mode 100644 index f39c0090fa4e3b7f842944951aa5086af582352c..0000000000000000000000000000000000000000 --- a/tests/test_public_snapshot_writer.py +++ /dev/null @@ -1,95 +0,0 @@ -import asyncio - -from api import public_snapshot - - -class _FakeTable: - def __init__(self, calls, failures=0): - self.calls = calls - self.failures = failures - - def upsert(self, row, on_conflict=None): - if self.failures: - self.failures -= 1 - raise RuntimeError("temporary Supabase failure") - self.calls.append((row, on_conflict)) - return self - - def execute(self): - return object() - - -class _FakeClient: - def __init__(self, failures=0): - self.calls = [] - self.failures = failures - - def table(self, name): - assert name == "public_dashboard_snapshot" - return _FakeTable(self.calls, self.failures) - - -def _run(coro): - return asyncio.run(coro) - - -def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch): - fake = _FakeClient() - monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: fake) - monkeypatch.setattr( - public_snapshot, - "_agent_tasks", - { - "queued": {"status": "QUEUED"}, - "running": {"status": "RUNNING"}, - "done": {"status": "SUCCESS"}, - }, - ) - monkeypatch.setattr(public_snapshot, "_loop_registry", {"session-1": object()}) - - assert _run(public_snapshot.write_public_dashboard_snapshot()) is True - assert _run(public_snapshot.write_public_dashboard_snapshot()) is True - - assert len(fake.calls) == 2 - first, second = fake.calls - assert first[1] == "singleton" - assert second[1] == "singleton" - assert first[0] == second[0] - assert first[0]["singleton"] is True - assert first[0]["active_sessions"] == 1 - assert first[0]["queued_tasks"] == 1 - assert first[0]["in_progress_tasks"] == 1 - assert first[0]["service_status"] == "operational" - - -def test_snapshot_writer_retries_transient_failure(monkeypatch): - calls = [] - - class _RetryTable: - def upsert(self, row, on_conflict=None): - calls.append((row, on_conflict)) - if len(calls) < 3: - raise RuntimeError("temporary Supabase failure") - return self - - def execute(self): - return object() - - class _RetryClient: - def table(self, name): - assert name == "public_dashboard_snapshot" - return _RetryTable() - - monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: _RetryClient()) - async def _no_sleep(_seconds): - return None - - monkeypatch.setattr(public_snapshot.asyncio, "sleep", _no_sleep) - - assert _run(public_snapshot.write_public_dashboard_snapshot()) is True - assert len(calls) == 3 - - -def test_snapshot_writer_returns_false_when_supabase_is_unavailable(monkeypatch): - monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: None) - assert _run(public_snapshot.write_public_dashboard_snapshot()) is False diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..3e39d03e3d92c3db35287e19ef6bcbf43dd6e10a --- /dev/null +++ b/tests/test_telegram_commands.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""tests/test_telegram_commands.py — Test funzionale dei command handler Telegram. + +Cosa testa +---------- +Ogni command handler (19 comandi + 2 callback dispatcher) viene eseguito +davvero, ma tutte le chiamate HTTP verso api.telegram.org sono intercettate. + +Nessun messaggio viene inviato su Telegram. +Nessuna env var richiesta. +Nessuna connessione di rete necessaria. + +Come funziona +------------- +1. Le dipendenze esterne (fastapi, pydantic, httpx, api.*) vengono stubbate. +2. Le funzioni _tg_reply / _tg_send / _tg_edit / _tg_typing / _tg_react + vengono rimpiazzate da AsyncMock PRIMA di ogni test, nel namespace del + modulo importato (import_module caching garantisce coerenza). +3. Il test verifica che ogni handler abbia chiamato almeno una funzione TG. + +Uso +--- + cd backend && python -m pytest tests/test_telegram_commands.py -v + cd backend && python tests/test_telegram_commands.py # standalone +""" +from __future__ import annotations +import asyncio, importlib, sys, types, unittest +from unittest.mock import AsyncMock, MagicMock, patch + +# ───────────────────────────────────────────────────────────────────────────── +# 0. Stub dipendenze esterne (identico a test_telegram_imports.py) +# ───────────────────────────────────────────────────────────────────────────── +def _stub(name: str, **attrs) -> types.ModuleType: + m = types.ModuleType(name) + for k, v in attrs.items(): + setattr(m, k, v) + sys.modules[name] = m + return m + +_fa = _stub("fastapi") +_fa.APIRouter = type("APIRouter", (), { + "__init__": lambda self, **kw: None, + "post": lambda self, *a, **kw: (lambda f: f), + "get": lambda self, *a, **kw: (lambda f: f), +}) +_fa.Request = type("Request", (), {}) +_fa.HTTPException = type("HTTPException", (Exception,), {}) +_fa.BackgroundTasks = type("BackgroundTasks", (), {}) +_stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {})) +_stub("fastapi.routing") +_stub("starlette.requests", Request=_fa.Request) +_stub("pydantic", BaseModel=type("BaseModel", (), {})) + +# httpx — il client viene moccato per intercettare tutte le chiamate TG +class _FakeResponse: + status_code = 200 + def json(self): return {"ok": True, "result": {"message_id": 999}} + async def aread(self): return b'{"ok":true,"result":{"message_id":999}}' + +class _FakeAsyncClient: + def __init__(self, *a, **kw): pass + async def __aenter__(self): return self + async def __aexit__(self, *a): pass + async def post(self, url, **kw): return _FakeResponse() + async def get(self, url, **kw): return _FakeResponse() + +_httpx = _stub("httpx", AsyncClient=_FakeAsyncClient, Response=_FakeResponse) +_stub("httpcore") +_stub("anyio") + +# Backend interno — stub per evitare import circolari +for _m in [ + "api.agent", "api.providers", "api.state", "api.unified_api", + "api.structured_log", "api.supabase_client", "api.exec", + "agents.unified_loop_fallback", "agents.goal_verifier", + "agents.memory_manager", "agents.audit_semantic_l2", +]: + _stub(_m) + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Import moduli (dopo aver stubbato le dipendenze) +# ───────────────────────────────────────────────────────────────────────────── +def _imp(name: str): + full = f"api.{name}" + return sys.modules.get(full) or importlib.import_module(full) + +# Import nell'ordine delle dipendenze +_tg_client = _imp("telegram_tg_client") +_keyboards = _imp("telegram_keyboards") +_mon = _imp("telegram_cmd_monitoring") +_ai = _imp("telegram_cmd_ai") +_cb = _imp("telegram_callbacks") + +CHAT_ID = 123456789 # chat_id fittizio — non viene mai usato realmente + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Helper — patch di tutte le _tg_* nel modulo target +# ───────────────────────────────────────────────────────────────────────────── +TG_FUNS = ["_tg_reply", "_tg_send", "_tg_edit", "_tg_typing", + "_tg_react", "_tg_photo", "_tg_answer_callback"] + +class TGCapture: + """Context manager: patcha le funzioni TG in 'mod', registra le chiamate.""" + def __init__(self, *mods): + self.mods = mods + self.mocks = {} # "mod.fun" → AsyncMock + self._patches = [] + + def __enter__(self): + for mod in self.mods: + for fun in TG_FUNS: + if hasattr(mod, fun): + m = AsyncMock(return_value=999) + p = patch.object(mod, fun, m) + p.start() + self._patches.append(p) + self.mocks[f"{mod.__name__}.{fun}"] = m + return self + + def __exit__(self, *a): + for p in self._patches: + p.stop() + + def called_any(self) -> bool: + return any(m.called for m in self.mocks.values()) + + def calls(self) -> list[str]: + return [k.split(".")[-1] for k,m in self.mocks.items() if m.called] + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Test suite +# ───────────────────────────────────────────────────────────────────────────── +class TestTelegramCommands(unittest.TestCase): + + # ── Monitoring ───────────────────────────────────────────────────────── + def test_cmd_help(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_help(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_help non ha chiamato nulla. Mocks: {cap.calls()}") + + def test_cmd_logs(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_logs(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_logs: {cap.calls()}") + + def test_cmd_logs_level(self): + """_cmd_logs accetta livello custom.""" + with TGCapture(_mon) as cap: + run(_mon._cmd_logs(CHAT_ID, level="ERROR")) + self.assertTrue(cap.called_any()) + + def test_cmd_status(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_status(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_status: {cap.calls()}") + + def test_cmd_commit_summary(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_commit_summary(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_commit_summary: {cap.calls()}") + + def test_cmd_check(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_check(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_check: {cap.calls()}") + + def test_cmd_tasks(self): + with TGCapture(_mon) as cap: + run(_mon._cmd_tasks(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_tasks: {cap.calls()}") + + # ── AI commands ──────────────────────────────────────────────────────── + def test_cmd_do_empty_goal(self): + """_cmd_do con goal vuoto deve rispondere con la keyboard quick-pick.""" + with TGCapture(_ai) as cap: + run(_ai._cmd_do(CHAT_ID, goal="")) + self.assertTrue(cap.called_any(), f"_cmd_do(goal=''): {cap.calls()}") + + def test_cmd_do_with_goal(self): + """_cmd_do con goal reale avvia lo streaming — la prima chiamata TG è _tg_send.""" + with TGCapture(_ai) as cap: + run(_ai._cmd_do(CHAT_ID, goal="analizza i log")) + self.assertTrue(cap.called_any(), f"_cmd_do(goal): {cap.calls()}") + + def test_cmd_do_saves_last_goal(self): + """_cmd_do salva il goal in _LAST_GOAL per il tasto Rifai.""" + with TGCapture(_ai): + run(_ai._cmd_do(CHAT_ID, goal="test retry goal")) + from api.telegram_keyboards import _LAST_GOAL + self.assertEqual(_LAST_GOAL.get(CHAT_ID), "test retry goal") + + def test_cmd_autofix(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_autofix(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_autofix: {cap.calls()}") + + def test_cmd_autofix_with_hint(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_autofix(CHAT_ID, hint="TypeError in providers.py")) + self.assertTrue(cap.called_any()) + + def test_cmd_nota(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_nota(CHAT_ID, text="ricordati di aggiornare il token")) + self.assertTrue(cap.called_any(), f"_cmd_nota: {cap.calls()}") + + def test_cmd_cerca(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_cerca(CHAT_ID, query="FastAPI async streaming")) + self.assertTrue(cap.called_any(), f"_cmd_cerca: {cap.calls()}") + + def test_cmd_meteo(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_meteo(CHAT_ID, city="Milano")) + self.assertTrue(cap.called_any(), f"_cmd_meteo: {cap.calls()}") + + def test_cmd_riepilogo(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_riepilogo(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_riepilogo: {cap.calls()}") + + def test_cmd_scan_now(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_scan_now(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_scan_now: {cap.calls()}") + + def test_cmd_coord(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_coord(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_coord: {cap.calls()}") + + def test_cmd_git(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_git(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_git: {cap.calls()}") + + def test_cmd_git_custom_n(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_git(CHAT_ID, n=10)) + self.assertTrue(cap.called_any()) + + def test_cmd_telemetry(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_telemetry(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_telemetry: {cap.calls()}") + + def test_cmd_score(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_score(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_score: {cap.calls()}") + + def test_cmd_bench(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_bench(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_bench: {cap.calls()}") + + def test_cmd_improve(self): + with TGCapture(_ai) as cap: + run(_ai._cmd_improve(CHAT_ID)) + self.assertTrue(cap.called_any(), f"_cmd_improve: {cap.calls()}") + + # ── Callback dispatcher ──────────────────────────────────────────────── + def _make_cb(self, data: str) -> dict: + return { + "id": "cb_001", + "from": {"id": CHAT_ID, "first_name": "Test"}, + "message": {"message_id": 42, "chat": {"id": CHAT_ID}}, + "chat_instance": "ci", + "data": data, + } + + def test_handle_callback_help(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_help"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_help: nessuna chiamata TG") + + def test_handle_callback_status(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_status"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_status") + + def test_handle_callback_tasks(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_tasks"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_tasks") + + def test_handle_callback_do(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("agent"), token="fake")) + self.assertTrue(cap.called_any(), "callback agent (→ _cmd_do)") + + def test_handle_callback_autofix(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_autofix"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_autofix") + + def test_handle_callback_bench(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_bench"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_bench") + + def test_handle_callback_score(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_score"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_score") + + def test_handle_callback_improve(self): + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_improve"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_improve") + + def test_handle_callback_retry(self): + """tgw_retry rilegge _LAST_GOAL — deve funzionare anche se vuoto.""" + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("tgw_retry"), token="fake")) + self.assertTrue(cap.called_any(), "callback tgw_retry") + + def test_handle_callback_qp_bug(self): + """quick-pick qp_bug lancia _cmd_do con goal preimpostato.""" + with TGCapture(_mon, _ai, _cb) as cap: + run(_cb._handle_callback(self._make_cb("qp_bug"), token="fake")) + self.assertTrue(cap.called_any(), "callback qp_bug") + + def test_handle_inline_empty(self): + """Inline query vuota → risponde con lista comandi.""" + iq = {"id": "iq_001", "from": {"id": CHAT_ID}, "query": ""} + with TGCapture(_cb) as cap: + run(_cb._handle_inline(iq, token="fake")) + # La risposta va a answerInlineQuery — httpx è stubbato, non fallisce + + def test_handle_inline_with_query(self): + iq = {"id": "iq_002", "from": {"id": CHAT_ID}, "query": "analizza bug login"} + with TGCapture(_cb): + run(_cb._handle_inline(iq, token="fake")) + + # ── Guardrail: nessun messaggio reale inviato ────────────────────────── + def test_no_real_http_calls(self): + """Verifica che httpx.AsyncClient.post non chiami api.telegram.org reale.""" + real_calls: list[str] = [] + original_post = _FakeAsyncClient.post + async def spy_post(self, url, **kw): + real_calls.append(url) + return _FakeResponse() + _FakeAsyncClient.post = spy_post + try: + with TGCapture(_mon): + run(_mon._cmd_help(CHAT_ID)) + # Se arriva qui senza errori di rete → ok (le _tg_* erano moccate, + # quindi httpx.post non è stato chiamato direttamente) + for url in real_calls: + self.assertNotIn("api.telegram.org", url, + f"Chiamata reale a api.telegram.org intercettata: {url}") + finally: + _FakeAsyncClient.post = original_post + + +if __name__ == "__main__": + import pathlib + backend = pathlib.Path(__file__).parent.parent + if str(backend) not in sys.path: + sys.path.insert(0, str(backend)) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramCommands)) + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_telegram_imports.py b/tests/test_telegram_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..97ccfb7ce9ca3929254718ef1cd560f4d8a3b60c --- /dev/null +++ b/tests/test_telegram_imports.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""tests/test_telegram_imports.py — Smoke test import chain moduli M2 split. + +Verifica che tutti i 6 moduli Telegram si importino correttamente senza +avviare FastAPI né connettersi a Telegram. Eseguibile in CI senza env vars. + +Uso: + cd backend && python -m pytest tests/test_telegram_imports.py -v + # oppure direttamente: + cd backend && python tests/test_telegram_imports.py +""" +from __future__ import annotations +import importlib, sys, types, unittest + +# ── Stub dipendenze esterne non disponibili in CI ───────────────────────────── +def _stub(name: str, **attrs) -> types.ModuleType: + m = types.ModuleType(name) + for k, v in attrs.items(): + setattr(m, k, v) + sys.modules[name] = m + return m + +# fastapi +_fa = _stub("fastapi") +_fa.APIRouter = type("APIRouter", (), {"__init__": lambda self, **kw: None, + "post": lambda self, *a, **kw: (lambda f: f), + "get": lambda self, *a, **kw: (lambda f: f)}) +_fa.Request = type("Request", (), {}) +_fa.HTTPException = type("HTTPException", (Exception,), {}) +_fa.BackgroundTasks = type("BackgroundTasks", (), {}) +_stub("fastapi.responses", JSONResponse=type("JSONResponse", (), {})) +_stub("fastapi.routing") +_stub("starlette.requests", Request=_fa.Request) + +# pydantic +_stub("pydantic", BaseModel=type("BaseModel", (), {})) + +# httpx +_httpx = _stub("httpx") +_httpx.AsyncClient = type("AsyncClient", (), { + "__aenter__": lambda self: self, "__aexit__": lambda *a: None, + "post": lambda self, *a, **kw: None, +}) +_stub("httpx._models") + +# httpcore, anyio (transitive deps di httpx) +_stub("httpcore") +_stub("anyio") + +# Altre dipendenze del backend usate dalle funzioni TG +for _mod in [ + "api.agent", "api.providers", "api.state", "api.unified_api", + "api.structured_log", "api.supabase_client", "api.exec", + "agents.unified_loop_fallback", "agents.goal_verifier", + "agents.memory_manager", "agents.audit_semantic_l2", +]: + _stub(_mod) + + +# ── Test ────────────────────────────────────────────────────────────────────── +class TestTelegramImportChain(unittest.TestCase): + + def _import(self, modname: str) -> types.ModuleType: + """Importa (o re-importa) un modulo dal package api.""" + full = f"api.{modname}" + if full in sys.modules: + return sys.modules[full] + return importlib.import_module(full) + + # ── Layer 0 ─────────────────────────────────────────────────────────────── + def test_01_tg_client(self): + """telegram_tg_client — nessuna dipendenza interna.""" + m = self._import("telegram_tg_client") + for sym in ("_get_bot_token", "_tg_reply", "_tg_send", "_tg_edit", + "_tg_photo", "_tg_typing", "_tg_react", + "_tg_answer_callback", "_fmt_elapsed", "_log_tg_exc"): + self.assertTrue(hasattr(m, sym), f"Manca {sym} in tg_client") + + # ── Layer 1 ─────────────────────────────────────────────────────────────── + def test_02_keyboards(self): + """telegram_keyboards — solo costanti/dict.""" + m = self._import("telegram_keyboards") + for sym in ("_MAIN_KB", "_QUICK_PICK_KB", "_after_task_kb", + "_LAST_GOAL", "_BENCH_CACHE"): + self.assertTrue(hasattr(m, sym), f"Manca {sym} in keyboards") + + # ── Layer 2 ─────────────────────────────────────────────────────────────── + def test_03_cmd_monitoring(self): + """telegram_cmd_monitoring — importa da tg_client + keyboards.""" + m = self._import("telegram_cmd_monitoring") + for sym in ("_cmd_help", "_cmd_logs", "_cmd_status", + "_cmd_commit_summary", "_cmd_check", "_cmd_tasks"): + self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_monitoring") + + def test_04_cmd_ai(self): + """telegram_cmd_ai — importa da tg_client + keyboards.""" + m = self._import("telegram_cmd_ai") + for sym in ("_cmd_do", "_cmd_autofix", "_cmd_nota", "_cmd_cerca", + "_cmd_meteo", "_cmd_riepilogo", "_cmd_score", "_cmd_bench", + "_cmd_improve", "_cmd_git", "_cmd_coord", + "_cmd_scan_now", "_cmd_telemetry"): + self.assertTrue(hasattr(m, sym), f"Manca {sym} in cmd_ai") + + # ── Layer 3 ─────────────────────────────────────────────────────────────── + def test_05_callbacks(self): + """telegram_callbacks — importa da tg_client + keyboards + cmd_ai + cmd_monitoring.""" + m = self._import("telegram_callbacks") + for sym in ("_handle_inline", "_handle_callback"): + self.assertTrue(hasattr(m, sym), f"Manca {sym} in callbacks") + + # ── Layer 4 (router) ────────────────────────────────────────────────────── + def test_06_webhook(self): + """telegram_webhook — router puro, importa da tutti i moduli.""" + m = self._import("telegram_webhook") + self.assertTrue(hasattr(m, "router"), + "Manca 'router' in telegram_webhook") + + # ── Import chain completo ───────────────────────────────────────────────── + def test_07_no_circular_imports(self): + """Nessun import circolare: webhook importa callbacks che NON importa webhook.""" + cb = sys.modules.get("api.telegram_callbacks") + wh = sys.modules.get("api.telegram_webhook") + if cb and wh: + # callbacks non deve avere 'telegram_webhook' come dipendenza diretta + cb_src = getattr(cb, "__file__", "") + import ast, pathlib + if cb_src and pathlib.Path(cb_src).exists(): + tree = ast.parse(pathlib.Path(cb_src).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + self.assertNotIn("telegram_webhook", (node.module or ""), + "Import circolare: callbacks → webhook") + + def test_08_main_kb_in_keyboards_not_tg_client(self): + """_MAIN_KB deve stare in keyboards, NON in tg_client.""" + tc = self._import("telegram_tg_client") + kb = self._import("telegram_keyboards") + self.assertFalse(hasattr(tc, "_MAIN_KB"), + "_MAIN_KB non deve essere in tg_client") + self.assertTrue(hasattr(kb, "_MAIN_KB"), + "_MAIN_KB deve essere in keyboards") + + def test_09_scan_now_in_cmd_ai(self): + """_cmd_scan_now/_cmd_git/_cmd_coord/_cmd_telemetry devono stare in cmd_ai.""" + ai = self._import("telegram_cmd_ai") + mon = self._import("telegram_cmd_monitoring") + for sym in ("_cmd_scan_now", "_cmd_git", "_cmd_coord", "_cmd_telemetry"): + self.assertTrue(hasattr(ai, sym), + f"{sym} deve essere in cmd_ai") + self.assertFalse(hasattr(mon, sym), + f"{sym} NON deve essere in cmd_monitoring") + + +if __name__ == "__main__": + # Aggiungi backend/ al path se eseguito da root + import os, pathlib + backend = pathlib.Path(__file__).parent.parent + if str(backend) not in sys.path: + sys.path.insert(0, str(backend)) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(unittest.TestLoader().loadTestsFromTestCase(TestTelegramImportChain)) + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_telemetry_alert_lifecycle.py b/tests/test_telemetry_alert_lifecycle.py deleted file mode 100644 index 21838acbd6f705361430eff4dfac802105535366..0000000000000000000000000000000000000000 --- a/tests/test_telemetry_alert_lifecycle.py +++ /dev/null @@ -1,45 +0,0 @@ -import ast -import pathlib -import unittest - - -_MAIN = pathlib.Path(__file__).parents[1] / "main.py" - - -class TelemetryAlertLifecycleTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.tree = ast.parse(_MAIN.read_text(encoding="utf-8")) - cls.source = _MAIN.read_text(encoding="utf-8") - cls.functions = { - node.name: node - for node in cls.tree.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } - - def test_startup_registers_named_telemetry_task(self): - startup = self.functions["startup_event"] - source = ast.get_source_segment(self.source, startup) - self.assertIn("telemetry_alert_loop", source) - self.assertIn('name="telemetry-alert-loop"', source) - self.assertIn("_telemetry_alert_task", source) - - def test_shutdown_cancels_and_awaits_telemetry_task(self): - shutdown = self.functions["shutdown_event"] - source = ast.get_source_segment(self.source, shutdown) - self.assertIn("task.cancel()", source) - self.assertIn("await task", source) - self.assertIn("asyncio.CancelledError", source) - - def test_task_reference_is_module_scoped(self): - assignment_names = { - target.id - for node in self.tree.body - if isinstance(node, ast.AnnAssign) - and isinstance(target := node.target, ast.Name) - } - self.assertIn("_telemetry_alert_task", assignment_names) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_vfs_atomic_rollback.py b/tests/test_vfs_atomic_rollback.py deleted file mode 100644 index b25f242c99e3a6adda49c903ef8e5e6388e1da56..0000000000000000000000000000000000000000 --- a/tests/test_vfs_atomic_rollback.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import asyncio -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from agents.unified_loop import UnifiedAgentLoop -from tools.registry import _delete_file, _read_file, _write_file - - -class FakeExecutor: - def __init__(self, failures: set[str] | None = None) -> None: - self.calls: list[tuple[str, dict]] = [] - self.failures = failures or set() - - async def run_tool(self, name: str, inputs: dict, timeout: float = 30.0) -> dict: - self.calls.append((name, inputs)) - if name in self.failures: - return {"success": False, "error": f"forced failure: {name}", "output": None} - return {"success": True, "output": {"ok": True}} - - -class VfsAtomicRollbackTests(unittest.IsolatedAsyncioTestCase): - def make_loop(self, executor: FakeExecutor) -> UnifiedAgentLoop: - loop = UnifiedAgentLoop.__new__(UnifiedAgentLoop) - loop.executor = executor - loop._write_snapshots = {} - return loop - - async def test_rollback_restores_existing_and_deletes_new_files(self) -> None: - executor = FakeExecutor() - loop = self.make_loop(executor) - loop._write_snapshots = {"existing.txt": "before", "created.txt": None} - - await loop._rollback_writes() - - self.assertEqual( - [(name, inputs) for name, inputs in executor.calls], - [ - ("write_file", {"path": "existing.txt", "content": "before"}), - ("delete_file", {"path": "created.txt"}), - ], - ) - self.assertEqual(loop._write_snapshots, {}) - - async def test_failed_restore_is_not_marked_clean(self) -> None: - executor = FakeExecutor({"write_file"}) - loop = self.make_loop(executor) - loop._write_snapshots = {"existing.txt": "before", "created.txt": None} - - with self.assertRaisesRegex(RuntimeError, "rollback incompleto"): - await loop._rollback_writes() - - self.assertEqual(loop._write_snapshots, {"existing.txt": "before"}) - self.assertEqual(executor.calls[1][0], "delete_file") - - async def test_delete_file_respects_fs_jail_and_is_idempotent(self) -> None: - with tempfile.TemporaryDirectory() as root: - with patch.dict("os.environ", {"FS_TOOL_ROOT": root}, clear=False): - target = Path(root) / "created.txt" - target.write_text("created", encoding="utf-8") - deleted = await _delete_file("created.txt") - repeated = await _delete_file("created.txt") - outside = await _delete_file("../outside.txt") - - self.assertTrue(deleted["ok"]) - self.assertTrue(deleted["deleted"]) - self.assertTrue(repeated["ok"]) - self.assertFalse(repeated["deleted"]) - self.assertFalse(outside["ok"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/gdrive_tool.py b/tools/gdrive_tool.py new file mode 100644 index 0000000000000000000000000000000000000000..1de1031e69bebde2666529fde60507c2b1d91e16 --- /dev/null +++ b/tools/gdrive_tool.py @@ -0,0 +1,102 @@ +"""backend/tools/gdrive_tool.py — Google Drive Tier 3 Memory Tool. +Consente all'agente di archiviare e recuperare dati da Google Drive. +Ottimizzato per sessioni lunghe e offloading di memoria. +""" +import os, json, logging, httpx +from typing import Any, Optional +from api.auth_managed import _decrypt, _sb_list_tokens + +_logger = logging.getLogger("gdrive_tool") +_TIMEOUT = 20.0 + +async def gdrive_rw( + action: str, + filename: Optional[str] = None, + content: Optional[str] = None, + file_id: Optional[str] = None, + query: Optional[str] = None, + user_id: str = "default" +) -> dict[str, Any]: + """ + Gestisce file su Google Drive per memoria a lungo termine. + action: search, read, write, update + """ + try: + # 1. Recupera token Google + tokens = await _sb_list_tokens(user_id) + g_token = next((t for t in tokens if t['provider'] == 'google'), None) + + if not g_token: + return {"ok": False, "error": "[CONNECTOR_NEEDED:google] Connetti Google Drive per usare la memoria Tier 3"} + + access_token = _decrypt(g_token['access_token']) + headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"} + + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + # --- SEARCH --- + if action == "search": + q = f"name contains '{query}'" if query else "mimeType = 'text/plain'" + r = await client.get( + "https://www.googleapis.com/drive/v3/files", + params={"q": q, "fields": "files(id, name, modifiedTime)"}, + headers=headers + ) + if r.status_code != 200: + return {"ok": False, "error": f"Drive Search Error: {r.text[:200]}"} + return {"ok": True, "files": r.json().get("files", [])} + + # --- READ --- + elif action == "read": + if not file_id: return {"ok": False, "error": "file_id mancante"} + r = await client.get(f"https://www.googleapis.com/drive/v3/files/{file_id}?alt=media", headers=headers) + if r.status_code != 200: + return {"ok": False, "error": f"Drive Read Error: {r.text[:200]}"} + return {"ok": True, "content": r.text} + + # --- WRITE (Create) --- + elif action == "write": + if not filename: return {"ok": False, "error": "filename mancante"} + meta = {"name": filename, "mimeType": "text/plain"} + files = {'data': ('metadata', json.dumps(meta), 'application/json'), + 'file': (filename, content or "", 'text/plain')} + r = await client.post("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", + headers={"Authorization": f"Bearer {access_token}"}, files=files) + if r.status_code not in (200, 201): + return {"ok": False, "error": f"Drive Write Error: {r.text[:200]}"} + return {"ok": True, "file_id": r.json().get("id"), "message": f"File {filename} creato su Drive"} + + # --- UPDATE --- + elif action == "update": + if not file_id: return {"ok": False, "error": "file_id mancante"} + r = await client.patch(f"https://www.googleapis.com/upload/drive/v3/files/{file_id}?uploadType=media", + headers=headers, content=content or "") + if r.status_code != 200: + return {"ok": False, "error": f"Drive Update Error: {r.text[:200]}"} + return {"ok": True, "message": "File aggiornato su Drive"} + + return {"ok": False, "error": f"Azione {action} non supportata"} + + except Exception as e: + _logger.error("GDrive Tool Error: %s", e) + return {"ok": False, "error": str(e)} + +TOOL_DESCRIPTOR = { + "name": "gdrive_memory", + "description": ( + "Gestisce la memoria a lungo termine su Google Drive (Tier 3). " + "Usa per archiviare contesti pesanti, log o file che superano i limiti di memoria locale. " + "Azioni: write (crea), read (legge), search (cerca), update (aggiorna)." + ), + "parameters": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["write", "read", "search", "update"]}, + "filename": {"type": "string", "description": "Nome del file (es. session_memory_2026.txt)"}, + "content": {"type": "string", "description": "Contenuto da archiviare"}, + "file_id": {"type": "string", "description": "ID del file Drive per read/update"}, + "query": {"type": "string", "description": "Termine di ricerca per action=search"} + }, + "required": ["action"] + }, + "fn": gdrive_rw +} diff --git a/tools/payload_chunker.py b/tools/payload_chunker.py new file mode 100644 index 0000000000000000000000000000000000000000..1ddf2d365c92a011e794a074bbac24c3410128d8 --- /dev/null +++ b/tools/payload_chunker.py @@ -0,0 +1,43 @@ +""" +backend/tools/payload_chunker.py — Utility per suddividere payload massicci. +""" +from typing import List, Dict, Any +import json +import sys + +def chunk_text(text: str, chunk_size_kb: int = 400) -> List[str]: + """Suddivide una stringa in pezzi di dimensione massima specificata.""" + chunk_size = chunk_size_kb * 1024 + return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] + +def chunk_payload(payload: Dict[str, Any], max_kb: int = 450) -> List[Dict[str, Any]]: + """ + Analizza un payload e lo suddivide se supera la dimensione massima. + Attualmente gestisce la suddivisione di campi testuali lunghi (es. 'code', 'content'). + """ + payload_str = json.dumps(payload) + if len(payload_str) <= max_kb * 1024: + return [payload] + + # Identifica il campo più grande da suddividere + target_field = None + max_len = 0 + for k, v in payload.items(): + if isinstance(v, str) and len(v) > max_len: + max_len = len(v) + target_field = k + + if not target_field: + return [payload] # Non è possibile suddividere ulteriormente + + chunks = chunk_text(payload[target_field], chunk_size_kb=max_kb) + result = [] + for i, chunk in enumerate(chunks): + new_payload = payload.copy() + new_payload[target_field] = chunk + new_payload["_chunk_index"] = i + new_payload["_chunk_total"] = len(chunks) + new_payload["_chunk_id"] = payload.get("task_id", "unknown") + result.append(new_payload) + + return result diff --git a/tools/registry.py b/tools/registry.py index 4ce627e278984c13029a7e23bc9919086fda317e..6b8dfa7f41d9ebecbb21d70c57287513a30afe53 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -685,24 +685,6 @@ async def _read_file(path: str, encoding: str = "utf-8") -> dict: return {"ok": False, "error": str(exc)} -async def _delete_file(path: str) -> dict: - """Rimuove un file dalla jail VFS in modo idempotente.""" - try: - _p, _err = _safe_fs_path(path) - if _err: - return {"ok": False, "error": _err} - if not _p.exists(): - return {"ok": True, "path": path, "deleted": False} - if not _p.is_file(): - return {"ok": False, "error": f"Non è un file: {path}"} - _p.unlink() - return {"ok": True, "path": path, "deleted": True} - except PermissionError: - return {"ok": False, "error": f"Accesso negato: {path}"} - except Exception as exc: - return {"ok": False, "error": str(exc)} - - async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict: """S666/GAP-2/SEC-FS-JAIL: Scrive/sovrascrive un file nel filesystem del backend, confinato a _fs_jail_root(). Manus Gap fix: read-back verifica completezza scrittura. @@ -2317,16 +2299,6 @@ TOOL_REGISTRY: dict[str, dict] = { "fallbacks": [], "_fn": _read_file, }, - "delete_file": { - "name": "delete_file", - "goal": "Rimuove un file dal filesystem del backend", - "description": "Rimuove un file locale confinato alla jail VFS; idempotente se il file è già assente.", - "required_inputs": ["path"], - "optional_inputs": {}, - "risk_level": "medium", - "fallbacks": [], - "_fn": _delete_file, - }, "write_file": { "name": "write_file", "goal": "Scrive o sovrascrive un file nel filesystem del backend", 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) + +