diff --git a/.env.example b/.env.example index 3eeb77a50d27aa356fd2a56cfc4a5a3504451f9a..513cefd9ce0e2b52f61d3f40f2456a1f110a72c1 100644 --- a/.env.example +++ b/.env.example @@ -12,50 +12,44 @@ VAULT_KEY= # AES-256 Hex NOTIFY_TOKEN= # Notifiche Interne # ── 2. Quadrante A (BRAIN - Primary) ───────────────────────── -BACKEND_URL=https://baida07-terminal.hf.space +BACKEND_URL=https://arjanit98-terminal.hf.space RAILWAY_TOKEN= -RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A +RAILWAY_PROJECT_ID=a9ce05f8-aeca-46c1-837b-8c2ca7a11081 SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY= GITHUB_TOKEN= -# Hugging Face Router: endpoint OpenAI-compatible per inferenza. HF_TOKEN= -HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct -# Pool opzionale: [{"profile":"primary","api_key":"...","model":"openai/gpt-oss-120b:fastest"}] -HF_ROUTER_PROFILES_JSON= # ── 3. Quadrante B (HANDS - Collab/Failover) ───────────────── RAILWAY_TOKEN_B= -RAILWAY_PROJECT_ID_B=YOUR_RAILWAY_PROJECT_ID_B +RAILWAY_PROJECT_ID_B=51c7f764-a8ca-4dff-b3cd-d91116e09d8a SUPABASE_URL_B= SUPABASE_SERVICE_ROLE_KEY_B= GITHUB_TOKEN_B= # ── 4. Quadrante C (DAEMON - Telegram) ─────────────────────── RAILWAY_TOKEN_C= -RAILWAY_PROJECT_ID_C=YOUR_RAILWAY_PROJECT_ID_C +RAILWAY_PROJECT_ID_C=d8843346-7c6a-48f1-adb3-0fd4a650b3e5 SUPABASE_URL_C= SUPABASE_SERVICE_ROLE_KEY_C= # ── 5. Quadrante D (AUDIT - Compliance) ────────────────────── RAILWAY_TOKEN_D= -RAILWAY_PROJECT_ID_D=YOUR_RAILWAY_PROJECT_ID_D +RAILWAY_PROJECT_ID_D=898b1c3e-6e64-4c5a-9609-afdd0dce84f8 SUPABASE_URL_D= SUPABASE_SERVICE_ROLE_KEY_D= # ── 6. Quadrante E (BOT-TG - Dedicated) ────────────────────── RAILWAY_TOKEN_E= -RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E +RAILWAY_PROJECT_ID_E=0834551e-51c8-4eff-aa4f-65c0b04ea933 # ── 7. LLM Unified Providers (A-E) ─────────────────────────── # Configurare nei Secrets del provider hosting (HF/Railway) GROQ_API_KEY= OPENROUTER_API_KEY= -# Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key. -# Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}] -OPENROUTER_PROFILES_JSON= GEMINI_API_KEY= NVIDIA_API_KEY= +OPENAI_API_KEY= # ── 8. Sandboxes & Tools ───────────────────────────────────── E2B_API_KEY= @@ -68,5 +62,4 @@ UPSTASH_REDIS_REST_TOKEN= # ── 9. Feature Flags ───────────────────────────────────────── VITE_ENABLE_BROWSER_SANDBOX=false UNIFIED_LOOP_MAX_STEPS=8 -LLM_MODEL=openai/gpt-oss-20b:free - +LLM_MODEL=deepseek/deepseek-r1:free diff --git a/REBUILD_TRIGGER b/REBUILD_TRIGGER new file mode 100644 index 0000000000000000000000000000000000000000..51bf50748aa39c526e9c3443fdfb42e56dae49cc --- /dev/null +++ b/REBUILD_TRIGGER @@ -0,0 +1,2 @@ +Rebuild trigger — 2026-07-03T13:39:25.801Z +Fix: rootDirectory corretto da /backend a backend (Railway backend service) 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/context_manager.py b/agents/context_manager.py index 9f24145ffc52cf7cdb5f16bea6af30a6133ad70f..df9be55439d9e483091f03c9875ca758e2be8b84 100644 --- a/agents/context_manager.py +++ b/agents/context_manager.py @@ -425,26 +425,3 @@ async def get_context_for_goal( return '\n\n'.join(parts) if parts else '' except Exception: return '' - -# ── S-CONTEXT-SHARDING: Gestione intelligente del contesto lungo (S482) ────── -def shard_context(full_context: str, max_shard_size: int = 2000) -> list[str]: - """Divide il contesto in shard logici basati sulla rilevanza semantica.""" - shards = [] - current_shard = [] - current_size = 0 - - # Dividiamo per blocchi logici (paragrafi o sezioni di codice) - blocks = re.split(r'\n(?=\s*[A-Z#])', full_context) - - for block in blocks: - block_size = len(block) - if current_size + block_size > max_shard_size and current_shard: - shards.append("\n".join(current_shard)) - current_shard = [] - current_size = 0 - current_shard.append(block) - current_size += block_size - - if current_shard: - shards.append("\n".join(current_shard)) - return shards diff --git a/agents/engineering_state.py b/agents/engineering_state.py deleted file mode 100644 index 201fe341e943f0eb891f7e75215511e6167ea203..0000000000000000000000000000000000000000 --- a/agents/engineering_state.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Versioned, bounded engineering lifecycle state for the unified agent loop. - -The module is deliberately dependency-free. It mirrors the legacy lifecycle without -being authoritative for recovery when the rollout mode is enabled, and it never stores -raw prompts, credentials, or arbitrary tool output. -""" -from __future__ import annotations - -import hashlib -import os -import re -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Mapping - -SCHEMA_VERSION = 1 -MAX_HISTORY = 64 -MAX_DIAGNOSTICS = 24 -MAX_PREVIEW_CHARS = 256 -MAX_ID_CHARS = 180 - -_SECRET_PATTERNS = ( - re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"), - re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"), - re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"), - re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"), - re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), -) - - -class EngineeringStateMode(str, Enum): - OFF = "off" - SHADOW = "shadow" - CANARY = "canary" - AUTHORITATIVE = "authoritative" - - -@dataclass(frozen=True) -class EngineeringStateConfig: - """Conservative rollout configuration read once per run.""" - - mode: EngineeringStateMode = EngineeringStateMode.OFF - canary_rate: float = 0.0 - - @classmethod - def from_env(cls) -> "EngineeringStateConfig": - raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode - try: - mode = EngineeringStateMode(raw_mode) - except ValueError: - mode = EngineeringStateMode.OFF - try: - rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0")) - except (TypeError, ValueError): - rate = 0.0 - return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0))) - - @property - def enabled(self) -> bool: - return self.mode is not EngineeringStateMode.OFF - - def selects_canary(self, run_id: str, session_id: str) -> bool: - if self.mode is not EngineeringStateMode.CANARY or not session_id: - return False - if self.canary_rate >= 1.0: - return True - if self.canary_rate <= 0.0: - return False - digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest() - bucket = int.from_bytes(digest[:8], "big") / float(2**64) - return bucket < self.canary_rate - - -def _bounded_id(value: str | None) -> str: - return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS] - - -def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str: - """Redact common credential forms before anything reaches a checkpoint.""" - text = str(value or "")[: max_chars * 4] - for pattern in _SECRET_PATTERNS: - if pattern.groups: - text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text) - else: - text = pattern.sub("[REDACTED]", text) - return text[:max_chars] - - -_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { - "IDLE": frozenset({"CLASSIFYING", "FAILED"}), - "CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}), - "TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}), - "THINKING": frozenset({"COMPLETED", "FAILED"}), - "FAILED": frozenset({"IDLE"}), - "COMPLETED": frozenset({"IDLE", "FAILED"}), -} - - -@dataclass -class EngineeringState: - """Bounded state envelope that can be persisted and safely restored.""" - - run_id: str - session_id: str - checkpoint_id: str - goal_digest: str - goal_preview: str - current_state: str = "IDLE" - history: list[dict[str, Any]] = field(default_factory=list) - diagnostics: list[str] = field(default_factory=list) - revision: int = 0 - sequence: int = 0 - created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000)) - updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000)) - - @classmethod - def start( - cls, - goal: str, - *, - run_id: str, - session_id: str = "", - checkpoint_id: str | None = None, - now_ms: int | None = None, - ) -> "EngineeringState": - now = int(time.time() * 1000) if now_ms is None else int(now_ms) - normalized_goal = str(goal or "") - return cls( - run_id=_bounded_id(run_id), - session_id=_bounded_id(session_id), - checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id), - goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(), - goal_preview=redact_text(normalized_goal), - created_at_ms=now, - updated_at_ms=now, - ) - - @property - def status(self) -> str: - if self.current_state == "COMPLETED": - return "completed" - if self.current_state == "FAILED": - return "failed" - return "active" - - def transition(self, next_state: str, *, now_ms: int | None = None) -> bool: - """Apply an idempotent transition; reject illegal transitions deterministically.""" - target = str(next_state) - if target == self.current_state: - return False - allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset()) - if target not in allowed: - raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}") - now = int(time.time() * 1000) if now_ms is None else int(now_ms) - self.sequence += 1 - self.revision += 1 - self.history.append({ - "sequence": self.sequence, - "from_state": self.current_state, - "to_state": target, - "at_ms": now, - }) - if len(self.history) > MAX_HISTORY: - del self.history[:-MAX_HISTORY] - self.current_state = target - self.updated_at_ms = now - return True - - def prepare_for_resume(self) -> None: - """Normalize a restored snapshot before a new loop execution.""" - if self.current_state != "IDLE": - self.current_state = "IDLE" - self.revision += 1 - self.updated_at_ms = int(time.time() * 1000) - self.diagnostic("resume normalized state to IDLE") - - def diagnostic(self, message: str) -> None: - value = redact_text(message, 180) - if not value or value in self.diagnostics: - return - self.diagnostics.append(value) - if len(self.diagnostics) > MAX_DIAGNOSTICS: - del self.diagnostics[:-MAX_DIAGNOSTICS] - self.revision += 1 - self.updated_at_ms = int(time.time() * 1000) - - def snapshot(self) -> dict[str, Any]: - """Return a bounded JSON-compatible envelope; never expose the raw goal.""" - return { - "schema_version": SCHEMA_VERSION, - "run_id": self.run_id, - "session_id": self.session_id, - "checkpoint_id": self.checkpoint_id, - "goal_digest": self.goal_digest, - "goal_preview": self.goal_preview, - "status": self.status, - "current_state": self.current_state, - "revision": self.revision, - "sequence": self.sequence, - "history": list(self.history[-MAX_HISTORY:]), - "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]), - "created_at_ms": self.created_at_ms, - "updated_at_ms": self.updated_at_ms, - } - - def projection(self) -> dict[str, Any]: - """Small read-only view safe for API/SSE consumers.""" - return { - "schema_version": SCHEMA_VERSION, - "status": self.status, - "current_state": self.current_state, - "revision": self.revision, - "sequence": self.sequence, - "checkpoint_id": self.checkpoint_id, - "history": [dict(item) for item in self.history[-16:]], - "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]), - } - - @classmethod - def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState": - if not isinstance(payload, Mapping): - raise ValueError("engineering state must be an object") - if int(payload.get("schema_version", -1)) != SCHEMA_VERSION: - raise ValueError("unsupported engineering state schema") - history = payload.get("history", []) - diagnostics = payload.get("diagnostics", []) - if not isinstance(history, list) or len(history) > MAX_HISTORY: - raise ValueError("invalid engineering state history") - if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS: - raise ValueError("invalid engineering state diagnostics") - current = str(payload.get("current_state", "")) - if current not in _ALLOWED_TRANSITIONS: - raise ValueError("invalid engineering state current state") - revision = int(payload.get("revision", -1)) - sequence = int(payload.get("sequence", -1)) - if revision < 0 or sequence < 0 or revision < sequence: - raise ValueError("invalid engineering state revision") - state = cls( - run_id=_bounded_id(str(payload.get("run_id", ""))), - session_id=_bounded_id(str(payload.get("session_id", ""))), - checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))), - goal_digest=str(payload.get("goal_digest", "")), - goal_preview=redact_text(payload.get("goal_preview", "")), - current_state=current, - history=[dict(item) for item in history if isinstance(item, Mapping)], - diagnostics=[redact_text(item, 180) for item in diagnostics], - revision=revision, - sequence=sequence, - created_at_ms=int(payload.get("created_at_ms", 0)), - updated_at_ms=int(payload.get("updated_at_ms", 0)), - ) - if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest): - raise ValueError("invalid engineering state goal digest") - return state diff --git a/agents/executor.py b/agents/executor.py index 7ba2b9fbd1117027a647b89815cbedecb80a3495..07b0afcdc25ce7fbcfebb69288c0d6ccd0532d5a 100644 --- a/agents/executor.py +++ b/agents/executor.py @@ -213,26 +213,11 @@ class Executor: # ── run_tool ───────────────────────────────────────────────────────────── - async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0, worker_hint: str | None = None) -> dict: - """ - Esegue un tool. Se worker_hint è fornito, tenta l'esecuzione sul worker specifico. - ARCH-I4.3: Tool Engine evoluto con Capability Resolver. - """ + async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict: tool = TOOL_REGISTRY.get(tool_name) if not tool: return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None} - # ARCH-E3.2/ARCH-I4.3: Risoluzione dinamica della capability via Kernel - if not worker_hint: - try: - from api.kernel import kernel - res = await kernel.resolve_capability(tool_name) - if res.get("status") == "resolved": - worker_hint = res["worker"]["id"] - _logger.info(f"[executor] capability '{tool_name}' risolta su worker: {worker_hint}") - except Exception as e: - _logger.debug(f"[executor] resolver bypass: {e}") - missing = [r for r in tool.get("required_inputs", []) if r not in inputs] if missing: return {"success": False, "error": f"Input mancanti: {missing}", "output": None} @@ -333,4 +318,3 @@ class Executor: await asyncio.sleep(0.5) return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None} - 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/file_conversion.py b/agents/file_conversion.py deleted file mode 100644 index fedcddb1855395db1db2aa931c409f8307265ed2..0000000000000000000000000000000000000000 --- a/agents/file_conversion.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Conversioni tabellari deterministiche per dati CSV espliciti nel goal. - -Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``. -Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM. -""" -from __future__ import annotations - -import csv -import io -import json -import re -from dataclasses import dataclass -from typing import Any - -_ATTACHMENT_RE = re.compile( - r"###\s*📎\s*(?P[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P[\s\S]*?)```", - re.IGNORECASE, -) -# Il target può essere espresso come "file chiamato foo.json" oppure come -# "poi crea foo.json". Il gruppo è limitato a nomi semplici, quindi il parser -# non accetta path traversal o istruzioni aggiuntive. -_TARGET_RE = re.compile( - r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)" - r"['`\"]?(?P[\w.-]+\.json)\b", - re.IGNORECASE, -) -_CONVERSION_RE = re.compile( - r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b", - re.IGNORECASE, -) -_INLINE_CSV_RE = re.compile( - r"\b(?:crea|scrivi)\s+(?P[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*" - r"(?P[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)", - re.IGNORECASE, -) - - -@dataclass(frozen=True) -class CsvJsonConversion: - source_name: str - target_name: str - content: str - row_count: int - source_content: str - source_is_inline: bool = False - - -def _coerce_scalar(value: str) -> Any: - value = value.strip() - if re.fullmatch(r"-?(?:0|[1-9]\d*)", value): - return int(value) - if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value): - return float(value) - return value - - -def _csv_body(raw_body: str) -> str: - lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n") - while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")): - lines.pop(0) - return "\n".join(lines).strip() - - -def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None: - """Legge CSV senza tollerare header/colonne ambigue o righe tronche.""" - try: - reader = csv.DictReader(io.StringIO(csv_body)) - raw_headers = reader.fieldnames - if not raw_headers: - return None - headers = [str(header or "").strip() for header in raw_headers] - if any(not header for header in headers) or len(set(headers)) != len(headers): - return None - - rows: list[dict[str, Any]] = [] - for raw_row in reader: - # DictReader usa None per colonne in eccesso e per celle mancanti. - if None in raw_row or any(raw_row.get(header) is None for header in raw_headers): - return None - row = { - headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "") - for index in range(len(headers)) - } - rows.append(row) - return rows - except (csv.Error, UnicodeError): - return None - - -def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]: - """Verifica che il JSON sia l’array esatto dei record CSV normalizzati. - - La verifica è intenzionalmente stretta: stessa cardinalità, stesso ordine, - stesse chiavi e stessi valori dopo la coercizione deterministica del CSV. - """ - expected = _parse_csv_rows(_csv_body(csv_content)) - if expected is None: - return False, "CSV non valido o ambiguo" - try: - actual = json.loads(json_content) - except (TypeError, json.JSONDecodeError): - return False, "JSON non valido" - if not isinstance(actual, list): - return False, "il JSON deve essere un array" - if any(not isinstance(record, dict) for record in actual): - return False, "ogni record JSON deve essere un oggetto" - if actual != expected: - return False, "i record JSON non corrispondono esattamente al CSV" - return True, "" - - -def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None: - csv_body = _csv_body(raw_body) - rows = _parse_csv_rows(csv_body) - if rows is None: - return None - content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n" - is_valid, _reason = validate_csv_json_equivalence(csv_body, content) - if not is_valid: - # Difesa di coerenza interna: una conversione diretta non può dichiararsi - # riuscita se il proprio serializzatore non supera il medesimo contratto. - return None - return CsvJsonConversion( - source_name=source_name.strip(), - target_name=target_name.strip(), - content=content, - row_count=len(rows), - source_content=csv_body + "\n", - source_is_inline=source_is_inline, - ) - - -def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None: - """Converte un CSV allegato o esplicitamente incluso nel goal in JSON. - - Il ritorno è ``None`` quando il goal non definisce una conversione tabellare - completa: il resto del loop conserva quindi il comportamento esistente. - """ - if not _CONVERSION_RE.search(goal): - return None - - target = _TARGET_RE.search(goal) - if not target: - return None - - inline = _INLINE_CSV_RE.search(goal) - if inline: - return _build_conversion( - inline.group("name"), - target.group("name"), - inline.group("body"), - source_is_inline=True, - ) - - attachment = _ATTACHMENT_RE.search(goal) - if not attachment: - return None - return _build_conversion( - attachment.group("name"), - target.group("name"), - attachment.group("body"), - source_is_inline=False, - ) diff --git a/agents/goal_verifier.py b/agents/goal_verifier.py index d00ae9b4e12a148771e75249a748bcde362f865e..132804e47de06a023731e1d3c21a4aec0f690560 100644 --- a/agents/goal_verifier.py +++ b/agents/goal_verifier.py @@ -40,7 +40,7 @@ class GoalVerificationStatus(str, Enum): FAIL = "FAIL" UNKNOWN = "UNKNOWN" -RETRY_THRESHOLD = 0.30 # S-BENCH-FIX: meno punitivo su near-misses +RETRY_THRESHOLD = 0.35 MAX_GOAL_CHARS = 400 MAX_ANS_CHARS = 1500 MAX_HINT_CHARS = 150 @@ -178,18 +178,7 @@ class GoalVerifier: r"flask|fastapi|django|express|nestjs|rails|laravel|" r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|" r"database|schema|migration|model|table|index|query|" - r"test|spec|fixture|mock|unit.*test|integration.*test)\b", - re.IGNORECASE, - ) - - _FILE_CONVERSION_RE = re.compile( - r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}" - r"\b(?:csv|tsv|xlsx|xls|json|pdf|txt|markdown|md|docx)\b", - re.IGNORECASE, - ) - _IMPLEMENTATION_CONTEXT_RE = re.compile( - r"\b(?:codice|script|funzione|function|class|componente|component|api|endpoint|" - r"typescript|javascript|python|react|backend|frontend|test\s+unit|test\s+e2e)\b", + r"test|spec|fixture|mock|e2e|unit.*test|integration.*test)\b", re.IGNORECASE, ) @@ -204,13 +193,7 @@ class GoalVerifier: @classmethod def is_code_goal(cls, goal: str) -> bool: - # Gli allegati sono serializzati dopo questo separatore: non devono trasformare - # una semplice lettura/conversione in un task di sviluppo da riparare. - user_goal = goal.split("--- **File allegati:**", 1)[0][:500] - if (cls._FILE_CONVERSION_RE.search(user_goal) - and not cls._IMPLEMENTATION_CONTEXT_RE.search(user_goal)): - return False - return bool(cls._CODE_RE.search(user_goal)) + return bool(cls._CODE_RE.search(goal[:500])) @classmethod def adaptive_threshold(cls, goal: str) -> float: @@ -220,9 +203,9 @@ class GoalVerifier: if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]): return 0.25 if _COMPLEX_CODE_RE.search(g[:500]): - return 0.48 # S-BENCH-FIX: 0.55 -> 0.48 bilanciamento rigore + return 0.55 if cls._CODE_RE.search(g[:500]): - return 0.38 # S-BENCH-FIX: 0.42 -> 0.38 + return 0.42 return RETRY_THRESHOLD def __init__(self, llm: Any) -> None: 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/planner.py b/agents/planner.py index 47933d546527aa7f420878e020a04744490942a5..6acce17570d1de8df393a4060734362d980259e1 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -118,14 +118,6 @@ REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numeric REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate, DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask. -REGOLA ORCHESTRATION (S-GAP9): Per task complessi (>5 passi), includi SEMPRE un subtask finale di "Verifica Integrazione e Test End-to-End". -Scomponi i rami Backend e Frontend in parallel_groups separati per massimizzare l'efficienza. - -REGOLA RECOVERY & ROBUSTNESS (S-GAP12, S-GAP7): -- Se l'obiettivo è ambiguo o i dati sembrano incoerenti, il primo subtask DEVE essere "Analisi Critica e Validazione Requisiti" (tool: direct_response). -- Per ogni integrazione API, aggiungi un subtask di "Health Check / Verifica Connettività" prima delle operazioni core. -- Se il task fallisce 2 volte, il piano deve includere un passo di "Debug e Analisi Log" (tool: read_file/execute_shell). - REGOLE GRAFO DI DIPENDENZE: - requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[] - requires:[N] → subtask che dipende dall'output di subtask id N @@ -194,7 +186,6 @@ def _parse_plan(raw: str) -> dict | None: class Planner: def __init__(self, llm_client: AIClient | None = None): - self._explicit_llm = llm_client is not None if llm_client is not None: self.llm = llm_client else: @@ -211,9 +202,7 @@ class Planner: def _get_fast_llm(self) -> AIClient: """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft. - Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente.""" - if self._explicit_llm: - return self.llm + Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente.""" try: from models.role_router import RoleRouter, Role return RoleRouter.get_client(Role.REASONER) # Cerebras 120B diff --git a/agents/reflection_sidecar.py b/agents/reflection_sidecar.py index ad11ff29c058de9aab07653a201da5d6d85702da..acf072743f09b6a1604bda98014dad60f548f907 100644 --- a/agents/reflection_sidecar.py +++ b/agents/reflection_sidecar.py @@ -87,14 +87,9 @@ class ReflectionSidecar: if count >= _MAX_ERRORS_BEFORE_REFLECT: # Avvia reflection in background (non blocca l'agente principale) if self._reflect_task is None or self._reflect_task.done(): - # BUGFIX: eccezioni di _reflect_and_update erano perse silenziosamente - def _log_ref_exc(t): - if not t.cancelled() and t.exception(): - _logger.warning("[reflection_sidecar] reflect task raised: %s", t.exception()) self._reflect_task = asyncio.create_task( self._reflect_and_update(tool, error, context) ) - self._reflect_task.add_done_callback(_log_ref_exc) async def _reflect_and_update( self, tool: str, last_error: str, context: str diff --git a/agents/strategic_healer.py b/agents/strategic_healer.py index 3f92d721e3f01ef78bca77449be2de562fbebad1..fdd16545dde212a8cbe10308ea78a289faa2211b 100644 --- a/agents/strategic_healer.py +++ b/agents/strategic_healer.py @@ -67,17 +67,6 @@ class StrategyDecision: # ── Healer principale ────────────────────────────────────────────────────────── class StrategicHealer: - - # ── S-DYNAMIC-TOOL-HEALING: Fallback dinamico per tool (S512) ──────────── - async def get_tool_fallback_strategy(self, tool_name: str, error: str) -> str: - """Determina una strategia alternativa se un tool specifico fallisce.""" - fallbacks = { - "google_search": "Il tool di ricerca web è instabile. Usa 'webpage_extract' direttamente sugli URL noti o tenta una ricerca mirata su GitHub/Wikipedia via shell.", - "web_fetch": "L'estrazione fallisce. Usa 'curl -s' via shell per ottenere il contenuto grezzo e analizzalo con regex.", - "python_exec": "L'esecuzione Python ha fallito. Tenta di risolvere il task tramite logica shell (bc, awk, sed) o semplifica lo script." - } - return fallbacks.get(tool_name, f"Il tool {tool_name} ha fallito. Analizza l'errore {error} e cambia approccio.") - """ Cognitive self-healing: costruisce comprensione incrementale dei fallimenti. diff --git a/agents/unified_loop.py b/agents/unified_loop.py index 795734956172799dae15cd8aa948b09b5d96e61c..5afff04a4ab68d30c0624abfc008e9ac08b4b622 100644 --- a/agents/unified_loop.py +++ b/agents/unified_loop.py @@ -55,57 +55,10 @@ from agents.unified_loop_types import ( _ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding _is_goal_ambiguous, _is_borderline_ambiguous, - AgentState, UnifiedLoopState, _maybe_await, ) -# I4.5: active state is scoped to the current asyncio task, not the loop instance. -# This lets the public guard close unexpected exceptions without sharing state across runs. -_ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None) -# P0: EngineeringState is a shadow/canary projection of the legacy lifecycle. -# Context-local storage keeps parallel runs isolated even when one loop instance is reused. -from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode - -_ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar( - "active_engineering_state", default=None -) -_ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar( - "active_engineering_mode", default=None -) - - -def _schedule_engineering_persist(engineering_state: EngineeringState) -> None: - """Persist a snapshot without blocking the loop or making observability fatal.""" - snapshot = engineering_state.snapshot() - - async def _persist() -> None: - try: - from api.persistence import sb_save_engineering_state - await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot) - except Exception as exc: # shadow state must never break the user task - _logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__) - - try: - task = asyncio.create_task(_persist()) - task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) - except RuntimeError: - # No running event loop during defensive/test-only calls. - return - - -async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None: - """Flush the terminal snapshot before returning a run result.""" - if engineering_state is None: - return - snapshot = engineering_state.snapshot() - try: - from api.persistence import sb_save_engineering_state - await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True) - except Exception as exc: # persistence must not turn a completed task into a crash - engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}") - _logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__) - # S404: Error Classifier — import lazy per evitare circular import issues def _get_classifier(): from agents.error_classifier import classify_error, format_for_context @@ -176,43 +129,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer # ── GAP-3: Rollback atomico scritture ───────────────────────────────────────── - async def _transition_state( - self, - state: UnifiedLoopState, - next_state: AgentState, - on_step: StepCallback | None = None, - ) -> None: - """Validate and publish one per-run state transition.""" - previous = state.state_machine.current - state.state_machine.transition(next_state) - - # P0 adapter: mirror every legacy transition into the versioned state. - engineering_state = _ACTIVE_ENGINEERING_STATE.get() - if engineering_state is not None: - try: - engineering_state.transition(next_state.value) - _schedule_engineering_persist(engineering_state) - except Exception as exc: - engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}") - if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE: - raise - _logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__) - - if previous == next_state or on_step is None: - return - try: - event = { - "action": "state_transition", - "status": "done", - "from_state": previous.value, - "to_state": next_state.value, - } - if engineering_state is not None: - event["engineering_state"] = engineering_state.projection() - await _maybe_await(on_step(event)) - except Exception as _state_callback_error: - _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error) - async def _rollback_writes(self, on_step=None) -> None: """ GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà. @@ -550,29 +466,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 ) @@ -589,7 +485,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", @@ -598,10 +494,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: @@ -626,12 +519,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, "explanation": "Il pianificatore ha impiegato troppo — procedo senza piano", "visibility": "progress", })) - # P1-RECOVERY: check if plan already exists in steps - existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None) - if existing_plan_step: - plan = existing_plan_step.get("result") - _logger.info("[P1-RECOVERY] Plan restored from steps") - elif plan is not None: + if plan is not None: state.steps.append({"action": "plan", "result": plan}) try: from api.state import record_timing as _rtc_pl @@ -1038,15 +926,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, 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: - # P1-RECOVERY: skip subtasks already completed in state.steps - _st_id = subtask.get("id") - _done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None) - if _done_step: - _logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id) - # Ripristiniamo l'output nel buffer per i dipendenti - _existing_out = _done_step.get("output", "") - _subtask_outputs[str(_st_id)] = _existing_out - continue _pending_exec.append((subtask, reg_name, inp_builder)) elif _s_tool: # COG-4: tool non in _TOOL_MAP — tenta generazione dinamica @@ -1782,16 +1661,16 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, _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) - if _tool_exec_errors and getattr(self, '_strategic_healer', None): + 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(_tool_exec_errors, _sh_ctx_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 fallback") - return {"success": False, "output": "", "error": "StrategicHealer ha interrotto il fallback dopo errori di esecuzione"} + return # _run_fallback: should_stop → esci dal fallback (non c'è loop da rompere) except Exception as _sh_loop_err: _logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err) # GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction. @@ -2254,58 +2133,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, _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 - # BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non - # modifica answer, retry, provider routing o scoring. - try: - from benchmarks.shadow_telemetry import validate_and_record_shadow - validate_and_record_shadow( - goal=state.goal, - answer=answer, - metadata={ - "provider": getattr(_active_llm, "provider", None), - "model": getattr(_active_llm, "model", None), - "profile": getattr(_active_llm, "profile", None), - "attempt": _llm_try, - "latency_ms": round(_llm_elapsed, 2), - "source": "unified_loop", - }, - ) - except Exception as _exc: - _logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__) - - # BENCH-CODE-RETRY: retry strutturato solo per output TypeScript - # non estraibile/non conforme. Non aggiunge tentativi oltre il budget - # esistente e non scatta su goal non-coding. - if not _is_last: - try: - from benchmarks.validators import validate_coding_retry - _code_validation = validate_coding_retry( - state.goal, - answer, - is_last_attempt=_is_last, - ) - if _code_validation is not None: - state.steps.append({ - "action": f"typescript_contract_retry_{_llm_try}", - "failure_code": _code_validation.failure_code, - }) - _code_repair = ( - "CONTRATTO TYPESCRIPT FALLITO: " - f"{_code_validation.failure_code}.\n" - "Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco " - "```typescript ... ``` non vuoto, completo e compilabile. " - "Mantieni la firma e tutti i simboli richiesti dal task. " - "Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder." - ) - messages = [ - messages[0], - {"role": "system", "content": _code_repair}, - *messages[1:], - ] - _error_severity = "syntax" - continue - except Exception as _exc: - _logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__) # P16-B4: segnala truncation SSE se finish_reason == "length" _fr = getattr(_active_llm, '_last_finish_reason', 'stop') if _fr == 'length' and on_step: @@ -3061,7 +2888,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, except Exception: pass # S455-P10: task supervisionato — done_callback logga eccezioni silenziate - _rv_t = asyncio.create_task(_reverify_task()) + 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 ) @@ -3533,57 +3360,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, async def run(self, goal: str, context: str = "", max_steps: int = 8, on_step: StepCallback | None = None, - session_id: str = "", allow_tools: bool = True, - allow_local_csv_conversion: bool = False) -> dict[str, Any]: - """Run the loop and close unexpected exceptions as a controlled FAILED state.""" - previous_state = _ACTIVE_LOOP_STATE.get() - previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get() - previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get() - try: - return await self._run_impl( - goal, context, max_steps, on_step, session_id, allow_tools, - allow_local_csv_conversion, - ) - except Exception as _run_error: - state = _ACTIVE_LOOP_STATE.get() - error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}" - if state is None: - return { - "success": False, - "goal": goal, - "error": error_text, - "agent_state": AgentState.FAILED.value, - "state_history": [AgentState.IDLE.value, AgentState.FAILED.value], - } - - state.errors.append(error_text) - previous = state.state_machine.current - if previous != AgentState.FAILED: - try: - await self._transition_state(state, AgentState.FAILED, on_step) - except Exception as _state_transition_error: - _logger.debug( - "[unified_loop] failure transition silenced: %s", - _state_transition_error, - ) - await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get()) - return { - "success": False, - "goal": state.goal, - "steps": state.steps, - "errors": state.errors, - "error": error_text, - **state.state_machine.snapshot(), - } - finally: - _ACTIVE_LOOP_STATE.set(previous_state) - _ACTIVE_ENGINEERING_STATE.set(previous_engineering_state) - _ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode) - - async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8, - on_step: StepCallback | None = None, - session_id: str = "", allow_tools: bool = True, - allow_local_csv_conversion: bool = False) -> dict[str, Any]: + session_id: str = "") -> dict[str, Any]: # S390-B-L: strip role prefixes che causano prompt injection # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso — input come @@ -3618,17 +3395,17 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, except Exception: _sid_token = None # fallback silente — registry usa default "agent_default" - # S750-GAP-B: pre-warm sandbox solo per task che possono usare tool. - # Con allow_tools=False non avviamo alcuna sessione esterna prima della risposta. - if allow_tools: - try: - from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl - if _eurl: - asyncio.ensure_future( - _ce({"session_id": self._run_task_id}, endpoint="/api/session") - ) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 + # S750-GAP-B: pre-warm sandbox backend-exec — POST /api/session in background. + # asyncio.create_task lancia la richiesta senza bloccare il routing: + # mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta. + try: + from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl + if _eurl: + asyncio.ensure_future( + _ce({"session_id": self._run_task_id}, endpoint="/api/session") + ) + except Exception as _exc: + _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # S568-B: reset _session_files ogni run — previene memory leak su sessioni lunghe. # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate. @@ -3643,121 +3420,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id) - # P1: EngineeringState is the recovery authority unless explicitly disabled. - engineering_config = EngineeringStateConfig.from_env() - _effective_mode = engineering_config.mode - _ACTIVE_ENGINEERING_MODE.set(_effective_mode) - - engineering_state: EngineeringState | None = None - recovery_status = "disabled" - if _effective_mode != EngineeringStateMode.OFF: - engineering_state = EngineeringState.start( - goal, - run_id=self._run_task_id, - session_id=session_id, - checkpoint_id=session_id or self._run_task_id, - ) - _ACTIVE_ENGINEERING_STATE.set(engineering_state) - recovery_status = "started" - - # RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition. - if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id: - try: - from api.persistence import sb_get_checkpoint - legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id) - candidate = (legacy_checkpoint or {}).get("engineering_state") - if candidate: - restored = EngineeringState.from_snapshot(candidate) - if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest: - engineering_state.diagnostic("restore conflict: identity mismatch") - recovery_status = "conflict" - elif _effective_mode == EngineeringStateMode.AUTHORITATIVE: - engineering_state = restored - engineering_state.prepare_for_resume() - _ACTIVE_ENGINEERING_STATE.set(engineering_state) - if legacy_checkpoint: - checkpoint_steps = legacy_checkpoint.get("steps") - checkpoint_errors = legacy_checkpoint.get("errors") - state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else [] - state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else [] - recovery_status = "restored" - _logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision) - else: - engineering_state.diagnostic("restore validated read-only") - recovery_status = "validated" - else: - recovery_status = "checkpoint_missing" - except Exception as restore_error: - engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}") - recovery_status = "rejected" - _logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__) - - _ACTIVE_LOOP_STATE.set(state) - await self._transition_state(state, AgentState.CLASSIFYING, on_step) - - def _with_state(result: dict[str, Any]) -> dict[str, Any]: - result.update(state.state_machine.snapshot()) - if engineering_state is not None: - result["engineering_state"] = engineering_state.projection() - return result - - if engineering_state is not None and on_step is not None: - try: - await _maybe_await(on_step({ - "action": "engineering_state", - "status": recovery_status, - "mode": _effective_mode.value, - "engineering_state": engineering_state.projection(), - })) - except Exception as recovery_event_error: - _logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__) - - async def _finish(result: dict[str, Any]) -> dict[str, Any]: - next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED - try: - await self._transition_state(state, next_state, on_step) - finally: - # P1 contract: persist the terminal state before returning to the caller. - await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get()) - return _with_state(result) - - # Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner, - # sandbox, speculazione o tool card è raggiungibile. L'unica eccezione è la - # conversione CSV→JSON già riconosciuta e validata dal parser puro al confine HTTP. - if not allow_tools: - if allow_local_csv_conversion: - await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step) - direct_results, _tools_count, _exec_success, _exec_errors = await self._run_direct_tools( - goal, on_step=on_step, local_csv_only=True, - ) - if direct_results.startswith("[DIRECT_TERMINAL]\n"): - _r = await _finish({ - "success": _exec_success > 0, - "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"), - "steps": state.steps, - }) - else: - _r = await _finish({ - "success": False, - "output": direct_results, - "steps": state.steps, - "errors": ["conversione CSV locale non completata"], - }) - _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 - return _r - 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 - return _r - # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo) try: from agents.strategic_healer import StrategicHealer as _SHClass @@ -3839,7 +3501,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, "title": "Specifica cosa vuoi fare", "explanation": _amb_answer, })) - _r_amb = await _finish({"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}) + _r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps} if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception: pass @@ -3956,7 +3618,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, "title": "Puoi essere più specifico?", "explanation": _bl_answer, })) - _r_bl = await _finish({"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}) + _r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps} if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception: pass @@ -3973,8 +3635,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fast_path(state, on_step)) + _r = await self._run_fast_path(state, on_step) _r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000)) _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # S749-D: reset ContextVar @@ -3993,8 +3654,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # Puro ragionamento — LLM diretto, nessun overhead tool - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fallback(state, on_step)) + _r = await self._run_fallback(state, on_step) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr @@ -4024,8 +3684,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, _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 = 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: @@ -4092,15 +3751,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception: pass - await self._transition_state(state, AgentState.THINKING, on_step) - return await _finish({ + return { "success": True, "answer": _p36_answer, "timing_ms": _p36_ms, "effective_max_steps": state.max_steps, "steps": [{"action": "p36_python_analyze", "status": "done", "output": _p36_answer[:300]}], - }) + } except Exception as _p36_exc: _logger.debug("P36 fast-path silenced: %s", _p36_exc) # fail-open: cade nel percorso normale @@ -4112,7 +3770,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _t_tool = _time.monotonic() - await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step) direct_results, _tools_count, _exec_success, _exec_errors = \ await self._run_direct_tools(goal, on_step=on_step) _tool_ms = int((_time.monotonic() - _t_tool) * 1000) @@ -4121,20 +3778,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, "loop": 0, "action": "direct_tools", "status": "done", "tools_fired": _tools_count, })) - if direct_results.startswith("[DIRECT_TERMINAL]\n"): - _r = await _finish({ - "success": _exec_success > 0, - "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"), - "steps": state.steps, - }) - else: - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fallback( - state, on_step, - preloaded_tool_results=direct_results or None, - preloaded_tool_exec_successes=_exec_success, - preloaded_tool_exec_errors=_exec_errors, - )) + _r = await self._run_fallback( + state, on_step, + preloaded_tool_results=direct_results or None, + preloaded_tool_exec_successes=_exec_success, + preloaded_tool_exec_errors=_exec_errors, + ) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr @@ -4161,7 +3810,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, # S193: tool diretti PRIMA (deterministici, nessun LLM per routing) # S402: unpack 4-tuple — aggiunto _exec_success/_exec_errors per Tool Integrity Guard _t_tool = _time.monotonic() - await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step) direct_results, _tools_count, _exec_success, _exec_errors = \ await self._run_direct_tools(goal, on_step=on_step) _tool_ms = int((_time.monotonic() - _t_tool) * 1000) @@ -4173,20 +3821,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, "loop": 0, "action": "direct_tools", "status": "done", "tools_fired": _tools_count, })) - if direct_results.startswith("[DIRECT_TERMINAL]\n"): - _r = await _finish({ - "success": _exec_success > 0, - "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"), - "steps": state.steps, - }) - else: - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fallback( - state, on_step, - preloaded_tool_results=direct_results, - preloaded_tool_exec_successes=_exec_success, - preloaded_tool_exec_errors=_exec_errors, - )) + _r = await self._run_fallback( + state, on_step, + preloaded_tool_results=direct_results, + preloaded_tool_exec_successes=_exec_success, + preloaded_tool_exec_errors=_exec_errors, + ) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr @@ -4210,8 +3850,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, # Rimosso: -25s worst case, path sempre: direct_tools → _run_fallback. # Fallback: LLM senza tool results (tool non triggered o tutti skip) - await self._transition_state(state, AgentState.THINKING, on_step) - _r = await _finish(await self._run_fallback(state, on_step)) + _r = await self._run_fallback(state, on_step) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr 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' + ), + "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) + + diff --git a/tools/trigger_webhook.py b/tools/trigger_webhook.py index 2803a28b44169d3c2145badb6cffe3d9d34a8cbf..251b728fdfb915dc8c4f2d521d5254cd2632a58b 100644 --- a/tools/trigger_webhook.py +++ b/tools/trigger_webhook.py @@ -9,10 +9,7 @@ from __future__ import annotations import json import logging import os -import socket -import ipaddress from typing import Any -from urllib.parse import urlparse import httpx _logger = logging.getLogger("agente_ai.tools.trigger_webhook") @@ -22,46 +19,19 @@ _ALLOWED_HOSTS: set[str] = {h.strip().lower() for h in _ALLOWED_HOSTS_RAW.split( def _check_host(url: str) -> tuple[bool, str]: - """ - Verifica l'host dell'URL contro la allowlist e previene SSRF. - Implementa risoluzione DNS e blocco IP privati/locali. - """ - try: - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - return False, f"Schema non supportato: {parsed.scheme}" - host = parsed.hostname or "" - - # 1. Allowlist check (se configurata) - if _ALLOWED_HOSTS: - allowed_match = False - if host.lower() in _ALLOWED_HOSTS: - allowed_match = True - else: - for allowed in _ALLOWED_HOSTS: - if allowed.startswith("*.") and host.lower().endswith(allowed[1:]): - allowed_match = True - break - if not allowed_match: - return False, f"Host '{host}' non nella allowlist WEBHOOK_ALLOWED_HOSTS" - - # 2. SSRF Protection (Risoluzione DNS + IP Check) - try: - # socket.gethostbyname() risolve l'host all'indirizzo IPv4 - ip_str = socket.gethostbyname(host) - ip = ipaddress.ip_address(ip_str) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: - _logger.warning("[trigger_webhook] SSRF bloccato: %s -> %s", host, ip_str) - return False, f"Accesso a indirizzi privati/locali ({ip_str}) non consentito per motivi di sicurezza." - except socket.gaierror: - # Host non risolvibile — httpx gestirà l'errore di connessione se procediamo - pass - except Exception as exc: - return False, f"Errore validazione IP: {exc}" - - return True, "" - except Exception as exc: - return False, f"URL non valido: {exc}" + if not _ALLOWED_HOSTS: + return True, "" + try: + from urllib.parse import urlparse + host = urlparse(url).hostname or "" + if host.lower() in _ALLOWED_HOSTS: + return True, "" + for allowed in _ALLOWED_HOSTS: + if allowed.startswith("*.") and host.lower().endswith(allowed[1:]): + return True, "" + return False, f"Host '{host}' non nella allowlist WEBHOOK_ALLOWED_HOSTS" + except Exception as exc: + return False, f"URL non valido: {exc}" async def trigger_webhook( @@ -85,7 +55,6 @@ async def trigger_webhook( method = method.upper().strip() if method not in _ALLOWED_METHODS: return {"ok": False, "error": f"Metodo non supportato: {method}. Usa: {sorted(_ALLOWED_METHODS)}"} - ok_host, err_host = _check_host(url) if not ok_host: return {"ok": False, "error": err_host} @@ -105,8 +74,7 @@ async def trigger_webhook( _timeout = min(float(timeout), 15.0) try: - # follow_redirects=False per prevenire bypass SSRF via redirect verso IP interni - async with httpx.AsyncClient(timeout=_timeout, follow_redirects=False) as client: + async with httpx.AsyncClient(timeout=_timeout, follow_redirects=True) as client: resp = await client.request( method, url, headers=_hdrs, content=body_bytes if method != "GET" else None,