diff --git "a/agents/unified_loop.py" "b/agents/unified_loop.py" --- "a/agents/unified_loop.py" +++ "b/agents/unified_loop.py" @@ -26,6 +26,9 @@ import asyncio import logging import os import re +from agents.watchdog import BidirectionalWatchdog +from .grid_rag import get_grid_rag +from memory.distiller import MemoryDistiller from contextvars import ContextVar from typing import Any @@ -108,9 +111,35 @@ async def _read_bb_upstash(session_id: str) -> str: except Exception: return "" - -class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin): - """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback.""" +# ── Nuovi mixin estratti (split 2026-06-30) ────────────────────────────────── +from agents.unified_loop_vfs import VFSMixin +from agents.unified_loop_delegate import DelegateMixin +from agents.unified_loop_routing import RoutingMixin +from agents.unified_loop_fallback import FallbackMixin + + +class UnifiedAgentLoop( + VFSMixin, + DelegateMixin, + RoutingMixin, + FallbackMixin, + DirectToolsMixin, + PromptBuilderMixin, + LLMSelectionMixin, + HelpersMixin, +): + """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback. + + MRO (sinistra = priorità alta): + VFSMixin → _rollback_writes, _vfs_git_backup, _get_vfs_lock + DelegateMixin → _reflective_debug, _budget_replan_check, _run_in_loop_delegate + RoutingMixin → _CODE_RE, _FILE_BLOCK_RE, _extract_written_files + FallbackMixin → _run_fallback + DirectToolsMixin → _run_direct_tools, _needs_tools, _is_simple_query + PromptBuilderMixin → _build_messages, _compress_goal + LLMSelectionMixin → _get_llm_for_goal, _get_fast_llm, _sanitize_agent_output + HelpersMixin → _run_fast_path, _proactive_reflect, _budget_replan_check (override) + """ def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, critic: Any = None, memory: Any = None, verifier: Any = None) -> None: @@ -129,3219 +158,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer # ── GAP-3: Rollback atomico scritture ───────────────────────────────────────── - async def _rollback_writes(self, on_step=None) -> None: - """ - GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà. - Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente. - Ogni file in _write_snapshots viene ripristinato al suo contenuto originale. - File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro). - """ - if not self._write_snapshots or not self.executor: - return - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n", - "status": "streaming", - })) - _rolled = 0 - for path, original in self._write_snapshots.items(): - if original is None: - continue # file non esisteva prima — saltiamo (non eliminiamo) - try: - await asyncio.wait_for( - self.executor.run_tool("write_file", {"path": path, "content": original}), - timeout=10.0, - ) - _rolled += 1 - except Exception: - pass # non-fatal — best effort rollback - _total = len(self._write_snapshots) # salva prima del clear - self._write_snapshots = {} - _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total) - - # ── GAP-NEW-4: Git VFS auto-snapshot ──────────────────────────────────────── - async def _vfs_git_backup(self) -> None: - """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub. - - Fire-and-forget — non blocca mai il loop principale, non solleva eccezioni. - Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo". - Crea automaticamente il branch vfs-backup se non esiste. - Force-push consentito su vfs-backup (non è main — nessun rischio di perdita). - """ - import os as _os_vfs - gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip() - gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip() - if not gh_token or not gh_repo: - return - files = dict(self._session_files) # snapshot immutabile - if not files: - return - run_id = self._run_task_id[:8] or "unknown" - try: - import httpx as _hx4 - headers = { - "Authorization": f"Bearer {gh_token}", - "Accept": "application/vnd.github+json", - "User-Agent": "agente-ai-vfs/1.0", - } - base = f"https://api.github.com/repos/{gh_repo}" - async with _hx4.AsyncClient(timeout=20.0) as _cli: - # 1. Leggi (o crea) branch vfs-backup - r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers) - if r_ref.status_code == 404: - r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers) - if r_main.status_code != 200: - return - r_cr = await _cli.post(f"{base}/git/refs", headers=headers, - json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]}) - if r_cr.status_code not in (200, 201): - return - backup_head = r_main.json()["object"]["sha"] - elif r_ref.status_code == 200: - backup_head = r_ref.json()["object"]["sha"] - else: - return - - # 2. Leggi base tree del backup HEAD - r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers) - if r_c.status_code != 200: - return - base_tree = r_c.json()["tree"]["sha"] - - # 3. Crea blob per ogni file (max 20 per backup, max 50KB per file) - tree_items = [] - for _path, _content in list(files.items())[:20]: - rb = await _cli.post(f"{base}/git/blobs", headers=headers, - json={"content": str(_content)[:50_000], "encoding": "utf-8"}) - if rb.status_code == 201: - tree_items.append({ - "path": f"vfs/{_path.lstrip('/')}", - "mode": "100644", - "type": "blob", - "sha": rb.json()["sha"], - }) - - if not tree_items: - return - - # 4. Tree + commit + force-push su vfs-backup - rt = await _cli.post(f"{base}/git/trees", headers=headers, - json={"base_tree": base_tree, "tree": tree_items}) - if rt.status_code != 201: - return - rc = await _cli.post(f"{base}/git/commits", headers=headers, - json={ - "message": f"vfs-backup: {len(tree_items)} file (run {run_id})", - "tree": rt.json()["sha"], - "parents": [backup_head], - }) - if rc.status_code != 201: - return - # force=True consentito: vfs-backup non è main, nessun rischio - await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers, - json={"sha": rc.json()["sha"], "force": True}) - _logger.info( - "GAP-NEW-4: vfs-backup aggiornato — %d file, run %s", - len(tree_items), run_id, - ) - except Exception as _vfs_err: - # Silent: il backup non deve MAI bloccare o crashare il loop principale - _logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80]) - - # ── GAP-VFS: per-path write lock ───────────────────────────────────────── - def _get_vfs_lock(self, path: str) -> asyncio.Lock: - """GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS. - Previene race condition quando subtask paralleli (asyncio.gather) - scrivono lo stesso file contemporaneamente. - Lock creato lazy: zero overhead per run che non usano write paralleli.""" - if path not in self._vfs_write_locks: - self._vfs_write_locks[path] = asyncio.Lock() - return self._vfs_write_locks[path] - - # ── GAP-1: Delega Dinamica In-Loop ───────────────────────────────────── - _DELEGATE_RESEARCH_RE = re.compile( - r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|' - r'news|notizie|fetch|scrape|pagina|sito|http)\b', - re.IGNORECASE, - ) - - async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict: - """GAP-1: Delega Dinamica In-Loop. - Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale. - Architettura: - - Stesso executor del parent → accesso ai tool reali (write_file, run_python, ...) - - LLM selezionato per ruolo → RESEARCHER, CODER o REASONER in base al goal - - _is_delegate_child = True → blocca ricorsione (max 1 livello di delega) - - max_steps = 4 → micro-agente leggero, non un loop completo - - output troncato a 4000 chars → evita context-window explosion nel parent - """ - # P18: defensive anti-recursion guard at entry point - if getattr(self, '_is_delegate_child', False): - _logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry") - return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False} - try: - from models.role_router import RoleRouter as _RR_d, Role as _Role_d - # Seleziona LLM specializzato in base al tipo di sotto-obiettivo - if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash - elif self._CODE_RE.search(sub_goal[:300]): - _sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout - else: - _sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B - except Exception: - _sub_llm = self.llm # fallback: usa LLM del parent - - # Crea loop figlio: stessi executor/planner/memory, LLM specializzato - _sub_loop = UnifiedAgentLoop( - llm_client=_sub_llm, - planner=self.planner, - executor=self.executor, - critic=None, # no critic — micro-agente leggero - memory=self.memory, - verifier=None, # no verifier — massima velocità - ) - # Anti-ricorsione: il figlio non può delegare ulteriormente - _sub_loop._is_delegate_child = True - # Propaga session_id per isolare sandbox backend-exec - _sub_loop._run_task_id = self._run_task_id + "_d" - # GAP-6: condividi dict mutabile _session_files con il parent loop - # Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent - # Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate - _sub_loop._session_files = self._session_files - - # P17-F1: buffer output parziale via on_step — sopravvive al timeout - _partial_steps: list[dict] = [] - async def _capture_partial(step: dict) -> None: - if step.get("output") or step.get("explanation"): - _partial_steps.append(step) - - try: - _res = await asyncio.wait_for( - _sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial), - timeout=timeout, - ) - _out = (_res.get("output") or "")[:4000] - _logger.info( - "GAP-1 delegate OK [%s] steps=%d: %s", - _res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60], - ) - return { - "success": _res.get("success", False), - "output": _out, - "engine": _res.get("engine", "delegate"), - "steps": len(_res.get("steps", [])), - } - except asyncio.TimeoutError: - # P17-F1: esponi stato parziale invece di stringa vuota - # _session_files già condiviso con parent → parent vede file scritti - _partial_files = list(getattr(_sub_loop, "_session_files", {}).keys()) - _partial_out = " ".join( - (s.get("output") or s.get("explanation") or "")[:300] - for s in _partial_steps[-3:] - ).strip()[:1500] - _logger.warning( - "GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s", - timeout, len(_partial_steps), len(_partial_files), sub_goal[:60], - ) - return { - "success": False, - "output": _partial_out, - "error": f"delegate timeout ({timeout:.0f}s) — risultato parziale", - "partial": True, - "partial_files": _partial_files, - "steps_done": len(_partial_steps), - } - except Exception as _de: - _logger.warning("GAP-1 delegate error: %s", _de) - return {"success": False, "output": "", "error": str(_de)[:200]} - - # ── S362: Role routing helpers ───────────────────────────────────────────── - - # S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi. - # Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM. - _CODE_RE = re.compile( - r'\b(scrivi|crea|genera|implementa|refactor|bug|fix|debug|test|codice|' - r'funzione|classe|componente|api|endpoint|typescript|javascript|python|' - r'react|vue|swift|kotlin|write|create|generate|implement|code|function|' - r'class|component|frontend|backend|server|client|hook|store|type|' - r'interface|migration|query|schema|dockerfile|workflow|' - # S427: verbi italiani azione-codice mancanti - r'sistema|sistemi|correggi|corregge|debugga|patch|patcha|rinomina|' - r'sostituisci|rimpiazza|ottimizza|refactorizza|ristruttura|' - r'aggiungi|aggiorna|integra|rimuovi|elimina|cancella|inserisci|' - # S427: verbi inglesi azione-codice mancanti - r'rename|replace|remove|delete|patch|optimize|restructure|' - r'add|update|integrate|insert|scaffold|bootstrap|deploy|' - # S427: framework/librerie/pattern aggiuntivi - r'svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|' - r'fastapi|flask|django|express|rails|laravel|spring|' - r'graphql|grpc|websocket|rest|sql|nosql|' - r'prisma|drizzle|sqlalchemy|mongoose|sequelize|' - r'css|scss|sass|html|rust|go|java|kotlin|dart|flutter|' - r'service|repository|controller|middleware|utility|helper|' - r'decorator|enum|zod|vite|webpack|eslint|prettier|jest|vitest)\b', - re.IGNORECASE, - ) - - # S416-Fix1: estrae path→content dei file scritti nella risposta LLM - # Pattern: "path/file.ext:" o "### file.ext" o "FILE: file.ext" seguito da code block - # S422-Fix1: esteso con 4 formati aggiuntivi (bold, inline code, lista, commento inline) - # Copre 9/9 formati LLM più comuni — S416 era silenziosamente rotto al 60-70% - _EXT = r'(?:tsx?|jsx?|py|css|html|md|json|ya?ml|sh|toml|sql|go|rs|rb|java|kt|swift|vue|svelte)' - _FILE_BLOCK_RE = re.compile( - r'(?:' - # p1: FILE: path o ## FILE: path - r'(?:^|\n)\s*(?:#{1,3}\s*)?(?:FILE|file|File):\s*[`"]?(?P[\w./\-]+\.\w+)[`"]?\s*\n' - # p2: path: o path- (solo con estensione nota) - r'|(?:^|\n)\s*[`"]?(?P[\w./\-]+\.' + _EXT + r')[`"]?\s*[:\-–]\s*\n' - # p3: ## path (markdown heading) - r'|(?:^|\n)#{1,3}\s+(?P[\w./\-]+\.' + _EXT + r')\s*\n' - # p4: **path** (bold) — formato più comune GPT/OpenRouter/Claude - r'|(?:^|\n)\s*\*\*(?P[\w./\-]+\.' + _EXT + r')\*\*\s*.*?\n' - # p5: `path` (inline code) prima del blocco - r'|(?:^|\n)\s*`(?P[\w./\-]+\.' + _EXT + r')`\s*.*?\n' - # p6: 1. **path** o - **path** (lista) - r'|(?:^|\n)\s*(?:\d+\.|[-*])\s+\*\*?(?P[\w./\-]+\.' + _EXT + r')\*?\*?\s*.*?\n' - r')' - # blocco codice — opzionale commento // path o # path come prima riga (p7) - r'```(?:\w+\n(?:(?://|#)\s*(?P[\w./\-]+\.' + _EXT + r')\s*\n))?' - r'(?P.+?)```', - re.DOTALL | re.MULTILINE, - ) - - @classmethod - def _extract_written_files(cls, answer: str) -> dict[str, str]: - """S422-Fix1: estrae file path→content dall'output LLM per iniettarli come contesto. - Copre tutti i formati comuni: FILE:, ##, **bold**, `inline`, lista, commento inline.""" - result: dict[str, str] = {} - for m in cls._FILE_BLOCK_RE.finditer(answer): - path = (m.group("p1") or m.group("p2") or m.group("p3") or - m.group("p4") or m.group("p5") or m.group("p6") or - m.group("p7") or "") - content = m.group("content") or "" - if path and content.strip(): - result[path.strip()] = content.strip()[:3000] - return result - - async def _run_fallback(self, state: UnifiedLoopState, - on_step: StepCallback | None, - preloaded_tool_results: str = "", - preloaded_tool_exec_successes: int = 0, - preloaded_tool_exec_errors: int = 0) -> dict[str, Any]: - outputs: list[str] = [] - try: - from api.state import record_timing as _rtc_ttfa - import time as _ttf_t - _t_rs = getattr(self, '_t_run_start', None) - if _t_rs is not None: - _rtc_ttfa("ttfa_ms", (_ttf_t.monotonic() - _t_rs) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S402: Tool Integrity Guard — propagato da run() tramite _run_direct_tools() - _tool_exec_successes = preloaded_tool_exec_successes - _tool_exec_errors = preloaded_tool_exec_errors - exec_warn: list[str] = [] # S-LOOP1: init precoce — evita NameError se planner va in timeout (S640) - - if self.memory: - mem_ctx = await self.memory.get_context(state.goal, code_length=len(state.context or '')) - if mem_ctx: - state.context = f"{state.context}\n\nMEMORIA:\n{mem_ctx}".strip() - - tool_results = preloaded_tool_results - - # S378: disclaimer quando la query è di tipo ricerca/notizie ma nessun dato - # reale è disponibile — evita che l'LLM risponda in silenzio dal training. - # S428: rimosso "rispondo con conoscenza al cut-off" — invitava hallucination. - if not tool_results and re.search( - r'\b(notizie|news|ultime|latest|breaking|recenti|aggiornamenti|' - r'cerca\s+(?:online|sul\s+web|in\s+rete)|cerca\s*:|search\s*:|' - r'ricerca\s+web|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente))\b', - state.goal, re.IGNORECASE - ): - tool_results = ( - "[NOTA: strumenti di ricerca web non disponibili al momento]" - ) - - # F17+B7: planner per task di progettazione/implementazione — soglia ridotta a 10 chars - # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50). - # _NEEDS_PLAN_RE filtra già query semplici — len guard serve solo per 1-8 char input. - _should_plan = ( - self.planner - and not tool_results - and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) - and len(state.goal) > 10 - ) - # S-FMT-ORCH FIX-FASTFIX: piano sintetico per fix singoli (<180 chars, pattern typo/rename/change-to) - # Salta ARCHITECT DeepSeek-R1 -> risparmio ~15s. Fallback safe: se no match, planner normale. - _fast_fix_plan = None - if (_should_plan - and len(state.goal) < 180 - and bool(self._FAST_FIX_RE.search(state.goal[:200]))): - _fast_fix_plan = { - "summary": state.goal[:80], - "subtasks": [{"id": 1, "description": state.goal, "tool": "apply_patch", "requires": []}], - "complexity": "low", - } - _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT") - _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing - if _should_plan: - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "started", - "title": "Pianificazione", - "explanation": "Analizzo la richiesta e preparo un piano", - })) - # S640: timeout planner + S-FMT-ORCH fast-fix bypass - # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati) - if _fast_fix_plan is not None: - plan = _fast_fix_plan - _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato") - else: - # S640: timeout sul planner — DeepSeek-R1 può essere lento ma non deve bloccare - # 30s è il 95° percentile osservato su prompt lunghi; oltre è quasi certamente stall. - # Su timeout: plan=None → esecuzione diretta senza subtask (comportamento pre-planner). - try: - plan = await asyncio.wait_for( - self.planner.create_plan( - state.goal, context=[{"role": "system", "content": state.context}] - ), - timeout=30.0, - ) - except asyncio.TimeoutError: - plan = None - _logger.warning("S640 planner timeout (30s) su goal: %s", state.goal[:80]) - exec_warn.append("⚠ [S640] piano non disponibile (timeout pianificatore 30s)") - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "warning", - "title": "Pianificazione scaduta", - "explanation": "Il pianificatore ha impiegato troppo — procedo senza piano", - "visibility": "progress", - })) - if plan is not None: - state.steps.append({"action": "plan", "result": plan}) - try: - from api.state import record_timing as _rtc_pl - _rtc_pl("plan_ms", (asyncio.get_running_loop().time() - _t0_plan) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S641: guard plan is not None prima di on_step e executor - # piano può essere None dopo timeout S640 — plan.get() crasherebbe con AttributeError - if plan is not None and on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "done", - "title": "Piano creato", - "explanation": f"Piano con {len(plan.get('subtasks', []))} passaggi — inizio esecuzione", - "subtasks": len(plan.get("subtasks", [])), - })) - - if self.executor and plan is not None and plan.get("subtasks"): - # S574-GAP4: completata _TOOL_MAP — read_page/code/calculate/image - # Prima: solo web_search eseguito; tutti gli altri subtask silenziosamente saltati - # Ora: 5 tool reali mappati → subtask del planner eseguiti davvero - _TOOL_MAP: dict[str, tuple[str, Any]] = { - "web_search": ("web_search", lambda desc: {"query": desc}), - "read_page": ("read_page", lambda desc: {"url": desc}), - "code": ("run_python", lambda desc: {"code": desc}), - "calculate": ("calculate", lambda desc: {"expression": desc}), - "image": ("generate_image", lambda desc: {"prompt": desc}), - # S601: nuovi tool V001-V007 aggiunti al planner — mappa anche questi - "web_research": ("web_research", lambda desc: {"topic": desc, "depth": 4, "synthesize": True}), - "generate_image": ("generate_image", lambda desc: {"prompt": desc}), - "run_python": ("run_python", lambda desc: {"code": desc}), - "send_email": ("send_email", lambda desc: { - # S643: estrai destinatario dalla descrizione — pattern "a " o "to " - "to": (lambda m: m.group(1) if m else "")( - __import__("re").search( - r"\b(?:a|to|invia\s+a|send\s+to)\s+([\w.+-]+@[\w-]+\.[\w.]+)", - desc, __import__("re").IGNORECASE - ) - ), - "subject": desc[:80], - "body": desc, - }), - "database_query": ("database_query", lambda desc: {"sql": desc}), - "execute_sql": ("execute_sql", lambda desc: {"sql": desc}), - "create_pdf": ("create_pdf", lambda desc: { - # S644+S645: estrai filename/title dalla prima frase (max 60 chars) - # S645: _create_pdf usa "filename" non "title" — fix campo ignorato - "content": desc, - "filename": ( - __import__("re").sub(r"[^\w\-]", "_", - desc.split(".")[0][:50].strip() or "documento" - ).lower() + ".pdf" - ), - }), - "call_api": ("call_api", lambda desc: { - # S644: estrai URL e method dalla description - "url": (lambda m: m.group(0) if m else desc)( - __import__("re").search(r"https?://[\S]+", desc) - ), - "method": ( - "POST" if __import__("re").search(r"\b(post|invia|crea|create|send)\b", desc, 2) else - "PUT" if __import__("re").search(r"\b(put|aggiorna|update|modifica)\b", desc, 2) else - "DELETE" if __import__("re").search(r"\b(delete|elimina|cancella|remove)\b", desc, 2) else - "GET" - ), - }), - # S659: write_file/read_file/apply_patch mancanti da _TOOL_MAP. - # Quando il planner generava subtask con questi tool, _TOOL_MAP.get() - # restituiva (None, None) → subtask silenziosamente saltati (nessuna esecuzione). - # Fix: aggiunta mapping con estrazione path dalla description. - "write_file": ("write_file", lambda desc: { - "path": (lambda m: m.group(1) if m else "output.txt")( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - "content": desc, - }), - "read_file": ("read_file", lambda desc: { - "path": (lambda m: m.group(1) if m else desc.strip()[:200])( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - }), - "apply_patch": ("apply_patch", lambda desc: { - "path": (lambda m: m.group(1) if m else "output.txt")( - __import__("re").search( - r"\b([\w./\-]+/[\w./\-]+\.[a-zA-Z]{1,10}|[\w\-]+\.[a-zA-Z]{1,10})\b", - desc - ) - ), - "patch": desc, - }), - # S669: execute_shell mancava da _TOOL_MAP — il planner poteva assegnare - # tool="execute_shell" ma _TOOL_MAP.get() → (None, None) → subtask saltato - # silenziosamente. Aggiunto mapping con estrazione comando da description. - "execute_shell": ("execute_shell", lambda desc: { - "command": next(iter(__import__("re").findall(r"`([^`]{1,200})`", desc)), desc.strip()[:200]), - }), - # S764: 10 nuovi tool (S763 registry) aggiunti a _TOOL_MAP - "directory_tree": ("directory_tree", lambda desc: { - "path": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "max_depth": 3, - }), - "file_search": ("file_search", lambda desc: { - "pattern": (lambda m: m.group(1) if m else desc.strip()[:80])( - __import__("re").search( - r"(?:grep\s+|cerca\s+|trova\s+|pattern[:\s]+)['\s]*([\w.\-\(\)\[\]]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "path": ".", - }), - "git_status": ("git_status", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - }), - "git_clone": ("git_clone", lambda desc: { - "url": (lambda m: m.group(0) if m else "")( - __import__("re").search( - r"https?://[\S]+\.git|https?://github\.com/[\S]+", desc - ) - ), - "depth": 1, - }), - "git_diff": ("git_diff", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "staged": bool(__import__("re").search( - r"\b(staged|cached|index)\b", desc, __import__("re").IGNORECASE - )), - }), - "get_image": ("get_image", lambda desc: { - "prompt": desc.strip()[:500], - "width": 512, - "height": 512, - }), - "create_project": ("create_project", lambda desc: { - "project_type": (lambda m: m.group(1) if m else "generic")( - __import__("re").search( - r"\b(react|vue|angular|python|node|fastapi|express|nextjs|flask|django)\b", - desc, __import__("re").IGNORECASE, - ) - ), - "project_name": (lambda m: m.group(1) if m else "my-project")( - __import__("re").search( - r"(?:chiama(?:to)?|nome|project|progetto)[:\s]+['\"\s]*([\w-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "description": desc.strip()[:200], - "path": ".", - }), - "recall": ("recall", lambda desc: { - "query": desc.strip()[:200], - "limit": 5, - }), - "list_files": ("list_files", lambda desc: { - "path": (__import__("re").search(r"[./\\][\w./\\]+", desc) or type("m",(),({"group":lambda s,n:n and "."}))() ).group(0) if __import__("re").search(r"[./\\][\w./\\]+", desc) else ".", - "recursive": bool(__import__("re").search(r"\b(ricorsiv|recursive|all|tutto|tutta|tutti)\b", desc, __import__("re").IGNORECASE)), - "max_items": 100, - }), - "diff_text": ("diff_text", lambda desc: { - "text_a": "", - "text_b": desc.strip()[:2000], - "context_lines": 3, - }), - "validate_json": ("validate_json", lambda desc: { - "json_str": desc.strip()[:8000], - "schema": None, - }), - "lint_code": ("lint_code", lambda desc: { - "content": desc.strip()[:8000], - "language": "auto", - "path": (lambda m: m.group(1) if m else "")( - __import__("re").search( - r"(?:file|path|percorso)[:\s]+['\"\s]*(\S+\.\w+)", - desc, __import__("re").IGNORECASE, - ) - ), - }), - "git_push": ("git_push", lambda desc: { - "remote": (lambda m: m.group(1).strip() if m else "origin")( - __import__("re").search( - r"(?:remote|origin|push\s+to)[:\s]+([\w\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "branch": (lambda m: m.group(1).strip() if m else "")( - __import__("re").search( - r"(?:branch|ramo|sul\s+branch)[:\s]+([\w\-\/]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - }), - "git_commit": ("git_commit", lambda desc: { - "message": (lambda m: m.group(1).strip() if m else desc.strip()[:80])( - __import__("re").search( - r"(?:messaggio|message|msg|commit\s+message)[:\s]+[']*(.{3,120}?)[']*(?:\.|$)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": ".", - "push": bool(__import__("re").search( - r"\b(push|pubblica|invia)\b", desc, __import__("re").IGNORECASE - )), - }), - "npm_install": ("npm_install", lambda desc: { - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "npm_run": ("npm_run", lambda desc: { - "script": (lambda m: m.group(1).strip() if m else "dev")( - __import__("re").search( - r"(?:npm\s+run|pnpm\s+run|yarn\s+run|run\s+script)[:\s]+([\w:_\-]+)", - desc, __import__("re").IGNORECASE, - ) - ), - "cwd": next(iter(__import__("re").findall( - r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc - )), "."), - "manager": "auto", - }), - "pip_install": ("pip_install", lambda desc: { - "packages": (lambda m: m.group(1).strip() if m else desc.strip()[:200])( - __import__("re").search( - r"(?:pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett[oi])[:\s]+([\w\s,>= str | None: - """S634: analisi statica — rileva mismatch tool/description PRIMA - che _resolve_inp invochi il CODER LLM. Non blocca mai l'esecuzione. - - Casi rilevati: - - run_python/execute_sql/database_query con URL → probabile 'read_page' - - read_page senza URL → il tool fallirà (attende un URL valido) - - code tool con description <8 chars → _resolve_inp avrà poco contesto - """ - if not s_desc: - return None - _is_code_tool = s_tool in ("run_python", "execute_sql", "database_query") - _has_url = bool(_S634_URL_RE.search(s_desc)) - _has_code_hint = bool(_S634_CODE_HINT.search(s_desc)) - _is_short = len(s_desc.strip()) < 8 - - if _is_code_tool and _has_url and not _has_code_hint: - return (f"[S634 routing] '{s_tool}' con URL senza hint codice " - f"→ potrebbe essere 'read_page' (desc: '{s_desc[:60]}')") - if s_tool == "read_page" and not _has_url: - return (f"[S634 routing] 'read_page' senza URL " - f"→ il tool si aspetta un URL valido (desc: '{s_desc[:60]}')") - if _is_code_tool and _is_short: - return (f"[S634 routing] '{s_tool}' con descrizione <8 chars " - f"→ _resolve_inp avrà contesto insufficiente (desc: '{s_desc}')") - return None - - _pending_exec: list[tuple[dict, str, Any]] = [] - for _s_idx, subtask in enumerate(plan.get("subtasks", []), start=1): - # S643: fallback id quando planner omette campo — evita None nei log - if "id" not in subtask or subtask["id"] is None: - subtask = {**subtask, "id": f"s{_s_idx}"} - _s_risk = subtask.get("risk", "low") - _s_tool = subtask.get("tool", "") - _s_desc_raw = subtask.get("description", "") - - # S634: static routing check — warning in exec_warn + logger, mai bloccante - _rt_warn = _check_subtask_routing(_s_tool, _s_desc_raw) - if _rt_warn: - _logger.warning("S634 %s", _rt_warn) - exec_warn.append(f"⚠ {_rt_warn}") - - if _s_risk == "high" and _s_tool not in _SAFE_EXEC_TOOLS: - # S627: alto rischio + tool destructive → inietta nota nel contesto - exec_warn.append( - f"\u26a0 subtask #{subtask.get('id')} " - f"'{subtask.get('description','')[:60]}' [{_s_tool}] \u2014 richiede approvazione" - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "plan", "status": "warning", - "title": "Subtask ad alto rischio", - "explanation": f"'{subtask.get('description','')[:60]}' \u2014 richiede approvazione", - "subtask_id": subtask.get("id"), "visibility": "progress", - })) - continue - tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None)) - reg_name, inp_builder = tool_key_pair - if reg_name and inp_builder is not None: - _pending_exec.append((subtask, reg_name, inp_builder)) - elif _s_tool: - # COG-4: tool non in _TOOL_MAP — tenta generazione dinamica - try: - from agents.tool_generator import needs_dynamic_tool, generate_and_register - if needs_dynamic_tool(_s_tool, _s_desc_raw): - _dyn_ok, _dyn_rn = await asyncio.wait_for( - generate_and_register(_s_desc_raw, _s_tool, self.llm, self.executor), - timeout=25.0, - ) - if _dyn_ok and _dyn_rn: - # tool_fn() non ha argomenti — inp_builder ritorna sempre {} - _dyn_ib = lambda _d: {} - _pending_exec.append((subtask, _dyn_rn, _dyn_ib)) - _logger.info( - "COG-4 tool generato dinamicamente: %s per subtask #%s", - _dyn_rn, subtask.get("id"), - ) - else: - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non in TOOL_MAP, " - f"generazione dinamica fallita" - ) - except Exception as _cog4_err: - _logger.warning("COG-4 tool_generator error: %s", str(_cog4_err)[:120]) - exec_warn.append( - f"⚠ [COG-4] tool '{_s_tool}' non disponibile " - f"(tool_generator error: {str(_cog4_err)[:60]})" - ) - # S629: fase 2 — parallel dispatch con asyncio.gather - # Provider diversi per tool diversi → rate limit indipendenti, nessun bottleneck - # (web_search/read_page → HTTP provider; run_python → sandbox; generate_image → HF) - # asyncio è single-thread: list.append e state.steps sono race-condition safe - - # S632: tool che richiedono codice reale — la descrizione NL non è eseguibile diretta - _CODE_TOOLS: set[str] = {"run_python", "execute_sql", "database_query"} - - # S744: research tools → RESEARCHER (Gemini) formula query strutturata - _RESEARCH_TOOLS: set[str] = {"web_research", "web_search"} - - async def _resolve_inp(tool_name: str, desc: str) -> str: - """S632/S744: converte descrizione NL → input ottimale per il tool. - - S632 (Groq/CODER): run_python/execute_sql/database_query → codice eseguibile - S744 (Gemini/RESEARCHER): web_research/web_search → query strutturata - Tutti gli altri: passthrough diretto. - - Timeout conservativo + fallback grezza — zero regressioni. - I/O parallelo via asyncio.gather: nessun overhead sequenziale aggiunto.""" - if tool_name in _CODE_TOOLS: - # S632: CODER path — Groq genera codice/SQL eseguibile (invariante) - try: - from models.role_router import RoleRouter, Role - _coder_client = RoleRouter.get_client(Role.CODER) - if tool_name == "run_python": - _sys = "Sei un esperto Python. Scrivi solo il codice Python, nessuna spiegazione." - _usr = f"Scrivi codice Python eseguibile per: {desc}" - else: # execute_sql / database_query - _sys = "Sei un esperto SQL. Scrivi solo la query SQL, nessuna spiegazione." - _usr = f"Scrivi una query SQL per: {desc}" - _resolved = await asyncio.wait_for( - _coder_client.chat( - [{"role": "system", "content": _sys}, - {"role": "user", "content": _usr}], - temperature=0.1, max_tokens=512, - ), - timeout=10.0, - ) - # Rimuovi markdown fence se il modello ha aggiunto ``` code block ``` - _resolved = _resolved.strip() - if _resolved.startswith("```"): - _lines_r = _resolved.splitlines() - _resolved = "\n".join( - l for l in _lines_r - if not l.strip().startswith("```") - ).strip() - return _resolved if _resolved else desc - except Exception: - return desc # fallback: descrizione grezza (comportamento pre-S632) - - elif tool_name in _RESEARCH_TOOLS: - # S744: RESEARCHER path — Gemini formula query strutturata per ricerca - # Vantaggio: query più precise → risultati meno rumorosi - # Timeout 8s (< code tools 10s) — query corta, Gemini è veloce - try: - from models.role_router import RoleRouter, Role - _researcher = RoleRouter.get_client(Role.RESEARCHER) - if tool_name == "web_research": - _sys = ( - "Sei un esperto di ricerca. Dato un obiettivo, formula un " - "topic di ricerca preciso e strutturato (max 200 chars). " - "Risposta: solo il topic ottimizzato, nessuna spiegazione." - ) - _usr = f"Obiettivo di ricerca: {desc}" - else: # web_search - _sys = ( - "Sei un esperto di ricerca. Formula la query di ricerca web " - "ottimale per il seguente obiettivo (max 100 chars). " - "Solo la query, nessuna spiegazione." - ) - _usr = f"Obiettivo: {desc}" - _resolved = await asyncio.wait_for( - _researcher.chat( - [{"role": "system", "content": _sys}, - {"role": "user", "content": _usr}], - temperature=0.1, max_tokens=256, - ), - timeout=8.0, - ) - _resolved = _resolved.strip() - # Sanity: accetta solo se la query ha senso (>= 8 chars) - if _resolved and len(_resolved) >= 8: - _logger.debug( - "S744 RESEARCHER query [%s]: '%s' → '%s'", - tool_name, desc[:60], _resolved[:80], - ) - return _resolved - except Exception: - pass # fallback: descrizione grezza (comportamento pre-S744) - - return desc # passthrough per tutti gli altri tool - - # S646: guard piano vuoto — plan non None ma subtasks=[] → warning degrado graceful - # Senza guard: exec_done=[], exec_warn=[] → nessun exec_block → LLM risponde senza contesto - if plan is not None and not plan.get("subtasks"): - _plan_goal_empty = plan.get("goal", state.goal)[:120] - exec_warn.append( - f"⚠ [S646] Piano generato senza subtask per: '{_plan_goal_empty}'. " - f"Nessuna azione eseguita — risposta basata solo su ragionamento LLM." - ) - - if _pending_exec: - async def _run_subtask( - st: dict, rn: str, ib: Any, _goal: str = state.goal - ) -> tuple[dict, str, dict]: - if on_step: - # GAP-A: arricchisce started event con reason e description - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{rn}", - "status": "started", "subtask_id": st.get("id"), - "reason": self._TOOL_NARRATION.get(rn, self._TOOL_NARRATION_DEFAULT), - "description": str(st.get("description", ""))[:80], - })) - # scaffold_project live preview: mostra albero file PRIMA dell'esecuzione - # Zero latency: O(1) dict lookup — utente vede struttura prima che il tool scriva - if rn == "scaffold_project" and on_step: - _desc_scaf = str(st.get("description", "react")).lower() - _fw_scaf = next( - (k for k in self._SCAFFOLD_FILE_TREE if k in _desc_scaf), - "react", - ) - _tree_files = self._SCAFFOLD_FILE_TREE.get(_fw_scaf, []) - if _tree_files: - _n = len(_tree_files) - _tree_lines = "\n".join( - f" {chr(0x251C) + chr(0x2500) if i < _n - 1 else chr(0x2514) + chr(0x2500)} {f}" - for i, f in enumerate(_tree_files) - ) - await _maybe_await(on_step({ - "action": "text_chunk", - "token": ( - f"_Scaffold **{_fw_scaf}** \u2014 struttura che verr\u00e0 creata:_\n" - f"```\nmy-project/\n{_tree_lines}\n```\n\n" - ), - "status": "streaming", - })) - # S632: risolvi description → codice/SQL prima di chiamare il tool - _raw_desc = st.get("description", _goal) - # S-ORCH-8GAP FIX-DAG-3: inietta output delle dipendenze come contesto - # Quando B richiede A, B vede l'output reale di A → _resolve_inp più preciso. - # Max 300 chars per parent (contesto senza context-window explosion). - _parent_ctx_parts = [ - f"[Output subtask #{_rid}]: {_subtask_outputs.get(str(_rid), '')[:300]}" - for _rid in st.get("requires", []) - if str(_rid) in _subtask_outputs - ] - if _parent_ctx_parts: - _raw_desc = ( - "\n".join(_parent_ctx_parts) - + "\n\nTask corrente: " + _raw_desc - ) - _inp_desc = await _resolve_inp(rn, _raw_desc) - # F4: pre-warning per tool lenti (>30s) — imposta aspettative prima dell'attesa - # List statica: no overhead runtime, aggiorna se aggiungi nuovi tool lenti - if rn in {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell","type_check","write_file","apply_patch"} and on_step: - _f16_secs = "20–30" if rn in {"write_file","apply_patch"} else "30–60" - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_⏳ {self._TOOL_NARRATION.get(rn, rn)} — può richiedere {_f16_secs} secondi…_\n", - "status": "streaming", - })) - # F5: timeout tool-specifico — override il default 30s dell'executor - # _npm_install/_git_clone hanno wait_for interno 120s che veniva cancellato a 30s - _TOOL_EXEC_TIMEOUT: dict[str, float] = { - "npm_install": 135.0, "npm_run": 135.0, - "pip_install": 135.0, "git_clone": 135.0, - "git_push": 70.0, "execute_shell": 105.0, - "type_check": 75.0, "web_research": 60.0, - } - _exec_timeout = _TOOL_EXEC_TIMEOUT.get(rn, 30.0) - # GAP-1: Delega Dinamica In-Loop — intercetta __delegate__ prima del routing - # Lancia micro-agente specializzato; anti-ricorsione via _is_delegate_child. - # early-return: non esegue write_file/executor path per tool delegati. - if rn == "__delegate__" and not getattr(self, '_is_delegate_child', False): - _delegate_result = {"success": False, "output": "", "error": "init"} - try: - _delegate_result = await asyncio.wait_for( - self._run_in_loop_delegate(st.get("description", _goal)), - timeout=45.0, - ) - except Exception as _de: - _delegate_result = {"success": False, "output": "", - "error": str(_de)[:200]} - return st, rn, _delegate_result - # F12: write_file/apply_patch — genera codice reale via CODER prima di scrivere - # Bug: _resolve_inp passava la descrizione NL as-is → - # write_file("main.py", "Scrivi FastAPI app") scriveva testo nel file - # Fix: CODER genera codice da path+descrizione → contenuto corretto - _wf_direct_inputs: dict | None = None - if rn in {"write_file", "apply_patch"}: - try: - _wf_path = ib(_raw_desc).get("path", "output.txt") - _wf_ext = _wf_path.rsplit(".", 1)[-1] if "." in _wf_path else "" - _wf_lang = { - "py": "Python", "ts": "TypeScript", "tsx": "TypeScript React", - "js": "JavaScript", "jsx": "JavaScript React", - "html": "HTML", "css": "CSS", "sql": "SQL", - "json": "JSON", "yaml": "YAML", "yml": "YAML", - "sh": "Bash", "md": "Markdown", "toml": "TOML", - }.get(_wf_ext, "codice") - from models.role_router import RoleRouter as _RR_wf, Role as _Role_wf - _coder_wf = _RR_wf.get_client(_Role_wf.CODER) - if rn == "write_file": - _wf_sys = ( - f"Sei un esperto {_wf_lang}. " - f"Scrivi SOLO il contenuto completo del file {_wf_path}. " - "Niente spiegazioni. Niente markdown fence. Solo il codice." - ) - _wf_usr = f"Scrivi {_wf_path}: {_raw_desc[:1000]}" - else: # apply_patch - _wf_sys = ( - "Sei un esperto di patch unified-diff. " - f"Genera SOLO la patch diff per {_wf_path}. " - "Formato: --- a/file\n+++ b/file\n@@ -N,M +N,M @@" - ) - _wf_usr = f"Patch per {_wf_path}: {_raw_desc[:1000]}" - _wf_generated = await asyncio.wait_for( - _coder_wf.chat( - [{"role": "system", "content": _wf_sys}, - {"role": "user", "content": _wf_usr}], - temperature=0.1, max_tokens=2000, - ), - timeout=20.0, - ) - if _wf_generated and not _wf_generated.startswith("[LLM"): - _wf_generated = _wf_generated.strip() - # Strip markdown fences se il modello le ha aggiunte - if _wf_generated.startswith("```"): - _wf_generated = "\n".join( - _wfl for _wfl in _wf_generated.splitlines() - if not _wfl.strip().startswith("```") - ).strip() - else: - _wf_generated = _raw_desc # fallback NL - except Exception as _wf_exc: - _wf_path = ib(_raw_desc).get("path", "output.txt") if ib else "output.txt" - _wf_generated = _raw_desc - _logger.debug("F12 CODER write_file fallback: %s", _wf_exc) - _wf_direct_inputs = ( - {"path": _wf_path, "content": _wf_generated} if rn == "write_file" - else {"path": _wf_path, "patch": _wf_generated} - ) - # GAP-3: snapshot pre-write — cattura originale per rollback atomico - if rn == "write_file" and _wf_path not in self._write_snapshots: - try: - _snap_r = await asyncio.wait_for( - self.executor.run_tool("read_file", {"path": _wf_path}), - timeout=4.0, - ) - self._write_snapshots[_wf_path] = ( - _snap_r.get("output") if _snap_r.get("success") else None - ) - except Exception: - self._write_snapshots[_wf_path] = None # file non esisteva - # GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file - _vfs_lock = self._get_vfs_lock(_wf_path) - async with _vfs_lock: - _r = await self.executor.run_tool(rn, _wf_direct_inputs, timeout=_exec_timeout) - else: - _r = await self.executor.run_tool(rn, ib(_inp_desc), timeout=_exec_timeout) - # GAP-SKILL-SYNC: registra successo/fallimento tool nel session skill tracker - # Sincrono (GIL-safe) — aggiorna Wilson score per routing adattivo futuro - try: - from agents.skill_tracker import get_skill_tracker as _gst - _gst().record(self._run_task_id, rn, bool(_r.get("success"))) - except Exception: - pass # mai bloccare tool execution per tracking - # COG-3: TypeScript TDD — dopo write_file/apply_patch su .ts/.tsx esegue type_check - # Zero overhead su file non-TS (_should_test_ts guard in run_tdd_check_ts) - if rn in {"write_file", "apply_patch"} and _r.get("success") and _wf_direct_inputs: - try: - _cog3_path = _wf_direct_inputs.get("path", "") - if _cog3_path.endswith((".ts", ".tsx")): - from agents.tdd_runner import run_tdd_check_ts - _cog3_content = _wf_direct_inputs.get( - "content", _wf_direct_inputs.get("patch", "") - ) - _cog3_res = await asyncio.wait_for( - run_tdd_check_ts(_cog3_content, _cog3_path, self.executor, on_step), - timeout=22.0, - ) - if _cog3_res.get("ran") and not _cog3_res.get("passed"): - exec_warn.append( - f"⚠ [COG-3] TypeScript error in {_cog3_path}: " - f"{str(_cog3_res.get('output', ''))[:200]}" - ) - _logger.info( - "COG-3 type_check failed: %s — warn aggiunti", _cog3_path - ) - except Exception as _cog3_err: - _logger.debug("COG-3 tdd_runner error: %s", str(_cog3_err)[:80]) - # COG-4: Python TDD — dopo run_python con codice complesso, genera micro-test e verifica - # Zero overhead su codice semplice (_should_test guard) o re-esecuzione TDD (anti-loop marker) - if rn == "run_python" and _r.get("success") and _wf_direct_inputs: - _cog4_code = _wf_direct_inputs.get("code", "") - # Anti-loop: skip se il codice è già un test TDD generato da run_tdd_check - if _cog4_code and "AUTO-TEST S-GAP3" not in _cog4_code: - try: - from agents.tdd_runner import run_tdd_check - _cog4_res = await asyncio.wait_for( - run_tdd_check(_cog4_code, self.executor, None), - timeout=32.0, - ) - if _cog4_res.get("ran") and not _cog4_res.get("passed"): - _cog4_warn = ( - f"⚠ [COG-4] Python TDD failed: " - f"{str(_cog4_res.get('output', ''))[:300]}" - ) - exec_warn.append(_cog4_warn) - self._tdd_fail_inject = _cog4_warn - _logger.info( - "COG-4 Python TDD failed — warn + inject set (%d chars)", - len(_cog4_warn), - ) - except Exception as _cog4_err: - _logger.debug("COG-4 tdd_runner error: %s", str(_cog4_err)[:80]) - # S635: retry una volta su fallimento non-timeout con back-off 0.5s - # Motivo: errori transitori (rate limit provider, cold-start sandbox) - # si auto-risolvono al secondo tentativo nella maggior parte dei casi. - # Mai retrya su TimeoutError — il tool è già lento, un secondo tentativo - # aggraverebbe la latenza. Il flag _s635_retry evita loop infiniti. - if not _r.get("success") and not _r.get("_s635_retry"): - _err_str = str(_r.get("error", "")).lower() - _is_timeout = "timeout" in _err_str or "timed out" in _err_str - if not _is_timeout: - await asyncio.sleep(0.5) - # S635+UI: retry visibile — utente capisce il ritardo - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_🔄 Errore transitorio ({rn}), riprovo…_\n", - "status": "streaming", - })) - _inp2 = await _resolve_inp(rn, _raw_desc) - # F12: retry usa direct inputs per write_file (evita NL fallback) - _retry_inp = _wf_direct_inputs if _wf_direct_inputs is not None else ib(_inp2) - _r2 = await self.executor.run_tool(rn, _retry_inp, timeout=_exec_timeout) - _r2["_s635_retry"] = True # marca per evitare loop - _logger.warning( - "S635 retry subtask #%s [%s]: %s → %s", - st.get("id"), rn, - "ok" if _r2.get("success") else "ancora fallito", - str(_r2.get("error", ""))[:80], - ) - _r = _r2 - # GAP-1: emetti file_written per VFS sync frontend — dopo write riuscito - if rn == "write_file" and _r.get("success") and _wf_direct_inputs and on_step: - await _maybe_await(on_step({ - "action": "file_written", - "path": _wf_direct_inputs.get("path", ""), - "content": _wf_direct_inputs.get("content", ""), - })) - # GAP-9: se scaffold fallisce emetti warning — evita preview albero orfano - # Il live-preview dell'albero e gia stato emesso PRE-esecuzione - if rn == "scaffold_project" and not _r.get("success") and on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": "\n_\u26a0 Scaffold non completato \u2014 riprovo con approccio alternativo..._\n", - "status": "streaming", - })) - # COG-3: type_check post-scaffold — verifica TS sull'intero progetto - # scaffold_project crea molti .ts/.tsx senza passare per write_file - if rn == "scaffold_project" and _r.get("success"): - try: - _scaf_out = _r.get("output", {}) - _scaf_path = ( - _scaf_out.get("path") if isinstance(_scaf_out, dict) - else ib(_raw_desc).get("path", ".") if ib else "." - ) - _scaf_path = _scaf_path or "." - from agents.tdd_runner import run_tdd_check_ts - _SCAF_TS_STUB = ( - "import React from 'react';\n" - "import { useState } from 'react';\n" - "const App: React.FC = () => null;\n" - "export type AppProps = Record;\n" - "export default App;\n" - ) - _scaf_res = await asyncio.wait_for( - run_tdd_check_ts( - _SCAF_TS_STUB, - f"{_scaf_path}/src/App.tsx", - self.executor, - on_step, - ), - timeout=25.0, - ) - if _scaf_res.get("ran") and not _scaf_res.get("passed"): - exec_warn.append( - f"\u26a0 [COG-3] TypeScript errors nel progetto scaffoldato " - f"'{_scaf_path}': {str(_scaf_res.get('output', ''))[:200]}" - ) - _logger.info("COG-3 scaffold type_check failed: %s", _scaf_path) - except Exception as _cog3_scaf: - _logger.debug("COG-3 scaffold type_check: %s", str(_cog3_scaf)[:80]) - return st, rn, _r - # GAP-A: narrazione strategia pre-gather — text_chunk visibile in chat - # Sintetizza i tool in 1-2 frasi prima di avviare l'esecuzione parallela. - # Mostra max 2 tool per non sovraccaricare; usa _TOOL_NARRATION lookup O(1). - if on_step and _pending_exec: - _narr_tools = [rn for _, rn, _ in _pending_exec] - _narr_parts = [ - self._TOOL_NARRATION.get(t, "") for t in _narr_tools[:2] - ] - _narr_str = " · ".join(p for p in _narr_parts if p) - if _narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_narr_str}…_\n\n", - "status": "streaming", - })) - - # F11+S639+F8: esecuzione a FASI con topological sort — rispetta "requires" - # Bug: gather flat → npm_run partiva prima che npm_install finisse (requires ignorato). - # Fix: fase 0 = subtask senza deps, fase 1 = subtask che dipendono dalla fase 0, etc. - # Ogni fase usa gather adattivo (150s se slow tool, 90s altrimenti). - # Invariante: max 8 fasi per prevenire loop infiniti su piani malformati. - _SLOW_GATHER_TOOLS = {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell"} - _completed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-1: cascade-skip su deps fallite - _failed_subtask_ids: set[str] = set() - # S-ORCH-8GAP FIX-DAG-3: output injection per subtask dipendenti - _subtask_outputs: dict[str, str] = {} - _phase_remaining = list(_pending_exec) - - for _phase_n in range(8): - if not _phase_remaining: - break - - # Partiziona: pronti (deps soddisfatte) vs bloccati - _phase_ready: list[tuple] = [] - _phase_blocked: list[tuple] = [] - for _ps, _prn, _pib in _phase_remaining: - _reqs = {str(r) for r in _ps.get("requires", [])} - # S-ORCH-8GAP FIX-DAG-1: cascade-skip se una dep è fallita - # Senza questo, il deadlock guard avrebbe eseguito il subtask - # senza l'output della sua dipendenza → tool call sprecata. - _failed_deps = _reqs & _failed_subtask_ids - if _failed_deps: - _dep_ids_str = ", ".join(sorted(_failed_deps)) - exec_warn.append( - f"\u26a0 [DAG] subtask #{_ps.get('id')} saltato — " - f"dipendenza fallita: {_dep_ids_str}" - ) - _failed_subtask_ids.add(str(_ps.get("id"))) # propaga cascade - _logger.info( - "DAG cascade-skip subtask #%s (failed deps: %s)", - _ps.get("id"), _dep_ids_str, - ) - elif _reqs.issubset(_completed_subtask_ids): - _phase_ready.append((_ps, _prn, _pib)) - else: - _phase_blocked.append((_ps, _prn, _pib)) - - # Deadlock guard — esegui i rimanenti comunque (plan malformato) - if not _phase_ready: - _phase_ready = _phase_remaining - _phase_blocked = [] - _logger.warning( - "F11 fase %d deadlock — eseguo %d subtask bloccati", - _phase_n, len(_phase_ready), - ) - - _has_slow_in_phase = any( - _prn in _SLOW_GATHER_TOOLS for _, _prn, _ in _phase_ready - ) - _gather_timeout = 150.0 if _has_slow_in_phase else 90.0 - - if _phase_n > 0: - _logger.info( - "F11 fase %d — %d subtask pronti (timeout %.0fs)", - _phase_n, len(_phase_ready), _gather_timeout, - ) - # F15: narrazione per fasi 1+ — mostra cosa sta per eseguire - # Fase 0 ha già narrazione da GAP-A (pre-gather); fasi successive erano silenziose. - if on_step and _phase_ready: - _ph_narr_parts = [ - self._TOOL_NARRATION.get(_prn, "") - for _, _prn, _ in _phase_ready[:2] - ] - _ph_narr_str = " · ".join(p for p in _ph_narr_parts if p) - if _ph_narr_str: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": f"_{_ph_narr_str}…_\n", - "status": "streaming", - })) - - # S-ORCH-8GAP FIX-DAG-2: Semaphore(3) per fase — max 3 subtask - # simultanei per non saturare TCP su iPhone (max 6 conn totali). - # asyncio single-thread: il semaforo è local-safe, zero race condition. - _phase_sem = asyncio.Semaphore(3) - - async def _sem_subtask(s, rn, ib, _psem=_phase_sem): - async with _psem: - return await _run_subtask(s, rn, ib) - - try: - _exec_results = await asyncio.wait_for( - asyncio.gather( - *[_sem_subtask(s, rn, ib) for s, rn, ib in _phase_ready], - return_exceptions=True, - ), - timeout=_gather_timeout, - ) - except asyncio.TimeoutError: - _logger.warning( - "S639 gather timeout (%.0fs) fase %d su %d subtask", - _gather_timeout, _phase_n, len(_phase_ready), - ) - exec_warn.append( - f"\u26a0 [S639] timeout globale executor fase {_phase_n} " - f"({len(_phase_ready)} subtask): nessun risultato disponibile" - ) - _exec_results = [] - - for _er in _exec_results: - if isinstance(_er, Exception): - # S636: eccezioni da asyncio.gather erano silenziosamente ignorate. - _exc_type = type(_er).__name__ - _exc_msg = str(_er)[:120] - _logger.error( - "S636 gather exception [%s]: %s", _exc_type, _exc_msg - ) - exec_warn.append( - f"\u26a0 [S636] eccezione subtask [{_exc_type}]: {_exc_msg}" - ) - continue - _st, _rn, _res = _er - if _res.get("success"): - _completed_subtask_ids.add(str(_st.get("id"))) - # S-ORCH-8GAP FIX-DAG-3: memorizza output per injection dipendenti - # F20: dict output → JSON (standard) invece di Python repr - # F21: scaffold/write_file → summary human-readable - _out_raw = _res.get("output", "") - if isinstance(_out_raw, dict): - # F21: output speciale per tool che producono file - _fw = _out_raw.get("framework") - _files = _out_raw.get("files_created", []) - _dir = _out_raw.get("directory", "") - _path = _out_raw.get("path", "") - _size = _out_raw.get("size") - if _fw and _files: - # scaffold_project: summary concisa - _flist = ", ".join(str(f) for f in _files[:6]) - _fmore = f" (+{len(_files)-6} altri)" if len(_files) > 6 else "" - _snippet = ( - f"Progetto {_fw} creato in {_dir} — " - f"{len(_files)} file: {_flist}{_fmore}" - ) - elif _path and _size is not None: - # write_file: conferma creazione file - _snippet = f"File scritto: {_path} ({_size} bytes)" - else: - try: - import json as _jmod, re as _re_jmod - _snippet = _jmod.dumps(_re_jmod.sub(r'[\ud800-\udfff]', '', str(_out_raw)) if isinstance(_out_raw, str) else _out_raw, ensure_ascii=False)[:500] - except Exception: - _snippet = str(_out_raw).strip()[:500] - else: - _snippet = str(_out_raw).strip()[:500] - # S647: hollow success — tool ok ma output vuoto → nota esplicita - if not _snippet: - _snippet = "(nessun output — operazione completata senza testo di risposta)" - _rtag = " \u26a0" if _st.get("risk", "low") == "high" else "" - _label = f"[subtask {_st.get('id')}{_rtag} \u2014 {_st.get('description','')[:60]}]" - exec_done.append(f"{_label}: {_snippet}") - # S-ORCH-8GAP FIX-DAG-3: salva output per injection subtask dipendenti - _subtask_outputs[str(_st.get("id"))] = _snippet[:400] - state.steps.append({ - "action": f"executor:{_rn}", - "subtask_id": _st.get("id"), - "output": _snippet, - }) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{_rn}", - "status": "done", "subtask_id": _st.get("id"), - })) - # S628: sintesi strutturata — sezioni separate done/warn invece di stringa piatta - else: - # S637: subtask fallito → feedback UI + exec_warn - # S-ORCH-8GAP FIX-DAG-1: traccia id falliti per cascade-skip - _failed_subtask_ids.add(str(_st.get("id"))) - _fail_err = str(_res.get("error", "errore sconosciuto"))[:100] - _fail_retry = _res.get("_s635_retry", False) - _fail_label = ( - f"[subtask {_st.get('id')} \u2014 {_st.get('description','')[:50]}]" - ) - _fail_note = " (dopo retry S635)" if _fail_retry else "" - exec_warn.append( - f"\u26a0 {_fail_label} fallito{_fail_note}: {_fail_err}" - ) - _logger.warning( - "S637 subtask #%s [%s] failed%s: %s", - _st.get("id"), _rn, _fail_note, _fail_err, - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": f"executor:{_rn}", - "status": "failed", - "subtask_id": _st.get("id"), - "explanation": _fail_err, - "visibility": "progress", - })) - - _phase_remaining = _phase_blocked # prossima fase: subtask rimasti - # COG-1: Dynamic Re-planner — rigenera piano se ci sono fallimenti reali - _cog1_real_failures = [ - w for w in exec_warn - if any(kw in w.lower() for kw in - ("fallito", "failed", "timeout", "exception", "error", "eccezione")) - ] - if _cog1_real_failures and not exec_warn == [] and not plan.get("_replanned"): - try: - from agents.dynamic_replanner import should_replan, replan - if should_replan(exec_warn, exec_done): - _logger.info( - "COG-1 should_replan=True (warn=%d done=%d)", - len(exec_warn), len(exec_done), - ) - _replan_goal = plan.get("goal", state.goal) - _new_plan = await asyncio.wait_for( - replan(self.planner, _replan_goal, exec_warn, exec_done, plan=plan), # P25-R1 - timeout=20.0, - ) - if _new_plan and _new_plan.get("subtasks"): - plan = _new_plan - exec_done.clear() - exec_warn.clear() - _logger.info( - "COG-1 replan ok: %d nuovi subtask", - len(plan.get("subtasks", [])), - ) - _pending_exec2: list[tuple] = [] - for _s2 in plan.get("subtasks", []): - _t2 = _s2.get("tool", "") - _tk2 = _TOOL_MAP.get(_t2, (None, None)) - _rn2, _ib2 = _tk2 - if _rn2 and _ib2 is not None: - _pending_exec2.append((_s2, _rn2, _ib2)) - _replan_sem = asyncio.Semaphore(3) - async def _replan_subtask(s, rn, ib, _sem=_replan_sem): - async with _sem: - return await _run_subtask(s, rn, ib) - try: - _replan_results = await asyncio.wait_for( - asyncio.gather( - *[_replan_subtask(s, rn, ib) for s, rn, ib in _pending_exec2], - return_exceptions=True, - ), - timeout=90.0, - ) - for _rr in _replan_results: - if isinstance(_rr, Exception): - exec_warn.append( - f"⚠ [COG-1 replan] eccezione: {str(_rr)[:80]}" - ) - continue - _rr_st, _rr_rn, _rr_res = _rr - if _rr_res.get("success"): - _out_r = str(_rr_res.get("output", ""))[:400] - exec_done.append( - f"[replan subtask {_rr_st.get('id')}]: {_out_r}" - ) - else: - exec_warn.append( - f"⚠ [COG-1 replan] subtask #{_rr_st.get('id')} " - f"fallito: {str(_rr_res.get('error',''))[:80]}" - ) - except asyncio.TimeoutError: - exec_warn.append("⚠ [COG-1 replan] timeout 90s sul piano alternativo") - except Exception as _cog1_err: - _logger.warning("COG-1 dynamic_replanner error: %s", str(_cog1_err)[:120]) - # COG-5: Goal Drift Detector — controlla ogni DRIFT_CHECK_EVERY_N subtask completati. - # Non-blocking: sincrono, nessun I/O. Se l'agente si è allontanato dal goal - # originale, inietta una micro-guida correttiva in exec_warn prima del LLM call. - try: - from agents.goal_drift_detector import detect_drift as _cog5_detect - _cog5_res = _cog5_detect( - goal=state.goal, - exec_done=exec_done, - step_count=len(exec_done), - last_check=_cog5_last_check, - ) - _cog5_last_check = _cog5_res["new_last_check"] - if _cog5_res.get("drifted"): - _drift_msg = ( - f"[COG-5 ⚠] Deriva dal goal rilevata " - f"({_cog5_res['reason']}). " - f"Goal originale: \"{state.goal[:80]}\". " - f"Concentra la risposta su questo obiettivo." - ) - exec_warn.append(_drift_msg) - _logger.info("COG-5 drift iniettato in exec_warn: %s", _cog5_res["reason"]) - except Exception as _cog5_err: - _logger.debug("COG-5 error (non-blocking): %s", str(_cog5_err)[:80]) - # GAP-NEW-2: TDD FAIL inject — se _t_run_python() ha rilevato un test fallito, - # inietta il traceback in exec_warn PRIMA del campionamento StrategicHealer. - # Questo chiude il ciclo: TDD FAIL → exec_warn → healer fingerprinting → strategia alternativa. - if getattr(self, '_tdd_fail_inject', None): - exec_warn.insert(0, self._tdd_fail_inject) - _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject)) - self._tdd_fail_inject = None - # GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction. - # PROBLEMA v1: MD5("ModuleNotFoundError: requests") ≠ MD5("ModuleNotFoundError: pandas") - # → 3 librerie diverse con stesso errore NON triggheravano il cambio strategia. - # SOLUZIONE v2: dual-mode — conta sia raw fingerprint sia classe di eccezione. - # max(raw_max, class_max) decide il trigger → cattura pattern nascosti. - try: - # Cap detection: analizza solo gli ultimi 50 item (più recenti = più rilevanti). - # Con 100+ subtask falliti analizzare tutta exec_warn è ridondante; - # i pattern recenti sono quelli su cui l'agente sta ancora iterando. - _SH_MAX_SAMPLE = 50 - _sh_sample = exec_warn[-_SH_MAX_SAMPLE:] if len(exec_warn) > _SH_MAX_SAMPLE else exec_warn - import hashlib as _selfheal_hs, re as _selfheal_re - # Mode 1: raw fingerprint (MD5 primi 120 chars) — errori identici alla lettera - _selfheal_fps: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: exec_warn può contenere None/dict da moduli esterni - _fp = _selfheal_hs.md5(_w.lower()[:120].encode(), usedforsecurity=False).hexdigest() - _selfheal_fps[_fp] = _selfheal_fps.get(_fp, 0) + 1 - _selfheal_raw_max = max(_selfheal_fps.values()) if _selfheal_fps else 0 - # Mode 2: error-class extraction — raggruppa per tipo di eccezione Python/JS - # Cattura ModuleNotFoundError×3 anche con moduli diversi (requests/pandas/numpy) - _ERRCLASS_RE = _selfheal_re.compile( - r'\b([A-Z][a-zA-Z]*(?:Error|Exception|Timeout|Warning|Failure|Fault))\b' # UL-BUG-1: era 0x08 backspace → ora word-boundary reale - ) - _selfheal_cls: dict[str, int] = {} - for _w in _sh_sample: - if not isinstance(_w, str): - continue # guard: stesso motivo del loop precedente - _cm = _ERRCLASS_RE.search(_w) - if _cm: - _ck = _cm.group(1).lower() - _selfheal_cls[_ck] = _selfheal_cls.get(_ck, 0) + 1 - _selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0 - _selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max) - if _selfheal_max >= 3: - # Hint specifico per classe di errore dominante - _ERRCLASS_HINTS: dict[str, str] = { - "modulenotfounderror": "Installa con pip o usa un'alternativa stdlib (es. json/csv/re/pathlib).", - "importerror": "Riorganizza gli import o usa un'alternativa built-in.", - "timeouterror": "Aumenta il timeout, usa asyncio con timeout maggiore, o spezza l'operazione.", - "connectionerror": "Verifica la rete, usa retry con backoff esponenziale, o usa dati cached.", - "filenotfounderror": "Verifica il path (usa os.path.exists), crea file se mancante.", - "permissionerror": "Usa un path alternativo con accesso in scrittura.", - "valueerror": "Valida l'input (None/empty/tipo errato) prima di processarlo.", - "typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite (str/int/list).", - "keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza prima.", - "attributeerror": "Controlla che l'oggetto non sia None con 'if obj is not None:'.", - "runtimeerror": "Decomponi in passi più piccoli, verifica lo stato dell'ambiente.", - # R2: 10 classi aggiunte — errori comuni che ricevevano hint generico - "nameerror": "Controlla typo nel nome variabile/funzione; verifica che sia definita prima dell'uso.", - "syntaxerror": "Esegui ast.parse() per trovare la riga esatta; usa un f-string o quote corrette.", - "indentationerror": "Usa solo spazi (4 per livello) o solo tab — non mescolare.", - "indexerror": "Controlla len() prima dell'accesso; usa slice o enumerate invece di indice fisso.", - "assertionerror": "Verifica i dati in ingresso con print/log prima dell'assert; aggiungi messaggio all'assert.", - "notimplementederror": "Implementa il metodo mancante o usa l'implementazione concreta invece della base class.", - "recursionerror": "Aggiungi caso base esplicito; converti la ricorsione in loop iterativo.", - "memoryerror": "Processa in chunk (es. itertools.islice), riduci dimensione dati in memoria.", - "oserror": "Controlla permessi e spazio disco; usa pathlib per path cross-platform.", - "zerodivisionerror": "Aggiungi guard 'if denominator != 0' prima della divisione.", - } - _dom_cls = ( - max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else "" - ) - _specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.") - _trigger_mode = "class" if _selfheal_cls_max >= _selfheal_raw_max else "raw" - _selfheal_msg = ( - f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}×{_selfheal_max}]: " - "lo stesso errore si è ripetuto senza progressi. " - f"Hint specifico: {_specific} " - "In ogni caso: NON ripetere lo stesso metodo — cambia libreria, " - "pattern o decomposizione del problema." - ) - # Deduplication: evita doppia iniezione se CAMBIO STRATEGIA già presente. - # Scenario reale: exec_warn.clear() a riga ~2058 non è sempre raggiunto - # prima del secondo trigger (es. doppio replan nello stesso batch). - _sh_already = any( - isinstance(_ew, str) and "CAMBIO STRATEGIA" in _ew - for _ew in exec_warn - ) - if not _sh_already: - exec_warn.insert(0, _selfheal_msg) - _logger.info( - "GAP-SELFHEAL v2: %s mode × %d [class=%s] → CAMBIO STRATEGIA%s", - _trigger_mode, _selfheal_max, _dom_cls or "n/a", - " (già presente, skip dedup)" if _sh_already else " iniettato", - ) - try: - from api.state import increment_stat as _inc_sh # type: ignore[import] - _inc_sh("selfheal_strategy_change_triggered") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # selfheal detection non-blocking — nessun impatto sul loop - # Prima: "\n".join(exec_parts) → blob grezzo, LLM non distingue risultati da warning - # Ora: ## PIANO — goal / ### Risultati / ### Attenzione → guida la risposta finale - if exec_done or exec_warn: - _plan_goal = plan.get("goal", state.goal)[:120] - _synth: list[str] = [f"## Piano eseguito — {_plan_goal}"] - if exec_done: - _synth.append(f"\n### Risultati ({len(exec_done)} subtask completati):") - _synth.extend(exec_done) - if exec_warn: - # Cap display: al LLM arrivano al massimo 50 avvisi (i più recenti). - # exec_warn con 100+ item produce ### Attenzione di decine di KB che - # satura il context window; warning più vecchi già processati in iter. precedenti. - _WARN_DISPLAY_CAP = 50 - _warn_omitted = max(0, len(exec_warn) - _WARN_DISPLAY_CAP) - _warn_display = exec_warn[-_WARN_DISPLAY_CAP:] if _warn_omitted > 0 else exec_warn - _cap_note = f', mostrati ultimi {_WARN_DISPLAY_CAP}' if _warn_omitted > 0 else '' - _synth.append( - f"\n### Non eseguiti — richiedono attenzione ({len(exec_warn)} totale{_cap_note}):" - ) - if _warn_omitted > 0: - _synth.append( - f'[... {_warn_omitted} avvisi precedenti omessi — ' - f'focus sui {_WARN_DISPLAY_CAP} più recenti]' - ) - _synth.extend(_warn_display) - # S638: sintesi totale failure — guida LLM verso risposta degrado graceful - # Prima: nessun avviso se exec_done=[] → LLM non capiva che TUTTO aveva fallito - if exec_warn and not exec_done: - _n_planned = len(plan.get("subtasks", [])) - _synth.append( - f"\n### ⚠ Tutti i subtask ({_n_planned}) non hanno prodotto risultati. " - f"Rispondi in modo onesto su cosa non è stato possibile eseguire." - ) - exec_block = "\n".join(_synth) - tool_results = (f"{tool_results}\n\n{exec_block}".strip() - if tool_results else exec_block) - # S642: aggiorna _tool_exec_successes/_tool_exec_errors da subtask results - # Prima: Tool Integrity Guard riceveva solo i contatori pre-executor (tool diretti) - # senza sapere quanti subtask del planner erano andati a buon fine o no. - _tool_exec_successes += len(exec_done) - _tool_exec_errors += len([w for w in exec_warn - if w.startswith("⚠") and "S640" not in w - and "S634" not in w and "S639" not in w]) - # S638: save_episode success=True solo se almeno 1 subtask completato - # Prima: True hardcoded anche con 0 risultati → episodi falsi in memoria - _ep_success = bool(exec_done) - if self.memory: - _mem_src = "\n".join(exec_done)[:800] if exec_done else exec_warn[0][:400] - await self.memory.save_episode( - "executor", state.goal, _mem_src, _ep_success, - tags=["executor", "plan"]) - - # S575-GAP1: ReasoningCore gate per task complessi - # Trigger: tok_budget >= 6144 (task grandi) + piano con 3+ subtask - # Azione: run_loop_to_answer() con max 5 iterazioni → inietta nel contesto - # Il loop multi-step arricchisce tool_results; l'LLM finale sintetizza la risposta. - # Timeout 55s — conservativo, mai blocca l'utente più di 1 min totale. - _n_subtasks = len(plan.get("subtasks", [])) if plan else 0 - _should_reason = ( - self._max_tokens_for_goal(state.goal) >= 6144 - and _n_subtasks >= 3 - ) - if _should_reason: - try: - from agents.reasoning_core import ReasoningCore as _RC - _rc = _RC( - llm_client=self._get_llm_for_goal(state.goal), - planner=self.planner, - critic=self.critic, - executor=self.executor, - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "reasoning_core", - "status": "started", - "title": "Analisi multi-step", - "explanation": f"ReasoningCore attivato — {_n_subtasks} subtask, loop fino a 5", - })) - # GAP-2: converti _session_files (path→content) in project_files per deep context - _rc_pf = [ - {"path": _pf_path, "content": _pf_content, "language": _pf_path.rsplit(".", 1)[-1].lower() if "." in _pf_path else ""} - for _pf_path, _pf_content in (self._session_files or {}).items() - ] or None - _rc_ctx = await asyncio.wait_for( - _rc.run_loop_to_answer( - state.goal, context=state.context or "", - on_step=on_step, max_loops=8, # S701: 5→8 - project_files=_rc_pf, # GAP-2: deep context multi-file - ), - timeout=55.0, - ) - if _rc_ctx: - tool_results = ( - f"{tool_results}\n\n[REASONING CORE]\n{_rc_ctx}".strip() - if tool_results else f"[REASONING CORE]\n{_rc_ctx}" - ) - if on_step: - await _maybe_await(on_step({ - "loop": 0, "action": "reasoning_core", - "status": "done", - "title": "Analisi multi-step completata ✓", - })) - except asyncio.TimeoutError: - pass # timeout → continua con tool_results già disponibili - except Exception: - pass # silente — non blocca il loop principale - - # RF-2: Skeleton Injection — se >=3 file in sessione, inietta skeleton compatto - # Attiva il context_manager (S364/S752-A): firme funzioni invece di file interi. - # Riduce token ~60% su sessioni multi-file senza perdere informazione strutturale. - if self._session_files and len(self._session_files) >= 3: - try: - _gcfg = _get_context_manager() - _cm_files = [ - { - "path": _p, - "content": _c, - "language": _p.rsplit(".", 1)[-1].lower() if "." in _p else "", - } - for _p, _c in self._session_files.items() - ] - _skeleton_ctx = await asyncio.wait_for( - _gcfg(state.goal, active_files=[], all_files=_cm_files, top_k=4), - timeout=2.0, - ) - if _skeleton_ctx and not _skeleton_ctx.startswith('[LLM'): - tool_results = ( - f"[SKELETON PROGETTO]\n{_skeleton_ctx}\n\n{tool_results}".strip() - if tool_results else f"[SKELETON PROGETTO]\n{_skeleton_ctx}" - ) - except Exception: - pass # RF-2: fail-safe, mai blocca il loop principale - - # GAP-4-TOOLCOMP: comprimi tool_results se > 3000 chars - # Evita context saturation con output grezzi di read_file/web_search. - # Usa fast_llm (8B), timeout 4s, fail-open — mai blocca il loop. - if tool_results and len(tool_results) > 3000: - try: - _tr_llm = self._get_fast_llm() - _tr_comp = await asyncio.wait_for( - _tr_llm.chat([ - {"role": "system", "content": ( - "Riassumi i risultati tool seguenti preservando: " - "dati concreti (URL, numeri, path file, errori esatti, codice), " - "risultati critici per il goal. Elimina verbosità e ridondanza. " - "Max 1500 chars. Sii chirurgico." - )}, - {"role": "user", "content": ( - f"GOAL: {state.goal[:200]}\n\nTOOL RESULTS:\n{tool_results[:4000]}" - )}, - ], temperature=0.1, max_tokens=400), - timeout=4.0, - ) - if _tr_comp and not _tr_comp.startswith('[LLM') and len(_tr_comp) < len(tool_results): - tool_results = f"[TOOL RESULTS COMPRESSI — GAP-4]\n{_tr_comp}" - except Exception: - pass # fail-open: usa tool_results originali se compressione fallisce - - # LLM call con dati tool iniettati - # S402: passa exec counts per Tool Integrity Guard in _build_messages() - messages = self._build_messages( - state, tool_results=tool_results, - tool_exec_successes=_tool_exec_successes, - tool_exec_errors=_tool_exec_errors, - session_files=self._session_files or None, # S416-Fix1 - ) - # S418-F3: Role.CONTEXT — comprime storia se > 20 messaggi per prevenire context bloat - if len(messages) > 20: - try: - from models.role_router import RoleRouter, Role as _Role - _ctx_llm = RoleRouter.get_client(_Role.CONTEXT) - _comp_input = [ - {"role": "system", "content": ( - "Riassumi questa conversazione in max 5 punti chiave. " - "Preserva dati concreti (URL, numeri, risultati tool). Sii molto conciso." - )}, - *messages[1:-2], - ] - _summary = await asyncio.wait_for( - _ctx_llm.chat(_comp_input, temperature=0.1, max_tokens=512), - timeout=4.0, # S423: ridotto da 10s a 4s — evita bottleneck su 429 - ) - if _summary and not _summary.startswith('[LLM'): - # S423-Fix8: preserva sempre l'ultimo user message — evita che la domanda - # corrente venga persa nella compressione quando è fuori da messages[-3:] - # S590: messages[-2:]→[-3:] — preserva più turns nella coda di compressione - _last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None) - _tail = list(messages[-3:]) - # S458: inserisci _last_user PRIMA della coda (user→assistant), non dopo - if _last_user and _last_user not in _tail: - _tail.insert(0, _last_user) - _compressed = [ - messages[0], - {"role": "system", "content": f"[STORIA COMPRESSA]\n{_summary}"}, - *_tail, - ] - messages = _compressed - except Exception: - pass # compressione fallita — usa messages originali - if on_step: - await _maybe_await(on_step({ - "loop": 1, "action": "llm", "status": "started", - "title": "Elaborazione AI", - "explanation": "Sto elaborando la risposta…", - })) - - # B10: usa state.has_files — non più '__HAS_FILES__' nel context string - _has_files = state.has_files - _llm_timeout = LLM_TIMEOUT * 1.8 if _has_files else LLM_TIMEOUT - - # S197 never-give-up: frasi di rifiuto che triggerano retry forzato - # S456-X2: SET CANONICO — sincronizzato con REFUSAL_RE in outputValidator.ts. - # Soglia: 600 chars (retry aggressivo, cheap). Frontend usa 350 (quality penalization). - # Soglie SEPARATE per design — qualsiasi aggiunta qui deve aggiornare anche il TS. - _REFUSAL_PHRASES = ( - # ── Italiano ────────────────────────────────────────────────────── - 'non posso', 'non sono in grado', 'mi dispiace ma non', - 'impossibile per me', 'non riesco', 'non ho accesso', - 'mi scuso ma non', 'purtroppo non posso', 'purtroppo non sono', - 'mi dispiace, non', 'non mi è possibile', 'non è possibile per me', - 'non ho trovato', # S456-X2: da TS REFUSAL_RE - 'sono spiacente', # S456-X2: da TS REFUSAL_RE - 'come ia non', # S456-X2: da TS REFUSAL_RE - # ── Inglese ─────────────────────────────────────────────────────── - 'i cannot', 'i am unable', 'i\'m unable', 'i\'m sorry but i', - 'as an ai', 'as an language model', 'as a language model', - 'i\'m not able to', 'that\'s not something i can', 'sorry, i can\'t', - 'unfortunately i cannot', 'i\'m afraid i cannot', - 'i lack the capability', # S456-X2: da TS REFUSAL_RE - "i don't have the ability", # S456-X2: da TS REFUSAL_RE - "i don't have information about", # S456-X2: da TS REFUSAL_RE - # ── Estensioni S-REFUSAL-EXT ───────────────────────────────── - 'non so come', # IT: mancava da _REFUSAL_PHRASES - 'non posso aiutarti', # IT: mancava da _REFUSAL_PHRASES - 'questo va oltre', # IT: va oltre capacità agente - 'non posso rispondere', # IT: rifiuto esplicito - 'i cannot assist', # EN: variante i cannot - "i'm not able", # EN: variante i'm not able to - 'beyond my capability', # EN: limite capacità - 'not within my', # EN: not within my capability/scope - 'i apologize but', # EN: scuse + rifiuto - 'mi scusi ma', # IT: scuse formali - ) - - def _is_refusal(text: str) -> bool: - low = text.lower() - # S-REFUSAL-EARLY: controlla anche i primi 400 chars per refusal verbosi - # Alcuni LLM premettono lunghe spiegazioni al rifiuto â len<600 li perdeva. - return any(p in low for p in _REFUSAL_PHRASES) and ( - len(text) < 600 or any(p in low[:400] for p in _REFUSAL_PHRASES) - ) - - # GAP-3: EscalationLadder — routing dinamico: attempt 0→CODER, 1→REASONER, 2+→DEFAULT - # Attempt 0: CODER (Llama 4 Scout) · Attempt 1: REASONER (Cerebras 120B) · Attempt 2+: DEFAULT - from agents.escalation_ladder import EscalationLadder as _EscLadder - _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal) - - # S376: error severity classifier — adatta la strategia di retry in base al tipo di errore - # Senza questo, tutti gli errori ricevono lo stesso trattamento (temperature 0.4, stesso hint) - # Con questo: syntax → fix preciso, runtime → retry tool, logic → ri-pianifica - # S376/GAP-3.3: usa error_classifier.py unificato (11 categorie, regex precisi) - # Rimussa funzione locale duplicata — mapping ErrorCategory → severity per _SEVERITY_HINTS - _EC_TO_SEVERITY = { - "syntax": "syntax", - "runtime": "runtime", "selector": "runtime", "navigation": "runtime", - "frame": "runtime", "auth": "runtime", "network": "runtime", - "limit": "runtime", - "logic": "logic", "db_error": "logic", - "unknown": "unknown", - } - try: - _clf_fn, _ = _get_classifier() - _clf_result = _clf_fn([str(e) for e in state.errors[-3:]]) - _error_severity = _EC_TO_SEVERITY.get(_clf_result.category.value, "unknown") - except Exception: - _error_severity = "unknown" - - # S376: severity-based retry hints - _SEVERITY_HINTS = { - 'syntax': ( - "ERRORE DI SINTASSI RILEVATO: correggi SOLO la sintassi — " - "non cambiare la logica. Verifica parentesi, virgole, indentazione." - ), - 'runtime': ( - "ERRORE RUNTIME RILEVATO: l'approccio precedente ha prodotto un errore " - "a runtime. Prova un approccio alternativo più robusto con gestione errori." - ), - 'logic': ( - "ERRORE LOGICO RILEVATO: il risultato ottenuto non è corretto. " - "Ripensa la logica dall'inizio — usa un approccio diverso." - ), - } - - # S195-Robust + S197: retry su errore/placeholder/rifiuto - # S385: adaptive retry budget — Q&A semplice 1 try, code 2, app multi-feature 3 - _tok_budget = self._max_tokens_for_goal(state.goal) - _max_llm_tries = 3 if _tok_budget >= 6144 else 2 if _tok_budget >= 4096 else 1 - answer = "" - _prev_llm_answer = "" # S759: repeated-answer stuck detection - for _llm_try in range(_max_llm_tries): - _is_last = _llm_try == _max_llm_tries - 1 - # GAP-3: aggiorna il client LLM per questo tentativo (escalation dinamica) - _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity) - try: - _msgs = messages - # S385-fix4: inietta force-response SOLO se ci sono stati tentativi precedenti - # (quando _max_llm_tries=1, _is_last è True al primo try — non iniettiamo mai l'istruzione aggressiva) - if _is_last and _llm_try > 0: - # Ultimo di più tentativi: inietta istruzione forza-risposta + severity hint - _force_content = ( - "ISTRUZIONE FINALE: NON puoi rifiutarti di rispondere. " - "Trova UN MODO alternativo, anche parziale, per aiutare. " - "Approccio A fallito? Prova B. Non scrivere mai 'non posso'. " - "Dai almeno una risposta parziale concreta." - ) - _sev_hint = _SEVERITY_HINTS.get(_error_severity, '') - if _sev_hint: - _force_content = f"{_sev_hint}\n\n{_force_content}" - _force = {"role": "system", "content": _force_content} - _msgs = [messages[0], _force, *messages[1:]] - elif _llm_try == _max_llm_tries - 2 and _max_llm_tries > 1 and _error_severity in _SEVERITY_HINTS: - # Penultimo tentativo: inietta solo il severity hint (meno aggressivo) - _sev_msg = {"role": "system", "content": _SEVERITY_HINTS[_error_severity]} - _msgs = [messages[0], _sev_msg, *messages[1:]] - # S376: temperatura adattiva in base alla severity - # syntax → bassa (0.1, precisione), logic → alta (0.5, creatività) - _temp_by_try = { - 'syntax': [0.1, 0.15, 0.2], - 'runtime': [0.2, 0.3, 0.4], - 'logic': [0.3, 0.45, 0.5], - 'unknown': [0.2, 0.4, 0.4], - } - _temp = _temp_by_try.get(_error_severity, [0.2, 0.4, 0.4])[min(_llm_try, 2)] - # S385: latency telemetry — misura durata chiamata LLM - _t0_llm = asyncio.get_running_loop().time() - # S420: stream tokens to frontend while accumulating full answer - _stream_parts: list[str] = [] - try: - async def _collect_stream(_msgs=_msgs, _temp=_temp, _tok_budget=_tok_budget) -> str: - async for _tok in _active_llm.stream_chat( - _msgs, temperature=_temp, max_tokens=_tok_budget - ): - _stream_parts.append(_tok) - if on_step: - await _maybe_await(on_step({ - "action": "text_chunk", - "token": _tok, - "status": "streaming", - })) - return "".join(_stream_parts) - answer = await asyncio.wait_for(_collect_stream(), timeout=_llm_timeout) - if not answer: - raise ValueError("stream vuoto") - except Exception: - _stream_parts.clear() - answer = await asyncio.wait_for( - _active_llm.chat(_msgs, temperature=_temp, max_tokens=_tok_budget), - timeout=_llm_timeout, - ) - try: - from api.state import record_timing as _rec_timing - _llm_elapsed = (asyncio.get_running_loop().time() - _t0_llm) * 1000 - _rec_timing("llm_total", _llm_elapsed) - _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B4: segnala truncation SSE se finish_reason == "length" - _fr = getattr(_active_llm, '_last_finish_reason', 'stop') - if _fr == 'length' and on_step: - await _maybe_await(on_step({ - "action": "step", "step": state.current_step, - "output": "⚠️ [TRUNCATION] Risposta LLM troncata (max_tokens raggiunto). Tenta riduzione contesto.", - "truncated": True, - })) - if answer.startswith('[LLM'): - state.steps.append({"action": f"llm_attempt_{_llm_try}", "output": answer}) - continue - if _is_refusal(answer) and not _is_last: - # S576: 200→400 — cattura rifiuto completo per debug - state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:600]}) # S603: 400→600 - continue - # S759: repeated-answer stuck detection - # Se risposta simile all'ultima (Jaccard bigram >0.75) e non è l'ultimo try → forza retry - if _llm_try > 0 and _prev_llm_answer and answer and not answer.startswith('[LLM'): - def _s759_bjac(_a: str, _b: str) -> float: - try: - _na, _nb = _a[:100].lower(), _b[:100].lower() - _sa = {_na[_i:_i+2] for _i in range(max(0, len(_na)-1))} - _sb = {_nb[_i:_i+2] for _i in range(max(0, len(_nb)-1))} - _inter = len(_sa & _sb); _union = len(_sa | _sb) - return _inter / _union if _union else 1.0 - except Exception: - return 0.0 - if _s759_bjac(answer, _prev_llm_answer) > 0.75 and not _is_last: - state.steps.append({ - "action": f"llm_stuck_{_llm_try}", - "output": "risposta ripetuta — cambio temperatura e strategia", - }) - _prev_llm_answer = answer[:100] - continue # riprova con temperatura più alta - _prev_llm_answer = answer[:100] if answer and not answer.startswith('[LLM') else _prev_llm_answer - - # S-BACKEND-ANTIREGRESS: rileva import injection e code rewrite. - # Se rilevato E non ultimo try, inietta hint chirurgico e riprova. - if not _is_last and answer and '```' in answer: - try: - from agents.backend_antiregress import check_regression as _ar_chk - _ar_hint = _ar_chk(state.goal, answer, state.context or "") - if _ar_hint: - state.steps.append({ - "action": "antiregress_retry", - "hint": _ar_hint[:200], - }) - _ar_msg = ( - "\n\n[CORREZIONE RICHIESTA]\n" - + _ar_hint - + "\n\nRiscrivi SOLO la parte difettosa. " - "Mantieni TUTTE le classi e funzioni originali. " - "Non aggiungere nuove dipendenze." - ) - _msgs = [_msgs[0], {"role": "user", "content": state.goal + _ar_msg}] - continue # retry con hint chirurgico - except Exception: - pass # S-BACKEND-ANTIREGRESS: non bloccante - - break # risposta reale non-rifiuto - except asyncio.TimeoutError: - answer = f"[LLM timeout {_llm_timeout:.0f}s]" - if not _is_last: - continue # riprova su timeout - break - except Exception as exc: - answer = f"[LLM error: {exc}]" - if not _is_last: - continue - break - - if answer.startswith("[LLM"): - state.errors.append(answer) - # S364: Chain-of-Verification — dopo 2+ errori, usa ARCHITECT per reflection - if len(state.errors) >= 1: # S701: reflection da 1 errore (era 2) - # GAP-D: progress card visibile PRIMA del reflection — utente sa che stiamo analizzando - if on_step: - _rd_n = len(state.errors) - _rd_label = "Strategia alternativa forzata" if _rd_n >= 3 else "Analisi dell'errore" - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "started", - "title": f"🔍 {_rd_label} (tentativo {_rd_n})", - "explanation": ( - "Ho riscontrato un ostacolo ripetuto. Sto elaborando una strategia completamente diversa con il modello Architect…" - if _rd_n >= 3 else - "Ho riscontrato un errore. Sto analizzando la causa principale con il modello Architect per cambiare approccio…" - ), - })) - # B4: strategic_ctx già presente → degrada ARCHITECT→fast_llm (-10-15s) - _b4_has_strategic = ( - '[GAP-SELFHEAL:' in (state.context or '') - or '♻️ Re-planning' in (state.context or '') - ) - _reflection = await self._reflective_debug( - state.goal, state.errors, - _force_fast=_b4_has_strategic, - ) - if _reflection: - state.context = (state.context or '') + _reflection - state.steps.append({"action": "reflective_debug", - "analysis": _reflection[:400]}) # S573: 200→400 - # GAP-D: progress card "done" con la nuova strategia — trasforma il fallimento in fiducia - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "💡 Nuova strategia identificata", - "explanation": _reflection[:300], - })) - # GAP-SELFHEAL: dopo 3+ errori, inietta regole concrete di cambio strategia - # Il reflective_debug da solo non rompe il loop di allucinazione (63% closure fail). - # R3: aggiunta dedup guard — senza di essa ogni iterazione LLM con state.errors>=3 - # appendeva un [GAP-SELFHEAL] blocco distinto a state.context (crescita O(n_errors)). - # Pattern: inietta SOLO SE state.context non contiene già "[GAP-SELFHEAL:". - if len(state.errors) >= 3: - _n_err = len(state.errors) - _sh2_already = "[GAP-SELFHEAL:" in (state.context or "") - if not _sh2_already: - _selfheal_inj = ( - "\n\n[GAP-SELFHEAL: tentativo " + str(_n_err) + " - CAMBIO STRATEGIA OBBLIGATORIO]\n" - "I precedenti " + str(_n_err) + " approcci sono falliti. Applica QUESTE regole:\n" - "1. NON ripetere il codice fallito - smontalo in passi atomici\n" - "2. Prima di scrivere usa read_file per verificare lo stato attuale\n" - "3. Scrivi SOLO la parte minima che fa passare UN test alla volta\n" - "4. Se libreria X fallisce, prova libreria Y alternativa\n" - "5. Se pattern A fallisce, usa pattern B completamente diverso." - ) - state.context = (state.context or "") + _selfheal_inj - state.steps.append({"action": "selfheal_strategy_injection", "n_errors": _n_err}) - - # GAP-1: Probabilistic Re-planning Trigger - # Chiamato dopo selfheal: step count = numero step completati finora. - # Agisce su state.context (append) — non modifica messages correnti. - _gap1_step_count = len([s for s in state.steps if s.get("action") == "llm"]) - _gap1_hint = await self._budget_replan_check(state, _gap1_step_count, on_step) - if _gap1_hint: - state.context = (state.context or '') + f'\n\n[GAP-1-REPLAN]\nNuovo approccio: {_gap1_hint}' - state.steps.append({"action": "budget_replan", "hint": _gap1_hint[:200]}) - - state.steps.append({"action": "llm", "output": answer}) - - # S428 Sprint1-Fix3: Claim Validation — safety net post-LLM. - # Anche quando _build_messages inietta "TENTATIVO TOOL FALLITO" con istruzione - # "NON affermare di aver trovato dati live", il LLM può ignorarla. - # Questo check è il secondo strato di difesa: aggiunge un disclaimer visibile - # se e solo se rileva false claim + goal realtime + tutti tool falliti. - if answer and not answer.startswith("[LLM"): - answer = self._validate_claims( - response=answer, - n_success=_tool_exec_successes, - n_errors=_tool_exec_errors, - goal=state.goal, - false_claim_re=self._FALSE_CLAIM_RE, - realtime_goal_re=self._REALTIME_GOAL_RE, - ) - - # S416-Fix1: aggiorna _session_files con file scritti in questa risposta - # così il prossimo run() li inietta come contesto (evita import rotti tra step) - if answer: - _written = self._extract_written_files(answer) - if _written: - self._session_files.update(_written) - # Sprint 3b ITEM 7: auto validate_project post-write - # Se _tok_budget >= 4096 e ci sono file Python scritti, verifica sintassi AST - if _tok_budget >= 4096: - import ast as _ast_chk - _py_errs: list[str] = [] - for _vp, _vc in list({p: c for p, c in _written.items() - if p.endswith(".py")}.items())[:5]: - try: - _ast_chk.parse(_vc) - except SyntaxError as _se: - _py_errs.append(f"{_vp}:{_se.lineno}: {_se.msg}") - if _py_errs: - # S594: _py_errs[:3]→[:5] — riporta più errori di sintassi per fix completo - _syn_rpt = "AUTO-VALIDATE sintassi: " + "; ".join(_py_errs[:5]) - state.errors.append(_syn_rpt) - if on_step: - await _maybe_await(on_step({ - "action": "validate_project", - "status": "needs_fix", - "title": "Validazione automatica", - "explanation": _syn_rpt[:400], # S576: 200→400 - })) - try: - from api.state import increment_stat as _inc_syn - _inc_syn("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif on_step: - _n_py = sum(1 for p in _written if p.endswith(".py")) - if _n_py > 0: - await _maybe_await(on_step({ - "action": "validate_project", - "status": "done", - "title": "Validazione automatica ✓", - "explanation": f"{_n_py} file Python — sintassi OK", - })) - # GAP-C: Ciclo di Test Automatizzato - # Trigger: sintassi OK + file Python scritti + task complesso (>=8192 tok) - # Genera test minimale via LLM (8s) → esegue via exec engine (20s) - # Fallimento → _reflective_debug → state.context aggiornato per il loop successivo - # Best-effort: Exception catturata in fondo — mai blocca la risposta utente - if not _py_errs: - _gac_py = {p: c for p, c in _written.items() if p.endswith(".py")} - if _gac_py and _tok_budget >= 8192: - try: - _gac_name, _gac_code = next(iter(_gac_py.items())) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "started", - "title": "🧪 Test automatico", - "explanation": f"Genero ed eseguo un test minimale per {_gac_name}…", - })) - _gac_msgs = [ - {"role": "system", "content": ( - "Scrivi UN test Python minimale (stdlib only, no pytest) per il codice.\n" - "Deve: importare funzioni principali, avere 1-3 assert concreti,\n" - "stampare 'PASS' o 'FAIL: '. Solo codice Python, niente markdown." - )}, - {"role": "user", "content": f"# {_gac_name}\n{_gac_code[:1500]}"}, - ] - _gac_raw = await asyncio.wait_for( - self.llm.chat(_gac_msgs, temperature=0.05, max_tokens=350), - timeout=8.0, - ) - import re as _gac_re - _gac_m = _gac_re.search(r'```python\n([\s\S]+?)```', _gac_raw or "") - _gac_run = _gac_m.group(1) if _gac_m else (_gac_raw or "").strip() - if len(_gac_run) > 10: - from tools.registry import _call_exec_engine as _gac_exec - _gac_res = await asyncio.wait_for( - _gac_exec({"code": _gac_run, "lang": "python", "timeout": 15}), - timeout=20.0, - ) or {} - _gac_exit = _gac_res.get("exit_code", 1) - _gac_out = ( - (_gac_res.get("stdout") or "") + (_gac_res.get("stderr") or "") - )[:300] - if _gac_exit == 0 and "FAIL" not in _gac_out.upper(): - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "done", - "title": "🧪 Test automatico ✅ PASS", - "explanation": _gac_out[:200] or "Tutti i test superati.", - })) - else: - state.errors.append( - f"Auto-test {_gac_name} exit={_gac_exit}: {_gac_out}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "auto_test", - "status": "needs_fix", - "title": "🧪 Test automatico ⚠ FAIL", - "explanation": _gac_out[:200], - })) - _gac_fix = await self._reflective_debug(state.goal, state.errors) - if _gac_fix: - state.context = ( - (state.context or "") - + f"\n\n[AUTO-TEST FAIL — {_gac_name}]\n{_gac_fix}" - ) - if on_step: - await _maybe_await(on_step({ - "action": "reflective_debug", - "status": "done", - "title": "💡 Fix suggerito da test fallito", - "explanation": _gac_fix[:300], - })) - except Exception: - pass # GAP-C best-effort — mai blocca la risposta utente - # S403-FIX: NON appendere a outputs qui — i repair loop (verifier, goal_verifier, - # self-healing Python/HTML) modificano `answer` ma non `outputs`. - # L'append viene fatto DOPO tutti i repair, appena prima di final_output, - # così "\n\n".join(outputs) riflette la risposta completamente riparata. - # (Prima: outputs.append(answer) qui → tutti i fix venivano scartati in silenzio) - - # Doc2-3a-FIX: quality_guardian integrato nel loop di repair. - # Prima: fire-and-forget → fix_hint emesso via SSE ma mai usato → codice bugato consegnato. - # Ora: await con timeout breve (8s). - # - Se risulta FAIL + fix_hint → 1 repair LLM call prima di restituire la risposta. - # - Se timeout → fire-and-forget solo per notifica SSE (comportamento precedente). - # Invariante B6 rispettata: solo timeout avvia il task async — nessun await bloccante lungo. - if answer and not answer.startswith('[LLM') and '```' in answer: - try: - import importlib as _imp_ev - try: - _qg_mod = _imp_ev.import_module('api.quality_guardian') - except ImportError: - _qg_mod = None - _qc_fn = getattr(_qg_mod, 'run_quality_check', None) if _qg_mod else None - if _qc_fn: - _answer_snap = answer - _qc_result: dict | None = None - - # Tenta quality check con timeout breve (8s) — permette repair integrato - try: - _qc_result = await asyncio.wait_for( - _qc_fn(task_id=self._run_task_id, goal=state.goal, - llm_output=_answer_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg - timeout=8.0, - ) - except asyncio.TimeoutError: - _qc_result = None # troppo lento → fire-and-forget sotto - except Exception: - _qc_result = None - - if _qc_result is not None: - # Risultato disponibile — repair integrato se FAIL + fix_hint - if _qc_result.get('passed') is False and _qc_result.get('fix_hint'): - # S594: fix_hint 300→500 — hint correttivo spesso multi-riga (era [:300] che limitava il successivo [:400]) - _fix_hint = str(_qc_result['fix_hint'])[:500] - if on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': _fix_hint, # S573: 200→400; S594: cap spostato a riga sopra - 'status': 'repairing', - })) - try: - # Usa messages originali (non _msgs con hint iniettati) - # per evitare confusion nel contesto del repair LLM - # S590: messages[-4:]→[-6:] — più contesto per repair LLM - _repair_msgs = [ - *messages[-6:], - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"Il tester automatico ha rilevato un bug:\n{_fix_hint}\n\n" - "Correggi SOLO il codice difettoso. " - "Riscrivi completi i file che contengono il bug." - )}, - ] - _repaired = await asyncio.wait_for( - _active_llm.chat( - _repair_msgs, temperature=0.1, - max_tokens=min(_tok_budget, 4096), - ), - timeout=25.0, - ) - if _repaired and not _repaired.startswith('[LLM'): - answer = _repaired - if on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'status': 'done', - 'title': 'Fix automatico applicato ✓', - })) - try: - from api.state import increment_stat as _inc_qg - _inc_qg("repair_success_count") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # repair silente — risposta originale invariata - elif _qc_result.get('passed') is False and on_step: - # FAIL senza hint → notifica UI - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': 'Quality check: bug rilevato — nessun hint specifico', - 'status': 'needs_fix', - })) - else: - # Timeout 8s → fire-and-forget per notifica SSE (B6 invariant) - _ff_snap = answer - _run_tid = self._run_task_id # S568-A: cattura prima del closure - async def _ev_task() -> None: - try: - _qc = await asyncio.wait_for( - _qc_fn(task_id=_run_tid, goal=state.goal, - llm_output=_ff_snap, on_event=on_step, - session_files=self._session_files or None), # S568-A/GAP-3qg ff - timeout=18.0, - ) - if _qc.get('passed') is False and _qc.get('fix_hint') and on_step: - await _maybe_await(on_step({ - 'action': 'execution_validator_fix', - 'fix_hint': _qc['fix_hint'][:400], # S573: 200→400 - 'status': 'needs_fix', - })) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # S455-P10: task supervisionato - _ev_t = asyncio.create_task(_ev_task()) - _ev_t.add_done_callback( - lambda t: t.exception() if not t.cancelled() and t.exception() is not None else None - ) - except Exception as _ev_exc: - _logger.warning("S624 ExecutionValidator failed (silent): %s", _ev_exc) # S624 - - # S274-BUG3: ResponseVerifier era salvato in self.verifier ma MAI chiamato. - # Wire-in: verifica JSON, markdown, coerenza. Retry con hint se suggerito. - if self.verifier and answer and not answer.startswith('[LLM'): - try: - _vr = self.verifier.verify_and_repair(state.goal, answer) - answer = _vr.output - if getattr(_vr, 'retry_suggested', False): - _hint_msg = [*messages, {"role": "assistant", "content": answer}, - {"role": "user", "content": f"Migliora: {getattr(_vr, 'retry_hint', 'rendi la risposta più completa')}"}] - try: - # S427-FixF: usa _active_llm (CODER per task di codice) invece del - # base self.llm — il retry del verifier usava il modello sbagliato - # per task di codice complessi (es. Groq 8B invece di 70B). - _retry_ans = await asyncio.wait_for( - _active_llm.chat(_hint_msg, temperature=0.3, max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=LLM_TIMEOUT) - if _retry_ans and not _retry_ans.startswith('[LLM'): - answer = _retry_ans - except Exception as _rv_retry_exc: - _logger.warning("S624 ResponseVerifier retry failed (silent): %s", _rv_retry_exc) # S624 - except Exception as _rv_exc: - _logger.warning("S624 ResponseVerifier failed (silent): %s", _rv_exc) # S624 - - # ── MIN-LENGTH-GATE (Checklist Item 1) ──────────────────────────────── - # Retry automatico per goal analitici con risposta troppo corta. - # Recupera RY (riassumi) e DA (data analysis) failures — output <150 parole. - # Trigger: _ANALYTICAL_VERBS_RE match + risposta < 150 parole. Fail-open. - if answer and not answer.startswith('[LLM'): - _mlg_words = len(answer.split()) - _is_goal_analytical = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - if _is_goal_analytical and _mlg_words < 150: - try: - _mlg_reinforce = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"La risposta è troppo breve ({_mlg_words} parole) " - f"rispetto a quanto richiesto dal goal. " - f"Sviluppa ogni punto in modo completo e dettagliato: " - f"almeno 200 parole, coprendo esaustivamente tutti gli aspetti." - )}, - ] - _mlg_retry = await asyncio.wait_for( - _active_llm.chat( - _mlg_reinforce, - temperature=0.3, - max_tokens=self._max_tokens_for_goal(state.goal), - ), - timeout=LLM_TIMEOUT, - ) - if (_mlg_retry and not _mlg_retry.startswith('[LLM') - and len(_mlg_retry.split()) > _mlg_words): - answer = _mlg_retry - _logger.debug( - "[unified_loop] min_length_gate: %d→%d words (goal=%s…)", - _mlg_words, len(answer.split()), state.goal[:40], - ) - try: - from api.state import increment_stat as _inc_mlg - _inc_mlg("min_length_gate_retry") - except Exception: - pass - except Exception: - pass # fail-open — mantieni risposta originale - - # S403: GoalVerifier — verifica semantica "obiettivo raggiunto" vs "azione eseguita" - # S410: adaptive threshold + double-pass re-verify per chiudere il loop di verifica. - # Il ciclo: verify → repair → re-verify → accept/reject conferma che il repair - # abbia davvero migliorato la coverage, non solo cambiato la risposta. - # S416-Fix2: attivato per is_code_goal anche senza backtick (app multi-file descrittiva) - # Sprint 2: GoalVerifier 2.0 — se RequirementEngine trova requisiti, usa verify_v2 - try: - from agents.goal_verifier import GoalVerifier as _GV_pre - _gv_should_run = _GV_pre.is_code_goal(state.goal) or '```' in answer - except Exception: - _gv_should_run = '```' in answer - if answer and not answer.startswith('[LLM') and _gv_should_run: - try: - from agents.goal_verifier import GoalVerifier as _GV - from api.state import increment_stat as _inc_stat - if _GV.is_code_goal(state.goal): - _gv = _GV(self._get_verifier_llm()) # P25-B4: cross-model - _threshold = _GV.adaptive_threshold(state.goal) # S410: adattivo - # Sprint 2: tenta verify_v2 se RequirementEngine disponibile e goal complesso - _gv2_reqs = None - if _tok_budget >= 4096: - try: - from agents.requirement_engine import RequirementEngine as _RE - from api.state import increment_stat as _inc_re - _re_engine = _RE(llm=self.llm) # BUG-5: LLM come fallback per goal complessi - _gv2_reqs = await _re_engine.decompose(state.goal) # P16-B1: async con LLM fallback — decompose_sync ignorava llm=self.llm - if _gv2_reqs: - _inc_re("req_engine_used") - try: - from api.state import increment_stat as _inc_re2 - _inc_re2.__module__ # no-op, just exist check - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - import api.state as _st_mod - _st_mod._REPAIR_STATS["req_engine_reqs_total"] += len(_gv2_reqs) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - _gv2_reqs = None - # FIX-2: fast-pass euristico — salta LLM verify se risposta gia completa. - # Condizioni: >600 chars + >=1 blocco codice + 60% keyword goal + no errori. - # Risparmio: -5s/iter su task dove LLM ha gia risposto bene (caso comune). - _goal_words_fp = set(re.findall(r'\w{4,}', state.goal.lower())) - _ans_words_fp = set(re.findall(r'\w{4,}', answer.lower())) - _kw_cov_fp = len(_goal_words_fp & _ans_words_fp) / max(len(_goal_words_fp), 1) - # B2: fast-pass ampliato — fast-fix senza errori saltano goal_verifier. - # Conseguenza: -5/-22s per ogni fix atomico andato a buon fine. - # Zero cons: FAST_FIX_RE+no errors garantisce completezza senza LLM. - _is_fast_fix_clean = ( - not getattr(state, 'errors', None) - and len(state.goal) < 200 - and bool(self._FAST_FIX_RE.search(state.goal[:200])) - and bool(answer.strip()) - ) - # P16-B5: soglia keyword adattiva in base alla lunghezza del goal - # Goal brevi (<80 chars): molto specifici → soglia più bassa (0.60) - # Goal medi (80-200 chars): default (0.72) - # Goal lunghi (>200 chars): molti requisiti → soglia più alta (0.82) - _gl = len(state.goal) - _fp_threshold = 0.60 if _gl < 80 else (0.82 if _gl > 200 else 0.72) - # Item 5: fast-pass non-coding branch — keyword coverage su prosa - _is_goal_analytical_fp = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) - _fast_pass = ( - _is_fast_fix_clean - or ( - # Existing: code-heavy answers (4+ code blocks) - len(answer) > 1200 - and answer.count('```') >= 4 - and _kw_cov_fp >= _fp_threshold # P16-B5: adattivo - and not getattr(state, 'errors', None) - ) - or ( - # NEW — Item 5: goal analitici — fast-pass via keyword coverage senza codice - # Evita LLM verify su risposte analitiche già esaustive (≥150 parole, 55% kw) - _is_goal_analytical_fp - and len(answer.split()) >= 150 - and _kw_cov_fp >= 0.55 - and not getattr(state, 'errors', None) - ) - ) - # P25-B2: Risk gate — blocca fast_pass se ci sono requisiti ad alto rischio. - # Previene shortcut euristico su operazioni sensibili (auth/pagamenti/delete/security). - # Solo per goal non-trivial (non _is_fast_fix_clean) con requisiti già estratti. - _P25_HIGH_RISK = {"auth", "payments", "crud", "security"} - if _fast_pass and not _is_fast_fix_clean and _gv2_reqs: - _has_risk_req = any( - r.get("feature", "") in _P25_HIGH_RISK for r in _gv2_reqs - ) - if _has_risk_req: - _fast_pass = False - try: - _inc_stat("fast_pass_blocked_risk") - except Exception: - pass - _logger.debug( - "[unified_loop] _fast_pass=%s kw_cov=%.2f goal_len=%d threshold=%.2f", - _fast_pass, _kw_cov_fp, _gl, _fp_threshold, - ) - if _fast_pass: - _inc_stat("goal_verify_fast_pass") - _gvr = type('_FPR', (), dict(goal_met=True, coverage_score=0.85, - missing_items=[], repair_hint=''))() - else: - # Sprint 2: usa verify_v2 se requisiti trovati, altrimenti verify v1 - _t0_gv = asyncio.get_running_loop().time() # Sprint 5 ITEM 14: verifier_ms - # GAP-1: Hard Gate — verify_with_execution() (esecuzione reale del codice) - # semantic(verify_v2) → extract code block → exec backend → PASS/FAIL - # exit_code != 0 → FAIL + traceback reale come repair_hint → self-healing loop - _gvr = await asyncio.wait_for( - _gv.verify_with_execution(state.goal, answer, requirements=_gv2_reqs or None), - timeout=22.0, # semantic(4s) + execution(18s) = 22s budget - ) - try: - from api.state import record_timing as _rtgv - _rtgv("verifier_ms", (asyncio.get_running_loop().time() - _t0_gv) * 1000) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # REMOVE-1: rimossa regola 17 README check (S416-Fix6). - # Causava -0.15 coverage su task senza 'readme' >= 6144 token — - # inclusi 'ottimizza funzione', 'spiega codice', 'crea grafico'. - # Falsi positivi sistematici -> repair spurio -> LLM call inutile. - _initial_score = _gvr.coverage_score - # S-CRITIC-1: rileva UNKNOWN prima del repair — on-demand Critic su task codice - _is_unknown = _gvr.repair_hint.startswith("[verifier_unavailable") - _skip_gv_repair = False - if (_is_unknown - and not _gvr.goal_met - and _gvr.coverage_score < _threshold - and _GV.is_code_goal(state.goal)): - try: - from agents.goal_verifier import CriticJudge as _CJ - _cj = _CJ(self._get_fast_llm()) - _cv = await asyncio.wait_for( - _cj.judge(state.goal, answer), timeout=8.0) - try: - _inc_stat(f"critic_j_{_cv.verdict.lower()}") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if _cv.verdict == "PASS": - _skip_gv_repair = True - try: - _inc_stat("critic_promoted_to_pass") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - elif ( - _cv.verdict in ("UNKNOWN", "ERROR") - or str(getattr(_cv, "raw", "")).startswith("[LLM") - ): - # GAP-8: verdict inaffidabile (rate limit 429 o timeout) - # Non triggerare repair spurio — CriticJudge non ha risposto - _skip_gv_repair = False # comportamento invariato ma esplicito - try: - _inc_stat("critic_unreliable") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # silent — UNKNOWN comportamento invariato - if not _skip_gv_repair and not _gvr.goal_met and _gvr.coverage_score < _threshold: - _inc_stat("goal_verify_repair_triggered") - if on_step: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Controllo qualità", - "explanation": ( - f"Risposta al {int(_gvr.coverage_score * 100)}% — ottimizzazione in corso" - ), - })) - _missing_str = "; ".join(_gvr.missing_items[:2]) if _gvr.missing_items else _gvr.repair_hint - # S-ORCH-8GAP FIX-GAP3+GAP6: Requirement-Driven Repair - # Arricchisce il repair context con acceptance_criteria specifici - # dei requisiti FAIL — repair "chirurgico" invece di generico. - # L'LLM sa ESATTAMENTE cosa implementare, non solo "manca qualcosa". - _criteria_hints: list[str] = [] - if _gv2_reqs and _gvr.missing_items: - _failed_ids = {m.lower().replace(" ", "_") for m in _gvr.missing_items} - for _req in _gv2_reqs: - _rname = getattr(_req, 'feature', '').lower().replace(' ', '_') - _rid = getattr(_req, 'id', '').lower() - if (_rname in _failed_ids or _rid in _failed_ids or - any(_fid in _rname or _fid in _rid for _fid in _failed_ids)): - _ac = getattr(_req, 'acceptance_criteria', []) - if _ac: - _criteria_hints.extend(_ac[:2]) - _criteria_block = ( - "\nCriteri di accettazione mancanti:\n" - + "\n".join(f" - {c}" for c in _criteria_hints[:4]) - if _criteria_hints else "" - ) - # Sprint1b: messaggio repair diversificato per UNKNOWN vs FAIL - # UNKNOWN = verifier non disponibile → non sappiamo cosa manca - # FAIL = sappiamo cosa manca → repair chirurgico - # _is_unknown già rilevato sopra (S-CRITIC-1) - if _is_unknown: - _repair_content = ( - f"Rivedi e completa la risposta al seguente goal: " - f"{state.goal[:300]}. " # S576: 200→300 - "Assicurati di coprire tutti gli aspetti richiesti " - f"in modo completo, corretto e dettagliato.{_criteria_block}" - ) - else: - _repair_content = ( - f"GOAL NON COMPLETATO ({int(_gvr.coverage_score*100)}%): " - f"{_missing_str}. " - "Completa esattamente quello che manca senza ripetere " - f"quanto già scritto.{_criteria_block}" - ) - _gv_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": _repair_content}, - ] - _repaired_score = _initial_score # default: nessun miglioramento - try: - # Fix 3 (S421): repair con il modello più capace per goal complessi - # self.llm = provider race winner (spesso 8B); app complesse hanno bisogno del 70B - _gv_repair_llm = self._get_llm_for_goal(state.goal) - _gv_ans = await asyncio.wait_for( - _gv_repair_llm.chat(_gv_msgs, temperature=0.2, - max_tokens=self._max_tokens_for_goal(state.goal)), - timeout=10.0, # S434: 20→10s - ) - if _gv_ans and not _gv_ans.startswith('[LLM'): - # S434: accetta repair immediatamente, re-verify fire-and-forget (telemetria) - answer = _gv_ans - try: - _inc_stat("goal_verify_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - _inc_stat("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - _gv_snap = _gv_ans - _is_snap = _initial_score - _gv_ref = _gv - _goal_snap = state.goal - _ostep_ref = on_step - async def _reverify_task( - _s=_gv_snap, _is=_is_snap, - _gref=_gv_ref, _g=_goal_snap, _os=_ostep_ref - ) -> None: - try: - _gvr2 = await asyncio.wait_for( - _gref.verify_with_execution(_g, _s), timeout=20.0) # BUG-4: exec verify - _rscore = _gvr2.coverage_score - _delta = _rscore - _is - if _delta < -0.05: - try: - from api.state import increment_stat as _inc_gi - _inc_gi("goal_verify_no_improvement") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if _os: - await _maybe_await(_os({ - "action": "goal_verifier", - "status": "done", - "visibility": "progress", - "title": "Controllo qualità", - "explanation": ( - f"Qualità risposta: {int(_rscore * 100)}% ✓" - if _delta >= 0 else - f"Risposta migliorata: {int(_rscore * 100)}%" - ), - "initial_score": round(_is, 3), - "repaired_score": round(_rscore, 3), - })) - except Exception: - if _os: - try: - await _maybe_await(_os({ - "action": "goal_verifier", "status": "done", - "visibility": "progress", "title": "Controllo qualità", - "explanation": f"Miglioramento inviato ({int(_is * 100)}% completato)", - "initial_score": round(_is, 3), - "repaired_score": round(_is, 3), - })) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # P16-B2: notifica UI che re-verify è in corso - if on_step: - try: - await _maybe_await(on_step({ - "action": "goal_verifier", - "status": "running", - "visibility": "progress", - "title": "Verifica qualità in corso…", - "explanation": ( - f"Copertura corrente: {int(_initial_score*100)}% " - "— verifica repair in corso" - ), - })) - except Exception: - pass - # S455-P10: task supervisionato — done_callback logga eccezioni silenziate - asyncio.create_task(_reverify_task()) - _rv_t.add_done_callback( - lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None - ) - pass # goal repair fallito — usa answer originale - except Exception: - pass # repair LLM silenzioso — answer originale invariato - else: - # Goal già soddisfatto al primo check — nessun repair necessario - _inc_stat("goal_verify_initial_pass") - # COG-2: record successful strategy for lesson injection - if self.memory and hasattr(self.memory, 'reflection'): - try: - _last_act = state.steps[-1].get('action', 'direct') if state.steps else 'direct' - self.memory.reflection.record_success( - state.goal[:300], f"goal_verify_pass|{_last_act}" - ) - except Exception: - pass # never blocks the response - except Exception as _gv_exc: - _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624 - # Sprint 3b ITEM 8: Browser Goal Verification — Playwright headless su app live - # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.) - # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs). - # Silent failure se Playwright non installato o URL non raggiungibile. - _DEPLOY_PATTERNS = ('.pages.dev', '.vercel.app', '.netlify.app', '.railway.app', - '.render.com', '.fly.dev', 'localhost:') - _browser_url: str | None = None - if answer and not answer.startswith('[LLM'): - import re as _re_bv - _url_candidates = _re_bv.findall(r'https?://[^\s\)\"\'<>]+', answer) - for _uc in _url_candidates: - if any(pat in _uc for pat in _DEPLOY_PATTERNS): - _browser_url = _uc.rstrip('.,;)') - break - if _browser_url and os.getenv("PLAYWRIGHT_ENABLED", "1") != "0": # S701: abilitato di default (playwright in requirements.txt) - try: - from api.browser import verify_goal_browser as _vgb - # Usa i requisiti già estratti dal blocco GoalVerifier v2 (se disponibili) - _bv_reqs = None - try: - _bv_reqs = _gv2_reqs # type: ignore[name-defined] - except NameError as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "browser_verifier", - "status": "running", - "visibility": "progress", - "title": "Test app in tempo reale", - "explanation": f"Verifica live: {_browser_url[:60]}…", - })) - _t0_bv = asyncio.get_running_loop().time() - _bv_result = await asyncio.wait_for( - _vgb(state.goal, _browser_url, _bv_reqs, timeout_s=25.0), - timeout=28.0, - ) - _bv_ms = (asyncio.get_running_loop().time() - _t0_bv) * 1000 - # Registra browser_ms per il phase_breakdown (Sprint 5 ITEM 14) - try: - from api.state import record_timing as _rt_bv - _rt_bv("browser_ms", _bv_ms) - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - # Telemetria: esito browser verifier - try: - from api.state import increment_stat as _inc_bv - _inc_bv(f"browser_verify_{_bv_result.get('overall', 'UNKNOWN').lower()}") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - _bv_overall = _bv_result.get("overall", "UNKNOWN") - _bv_per = _bv_result.get("per_criterion", {}) - _bv_pass_n = sum(1 for v in _bv_per.values() if v == "PASS") - _bv_total = len(_bv_per) - _bv_summary = ( - f"{_bv_pass_n}/{_bv_total} criteri OK" - if _bv_total > 0 else "nessun criterio testato" - ) - await _maybe_await(on_step({ - "action": "browser_verifier", - "status": "done", - "visibility": "progress", - "title": "Test app in tempo reale", - "explanation": f"Verifica live: {_bv_overall} — {_bv_summary}", - "url": _browser_url, - "overall": _bv_overall, - "per_criterion": _bv_per, - })) - # Se FAIL con requisiti → aggiungi nota all'answer (non modifica il codice) - if _bv_result.get("overall") == "FAIL" and _bv_per: - _failed_criteria = [c for c, v in _bv_per.items() if v == "FAIL"] - if _failed_criteria and answer: - _bv_note = ( - f"\n\n> ⚠️ **Test app live**: verifica su `{_browser_url}` " - f"ha rilevato {len(_failed_criteria)} criterio/i non soddisfatto/i: " - # S591: _failed_criteria[:3]→[:5] — mostra più criteri falliti - + ", ".join(f"`{c}`" for c in _failed_criteria[:5]) + "." - ) - answer += _bv_note - except asyncio.TimeoutError: - try: - from api.state import increment_stat as _inc_bv2 - _inc_bv2("browser_verify_timeout") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - pass # Browser verifier sempre silent - - # S393 Priority 2: Self-Healing — inline Python syntax repair loop (max 1 cycle, 20s budget) - # Il fire-and-forget precedente non correggeva la risposta finale al client. - # Ora: rileva SyntaxError → repair prompt → sostituisce answer inline prima del return. - if answer and not answer.startswith('[LLM') and '```python' in answer.lower(): - import re as _re_sh - _py_blocks = _re_sh.findall(r'```python\s*(.*?)```', answer, _re_sh.DOTALL | _re_sh.IGNORECASE) - for _blk in _py_blocks[:1]: # solo primo blocco — fast path, non blocca la risposta - try: - compile(_blk.strip(), '', 'exec') - except SyntaxError as _syn_err: - # S395: telemetria - try: - from api.state import increment_stat as _inc_s - _inc_s("syntax_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Auto-fix sintassi", - "explanation": "Errore di sintassi rilevato — correzione automatica in corso", - })) - _fix_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - f"Il codice Python ha un SyntaxError: {_syn_err}\n" - "Correggi SOLO la sintassi — NON cambiare la logica. " - "Rispondi con la versione corretta completa del codice." - )}, - ] - try: - _repaired = await asyncio.wait_for( - _active_llm.chat(_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=10.0, # S434: 20→10s - ) - if _repaired and not _repaired.startswith('[LLM'): - answer = _repaired - state.steps.append({"action": "execution_validator_fix", - "output": "SyntaxError riparato dal repair loop"}) - try: - from api.state import increment_stat as _inc_s2 - _inc_s2("syntax_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rs2 - _inc_rs2("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Auto-fix completato", - "explanation": "Codice corretto automaticamente ✓", - })) - else: - try: - from api.state import increment_stat as _inc_s3 - _inc_s3("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_s4 - _inc_s4("syntax_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair fallito — usa answer originale - break # un solo ciclo di repair - else: - # S394: Runtime self-healing — compile() OK → esegui e ripara runtime errors (max 1 ciclo, 35s) - _RUN_INTENT_RT = _re_sh.compile( - r"\b(esegui|run|execute|lancia|testa|prova|verifica)\b.*\b(codice|script|programma|code)\b", # UL-BUG-2: era r"\\b" (literal backslash-b non word-boundary) → self-healing S394 ora attivo, - _re_sh.IGNORECASE, - ) - if _RUN_INTENT_RT.search(state.goal): - try: - from tools.registry import TOOL_REGISTRY as _TR_rt - if "run_python" in _TR_rt: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "title": "Test esecuzione", - "explanation": "Eseguo il codice per verificare…", - })) - _run_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_blk.strip()), - timeout=15.0, - ) - _stderr_rt = (_run_r.get("stderr") or "").strip() - _rc_rt = _run_r.get("returncode", 0) - if _rc_rt != 0 and _stderr_rt: - # S395: telemetria runtime error - try: - from api.state import increment_stat as _inc_rt - _inc_rt("runtime_errors") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", "status": "running", - "title": "Errore nel codice — correzione automatica", - "explanation": "Errore nel codice rilevato — avvio correzione automatica…", - })) - _rt_fix_msgs = [ - *messages, - {"role": "assistant", "content": answer}, - {"role": "user", "content": ( - # S593: 400→600 — stderr runtime può contenere traceback completo - f"Il codice ha prodotto un errore runtime:\n{_stderr_rt[:600]}\n" - "Correggi SOLO il bug — NON cambiare la logica. " - "Rispondi con la versione corretta completa." - )}, - ] - try: - _rt_repaired = await asyncio.wait_for( - _active_llm.chat(_rt_fix_msgs, temperature=0.05, - max_tokens=min(_tok_budget, 4096)), - timeout=20.0, - ) - if _rt_repaired and not _rt_repaired.startswith("[LLM"): - answer = _rt_repaired - state.steps.append({ - "action": "execution_validator_fix", - "output": f"Runtime error riparato: {_stderr_rt[:300]}", # S605: 200→300 - }) - try: - from api.state import increment_stat as _inc_rt2 - _inc_rt2("runtime_repaired") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - try: - from api.state import increment_stat as _inc_rrt - _inc_rrt("repair_success_count") # S453: aggregato riparazioni riuscite - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "running", - "title": "Verifica finale…", - "explanation": "Verifico che il codice funzioni correttamente", - })) - # S395: GREEN confirmation — re-run repaired code (max 15s) - try: - _green_blks = _re_sh.findall( - r'```python\s*(.*?)```', - _rt_repaired, - _re_sh.DOTALL | _re_sh.IGNORECASE, - ) - _green_code = _green_blks[0].strip() if _green_blks else _rt_repaired.strip() - _green_r = await asyncio.wait_for( - _TR_rt["run_python"]["_fn"](code=_green_code), - timeout=15.0, - ) - _green_rc = _green_r.get("returncode", 0) - _green_stderr = (_green_r.get("stderr") or "").strip() - if _green_rc == 0 and not _green_stderr: - try: - from api.state import increment_stat as _inc_g - _inc_g("green_confirmed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "✓ Codice funzionante", - "explanation": "Nessun errore rilevato ✓", - })) - else: - try: - from api.state import increment_stat as _inc_gf - _inc_gf("green_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "warning", - "title": "⚠️ Repair parziale", - "explanation": "Correzione parziale — potrebbe esserci un errore residuo", - })) - except Exception: - pass # GREEN check non bloccante - else: - try: - from api.state import increment_stat as _inc_rtf - _inc_rtf("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - except Exception: - try: - from api.state import increment_stat as _inc_rtf2 - _inc_rtf2("runtime_failed") - except Exception as _exc: - _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 - pass # repair runtime fallito — usa answer originale - else: - if on_step: - await _maybe_await(on_step({ - "action": "execution_validator_fix", - "status": "done", - "title": "Codice verificato ✓", - "explanation": "Codice eseguito correttamente ✓", - })) - except Exception: - pass # run_python non disponibile — skip gracefully - - # S401: HTML/JS repair loop — rileva blocchi strutturalmente rotti e li ripara (max 1 ciclo, 20s) - # Copre ciò che il repair Python non tocca: HTML unclosed tags, JS unbalanced braces. - if answer and not answer.startswith('[LLM') and ( - '```html' in answer.lower() or - '```javascript' in answer.lower() or - '```js\n' in answer.lower() - ): - import re as _re_web - _WEB_PATTERNS = [ - (r'```html\s*(.*?)```', 'HTML', 'html'), - (r'```(?:javascript|js)\s*(.*?)```', 'JavaScript', 'javascript'), - ] - _VOID_TAGS = {'area','base','br','col','embed','hr','img','input', - 'link','meta','param','source','track','wbr'} - for _wpat, _wname, _wlang in _WEB_PATTERNS: - _wblocks = _re_web.findall(_wpat, answer, _re_web.DOTALL | _re_web.IGNORECASE) - if not _wblocks: - continue - _wblk = _wblocks[0] - _wissues: list[str] = [] - - if _wlang == 'html': - # Tag bilanciamento - _open = _re_web.findall(r'<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>', _wblk) - _close = _re_web.findall(r'', _wblk) - _cnt: dict[str, int] = {} - for _t in _open: - _tl = _t.lower() - if _tl not in _VOID_TAGS: - _cnt[_tl] = _cnt.get(_tl, 0) + 1 - for _t in _close: - _tl = _t.lower() - _cnt[_tl] = _cnt.get(_tl, 0) - 1 - _unbal = [_t for _t, _c in _cnt.items() if _c != 0] - if _unbal: - # S594: _unbal[:4]→[:6] — più tag sbilanciati visibili nel report - _wissues.append(f"Tag non bilanciati: {', '.join(_unbal[:6])}") - if _wblk.count(''): - _wissues.append('Tag