Spaces:
Running
Running
| """unified_loop_fallback.py — FallbackMixin: loop LLM principale (_run_fallback). | |
| Estratto da unified_loop.py per ridurre il file principale da 3954 a ~640 righe. | |
| Contiene: | |
| _run_fallback(state, on_step, ...): loop LLM multi-step con planner, executor, | |
| verifier, goal_verifier, self-healing, | |
| browser vision, repair loop Python/HTML. | |
| Dipendenze via MRO (self.*): | |
| DirectToolsMixin — _run_direct_tools, _needs_tools, _is_simple_query | |
| PromptBuilderMixin — _build_messages, _compress_goal, _SYSTEM_IDENTITY | |
| LLMSelectionMixin — _get_llm_for_goal, _get_fast_llm, _sanitize_agent_output | |
| HelpersMixin — _run_fast_path, _proactive_reflect | |
| DelegateMixin — _budget_replan_check, _run_in_loop_delegate | |
| RoutingMixin — _extract_written_files, _CODE_RE | |
| VFSMixin — _rollback_writes | |
| Invariante B1: nessun corpo duplicato con unified_loop.py. | |
| """ | |
| from __future__ import annotations | |
| from .fallback_utils import _is_refusal, _s759_bjac, _avg10 | |
| from .fallback_healer import StrategicHealer | |
| import asyncio | |
| import logging | |
| import os | |
| import re | |
| from typing import Any | |
| from agents.unified_loop_types import ( | |
| StepCallback, | |
| UnifiedLoopState, | |
| _maybe_await, | |
| _LANG_INSTRUCTIONS, | |
| _ANALYTICAL_VERBS_RE, | |
| _TASK_VERBS_RE, | |
| _is_goal_ambiguous, | |
| _is_borderline_ambiguous, | |
| _BORDERLINE_FIX_RE, | |
| _BORDERLINE_HELP_RE, | |
| _BORDERLINE_MAKE_RE, | |
| _detect_user_lang, | |
| ) | |
| _logger = logging.getLogger("agente_ai") | |
| # QF-2: costanti timeout — replicate da unified_loop.py (evita circular import) | |
| LLM_TIMEOUT: float = float(os.getenv('LLM_CALL_TIMEOUT', '60')) | |
| TOOL_TIMEOUT: float = float(os.getenv('TOOL_CALL_TIMEOUT', '25')) | |
| class FallbackMixin: | |
| async def _run_fallback(self, state: UnifiedLoopState, | |
| on_step: StepCallback | None, | |
| preloaded_tool_results: str = "", | |
| preloaded_tool_exec_successes: int = 0, | |
| preloaded_tool_exec_errors: int = 0) -> dict[str, Any]: | |
| outputs: list[str] = [] | |
| try: | |
| from api.state import record_timing as _rtc_ttfa | |
| import time as _ttf_t | |
| _t_rs = getattr(self, '_t_run_start', None) | |
| if _t_rs is not None: | |
| _rtc_ttfa("ttfa_ms", (_ttf_t.monotonic() - _t_rs) * 1000) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # S402: Tool Integrity Guard â propagato da run() tramite _run_direct_tools() | |
| _tool_exec_successes = preloaded_tool_exec_successes | |
| _tool_exec_errors = preloaded_tool_exec_errors | |
| exec_warn: list[str] = [] # S-LOOP1: init precoce — evita NameError se planner va in timeout (S640) | |
| if self.memory: | |
| mem_ctx = await self.memory.get_context(state.goal, code_length=len(state.context or '')) | |
| if mem_ctx: | |
| state.context = f"{state.context}\n\nMEMORIA:\n{mem_ctx}".strip() | |
| tool_results = preloaded_tool_results | |
| # S378: disclaimer quando la query è di tipo ricerca/notizie ma nessun dato | |
| # reale è disponibile â evita che l'LLM risponda in silenzio dal training. | |
| # S428: rimosso "rispondo con conoscenza al cut-off" â invitava hallucination. | |
| if not tool_results and re.search( | |
| r'\b(notizie|news|ultime|latest|breaking|recenti|aggiornamenti|' | |
| r'cerca\s+(?:online|sul\s+web|in\s+rete)|cerca\s*:|search\s*:|' | |
| r'ricerca\s+web|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente))\b', | |
| state.goal, re.IGNORECASE | |
| ): | |
| tool_results = ( | |
| "[NOTA: strumenti di ricerca web non disponibili al momento]" | |
| ) | |
| # F17+B7: planner per task di progettazione/implementazione â soglia ridotta a 10 chars | |
| # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50). | |
| # _NEEDS_PLAN_RE filtra già query semplici â len guard serve solo per 1-8 char input. | |
| _should_plan = ( | |
| self.planner | |
| and not tool_results | |
| and bool(self._NEEDS_PLAN_RE.search(state.goal[:200])) | |
| and len(state.goal) > 10 | |
| ) | |
| # S-FMT-ORCH FIX-FASTFIX: piano sintetico per fix singoli (<180 chars, pattern typo/rename/change-to) | |
| # Salta ARCHITECT DeepSeek-R1 -> risparmio ~15s. Fallback safe: se no match, planner normale. | |
| _fast_fix_plan = None | |
| if (_should_plan | |
| and len(state.goal) < 180 | |
| and bool(self._FAST_FIX_RE.search(state.goal[:200]))): | |
| _fast_fix_plan = { | |
| "summary": state.goal[:80], | |
| "subtasks": [{"id": 1, "description": state.goal, "tool": "apply_patch", "requires": []}], | |
| "complexity": "low", | |
| } | |
| _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT") | |
| _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing | |
| if _should_plan: | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "plan", "status": "started", | |
| "title": "Pianificazione", | |
| "explanation": "Analizzo la richiesta e preparo un piano", | |
| })) | |
| # S640: timeout planner + S-FMT-ORCH fast-fix bypass | |
| # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati) | |
| if _fast_fix_plan is not None: | |
| plan = _fast_fix_plan | |
| _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato") | |
| else: | |
| # S640: timeout sul planner â DeepSeek-R1 può essere lento ma non deve bloccare | |
| # 30s è il 95° percentile osservato su prompt lunghi; oltre è quasi certamente stall. | |
| # Su timeout: plan=None â esecuzione diretta senza subtask (comportamento pre-planner). | |
| try: | |
| plan = await asyncio.wait_for( | |
| self.planner.create_plan( | |
| state.goal, context=[{"role": "system", "content": state.context}] | |
| ), | |
| timeout=30.0, | |
| ) | |
| except asyncio.TimeoutError: | |
| plan = None | |
| _logger.warning("S640 planner timeout (30s) su goal: %s", state.goal[:80]) | |
| exec_warn.append("â [S640] piano non disponibile (timeout pianificatore 30s)") | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "plan", "status": "warning", | |
| "title": "Pianificazione scaduta", | |
| "explanation": "Il pianificatore ha impiegato troppo â procedo senza piano", | |
| "visibility": "progress", | |
| })) | |
| if plan is not None: | |
| state.steps.append({"action": "plan", "result": plan}) | |
| try: | |
| from api.state import record_timing as _rtc_pl | |
| _rtc_pl("plan_ms", (asyncio.get_running_loop().time() - _t0_plan) * 1000) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # S641: guard plan is not None prima di on_step e executor | |
| # piano può essere None dopo timeout S640 â plan.get() crasherebbe con AttributeError | |
| if plan is not None and on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "plan", "status": "done", | |
| "title": "Piano creato", | |
| "explanation": f"Piano con {len(plan.get('subtasks', []))} passaggi â inizio esecuzione", | |
| "subtasks": len(plan.get("subtasks", [])), | |
| })) | |
| if self.executor and plan is not None and plan.get("subtasks"): | |
| # S574-GAP4: completata _TOOL_MAP â read_page/code/calculate/image | |
| # Prima: solo web_search eseguito; tutti gli altri subtask silenziosamente saltati | |
| # Ora: 5 tool reali mappati â subtask del planner eseguiti davvero | |
| _TOOL_MAP: dict[str, tuple[str, Any]] = { | |
| "web_search": ("web_search", lambda desc: {"query": desc}), | |
| "read_page": ("read_page", lambda desc: {"url": desc}), | |
| "code": ("run_python", lambda desc: {"code": desc}), | |
| "calculate": ("calculate", lambda desc: {"expression": desc}), | |
| "image": ("generate_image", lambda desc: {"prompt": desc}), | |
| # S601: nuovi tool V001-V007 aggiunti al planner â mappa anche questi | |
| "web_research": ("web_research", lambda desc: {"topic": desc, "depth": 4, "synthesize": True}), | |
| "generate_image": ("generate_image", lambda desc: {"prompt": desc}), | |
| "run_python": ("run_python", lambda desc: {"code": desc}), | |
| "send_email": ("send_email", lambda desc: { | |
| # S643: estrai destinatario dalla descrizione â pattern "a <email>" o "to <email>" | |
| "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,>=<!=.\[\]]+)", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| }), | |
| "type_check": ("type_check", lambda desc: { | |
| "path": next(iter(__import__("re").findall( | |
| r"[./][\w./\-]+|\b[\w\-]+/[\w./\-]+", desc | |
| )), "."), | |
| "checker": "auto", | |
| "strict": bool(__import__("re").search( | |
| r"\b(strict|rigoroso|--strict)\b", desc, __import__("re").IGNORECASE | |
| )), | |
| }), | |
| # S766: 5 tool in TOOL_REGISTRY ma assenti da _TOOL_MAP â subtask erano silenziosamente saltati | |
| "get_weather": ("get_weather", lambda desc: { | |
| "city": (lambda m: m.group(1).strip() if m else "Milano")( | |
| __import__("re").search( | |
| r"(?:^|\b)(?:a|in|per|at|for|city[:\s]+|citta[:\s]+)\s+([\w\s]{2,30}?)(?:\s*\?|$|,|\bdomani\b|\boggi\b)", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| }), | |
| "get_news": ("get_news", lambda desc: { | |
| "query": (lambda m: m.group(1).strip() if m else desc.strip()[:120])( | |
| __import__("re").search( | |
| r"(?:notizie|news|ultime\s+notizie|notiz[ie]+\s+su|news\s+about|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120}?)(?:\?|$|\.|,)", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| "max_results": 5, | |
| }), | |
| # Browser tools â usati dal planner per navigazione/interazione web | |
| "browser_navigate": ("browser_navigate", lambda desc: { | |
| "url": (lambda m: m.group(0) if m else "")( | |
| __import__("re").search(r"https?://[\S]+", desc) | |
| ), | |
| }), | |
| "browser_session_open": ("browser_session_open", lambda desc: { | |
| "url": (lambda m: m.group(0) if m else "")( | |
| __import__("re").search(r"https?://[\S]+", desc) | |
| ), | |
| }), | |
| "browser_session_act": ("browser_session_act", lambda desc: { | |
| "action": desc.strip()[:300], | |
| "session_id": "", | |
| }), | |
| # GAP-B: scaffold_project â genera boilerplate istantaneo | |
| "scaffold_project": ("scaffold_project", lambda desc: { | |
| "framework": (lambda m: m.group(1).strip().lower() if m else "react")( | |
| __import__("re").search( | |
| r"\b(react|next\.?js|nextjs|fastapi|flask|django|express|vue|svelte)\b", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| "project_name": (lambda m: m.group(1).strip() if m else "my-project")( | |
| __import__("re").search( | |
| r"(?:progetto|project|app|chiamato|named|nome)[:\s]+['\"\s]*(\w[\w\-]{0,28})", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| "target_dir": "/tmp", | |
| }), | |
| "create_chart": ("create_chart", lambda desc: { | |
| "chart_type": (lambda m: m.group(1).lower() if m else "bar")( | |
| __import__("re").search( | |
| r"\b(bar|line|pie|scatter|barre|linee|torta|dispersione)\b", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| "title": (lambda m: m.group(1).strip() if m else "")( | |
| __import__("re").search( | |
| r"(?:titolo|title|chiamato|intitolato)[:\s]+['\"\s]*([^'\"\n]{2,80}?)(?:['\"\n]|$)", | |
| desc, __import__("re").IGNORECASE, | |
| ) | |
| ), | |
| "data": None, | |
| "labels": [], | |
| "values": [], | |
| }), | |
| # GAP-1: Delega Dinamica In-Loop | |
| # delegate_task → micro-agente specializzato con accesso ai tool reali | |
| # Disabilitato se già dentro un micro-agente (_is_delegate_child) per anti-ricorsione | |
| "delegate_task": ( | |
| (None, None) if getattr(self, '_is_delegate_child', False) | |
| else ("__delegate__", lambda desc: {"goal": desc}) | |
| ), | |
| "memory": (None, None), # gestito dalla memoria, non un tool | |
| "direct_response": (None, None), # risposta LLM diretta, non un tool | |
| "browser": (None, None), # browser tool non disponibile su HF | |
| } | |
| exec_done: list[str] = [] # S629: subtask completati con successo | |
| exec_warn: list[str] = [] # S628: subtask high-risk non eseguiti | |
| _cog5_last_check: int = 0 # COG-5: indice step dell'ultimo drift check | |
| # S627: tool read-only sicuri â eseguiti anche con risk=high | |
| # S662: set safe-exec â tool read-only eseguibili anche con risk=high (no side-effect). | |
| # S676: esteso con list_files (VFS read-only), validate_json/diff_text (computazione locale). | |
| # S764: aggiunti tool read-only S763 | |
| _SAFE_EXEC_TOOLS = {"web_search", "read_page", "web_research", "read_file", "recall", | |
| "list_files", "validate_json", "diff_text", | |
| "directory_tree", "file_search", "git_status", "git_diff", "type_check"} | |
| # S629: fase 1 â categorizza subtask (warning vs eseguibili) | |
| # S634: regex per routing validator â compilati una volta per il batch | |
| _S634_URL_RE = re.compile(r"https?://", re.IGNORECASE) | |
| _S634_CODE_HINT = re.compile( | |
| r"(scrivi|genera|crea|costruisci|implementa|calcola|esegui" | |
| r"|python|script|codice|funzione|classe|loop|if |for |while " | |
| r"|def |return |import |print)", | |
| re.IGNORECASE, | |
| ) | |
| def _check_subtask_routing(s_tool: str, s_desc: str) -> str | None: | |
| """S634: analisi statica â rileva mismatch tool/description PRIMA | |
| che _resolve_inp invochi il CODER LLM. Non blocca mai l'esecuzione. | |
| Casi rilevati: | |
| - run_python/execute_sql/database_query con URL â probabile 'read_page' | |
| - read_page senza URL â il tool fallirà (attende un URL valido) | |
| - code tool con description <8 chars â _resolve_inp avrà poco contesto | |
| """ | |
| if not s_desc: | |
| return None | |
| _is_code_tool = s_tool in ("run_python", "execute_sql", "database_query") | |
| _has_url = bool(_S634_URL_RE.search(s_desc)) | |
| _has_code_hint = bool(_S634_CODE_HINT.search(s_desc)) | |
| _is_short = len(s_desc.strip()) < 8 | |
| if _is_code_tool and _has_url and not _has_code_hint: | |
| return (f"[S634 routing] '{s_tool}' con URL senza hint codice " | |
| f"â potrebbe essere 'read_page' (desc: '{s_desc[:60]}')") | |
| if s_tool == "read_page" and not _has_url: | |
| return (f"[S634 routing] 'read_page' senza URL " | |
| f"â il tool si aspetta un URL valido (desc: '{s_desc[:60]}')") | |
| if _is_code_tool and _is_short: | |
| return (f"[S634 routing] '{s_tool}' con descrizione <8 chars " | |
| f"â _resolve_inp avrà contesto insufficiente (desc: '{s_desc}')") | |
| return None | |
| _pending_exec: list[tuple[dict, str, Any]] = [] | |
| for _s_idx, subtask in enumerate(plan.get("subtasks", []), start=1): | |
| # S643: fallback id quando planner omette campo â evita None nei log | |
| if "id" not in subtask or subtask["id"] is None: | |
| subtask = {**subtask, "id": f"s{_s_idx}"} | |
| _s_risk = subtask.get("risk", "low") | |
| _s_tool = subtask.get("tool", "") | |
| _s_desc_raw = subtask.get("description", "") | |
| # S634: static routing check â warning in exec_warn + logger, mai bloccante | |
| _rt_warn = _check_subtask_routing(_s_tool, _s_desc_raw) | |
| if _rt_warn: | |
| _logger.warning("S634 %s", _rt_warn) | |
| exec_warn.append(f"â {_rt_warn}") | |
| if _s_risk == "high" and _s_tool not in _SAFE_EXEC_TOOLS: | |
| # S627: alto rischio + tool destructive â inietta nota nel contesto | |
| exec_warn.append( | |
| f"\u26a0 subtask #{subtask.get('id')} " | |
| f"'{subtask.get('description','')[:60]}' [{_s_tool}] \u2014 richiede approvazione" | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "plan", "status": "warning", | |
| "title": "Subtask ad alto rischio", | |
| "explanation": f"'{subtask.get('description','')[:60]}' \u2014 richiede approvazione", | |
| "subtask_id": subtask.get("id"), "visibility": "progress", | |
| })) | |
| continue | |
| tool_key_pair = _TOOL_MAP.get(_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<string, unknown>;\n" | |
| "export default App;\n" | |
| ) | |
| _scaf_res = await asyncio.wait_for( | |
| run_tdd_check_ts( | |
| _SCAF_TS_STUB, | |
| f"{_scaf_path}/src/App.tsx", | |
| self.executor, | |
| on_step, | |
| ), | |
| timeout=25.0, | |
| ) | |
| if _scaf_res.get("ran") and not _scaf_res.get("passed"): | |
| exec_warn.append( | |
| f"\u26a0 [COG-3] TypeScript errors nel progetto scaffoldato " | |
| f"'{_scaf_path}': {str(_scaf_res.get('output', ''))[:200]}" | |
| ) | |
| _logger.info("COG-3 scaffold type_check failed: %s", _scaf_path) | |
| except Exception as _cog3_scaf: | |
| _logger.debug("COG-3 scaffold type_check: %s", str(_cog3_scaf)[:80]) | |
| return st, rn, _r | |
| # GAP-A: narrazione strategia pre-gather â text_chunk visibile in chat | |
| # Sintetizza i tool in 1-2 frasi prima di avviare l'esecuzione parallela. | |
| # Mostra max 2 tool per non sovraccaricare; usa _TOOL_NARRATION lookup O(1). | |
| if on_step and _pending_exec: | |
| _narr_tools = [rn for _, rn, _ in _pending_exec] | |
| _narr_parts = [ | |
| self._TOOL_NARRATION.get(t, "") for t in _narr_tools[:2] | |
| ] | |
| _narr_str = " · ".join(p for p in _narr_parts if p) | |
| if _narr_str: | |
| await _maybe_await(on_step({ | |
| "action": "text_chunk", | |
| "token": f"_{_narr_str}â¦_\n\n", | |
| "status": "streaming", | |
| })) | |
| # F11+S639+F8: esecuzione a FASI con topological sort â rispetta "requires" | |
| # Bug: gather flat â npm_run partiva prima che npm_install finisse (requires ignorato). | |
| # Fix: fase 0 = subtask senza deps, fase 1 = subtask che dipendono dalla fase 0, etc. | |
| # Ogni fase usa gather adattivo (150s se slow tool, 90s altrimenti). | |
| # Invariante: max 8 fasi per prevenire loop infiniti su piani malformati. | |
| _SLOW_GATHER_TOOLS = {"npm_install","npm_run","pip_install","git_clone","git_push","execute_shell"} | |
| _completed_subtask_ids: set[str] = set() | |
| # S-ORCH-8GAP FIX-DAG-1: cascade-skip su deps fallite | |
| _failed_subtask_ids: set[str] = set() | |
| # S-ORCH-8GAP FIX-DAG-3: output injection per subtask dipendenti | |
| _subtask_outputs: dict[str, str] = {} | |
| _phase_remaining = list(_pending_exec) | |
| for _phase_n in range(8): | |
| if not _phase_remaining: | |
| break | |
| # Partiziona: pronti (deps soddisfatte) vs bloccati | |
| _phase_ready: list[tuple] = [] | |
| _phase_blocked: list[tuple] = [] | |
| for _ps, _prn, _pib in _phase_remaining: | |
| _reqs = {str(r) for r in _ps.get("requires", [])} | |
| # S-ORCH-8GAP FIX-DAG-1: cascade-skip se una dep è fallita | |
| # Senza questo, il deadlock guard avrebbe eseguito il subtask | |
| # senza l'output della sua dipendenza â tool call sprecata. | |
| _failed_deps = _reqs & _failed_subtask_ids | |
| if _failed_deps: | |
| _dep_ids_str = ", ".join(sorted(_failed_deps)) | |
| exec_warn.append( | |
| f"\u26a0 [DAG] subtask #{_ps.get('id')} saltato â " | |
| f"dipendenza fallita: {_dep_ids_str}" | |
| ) | |
| _failed_subtask_ids.add(str(_ps.get("id"))) # propaga cascade | |
| _logger.info( | |
| "DAG cascade-skip subtask #%s (failed deps: %s)", | |
| _ps.get("id"), _dep_ids_str, | |
| ) | |
| elif _reqs.issubset(_completed_subtask_ids): | |
| _phase_ready.append((_ps, _prn, _pib)) | |
| else: | |
| _phase_blocked.append((_ps, _prn, _pib)) | |
| # Deadlock guard â esegui i rimanenti comunque (plan malformato) | |
| if not _phase_ready: | |
| _phase_ready = _phase_remaining | |
| _phase_blocked = [] | |
| _logger.warning( | |
| "F11 fase %d deadlock â eseguo %d subtask bloccati", | |
| _phase_n, len(_phase_ready), | |
| ) | |
| _has_slow_in_phase = any( | |
| _prn in _SLOW_GATHER_TOOLS for _, _prn, _ in _phase_ready | |
| ) | |
| _gather_timeout = 150.0 if _has_slow_in_phase else 90.0 | |
| if _phase_n > 0: | |
| _logger.info( | |
| "F11 fase %d â %d subtask pronti (timeout %.0fs)", | |
| _phase_n, len(_phase_ready), _gather_timeout, | |
| ) | |
| # F15: narrazione per fasi 1+ â mostra cosa sta per eseguire | |
| # Fase 0 ha già narrazione da GAP-A (pre-gather); fasi successive erano silenziose. | |
| if on_step and _phase_ready: | |
| _ph_narr_parts = [ | |
| self._TOOL_NARRATION.get(_prn, "") | |
| for _, _prn, _ in _phase_ready[:2] | |
| ] | |
| _ph_narr_str = " · ".join(p for p in _ph_narr_parts if p) | |
| if _ph_narr_str: | |
| await _maybe_await(on_step({ | |
| "action": "text_chunk", | |
| "token": f"_{_ph_narr_str}â¦_\n", | |
| "status": "streaming", | |
| })) | |
| # S-ORCH-8GAP FIX-DAG-2: Semaphore(3) per fase â max 3 subtask | |
| # simultanei per non saturare TCP su iPhone (max 6 conn totali). | |
| # asyncio single-thread: il semaforo è local-safe, zero race condition. | |
| _phase_sem = asyncio.Semaphore(3) | |
| async def _sem_subtask(s, rn, ib, _psem=_phase_sem): | |
| async with _psem: | |
| return await _run_subtask(s, rn, ib) | |
| try: | |
| _exec_results = await asyncio.wait_for( | |
| asyncio.gather( | |
| *[_sem_subtask(s, rn, ib) for s, rn, ib in _phase_ready], | |
| return_exceptions=True, | |
| ), | |
| timeout=_gather_timeout, | |
| ) | |
| except asyncio.TimeoutError: | |
| _logger.warning( | |
| "S639 gather timeout (%.0fs) fase %d su %d subtask", | |
| _gather_timeout, _phase_n, len(_phase_ready), | |
| ) | |
| exec_warn.append( | |
| f"\u26a0 [S639] timeout globale executor fase {_phase_n} " | |
| f"({len(_phase_ready)} subtask): nessun risultato disponibile" | |
| ) | |
| _exec_results = [] | |
| for _er in _exec_results: | |
| if isinstance(_er, Exception): | |
| # S636: eccezioni da asyncio.gather erano silenziosamente ignorate. | |
| _exc_type = type(_er).__name__ | |
| _exc_msg = str(_er)[:120] | |
| _logger.error( | |
| "S636 gather exception [%s]: %s", _exc_type, _exc_msg | |
| ) | |
| exec_warn.append( | |
| f"\u26a0 [S636] eccezione subtask [{_exc_type}]: {_exc_msg}" | |
| ) | |
| continue | |
| _st, _rn, _res = _er | |
| if _res.get("success"): | |
| _completed_subtask_ids.add(str(_st.get("id"))) | |
| # S-ORCH-8GAP FIX-DAG-3: memorizza output per injection dipendenti | |
| # F20: dict output â JSON (standard) invece di Python repr | |
| # F21: scaffold/write_file â summary human-readable | |
| _out_raw = _res.get("output", "") | |
| if isinstance(_out_raw, dict): | |
| # F21: output speciale per tool che producono file | |
| _fw = _out_raw.get("framework") | |
| _files = _out_raw.get("files_created", []) | |
| _dir = _out_raw.get("directory", "") | |
| _path = _out_raw.get("path", "") | |
| _size = _out_raw.get("size") | |
| if _fw and _files: | |
| # scaffold_project: summary concisa | |
| _flist = ", ".join(str(f) for f in _files[:6]) | |
| _fmore = f" (+{len(_files)-6} altri)" if len(_files) > 6 else "" | |
| _snippet = ( | |
| f"Progetto {_fw} creato in {_dir} â " | |
| f"{len(_files)} file: {_flist}{_fmore}" | |
| ) | |
| elif _path and _size is not None: | |
| # write_file: conferma creazione file | |
| _snippet = f"File scritto: {_path} ({_size} bytes)" | |
| else: | |
| try: | |
| import json as _jmod, re as _re_jmod | |
| _snippet = _jmod.dumps(_re_jmod.sub(r'[\ud800-\udfff]', '', str(_out_raw)) if isinstance(_out_raw, str) else _out_raw, ensure_ascii=False)[:500] | |
| except Exception: | |
| _snippet = str(_out_raw).strip()[:500] | |
| else: | |
| _snippet = str(_out_raw).strip()[:500] | |
| # S647: hollow success â tool ok ma output vuoto â nota esplicita | |
| if not _snippet: | |
| _snippet = "(nessun output â operazione completata senza testo di risposta)" | |
| _rtag = " \u26a0" if _st.get("risk", "low") == "high" else "" | |
| _label = f"[subtask {_st.get('id')}{_rtag} \u2014 {_st.get('description','')[:60]}]" | |
| exec_done.append(f"{_label}: {_snippet}") | |
| # S-ORCH-8GAP FIX-DAG-3: salva output per injection subtask dipendenti | |
| _subtask_outputs[str(_st.get("id"))] = _snippet[:400] | |
| state.steps.append({ | |
| "action": f"executor:{_rn}", | |
| "subtask_id": _st.get("id"), | |
| "output": _snippet, | |
| }) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": f"executor:{_rn}", | |
| "status": "done", "subtask_id": _st.get("id"), | |
| })) | |
| # S628: sintesi strutturata â sezioni separate done/warn invece di stringa piatta | |
| else: | |
| # S637: subtask fallito â feedback UI + exec_warn | |
| # S-ORCH-8GAP FIX-DAG-1: traccia id falliti per cascade-skip | |
| _failed_subtask_ids.add(str(_st.get("id"))) | |
| _fail_err = str(_res.get("error", "errore sconosciuto"))[:100] | |
| _fail_retry = _res.get("_s635_retry", False) | |
| _fail_label = ( | |
| f"[subtask {_st.get('id')} \u2014 {_st.get('description','')[:50]}]" | |
| ) | |
| _fail_note = " (dopo retry S635)" if _fail_retry else "" | |
| exec_warn.append( | |
| f"\u26a0 {_fail_label} fallito{_fail_note}: {_fail_err}" | |
| ) | |
| _logger.warning( | |
| "S637 subtask #%s [%s] failed%s: %s", | |
| _st.get("id"), _rn, _fail_note, _fail_err, | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": f"executor:{_rn}", | |
| "status": "failed", | |
| "subtask_id": _st.get("id"), | |
| "explanation": _fail_err, | |
| "visibility": "progress", | |
| })) | |
| _phase_remaining = _phase_blocked # prossima fase: subtask rimasti | |
| # COG-1: Dynamic Re-planner — rigenera piano se ci sono fallimenti reali | |
| _cog1_real_failures = [ | |
| w for w in exec_warn | |
| if any(kw in w.lower() for kw in | |
| ("fallito", "failed", "timeout", "exception", "error", "eccezione")) | |
| ] | |
| if _cog1_real_failures and not exec_warn == [] and not plan.get("_replanned"): | |
| try: | |
| from agents.dynamic_replanner import should_replan, replan | |
| if should_replan(exec_warn, exec_done): | |
| _logger.info( | |
| "COG-1 should_replan=True (warn=%d done=%d)", | |
| len(exec_warn), len(exec_done), | |
| ) | |
| _replan_goal = plan.get("goal", state.goal) | |
| _new_plan = await asyncio.wait_for( | |
| replan(self.planner, _replan_goal, exec_warn, exec_done, plan=plan), # P25-R1 | |
| timeout=20.0, | |
| ) | |
| if _new_plan and _new_plan.get("subtasks"): | |
| plan = _new_plan | |
| exec_done.clear() | |
| exec_warn.clear() | |
| _logger.info( | |
| "COG-1 replan ok: %d nuovi subtask", | |
| len(plan.get("subtasks", [])), | |
| ) | |
| _pending_exec2: list[tuple] = [] | |
| for _s2 in plan.get("subtasks", []): | |
| _t2 = _s2.get("tool", "") | |
| _tk2 = _TOOL_MAP.get(_t2, (None, None)) | |
| _rn2, _ib2 = _tk2 | |
| if _rn2 and _ib2 is not None: | |
| _pending_exec2.append((_s2, _rn2, _ib2)) | |
| _replan_sem = asyncio.Semaphore(3) | |
| async def _replan_subtask(s, rn, ib, _sem=_replan_sem): | |
| async with _sem: | |
| return await _run_subtask(s, rn, ib) | |
| try: | |
| _replan_results = await asyncio.wait_for( | |
| asyncio.gather( | |
| *[_replan_subtask(s, rn, ib) for s, rn, ib in _pending_exec2], | |
| return_exceptions=True, | |
| ), | |
| timeout=90.0, | |
| ) | |
| for _rr in _replan_results: | |
| if isinstance(_rr, Exception): | |
| exec_warn.append( | |
| f"⚠ [COG-1 replan] eccezione: {str(_rr)[:80]}" | |
| ) | |
| continue | |
| _rr_st, _rr_rn, _rr_res = _rr | |
| if _rr_res.get("success"): | |
| _out_r = str(_rr_res.get("output", ""))[:400] | |
| exec_done.append( | |
| f"[replan subtask {_rr_st.get('id')}]: {_out_r}" | |
| ) | |
| else: | |
| exec_warn.append( | |
| f"⚠ [COG-1 replan] subtask #{_rr_st.get('id')} " | |
| f"fallito: {str(_rr_res.get('error',''))[:80]}" | |
| ) | |
| except asyncio.TimeoutError: | |
| exec_warn.append("⚠ [COG-1 replan] timeout 90s sul piano alternativo") | |
| except Exception as _cog1_err: | |
| _logger.warning("COG-1 dynamic_replanner error: %s", str(_cog1_err)[:120]) | |
| # COG-5: Goal Drift Detector — controlla ogni DRIFT_CHECK_EVERY_N subtask completati. | |
| # Non-blocking: sincrono, nessun I/O. Se l'agente si è allontanato dal goal | |
| # originale, inietta una micro-guida correttiva in exec_warn prima del LLM call. | |
| try: | |
| from agents.goal_drift_detector import detect_drift as _cog5_detect | |
| _cog5_res = _cog5_detect( | |
| goal=state.goal, | |
| exec_done=exec_done, | |
| step_count=len(exec_done), | |
| last_check=_cog5_last_check, | |
| ) | |
| _cog5_last_check = _cog5_res["new_last_check"] | |
| if _cog5_res.get("drifted"): | |
| _drift_msg = ( | |
| f"[COG-5 ⚠] Deriva dal goal rilevata " | |
| f"({_cog5_res['reason']}). " | |
| f"Goal originale: \"{state.goal[:80]}\". " | |
| f"Concentra la risposta su questo obiettivo." | |
| ) | |
| exec_warn.append(_drift_msg) | |
| _logger.info("COG-5 drift iniettato in exec_warn: %s", _cog5_res["reason"]) | |
| except Exception as _cog5_err: | |
| _logger.debug("COG-5 error (non-blocking): %s", str(_cog5_err)[:80]) | |
| # GAP-NEW-2: TDD FAIL inject — se _t_run_python() ha rilevato un test fallito, | |
| # inietta il traceback in exec_warn PRIMA del campionamento StrategicHealer. | |
| # Questo chiude il ciclo: TDD FAIL → exec_warn → healer fingerprinting → strategia alternativa. | |
| if getattr(self, '_tdd_fail_inject', None): | |
| exec_warn.insert(0, self._tdd_fail_inject) | |
| _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject)) | |
| self._tdd_fail_inject = None | |
| # GAP-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2) | |
| # GAP-RUN-NAMEERROR FIX: define exec_errors from exec_warn | |
| exec_errors = [w for w in exec_warn if isinstance(w, str) and w.startswith('⚠')] | |
| if exec_errors and getattr(self, '_strategic_healer', None): | |
| try: | |
| _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str)) | |
| _sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str) | |
| if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None): | |
| exec_warn.insert(0, _sh_decision.strategy_prompt) | |
| _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn") | |
| if _sh_decision and getattr(_sh_decision, 'should_stop', False): | |
| _logger.info("GAP-4: StrategicHealer → should_stop, interruzione loop") | |
| # [GAP-4-FIXSYN] break rimosso: era dentro async def _run_subtask fuori da loop | |
| # GAP-4-FIX: Re-implement stop logic via state flag | |
| if hasattr(state, 'should_stop'): state.should_stop = True | |
| return # Interrompe l'esecuzione del fallback corrente | |
| # SyntaxError a compile-time — strategia gia iniettata in exec_warn sopra. | |
| except Exception as _sh_loop_err: | |
| _logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err) | |
| # GAP-SELFHEAL v2: delegated to StrategicHealer | |
| StrategicHealer.analyze_errors(exec_errors, exec_warn) | |
| if exec_done or exec_warn: | |
| _plan_goal = plan.get("goal", state.goal)[:120] | |
| _synth: list[str] = [f"## Piano eseguito â {_plan_goal}"] | |
| if exec_done: | |
| _synth.append(f"\n### Risultati ({len(exec_done)} subtask completati):") | |
| _synth.extend(exec_done) | |
| if exec_warn: | |
| # Cap display: al LLM arrivano al massimo 50 avvisi (i più recenti). | |
| # exec_warn con 100+ item produce ### Attenzione di decine di KB che | |
| # satura il context window; warning più vecchi già processati in iter. precedenti. | |
| _WARN_DISPLAY_CAP = 50 | |
| _warn_omitted = max(0, len(exec_warn) - _WARN_DISPLAY_CAP) | |
| _warn_display = exec_warn[-_WARN_DISPLAY_CAP:] if _warn_omitted > 0 else exec_warn | |
| _cap_note = f', mostrati ultimi {_WARN_DISPLAY_CAP}' if _warn_omitted > 0 else '' | |
| _synth.append( | |
| f"\n### Non eseguiti — richiedono attenzione ({len(exec_warn)} totale{_cap_note}):" | |
| ) | |
| if _warn_omitted > 0: | |
| _synth.append( | |
| f'[... {_warn_omitted} avvisi precedenti omessi — ' | |
| f'focus sui {_WARN_DISPLAY_CAP} più recenti]' | |
| ) | |
| _synth.extend(_warn_display) | |
| # S638: sintesi totale failure â guida LLM verso risposta degrado graceful | |
| # Prima: nessun avviso se exec_done=[] â LLM non capiva che TUTTO aveva fallito | |
| if exec_warn and not exec_done: | |
| _n_planned = len(plan.get("subtasks", [])) | |
| _synth.append( | |
| f"\n### â Tutti i subtask ({_n_planned}) non hanno prodotto risultati. " | |
| f"Rispondi in modo onesto su cosa non è stato possibile eseguire." | |
| ) | |
| exec_block = "\n".join(_synth) | |
| tool_results = (f"{tool_results}\n\n{exec_block}".strip() | |
| if tool_results else exec_block) | |
| # S642: aggiorna _tool_exec_successes/_tool_exec_errors da subtask results | |
| # Prima: Tool Integrity Guard riceveva solo i contatori pre-executor (tool diretti) | |
| # senza sapere quanti subtask del planner erano andati a buon fine o no. | |
| _tool_exec_successes += len(exec_done) | |
| _tool_exec_errors += len([w for w in exec_warn | |
| if w.startswith("â ") and "S640" not in w | |
| and "S634" not in w and "S639" not in w]) | |
| # S638: save_episode success=True solo se almeno 1 subtask completato | |
| # Prima: True hardcoded anche con 0 risultati â episodi falsi in memoria | |
| _ep_success = bool(exec_done) | |
| if self.memory: | |
| _mem_src = "\n".join(exec_done)[:800] if exec_done else exec_warn[0][:400] | |
| await self.memory.save_episode( | |
| "executor", state.goal, _mem_src, _ep_success, | |
| tags=["executor", "plan"]) | |
| # S575-GAP1: ReasoningCore gate per task complessi | |
| # Trigger: tok_budget >= 6144 (task grandi) + piano con 3+ subtask | |
| # Azione: run_loop_to_answer() con max 5 iterazioni â inietta nel contesto | |
| # Il loop multi-step arricchisce tool_results; l'LLM finale sintetizza la risposta. | |
| # Timeout 55s â conservativo, mai blocca l'utente più di 1 min totale. | |
| _n_subtasks = len(plan.get("subtasks", [])) if plan else 0 | |
| _should_reason = ( | |
| self._max_tokens_for_goal(state.goal) >= 6144 | |
| and _n_subtasks >= 3 | |
| ) | |
| if _should_reason: | |
| try: | |
| from agents.reasoning_core import ReasoningCore as _RC | |
| _rc = _RC( | |
| llm_client=self._get_llm_for_goal(state.goal), | |
| planner=self.planner, | |
| critic=self.critic, | |
| executor=self.executor, | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "reasoning_core", | |
| "status": "started", | |
| "title": "Analisi multi-step", | |
| "explanation": f"ReasoningCore attivato â {_n_subtasks} subtask, loop fino a 5", | |
| })) | |
| # GAP-2: converti _session_files (pathâcontent) in project_files per deep context | |
| _rc_pf = [ | |
| {"path": _pf_path, "content": _pf_content, "language": _pf_path.rsplit(".", 1)[-1].lower() if "." in _pf_path else ""} | |
| for _pf_path, _pf_content in (self._session_files or {}).items() | |
| ] or None | |
| _rc_ctx = await asyncio.wait_for( | |
| _rc.run_loop_to_answer( | |
| state.goal, context=state.context or "", | |
| on_step=on_step, max_loops=8, # S701: 5â8 | |
| project_files=_rc_pf, # GAP-2: deep context multi-file | |
| ), | |
| timeout=55.0, | |
| ) | |
| if _rc_ctx: | |
| tool_results = ( | |
| f"{tool_results}\n\n[REASONING CORE]\n{_rc_ctx}".strip() | |
| if tool_results else f"[REASONING CORE]\n{_rc_ctx}" | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 0, "action": "reasoning_core", | |
| "status": "done", | |
| "title": "Analisi multi-step completata â", | |
| })) | |
| except asyncio.TimeoutError: | |
| pass # timeout â continua con tool_results già disponibili | |
| except Exception: | |
| pass # silente â non blocca il loop principale | |
| # RF-2: Skeleton Injection — se >=3 file in sessione, inietta skeleton compatto | |
| # Attiva il context_manager (S364/S752-A): firme funzioni invece di file interi. | |
| # Riduce token ~60% su sessioni multi-file senza perdere informazione strutturale. | |
| if self._session_files and len(self._session_files) >= 3: | |
| try: | |
| _gcfg = _get_context_manager() | |
| _cm_files = [ | |
| { | |
| "path": _p, | |
| "content": _c, | |
| "language": _p.rsplit(".", 1)[-1].lower() if "." in _p else "", | |
| } | |
| for _p, _c in self._session_files.items() | |
| ] | |
| _skeleton_ctx = await asyncio.wait_for( | |
| _gcfg(state.goal, active_files=[], all_files=_cm_files, top_k=4), | |
| timeout=2.0, | |
| ) | |
| if _skeleton_ctx and not _skeleton_ctx.startswith('[LLM'): | |
| tool_results = ( | |
| f"[SKELETON PROGETTO]\n{_skeleton_ctx}\n\n{tool_results}".strip() | |
| if tool_results else f"[SKELETON PROGETTO]\n{_skeleton_ctx}" | |
| ) | |
| except Exception: | |
| pass # RF-2: fail-safe, mai blocca il loop principale | |
| # GAP-4-TOOLCOMP: comprimi tool_results se > 3000 chars | |
| # Evita context saturation con output grezzi di read_file/web_search. | |
| # Usa fast_llm (8B), timeout 4s, fail-open — mai blocca il loop. | |
| if tool_results and len(tool_results) > 3000: | |
| try: | |
| _tr_llm = self._get_fast_llm() | |
| _tr_comp = await asyncio.wait_for( | |
| _tr_llm.chat([ | |
| {"role": "system", "content": ( | |
| "Riassumi i risultati tool seguenti preservando: " | |
| "dati concreti (URL, numeri, path file, errori esatti, codice), " | |
| "risultati critici per il goal. Elimina verbosità e ridondanza. " | |
| "Max 1500 chars. Sii chirurgico." | |
| )}, | |
| {"role": "user", "content": ( | |
| f"GOAL: {state.goal[:200]}\n\nTOOL RESULTS:\n{tool_results[:4000]}" | |
| )}, | |
| ], temperature=0.1, max_tokens = 400), # S586: 250->400 | |
| timeout=4.0, | |
| ) | |
| if _tr_comp and not _tr_comp.startswith('[LLM') and len(_tr_comp) < len(tool_results): | |
| tool_results = f"[TOOL RESULTS COMPRESSI — GAP-4]\n{_tr_comp}" | |
| except Exception: | |
| pass # fail-open: usa tool_results originali se compressione fallisce | |
| # LLM call con dati tool iniettati | |
| # S402: passa exec counts per Tool Integrity Guard in _build_messages() | |
| messages = self._build_messages( | |
| state, tool_results=tool_results, | |
| tool_exec_successes=_tool_exec_successes, | |
| tool_exec_errors=_tool_exec_errors, | |
| session_files=self._session_files or None, # S416-Fix1 | |
| ) | |
| # S418-F3: Role.CONTEXT â comprime storia se > 20 messaggi per prevenire context bloat | |
| if len(messages) > 20: | |
| try: | |
| from models.role_router import RoleRouter, Role as _Role | |
| _ctx_llm = RoleRouter.get_client(_Role.CONTEXT) | |
| _comp_input = [ | |
| {"role": "system", "content": ( | |
| "Riassumi questa conversazione in max 5 punti chiave. " | |
| "Preserva dati concreti (URL, numeri, risultati tool). Sii molto conciso." | |
| )}, | |
| *messages[1:-2], | |
| ] | |
| _summary = await asyncio.wait_for( | |
| _ctx_llm.chat(_comp_input, temperature=0.1, max_tokens=512), | |
| timeout=4.0, # S423: ridotto da 10s a 4s â evita bottleneck su 429 | |
| ) | |
| if _summary and not _summary.startswith('[LLM'): | |
| # S423-Fix8: preserva sempre l'ultimo user message â evita che la domanda | |
| # corrente venga persa nella compressione quando è fuori da messages[-3:] | |
| # S590: messages[-2:]â[-3:] â preserva più turns nella coda di compressione | |
| _last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None) | |
| _tail = list(messages[-3:]) | |
| # S458: inserisci _last_user PRIMA della coda (userâassistant), non dopo | |
| if _last_user and _last_user not in _tail: | |
| _tail.insert(0, _last_user) | |
| _compressed = [ | |
| messages[0], | |
| {"role": "system", "content": f"[STORIA COMPRESSA]\n{_summary}"}, | |
| *_tail, | |
| ] | |
| messages = _compressed | |
| except Exception: | |
| pass # compressione fallita â usa messages originali | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 1, "action": "llm", "status": "started", | |
| "title": "Elaborazione AI", | |
| "explanation": "Sto elaborando la rispostaâ¦", | |
| })) | |
| # B10: usa state.has_files â non più '__HAS_FILES__' nel context string | |
| _has_files = state.has_files | |
| _llm_timeout = LLM_TIMEOUT * 1.8 if _has_files else LLM_TIMEOUT | |
| # S197 never-give-up: frasi di rifiuto che triggerano retry forzato | |
| # S456-X2: SET CANONICO â sincronizzato con REFUSAL_RE in outputValidator.ts. | |
| # Soglia: 600 chars (retry aggressivo, cheap). Frontend usa 350 (quality penalization). | |
| # Soglie SEPARATE per design â qualsiasi aggiunta qui deve aggiornare anche il TS. | |
| _REFUSAL_PHRASES = ( | |
| # ââ Italiano ââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |
| 'non posso', 'non sono in grado', 'mi dispiace ma non', | |
| 'impossibile per me', 'non riesco', 'non ho accesso', | |
| 'mi scuso ma non', 'purtroppo non posso', 'purtroppo non sono', | |
| 'mi dispiace, non', 'non mi è possibile', 'non è possibile per me', | |
| 'non ho trovato', # S456-X2: da TS REFUSAL_RE | |
| 'sono spiacente', # S456-X2: da TS REFUSAL_RE | |
| 'come ia non', # S456-X2: da TS REFUSAL_RE | |
| # ââ Inglese âââââââââââââââââââââââââââââââââââââââââââââââââââââââ | |
| 'i cannot', 'i am unable', 'i\'m unable', 'i\'m sorry but i', | |
| 'as an ai', 'as an language model', 'as a language model', | |
| 'i\'m not able to', 'that\'s not something i can', 'sorry, i can\'t', | |
| 'unfortunately i cannot', 'i\'m afraid i cannot', | |
| 'i lack the capability', # S456-X2: da TS REFUSAL_RE | |
| "i don't have the ability", # S456-X2: da TS REFUSAL_RE | |
| "i don't have information about", # S456-X2: da TS REFUSAL_RE | |
| # ── Estensioni S-REFUSAL-EXT ───────────────────────────────── | |
| 'non so come', # IT: mancava da _REFUSAL_PHRASES | |
| 'non posso aiutarti', # IT: mancava da _REFUSAL_PHRASES | |
| 'questo va oltre', # IT: va oltre capacità agente | |
| 'non posso rispondere', # IT: rifiuto esplicito | |
| 'i cannot assist', # EN: variante i cannot | |
| "i'm not able", # EN: variante i'm not able to | |
| 'beyond my capability', # EN: limite capacità | |
| 'not within my', # EN: not within my capability/scope | |
| 'i apologize but', # EN: scuse + rifiuto | |
| 'mi scusi ma', # IT: scuse formali | |
| ) | |
| # GAP-3: EscalationLadder — routing dinamico: attempt 0→CODER, 1→REASONER, 2+→DEFAULT | |
| # Attempt 0: CODER (Llama 4 Scout) · Attempt 1: REASONER (Cerebras 120B) · Attempt 2+: DEFAULT | |
| from agents.escalation_ladder import EscalationLadder as _EscLadder | |
| _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal) | |
| # S376: error severity classifier â adatta la strategia di retry in base al tipo di errore | |
| # Senza questo, tutti gli errori ricevono lo stesso trattamento (temperature 0.4, stesso hint) | |
| # Con questo: syntax â fix preciso, runtime â retry tool, logic â ri-pianifica | |
| # S376/GAP-3.3: usa error_classifier.py unificato (11 categorie, regex precisi) | |
| # Rimussa funzione locale duplicata â mapping ErrorCategory â severity per _SEVERITY_HINTS | |
| _EC_TO_SEVERITY = { | |
| "syntax": "syntax", | |
| "runtime": "runtime", "selector": "runtime", "navigation": "runtime", | |
| "frame": "runtime", "auth": "runtime", "network": "runtime", | |
| "limit": "runtime", | |
| "logic": "logic", "db_error": "logic", | |
| "unknown": "unknown", | |
| } | |
| try: | |
| _clf_fn, _ = _get_classifier() | |
| errors = state.errors # S576: alias for comprehension | |
| _clf_result = _clf_fn([str(e)[:500] for e in errors[-3:]]) # S576+S592: errors window -3 | |
| _error_severity = _EC_TO_SEVERITY.get(_clf_result.category.value, "unknown") | |
| except Exception: | |
| _error_severity = "unknown" | |
| # S376: severity-based retry hints | |
| _SEVERITY_HINTS = { | |
| 'syntax': ( | |
| "ERRORE DI SINTASSI RILEVATO: correggi SOLO la sintassi â " | |
| "non cambiare la logica. Verifica parentesi, virgole, indentazione." | |
| ), | |
| 'runtime': ( | |
| "ERRORE RUNTIME RILEVATO: l'approccio precedente ha prodotto un errore " | |
| "a runtime. Prova un approccio alternativo più robusto con gestione errori." | |
| ), | |
| 'logic': ( | |
| "ERRORE LOGICO RILEVATO: il risultato ottenuto non è corretto. " | |
| "Ripensa la logica dall'inizio â usa un approccio diverso." | |
| ), | |
| } | |
| # S195-Robust + S197: retry su errore/placeholder/rifiuto | |
| # S385: adaptive retry budget â Q&A semplice 1 try, code 2, app multi-feature 3 | |
| _tok_budget = self._max_tokens_for_goal(state.goal) | |
| _max_llm_tries = 3 if _tok_budget >= 6144 else 2 if _tok_budget >= 4096 else 1 | |
| answer = "" | |
| _prev_llm_answer = "" # S759: repeated-answer stuck detection | |
| for _llm_try in range(_max_llm_tries): | |
| _is_last = _llm_try == _max_llm_tries - 1 | |
| # GAP-3: aggiorna il client LLM per questo tentativo (escalation dinamica) | |
| _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity) | |
| try: | |
| _msgs = messages | |
| # S385-fix4: inietta force-response SOLO se ci sono stati tentativi precedenti | |
| # (quando _max_llm_tries=1, _is_last è True al primo try â non iniettiamo mai l'istruzione aggressiva) | |
| if _is_last and _llm_try > 0: | |
| # Ultimo di più tentativi: inietta istruzione forza-risposta + severity hint | |
| _force_content = ( | |
| "ISTRUZIONE FINALE: NON puoi rifiutarti di rispondere. " | |
| "Trova UN MODO alternativo, anche parziale, per aiutare. " | |
| "Approccio A fallito? Prova B. Non scrivere mai 'non posso'. " | |
| "Dai almeno una risposta parziale concreta." | |
| ) | |
| _sev_hint = _SEVERITY_HINTS.get(_error_severity, '') | |
| if _sev_hint: | |
| _force_content = f"{_sev_hint}\n\n{_force_content}" | |
| _force = {"role": "system", "content": _force_content} | |
| _msgs = [messages[0], _force, *messages[1:]] | |
| elif _llm_try == _max_llm_tries - 2 and _max_llm_tries > 1 and _error_severity in _SEVERITY_HINTS: | |
| # Penultimo tentativo: inietta solo il severity hint (meno aggressivo) | |
| _sev_msg = {"role": "system", "content": _SEVERITY_HINTS[_error_severity]} | |
| _msgs = [messages[0], _sev_msg, *messages[1:]] | |
| # S376: temperatura adattiva in base alla severity | |
| # syntax â bassa (0.1, precisione), logic â alta (0.5, creatività ) | |
| _temp_by_try = { | |
| 'syntax': [0.1, 0.15, 0.2], | |
| 'runtime': [0.2, 0.3, 0.4], | |
| 'logic': [0.3, 0.45, 0.5], | |
| 'unknown': [0.2, 0.4, 0.4], | |
| } | |
| _temp = _temp_by_try.get(_error_severity, [0.2, 0.4, 0.4])[min(_llm_try, 2)] | |
| # S385: latency telemetry â misura durata chiamata LLM | |
| _t0_llm = asyncio.get_running_loop().time() | |
| # S420: stream tokens to frontend while accumulating full answer | |
| _stream_parts: list[str] = [] | |
| try: | |
| async def _collect_stream(_msgs=_msgs, _temp=_temp, _tok_budget=_tok_budget) -> str: | |
| async for _tok in _active_llm.stream_chat( | |
| _msgs, temperature=_temp, max_tokens=_tok_budget | |
| ): | |
| _stream_parts.append(_tok) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "text_chunk", | |
| "token": _tok, | |
| "status": "streaming", | |
| })) | |
| return "".join(_stream_parts) | |
| answer = await asyncio.wait_for(_collect_stream(), timeout=_llm_timeout) | |
| if not answer: | |
| raise ValueError("stream vuoto") | |
| except Exception: | |
| _stream_parts.clear() | |
| answer = await asyncio.wait_for( | |
| _active_llm.chat(_msgs, temperature=_temp, max_tokens=_tok_budget), | |
| timeout=_llm_timeout, | |
| ) | |
| try: | |
| from api.state import record_timing as _rec_timing | |
| _llm_elapsed = (asyncio.get_running_loop().time() - _t0_llm) * 1000 | |
| _rec_timing("llm_total", _llm_elapsed) | |
| _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # P16-B4: segnala truncation SSE se finish_reason == "length" | |
| _fr = getattr(_active_llm, '_last_finish_reason', 'stop') | |
| if _fr == 'length' and on_step: | |
| await _maybe_await(on_step({ | |
| "action": "step", "step": state.current_step, | |
| "output": "⚠️ [TRUNCATION] Risposta LLM troncata (max_tokens raggiunto). Tenta riduzione contesto.", | |
| "truncated": True, | |
| })) | |
| if answer.startswith('[LLM'): | |
| state.steps.append({"action": f"llm_attempt_{_llm_try}", "output": answer}) | |
| continue | |
| if _is_refusal(answer) and not _is_last: | |
| # S576: 200â400 â cattura rifiuto completo per debug | |
| state.steps.append({"action": f"llm_refusal_{_llm_try}", "output": answer[:600]}) # S603: 400â600 | |
| continue | |
| # S759: repeated-answer stuck detection | |
| # Se risposta simile all'ultima (Jaccard bigram >0.75) e non è l'ultimo try â forza retry | |
| if _llm_try > 0 and _prev_llm_answer and answer and not answer.startswith('[LLM'): | |
| if _s759_bjac(answer, _prev_llm_answer) > 0.75 and not _is_last: | |
| state.steps.append({ | |
| "action": f"llm_stuck_{_llm_try}", | |
| "output": "risposta ripetuta â cambio temperatura e strategia", | |
| }) | |
| _prev_llm_answer = answer[:100] | |
| continue # riprova con temperatura più alta | |
| _prev_llm_answer = answer[:100] if answer and not answer.startswith('[LLM') else _prev_llm_answer | |
| # S-BACKEND-ANTIREGRESS: rileva import injection e code rewrite. | |
| # Se rilevato E non ultimo try, inietta hint chirurgico e riprova. | |
| if not _is_last and answer and '```' in answer: | |
| try: | |
| from agents.backend_antiregress import check_regression as _ar_chk | |
| _ar_hint = _ar_chk(state.goal, answer, state.context or "") | |
| if _ar_hint: | |
| state.steps.append({ | |
| "action": "antiregress_retry", | |
| "hint": _ar_hint[:200], | |
| }) | |
| _ar_msg = ( | |
| "\n\n[CORREZIONE RICHIESTA]\n" | |
| + _ar_hint | |
| + "\n\nRiscrivi SOLO la parte difettosa. " | |
| "Mantieni TUTTE le classi e funzioni originali. " | |
| "Non aggiungere nuove dipendenze." | |
| ) | |
| _msgs = [_msgs[0], {"role": "user", "content": state.goal + _ar_msg}] | |
| continue # retry con hint chirurgico | |
| except Exception: | |
| pass # S-BACKEND-ANTIREGRESS: non bloccante | |
| break # risposta reale non-rifiuto | |
| except asyncio.TimeoutError: | |
| answer = f"[LLM timeout {_llm_timeout:.0f}s]" | |
| if not _is_last: | |
| continue # riprova su timeout | |
| break | |
| except Exception as exc: | |
| answer = f"[LLM error: {exc}]" | |
| if not _is_last: | |
| continue | |
| break | |
| if answer.startswith("[LLM"): | |
| state.errors.append(answer) | |
| # S364: Chain-of-Verification â dopo 2+ errori, usa ARCHITECT per reflection | |
| if len(state.errors) >= 1: # S701: reflection da 1 errore (era 2) | |
| # GAP-D: progress card visibile PRIMA del reflection â utente sa che stiamo analizzando | |
| if on_step: | |
| _rd_n = len(state.errors) | |
| _rd_label = "Strategia alternativa forzata" if _rd_n >= 3 else "Analisi dell'errore" | |
| await _maybe_await(on_step({ | |
| "action": "reflective_debug", | |
| "status": "started", | |
| "title": f"ð {_rd_label} (tentativo {_rd_n})", | |
| "explanation": ( | |
| "Ho riscontrato un ostacolo ripetuto. Sto elaborando una strategia completamente diversa con il modello Architectâ¦" | |
| if _rd_n >= 3 else | |
| "Ho riscontrato un errore. Sto analizzando la causa principale con il modello Architect per cambiare approccioâ¦" | |
| ), | |
| })) | |
| # B4: strategic_ctx già presente → degrada ARCHITECT→fast_llm (-10-15s) | |
| _b4_has_strategic = ( | |
| '[GAP-SELFHEAL:' in (state.context or '') | |
| or '♻️ Re-planning' in (state.context or '') | |
| ) | |
| _reflection = await self._reflective_debug( | |
| state.goal, state.errors, | |
| _force_fast=_b4_has_strategic, | |
| ) | |
| if _reflection: | |
| state.context = (state.context or '') + _reflection | |
| state.steps.append({"action": "reflective_debug", | |
| "analysis": _reflection[:400]}) # S573: 200â400 | |
| # GAP-D: progress card "done" con la nuova strategia â trasforma il fallimento in fiducia | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "reflective_debug", | |
| "status": "done", | |
| "title": "ð¡ Nuova strategia identificata", | |
| "explanation": _reflection[:300], | |
| })) | |
| # GAP-SELFHEAL: dopo 3+ errori, inietta regole concrete di cambio strategia | |
| # Il reflective_debug da solo non rompe il loop di allucinazione (63% closure fail). | |
| # R3: aggiunta dedup guard — senza di essa ogni iterazione LLM con state.errors>=3 | |
| # appendeva un [GAP-SELFHEAL] blocco distinto a state.context (crescita O(n_errors)). | |
| # Pattern: inietta SOLO SE state.context non contiene già "[GAP-SELFHEAL:". | |
| if len(state.errors) >= 3: | |
| _n_err = len(state.errors) | |
| _sh2_already = "[GAP-SELFHEAL:" in (state.context or "") | |
| if not _sh2_already: | |
| _selfheal_inj = ( | |
| "\n\n[GAP-SELFHEAL: tentativo " + str(_n_err) + " - CAMBIO STRATEGIA OBBLIGATORIO]\n" | |
| "I precedenti " + str(_n_err) + " approcci sono falliti. Applica QUESTE regole:\n" | |
| "1. NON ripetere il codice fallito - smontalo in passi atomici\n" | |
| "2. Prima di scrivere usa read_file per verificare lo stato attuale\n" | |
| "3. Scrivi SOLO la parte minima che fa passare UN test alla volta\n" | |
| "4. Se libreria X fallisce, prova libreria Y alternativa\n" | |
| "5. Se pattern A fallisce, usa pattern B completamente diverso." | |
| ) | |
| state.context = (state.context or "") + _selfheal_inj | |
| state.steps.append({"action": "selfheal_strategy_injection", "n_errors": _n_err}) | |
| # GAP-1: Probabilistic Re-planning Trigger | |
| # Chiamato dopo selfheal: step count = numero step completati finora. | |
| # Agisce su state.context (append) — non modifica messages correnti. | |
| _gap1_step_count = len([s for s in state.steps if s.get("action") == "llm"]) | |
| # GAP-1 guards (mirrors _budget_replan_check): skip se _n_err < 2 o _budget_ratio < 0.6 | |
| _gap1_hint = await self._budget_replan_check(state, _gap1_step_count, on_step) | |
| if _gap1_hint: | |
| state.context = (state.context or '') + f'\n\n[GAP-1-REPLAN]\nNuovo approccio: {_gap1_hint}' | |
| state.steps.append({"action": "budget_replan", "hint": _gap1_hint[:200]}) | |
| state.steps.append({"action": "llm", "output": answer}) | |
| # S428 Sprint1-Fix3: Claim Validation â safety net post-LLM. | |
| # Anche quando _build_messages inietta "TENTATIVO TOOL FALLITO" con istruzione | |
| # "NON affermare di aver trovato dati live", il LLM può ignorarla. | |
| # Questo check è il secondo strato di difesa: aggiunge un disclaimer visibile | |
| # se e solo se rileva false claim + goal realtime + tutti tool falliti. | |
| if answer and not answer.startswith("[LLM"): | |
| answer = self._validate_claims( | |
| response=answer, | |
| n_success=_tool_exec_successes, | |
| n_errors=_tool_exec_errors, | |
| goal=state.goal, | |
| false_claim_re=self._FALSE_CLAIM_RE, | |
| realtime_goal_re=self._REALTIME_GOAL_RE, | |
| ) | |
| # S416-Fix1: aggiorna _session_files con file scritti in questa risposta | |
| # così il prossimo run() li inietta come contesto (evita import rotti tra step) | |
| if answer: | |
| _written = self._extract_written_files(answer) | |
| if _written: | |
| self._session_files.update(_written) | |
| # Sprint 3b ITEM 7: auto validate_project post-write | |
| # Se _tok_budget >= 4096 e ci sono file Python scritti, verifica sintassi AST | |
| if _tok_budget >= 4096: | |
| import ast as _ast_chk | |
| _py_errs: list[str] = [] | |
| for _vp, _vc in list({p: c for p, c in _written.items() | |
| if p.endswith(".py")}.items())[:5]: | |
| try: | |
| _ast_chk.parse(_vc) | |
| except SyntaxError as _se: | |
| _py_errs.append(f"{_vp}:{_se.lineno}: {_se.msg}") | |
| if _py_errs: | |
| # S594: _py_errs[:3]â[:5] â riporta più errori di sintassi per fix completo | |
| _syn_rpt = "AUTO-VALIDATE sintassi: " + "; ".join(_py_errs[:5]) | |
| state.errors.append(_syn_rpt) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "validate_project", | |
| "status": "needs_fix", | |
| "title": "Validazione automatica", | |
| "explanation": _syn_rpt[:400], # S576: 200â400 | |
| })) | |
| try: | |
| from api.state import increment_stat as _inc_syn | |
| _inc_syn("syntax_errors") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| elif on_step: | |
| _n_py = sum(1 for p in _written if p.endswith(".py")) | |
| if _n_py > 0: | |
| await _maybe_await(on_step({ | |
| "action": "validate_project", | |
| "status": "done", | |
| "title": "Validazione automatica â", | |
| "explanation": f"{_n_py} file Python â sintassi OK", | |
| })) | |
| # GAP-C: Ciclo di Test Automatizzato | |
| # Trigger: sintassi OK + file Python scritti + task complesso (>=8192 tok) | |
| # Genera test minimale via LLM (8s) â esegue via exec engine (20s) | |
| # Fallimento â _reflective_debug â state.context aggiornato per il loop successivo | |
| # Best-effort: Exception catturata in fondo â mai blocca la risposta utente | |
| if not _py_errs: | |
| _gac_py = {p: c for p, c in _written.items() if p.endswith(".py")} | |
| if _gac_py and _tok_budget >= 8192: | |
| try: | |
| _gac_name, _gac_code = next(iter(_gac_py.items())) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "auto_test", | |
| "status": "started", | |
| "title": "𧪠Test automatico", | |
| "explanation": f"Genero ed eseguo un test minimale per {_gac_name}â¦", | |
| })) | |
| _gac_msgs = [ | |
| {"role": "system", "content": ( | |
| "Scrivi UN test Python minimale (stdlib only, no pytest) per il codice.\n" | |
| "Deve: importare funzioni principali, avere 1-3 assert concreti,\n" | |
| "stampare 'PASS' o 'FAIL: <msg>'. Solo codice Python, niente markdown." | |
| )}, | |
| {"role": "user", "content": f"# {_gac_name}\n{_gac_code[:1500]}"}, | |
| ] | |
| _gac_raw = await asyncio.wait_for( | |
| self.llm.chat(_gac_msgs, temperature=0.05, max_tokens = 500), # S586: 350->500 | |
| timeout=8.0, | |
| ) | |
| import re as _gac_re | |
| _gac_m = _gac_re.search(r'```python\n([\s\S]+?)```', _gac_raw or "") | |
| _gac_run = _gac_m.group(1) if _gac_m else (_gac_raw or "").strip() | |
| if len(_gac_run) > 10: | |
| from tools.registry import _call_exec_engine as _gac_exec | |
| _gac_res = await asyncio.wait_for( | |
| _gac_exec({"code": _gac_run, "lang": "python", "timeout": 15}), | |
| timeout=20.0, | |
| ) or {} | |
| _gac_exit = _gac_res.get("exit_code", 1) | |
| _gac_out = ( | |
| (_gac_res.get("stdout") or "") + (_gac_res.get("stderr") or "") | |
| )[:300] | |
| if _gac_exit == 0 and "FAIL" not in _gac_out.upper(): | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "auto_test", | |
| "status": "done", | |
| "title": "𧪠Test automatico â PASS", | |
| "explanation": _gac_out[:200] or "Tutti i test superati.", | |
| })) | |
| else: | |
| state.errors.append( | |
| f"Auto-test {_gac_name} exit={_gac_exit}: {_gac_out}" | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "auto_test", | |
| "status": "needs_fix", | |
| "title": "𧪠Test automatico â FAIL", | |
| "explanation": _gac_out[:200], | |
| })) | |
| _gac_fix = await self._reflective_debug(state.goal, state.errors) | |
| if _gac_fix: | |
| state.context = ( | |
| (state.context or "") | |
| + f"\n\n[AUTO-TEST FAIL â {_gac_name}]\n{_gac_fix}" | |
| ) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "reflective_debug", | |
| "status": "done", | |
| "title": "ð¡ Fix suggerito da test fallito", | |
| "explanation": _gac_fix[:300], | |
| })) | |
| except Exception: | |
| pass # GAP-C best-effort â mai blocca la risposta utente | |
| # S403-FIX: NON appendere a outputs qui â i repair loop (verifier, goal_verifier, | |
| # self-healing Python/HTML) modificano `answer` ma non `outputs`. | |
| # L'append viene fatto DOPO tutti i repair, appena prima di final_output, | |
| # così "\n\n".join(outputs) riflette la risposta completamente riparata. | |
| # (Prima: outputs.append(answer) qui â tutti i fix venivano scartati in silenzio) | |
| # Doc2-3a-FIX: quality_guardian integrato nel loop di repair. | |
| # Prima: fire-and-forget â fix_hint emesso via SSE ma mai usato â codice bugato consegnato. | |
| # Ora: await con timeout breve (8s). | |
| # - Se risulta FAIL + fix_hint â 1 repair LLM call prima di restituire la risposta. | |
| # - Se timeout â fire-and-forget solo per notifica SSE (comportamento precedente). | |
| # Invariante B6 rispettata: solo timeout avvia il task async â nessun await bloccante lungo. | |
| if answer and not answer.startswith('[LLM') and '```' in answer: | |
| try: | |
| import importlib as _imp_ev | |
| try: | |
| _qg_mod = _imp_ev.import_module('api.quality_guardian') | |
| except ImportError: | |
| _qg_mod = None | |
| _qc_fn = getattr(_qg_mod, 'run_quality_check', None) if _qg_mod else None | |
| if _qc_fn: | |
| _answer_snap = answer | |
| _qc_result: dict | None = None | |
| # Tenta quality check con timeout breve (8s) â permette repair integrato | |
| try: | |
| _qc_result = await asyncio.wait_for( | |
| _qc_fn(task_id=self._run_task_id, goal=state.goal, | |
| llm_output=_answer_snap, on_event=on_step, | |
| session_files=self._session_files or None), # S568-A/GAP-3qg | |
| timeout=8.0, | |
| ) | |
| except asyncio.TimeoutError: | |
| _qc_result = None # troppo lento â fire-and-forget sotto | |
| except Exception: | |
| _qc_result = None | |
| if _qc_result is not None: | |
| # Risultato disponibile â repair integrato se FAIL + fix_hint | |
| if _qc_result.get('passed') is False and _qc_result.get('fix_hint'): | |
| # S594: fix_hint 300â500 â hint correttivo spesso multi-riga (era [:300] che limitava il successivo [:400]) | |
| _fix_hint = str(_qc_result['fix_hint'])[:500] | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'fix_hint': _fix_hint, # S573: 200â400; S594: cap spostato a riga sopra | |
| 'status': 'repairing', | |
| })) | |
| try: | |
| # Usa messages originali (non _msgs con hint iniettati) | |
| # per evitare confusion nel contesto del repair LLM | |
| # S590: messages[-4:]â[-6:] â più contesto per repair LLM | |
| _repair_msgs = [ | |
| *messages[-6:], | |
| {"role": "assistant", "content": answer}, | |
| {"role": "user", "content": ( | |
| f"Il tester automatico ha rilevato un bug:\n{_fix_hint}\n\n" | |
| "Correggi SOLO il codice difettoso. " | |
| "Riscrivi completi i file che contengono il bug." | |
| )}, | |
| ] | |
| _repaired = await asyncio.wait_for( | |
| _active_llm.chat( | |
| _repair_msgs, temperature=0.1, | |
| max_tokens=min(_tok_budget, 4096), | |
| ), | |
| timeout=25.0, | |
| ) | |
| if _repaired and not _repaired.startswith('[LLM'): | |
| answer = _repaired | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'status': 'done', | |
| 'title': 'Fix automatico applicato â', | |
| })) | |
| try: | |
| from api.state import increment_stat as _inc_qg | |
| _inc_qg("repair_success_count") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| except Exception: | |
| pass # repair silente â risposta originale invariata | |
| elif _qc_result.get('passed') is False and on_step: | |
| # FAIL senza hint â notifica UI | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'fix_hint': 'Quality check: bug rilevato â nessun hint specifico', | |
| 'status': 'needs_fix', | |
| })) | |
| else: | |
| # Timeout 8s â fire-and-forget per notifica SSE (B6 invariant) | |
| _ff_snap = answer | |
| _run_tid = self._run_task_id # S568-A: cattura prima del closure | |
| async def _ev_task() -> None: | |
| try: | |
| _qc = await asyncio.wait_for( | |
| _qc_fn(task_id=_run_tid, goal=state.goal, | |
| llm_output=_ff_snap, on_event=on_step, | |
| session_files=self._session_files or None), # S568-A/GAP-3qg ff | |
| timeout=18.0, | |
| ) | |
| if _qc.get('passed') is False and _qc.get('fix_hint') and on_step: | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'fix_hint': _qc['fix_hint'][:400], # S573: 200â400 | |
| 'status': 'needs_fix', | |
| })) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # S455-P10: task supervisionato | |
| _ev_t = asyncio.create_task(_ev_task()) | |
| _ev_t.add_done_callback( | |
| lambda t: t.exception() if not t.cancelled() and t.exception() is not None else None | |
| ) | |
| except Exception as _ev_exc: | |
| _logger.warning("S624 ExecutionValidator failed (silent): %s", _ev_exc) # S624 | |
| # S274-BUG3: ResponseVerifier era salvato in self.verifier ma MAI chiamato. | |
| # Wire-in: verifica JSON, markdown, coerenza. Retry con hint se suggerito. | |
| if self.verifier and answer and not answer.startswith('[LLM'): | |
| try: | |
| _vr = self.verifier.verify_and_repair(state.goal, answer) | |
| answer = _vr.output | |
| if getattr(_vr, 'retry_suggested', False): | |
| _hint_msg = [*messages, {"role": "assistant", "content": answer}, | |
| {"role": "user", "content": f"Migliora: {getattr(_vr, 'retry_hint', 'rendi la risposta più completa')}"}] | |
| try: | |
| # S427-FixF: usa _active_llm (CODER per task di codice) invece del | |
| # base self.llm â il retry del verifier usava il modello sbagliato | |
| # per task di codice complessi (es. Groq 8B invece di 70B). | |
| _retry_ans = await asyncio.wait_for( | |
| _active_llm.chat(_hint_msg, temperature=0.3, max_tokens=self._max_tokens_for_goal(state.goal)), | |
| timeout=LLM_TIMEOUT) | |
| if _retry_ans and not _retry_ans.startswith('[LLM'): | |
| answer = _retry_ans | |
| except Exception as _rv_retry_exc: | |
| _logger.warning("S624 ResponseVerifier retry failed (silent): %s", _rv_retry_exc) # S624 | |
| except Exception as _rv_exc: | |
| _logger.warning("S624 ResponseVerifier failed (silent): %s", _rv_exc) # S624 | |
| # ── MIN-LENGTH-GATE (Checklist Item 1) ──────────────────────────────── | |
| # Retry automatico per goal analitici con risposta troppo corta. | |
| # Recupera RY (riassumi) e DA (data analysis) failures — output <150 parole. | |
| # Trigger: _ANALYTICAL_VERBS_RE match + risposta < 150 parole. Fail-open. | |
| if answer and not answer.startswith('[LLM'): | |
| _mlg_words = len(answer.split()) | |
| _is_goal_analytical = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) | |
| if _is_goal_analytical and _mlg_words < 150: | |
| try: | |
| _mlg_reinforce = [ | |
| *messages, | |
| {"role": "assistant", "content": answer}, | |
| {"role": "user", "content": ( | |
| f"La risposta è troppo breve ({_mlg_words} parole) " | |
| f"rispetto a quanto richiesto dal goal. " | |
| f"Sviluppa ogni punto in modo completo e dettagliato: " | |
| f"almeno 200 parole, coprendo esaustivamente tutti gli aspetti." | |
| )}, | |
| ] | |
| _mlg_retry = await asyncio.wait_for( | |
| _active_llm.chat( | |
| _mlg_reinforce, | |
| temperature=0.3, | |
| max_tokens=self._max_tokens_for_goal(state.goal), | |
| ), | |
| timeout=LLM_TIMEOUT, | |
| ) | |
| if (_mlg_retry and not _mlg_retry.startswith('[LLM') | |
| and len(_mlg_retry.split()) > _mlg_words): | |
| answer = _mlg_retry | |
| _logger.debug( | |
| "[unified_loop] min_length_gate: %d→%d words (goal=%s…)", | |
| _mlg_words, len(answer.split()), state.goal[:40], | |
| ) | |
| try: | |
| from api.state import increment_stat as _inc_mlg | |
| _inc_mlg("min_length_gate_retry") | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass # fail-open — mantieni risposta originale | |
| # S403: GoalVerifier â verifica semantica "obiettivo raggiunto" vs "azione eseguita" | |
| # S410: adaptive threshold + double-pass re-verify per chiudere il loop di verifica. | |
| # Il ciclo: verify â repair â re-verify â accept/reject conferma che il repair | |
| # abbia davvero migliorato la coverage, non solo cambiato la risposta. | |
| # S416-Fix2: attivato per is_code_goal anche senza backtick (app multi-file descrittiva) | |
| # Sprint 2: GoalVerifier 2.0 â se RequirementEngine trova requisiti, usa verify_v2 | |
| try: | |
| from agents.goal_verifier import GoalVerifier as _GV_pre | |
| _gv_should_run = _GV_pre.is_code_goal(state.goal) or '```' in answer | |
| except Exception: | |
| _gv_should_run = '```' in answer | |
| if answer and not answer.startswith('[LLM') and _gv_should_run: | |
| try: | |
| from agents.goal_verifier import GoalVerifier as _GV | |
| from api.state import increment_stat as _inc_stat | |
| if _GV.is_code_goal(state.goal): | |
| _gv = _GV(self._get_verifier_llm()) # P25-B4: cross-model | |
| _threshold = _GV.adaptive_threshold(state.goal) # S410: adattivo | |
| # Sprint 2: tenta verify_v2 se RequirementEngine disponibile e goal complesso | |
| _gv2_reqs = None | |
| if _tok_budget >= 4096: | |
| try: | |
| from agents.requirement_engine import RequirementEngine as _RE | |
| from api.state import increment_stat as _inc_re | |
| _re_engine = _RE(llm=self.llm) # BUG-5: LLM come fallback per goal complessi | |
| _gv2_reqs = await _re_engine.decompose(state.goal) # P16-B1: async con LLM fallback — decompose_sync ignorava llm=self.llm | |
| if _gv2_reqs: | |
| _inc_re("req_engine_used") | |
| try: | |
| from api.state import increment_stat as _inc_re2 | |
| _inc_re2.__module__ # no-op, just exist check | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| try: | |
| import api.state as _st_mod | |
| _st_mod._REPAIR_STATS["req_engine_reqs_total"] += len(_gv2_reqs) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| except Exception: | |
| _gv2_reqs = None | |
| # FIX-2: fast-pass euristico â salta LLM verify se risposta gia completa. | |
| # Condizioni: >600 chars + >=1 blocco codice + 60% keyword goal + no errori. | |
| # Risparmio: -5s/iter su task dove LLM ha gia risposto bene (caso comune). | |
| _goal_words_fp = set(re.findall(r'\w{4,}', state.goal.lower())) | |
| _ans_words_fp = set(re.findall(r'\w{4,}', answer.lower())) | |
| _kw_cov_fp = len(_goal_words_fp & _ans_words_fp) / max(len(_goal_words_fp), 1) | |
| # B2: fast-pass ampliato — fast-fix senza errori saltano goal_verifier. | |
| # Conseguenza: -5/-22s per ogni fix atomico andato a buon fine. | |
| # Zero cons: FAST_FIX_RE+no errors garantisce completezza senza LLM. | |
| _is_fast_fix_clean = ( | |
| not getattr(state, 'errors', None) | |
| and len(state.goal) < 200 | |
| and bool(self._FAST_FIX_RE.search(state.goal[:200])) | |
| and bool(answer.strip()) | |
| ) | |
| # P16-B5: soglia keyword adattiva in base alla lunghezza del goal | |
| # Goal brevi (<80 chars): molto specifici → soglia più bassa (0.60) | |
| # Goal medi (80-200 chars): default (0.72) | |
| # Goal lunghi (>200 chars): molti requisiti → soglia più alta (0.82) | |
| _gl = len(state.goal) | |
| _fp_threshold = 0.60 if _gl < 80 else (0.82 if _gl > 200 else 0.72) | |
| # Item 5: fast-pass non-coding branch — keyword coverage su prosa | |
| _is_goal_analytical_fp = bool(_ANALYTICAL_VERBS_RE.search(state.goal)) | |
| _fast_pass = ( | |
| _is_fast_fix_clean | |
| or ( | |
| # Existing: code-heavy answers (4+ code blocks) | |
| len(answer) > 1200 | |
| and answer.count('```') >= 4 | |
| and _kw_cov_fp >= _fp_threshold # P16-B5: adattivo | |
| and not getattr(state, 'errors', None) | |
| ) | |
| or ( | |
| # NEW — Item 5: goal analitici — fast-pass via keyword coverage senza codice | |
| # Evita LLM verify su risposte analitiche già esaustive (≥150 parole, 55% kw) | |
| _is_goal_analytical_fp | |
| and len(answer.split()) >= 150 | |
| and _kw_cov_fp >= 0.55 | |
| and not getattr(state, 'errors', None) | |
| ) | |
| ) | |
| # P25-B2: Risk gate — blocca fast_pass se ci sono requisiti ad alto rischio. | |
| # Previene shortcut euristico su operazioni sensibili (auth/pagamenti/delete/security). | |
| # Solo per goal non-trivial (non _is_fast_fix_clean) con requisiti già estratti. | |
| _P25_HIGH_RISK = {"auth", "payments", "crud", "security"} | |
| if _fast_pass and not _is_fast_fix_clean and _gv2_reqs: | |
| _has_risk_req = any( | |
| r.get("feature", "") in _P25_HIGH_RISK for r in _gv2_reqs | |
| ) | |
| if _has_risk_req: | |
| _fast_pass = False | |
| try: | |
| _inc_stat("fast_pass_blocked_risk") | |
| except Exception: | |
| pass | |
| _logger.debug( | |
| "[unified_loop] _fast_pass=%s kw_cov=%.2f goal_len=%d threshold=%.2f", | |
| _fast_pass, _kw_cov_fp, _gl, _fp_threshold, | |
| ) | |
| if _fast_pass: | |
| _inc_stat("goal_verify_fast_pass") | |
| _gvr = type('_FPR', (), dict(goal_met=True, coverage_score=0.85, | |
| missing_items=[], repair_hint=''))() | |
| else: | |
| # Sprint 2: usa verify_v2 se requisiti trovati, altrimenti verify v1 | |
| _t0_gv = asyncio.get_running_loop().time() # Sprint 5 ITEM 14: verifier_ms | |
| # GAP-1: Hard Gate — verify_with_execution() (esecuzione reale del codice) | |
| # semantic(verify_v2) → extract code block → exec backend → PASS/FAIL | |
| # exit_code != 0 → FAIL + traceback reale come repair_hint → self-healing loop | |
| _gvr = await asyncio.wait_for( | |
| _gv.verify_with_execution(state.goal, answer, requirements=_gv2_reqs or None), | |
| timeout=22.0, # semantic(4s) + execution(18s) = 22s budget | |
| ) | |
| try: | |
| from api.state import record_timing as _rtgv | |
| _rtgv("verifier_ms", (asyncio.get_running_loop().time() - _t0_gv) * 1000) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # REMOVE-1: rimossa regola 17 README check (S416-Fix6). | |
| # Causava -0.15 coverage su task senza 'readme' >= 6144 token â | |
| # inclusi 'ottimizza funzione', 'spiega codice', 'crea grafico'. | |
| # Falsi positivi sistematici -> repair spurio -> LLM call inutile. | |
| _initial_score = _gvr.coverage_score | |
| # S-CRITIC-1: rileva UNKNOWN prima del repair — on-demand Critic su task codice | |
| _is_unknown = _gvr.repair_hint.startswith("[verifier_unavailable") | |
| _skip_gv_repair = False | |
| if (_is_unknown | |
| and not _gvr.goal_met | |
| and _gvr.coverage_score < _threshold | |
| and _GV.is_code_goal(state.goal)): | |
| try: | |
| from agents.goal_verifier import CriticJudge as _CJ | |
| _cj = _CJ(self._get_fast_llm()) | |
| _cv = await asyncio.wait_for( | |
| _cj.judge(state.goal, answer), timeout=8.0) | |
| try: | |
| _inc_stat(f"critic_j_{_cv.verdict.lower()}") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if _cv.verdict == "PASS": | |
| _skip_gv_repair = True | |
| try: | |
| _inc_stat("critic_promoted_to_pass") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| elif ( | |
| _cv.verdict in ("UNKNOWN", "ERROR") | |
| or str(getattr(_cv, "raw", "")).startswith("[LLM") | |
| ): | |
| # GAP-8: verdict inaffidabile (rate limit 429 o timeout) | |
| # Non triggerare repair spurio — CriticJudge non ha risposto | |
| _skip_gv_repair = False # comportamento invariato ma esplicito | |
| try: | |
| _inc_stat("critic_unreliable") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| except Exception: | |
| pass # silent — UNKNOWN comportamento invariato | |
| if not _skip_gv_repair and not _gvr.goal_met and _gvr.coverage_score < _threshold: | |
| _inc_stat("goal_verify_repair_triggered") | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "goal_verifier", | |
| "status": "running", | |
| "visibility": "progress", | |
| "title": "Controllo qualità ", | |
| "explanation": ( | |
| f"Risposta al {int(_gvr.coverage_score * 100)}% â ottimizzazione in corso" | |
| ), | |
| })) | |
| _missing_str = "; ".join(_gvr.missing_items[:2]) if _gvr.missing_items else _gvr.repair_hint | |
| # S-ORCH-8GAP FIX-GAP3+GAP6: Requirement-Driven Repair | |
| # Arricchisce il repair context con acceptance_criteria specifici | |
| # dei requisiti FAIL â repair "chirurgico" invece di generico. | |
| # L'LLM sa ESATTAMENTE cosa implementare, non solo "manca qualcosa". | |
| _criteria_hints: list[str] = [] | |
| if _gv2_reqs and _gvr.missing_items: | |
| _failed_ids = {m.lower().replace(" ", "_") for m in _gvr.missing_items} | |
| for _req in _gv2_reqs: | |
| _rname = getattr(_req, 'feature', '').lower().replace(' ', '_') | |
| _rid = getattr(_req, 'id', '').lower() | |
| if (_rname in _failed_ids or _rid in _failed_ids or | |
| any(_fid in _rname or _fid in _rid for _fid in _failed_ids)): | |
| _ac = getattr(_req, 'acceptance_criteria', []) | |
| if _ac: | |
| _criteria_hints.extend(_ac[:2]) | |
| _criteria_block = ( | |
| "\nCriteri di accettazione mancanti:\n" | |
| + "\n".join(f" - {c}" for c in _criteria_hints[:4]) | |
| if _criteria_hints else "" | |
| ) | |
| # Sprint1b: messaggio repair diversificato per UNKNOWN vs FAIL | |
| # UNKNOWN = verifier non disponibile â non sappiamo cosa manca | |
| # FAIL = sappiamo cosa manca â repair chirurgico | |
| # _is_unknown già rilevato sopra (S-CRITIC-1) | |
| if _is_unknown: | |
| _repair_content = ( | |
| f"Rivedi e completa la risposta al seguente goal: " | |
| f"{state.goal[:300]}. " # S576: 200â300 | |
| "Assicurati di coprire tutti gli aspetti richiesti " | |
| f"in modo completo, corretto e dettagliato.{_criteria_block}" | |
| ) | |
| else: | |
| _repair_content = ( | |
| f"GOAL NON COMPLETATO ({int(_gvr.coverage_score*100)}%): " | |
| f"{_missing_str}. " | |
| "Completa esattamente quello che manca senza ripetere " | |
| f"quanto già scritto.{_criteria_block}" | |
| ) | |
| _gv_msgs = [ | |
| *messages, | |
| {"role": "assistant", "content": answer}, | |
| {"role": "user", "content": _repair_content}, | |
| ] | |
| _repaired_score = _initial_score # default: nessun miglioramento | |
| try: | |
| # Fix 3 (S421): repair con il modello più capace per goal complessi | |
| # self.llm = provider race winner (spesso 8B); app complesse hanno bisogno del 70B | |
| _gv_repair_llm = self._get_llm_for_goal(state.goal) | |
| _gv_ans = await asyncio.wait_for( | |
| _gv_repair_llm.chat(_gv_msgs, temperature=0.2, | |
| max_tokens=self._max_tokens_for_goal(state.goal)), | |
| timeout=10.0, # S434: 20â10s | |
| ) | |
| if _gv_ans and not _gv_ans.startswith('[LLM'): | |
| # S434: accetta repair immediatamente, re-verify fire-and-forget (telemetria) | |
| answer = _gv_ans | |
| try: | |
| _inc_stat("goal_verify_repaired") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| try: | |
| _inc_stat("repair_success_count") # S453: aggregato riparazioni riuscite | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| _gv_snap = _gv_ans | |
| _is_snap = _initial_score | |
| _gv_ref = _gv | |
| _goal_snap = state.goal | |
| _ostep_ref = on_step | |
| async def _reverify_task( | |
| _s=_gv_snap, _is=_is_snap, | |
| _gref=_gv_ref, _g=_goal_snap, _os=_ostep_ref | |
| ) -> None: | |
| try: | |
| _gvr2 = await asyncio.wait_for( | |
| _gref.verify_with_execution(_g, _s), timeout=20.0) # BUG-4: exec verify | |
| _rscore = _gvr2.coverage_score | |
| _delta = _rscore - _is | |
| if _delta < -0.05: | |
| try: | |
| from api.state import increment_stat as _inc_gi | |
| _inc_gi("goal_verify_no_improvement") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if _os: | |
| await _maybe_await(_os({ | |
| "action": "goal_verifier", | |
| "status": "done", | |
| "visibility": "progress", | |
| "title": "Controllo qualità ", | |
| "explanation": ( | |
| f"Qualità risposta: {int(_rscore * 100)}% â" | |
| if _delta >= 0 else | |
| f"Risposta migliorata: {int(_rscore * 100)}%" | |
| ), | |
| "initial_score": round(_is, 3), | |
| "repaired_score": round(_rscore, 3), | |
| })) | |
| except Exception: | |
| if _os: | |
| try: | |
| await _maybe_await(_os({ | |
| "action": "goal_verifier", "status": "done", | |
| "visibility": "progress", "title": "Controllo qualità ", | |
| "explanation": f"Miglioramento inviato ({int(_is * 100)}% completato)", | |
| "initial_score": round(_is, 3), | |
| "repaired_score": round(_is, 3), | |
| })) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # P16-B2: notifica UI che re-verify è in corso | |
| if on_step: | |
| try: | |
| await _maybe_await(on_step({ | |
| "action": "goal_verifier", | |
| "status": "running", | |
| "visibility": "progress", | |
| "title": "Verifica qualità in corso…", | |
| "explanation": ( | |
| f"Copertura corrente: {int(_initial_score*100)}% " | |
| "— verifica repair in corso" | |
| ), | |
| })) | |
| except Exception: | |
| pass | |
| # S455-P10: task supervisionato — done_callback logga eccezioni silenziate | |
| asyncio.create_task(_reverify_task()) | |
| _rv_t.add_done_callback( | |
| lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None | |
| ) | |
| pass # goal repair fallito â usa answer originale | |
| except Exception: | |
| pass # repair LLM silenzioso â answer originale invariato | |
| else: | |
| # Goal già soddisfatto al primo check â nessun repair necessario | |
| _inc_stat("goal_verify_initial_pass") | |
| # COG-2: record successful strategy for lesson injection | |
| if self.memory and hasattr(self.memory, 'reflection'): | |
| try: | |
| _last_act = state.steps[-1].get('action', 'direct') if state.steps else 'direct' | |
| self.memory.reflection.record_success( | |
| state.goal[:300], f"goal_verify_pass|{_last_act}" | |
| ) | |
| except Exception: | |
| pass # never blocks the response | |
| except Exception as _gv_exc: | |
| _logger.warning("S624 GoalVerifier failed (silent): %s", _gv_exc) # S624 | |
| # S303: Audit Semantico L2 — verifica coerenza logica interna dell'output. | |
| # Eseguito DOPO GoalVerifier L1, solo se L1 ≠ FAIL (risparmio token). | |
| # Rileva: hallucination claims (deploy/push/send non verificati), | |
| # contraddizioni interne (errore + completato), sotto-obiettivi mancanti. | |
| # Silent failure totale — non blocca mai la risposta al client. | |
| _l2_should_run = ( | |
| answer | |
| and not answer.startswith('[LLM') | |
| and len(answer.split()) > 15 | |
| ) | |
| try: | |
| _l1_was_fail = ( | |
| getattr(_gvr, 'verification_status', None) is not None # type: ignore[name-defined] | |
| and str(getattr(_gvr, 'verification_status', '')).endswith('FAIL') | |
| ) | |
| except NameError: | |
| _l1_was_fail = False # _gvr non definito — L1 non era attivo (goal non-code) | |
| if _l2_should_run and not _l1_was_fail: | |
| try: | |
| from agents.audit_semantic_l2 import get_auditor as _get_auditor_l2 | |
| _auditor_l2 = _get_auditor_l2( | |
| ai_client=self._get_verifier_llm(), # cross-model (P25-B4) | |
| timeout_s=12.0, | |
| ) | |
| _l2_result = await asyncio.wait_for( | |
| _auditor_l2.audit(state.goal, answer), | |
| timeout=13.0, | |
| ) | |
| # Telemetria | |
| try: | |
| from api.state import increment_stat as _inc_l2 | |
| _inc_l2(f"audit_l2_{_l2_result.status.value.lower()}") | |
| except Exception as _exc: | |
| _logger.debug("[S303] telemetry silenced: %s", type(_exc).__name__) | |
| _logger.info( | |
| "[S303] AuditL2 %s (conf=%.2f engine=%s) issues=%d", | |
| _l2_result.status.value, | |
| _l2_result.confidence, | |
| _l2_result.engine, | |
| len(_l2_result.issues), | |
| ) | |
| if _l2_result.status.value == "FAIL" and _l2_result.issues: | |
| # Notifica UI — step visibile nel pannello avanzamento | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "audit_l2", | |
| "status": "warning", | |
| "visibility": "progress", | |
| "title": "⚠️ Verifica coerenza risposta", | |
| "explanation": _l2_result.issues[0][:120], | |
| })) | |
| # Appende nota discreta — non modifica il codice, solo avvisa | |
| if answer: | |
| _l2_note_parts = ["\n\n> ⚠️ **Nota di coerenza**:"] | |
| for _iss in _l2_result.issues[:2]: | |
| _l2_note_parts.append(f" {_iss}") | |
| if _l2_result.repair_hint: | |
| _l2_note_parts.append(f" \n> 💡 {_l2_result.repair_hint}") | |
| answer += "".join(_l2_note_parts) | |
| except asyncio.TimeoutError: | |
| try: | |
| from api.state import increment_stat as _inc_l2t | |
| _inc_l2t("audit_l2_timeout") | |
| except Exception as _exc: | |
| _logger.debug("[S303] timeout counter silenced: %s", type(_exc).__name__) | |
| except Exception as _l2_exc: | |
| _logger.debug("[S303] AuditL2 silenced: %s", type(_l2_exc).__name__) # S624 | |
| # Sprint 3b ITEM 8: Browser Goal Verification â Playwright headless su app live | |
| # Attivato solo se l'answer contiene un URL di deploy (pages.dev / vercel.app / ecc.) | |
| # e il RequirementEngine ha trovato requisiti (già estratti sopra in _gv2_reqs). | |
| # Silent failure se Playwright non installato o URL non raggiungibile. | |
| _DEPLOY_PATTERNS = ('.pages.dev', '.vercel.app', '.netlify.app', '.railway.app', | |
| '.render.com', '.fly.dev', 'localhost:') | |
| _browser_url: str | None = None | |
| if answer and not answer.startswith('[LLM'): | |
| import re as _re_bv | |
| _url_candidates = _re_bv.findall(r'https?://[^\s\)\"\'<>]+', answer) | |
| for _uc in _url_candidates: | |
| if any(pat in _uc for pat in _DEPLOY_PATTERNS): | |
| _browser_url = _uc.rstrip('.,;)') | |
| break | |
| if _browser_url and os.getenv("PLAYWRIGHT_ENABLED", "1") != "0": # S701: abilitato di default (playwright in requirements.txt) | |
| try: | |
| from api.browser import verify_goal_browser as _vgb | |
| # Usa i requisiti già estratti dal blocco GoalVerifier v2 (se disponibili) | |
| _bv_reqs = None | |
| try: | |
| _bv_reqs = _gv2_reqs # type: ignore[name-defined] | |
| except NameError as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "action": "browser_verifier", | |
| "status": "running", | |
| "visibility": "progress", | |
| "title": "Test app in tempo reale", | |
| "explanation": f"Verifica live: {_browser_url[:60]}â¦", | |
| })) | |
| _t0_bv = asyncio.get_running_loop().time() | |
| _bv_result = await asyncio.wait_for( | |
| _vgb(state.goal, _browser_url, _bv_reqs, timeout_s=25.0), | |
| timeout=28.0, | |
| ) | |
| _bv_ms = (asyncio.get_running_loop().time() - _t0_bv) * 1000 | |
| # Registra browser_ms per il phase_breakdown (Sprint 5 ITEM 14) | |
| try: | |
| from api.state import record_timing as _rt_bv | |
| _rt_bv("browser_ms", _bv_ms) | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| # Telemetria: esito browser verifier | |
| try: | |
| from api.state import increment_stat as _inc_bv | |
| _inc_bv(f"browser_verify_{_bv_result.get('overall', 'UNKNOWN').lower()}") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if on_step: | |
| _bv_overall = _bv_result.get("overall", "UNKNOWN") | |
| _bv_per = _bv_result.get("per_criterion", {}) | |
| _bv_pass_n = sum(1 for v in _bv_per.values() if v == "PASS") | |
| _bv_total = len(_bv_per) | |
| _bv_summary = ( | |
| f"{_bv_pass_n}/{_bv_total} criteri OK" | |
| if _bv_total > 0 else "nessun criterio testato" | |
| ) | |
| await _maybe_await(on_step({ | |
| "action": "browser_verifier", | |
| "status": "done", | |
| "visibility": "progress", | |
| "title": "Test app in tempo reale", | |
| "explanation": f"Verifica live: {_bv_overall} â {_bv_summary}", | |
| "url": _browser_url, | |
| "overall": _bv_overall, | |
| "per_criterion": _bv_per, | |
| })) | |
| # Se FAIL con requisiti â aggiungi nota all'answer (non modifica il codice) | |
| if _bv_result.get("overall") == "FAIL" and _bv_per: | |
| _failed_criteria = [c for c, v in _bv_per.items() if v == "FAIL"] | |
| if _failed_criteria and answer: | |
| _bv_note = ( | |
| f"\n\n> â ï¸ **Test app live**: verifica su `{_browser_url}` " | |
| f"ha rilevato {len(_failed_criteria)} criterio/i non soddisfatto/i: " | |
| # S591: _failed_criteria[:3]â[:5] â mostra più criteri falliti | |
| + ", ".join(f"`{c}`" for c in _failed_criteria[:5]) + "." | |
| ) | |
| answer += _bv_note | |
| except asyncio.TimeoutError: | |
| try: | |
| from api.state import increment_stat as _inc_bv2 | |
| _inc_bv2("browser_verify_timeout") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| except Exception: | |
| pass # Browser verifier sempre silent | |
| # S393 Priority 2: Self-Healing â inline Python syntax repair loop (max 1 cycle, 20s budget) | |
| # Il fire-and-forget precedente non correggeva la risposta finale al client. | |
| # Ora: rileva SyntaxError â repair prompt â sostituisce answer inline prima del return. | |
| if answer and not answer.startswith('[LLM') and '```python' in answer.lower(): | |
| import re as _re_sh | |
| _py_blocks = _re_sh.findall(r'```python\s*(.*?)```', answer, _re_sh.DOTALL | _re_sh.IGNORECASE) | |
| for _blk in _py_blocks[:1]: # solo primo blocco â fast path, non blocca la risposta | |
| try: | |
| compile(_blk.strip(), '<string>', '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'</([a-zA-Z][a-zA-Z0-9]*)>', _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('<script') != _wblk.count('</script>'): | |
| _wissues.append('Tag <script> non chiuso') | |
| if _wblk.count('<style') != _wblk.count('</style>'): | |
| _wissues.append('Tag <style> non chiuso') | |
| elif _wlang == 'javascript': | |
| # Rimuovi commenti e stringhe per conteggio bilanciato | |
| _js_clean = _re_web.sub(r'//[^\n]*', '', _wblk) | |
| _js_clean = _re_web.sub(r'/\*.*?\*/', '', _js_clean, flags=_re_web.DOTALL) | |
| _js_clean = _re_web.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', '""', _js_clean) | |
| _js_clean = _re_web.sub(r"'[^'\\]*(?:\\.[^'\\]*)*'", "''", _js_clean) | |
| _br = _js_clean.count('{') - _js_clean.count('}') | |
| _pa = _js_clean.count('(') - _js_clean.count(')') | |
| if abs(_br) > 0: | |
| _wissues.append(f'Graffe sbilanciate ({_br:+d})') | |
| if abs(_pa) > 0: | |
| _wissues.append(f'Parentesi sbilanciate ({_pa:+d})') | |
| if not _wissues: | |
| continue # blocco strutturalmente OK â skip | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'status': 'running', | |
| 'title': f'Auto-fix {_wname}', | |
| 'explanation': "Problemi rilevati nel codice web â correzione in corso", | |
| })) | |
| _web_fix_msgs = [ | |
| *messages, | |
| {'role': 'assistant', 'content': answer}, | |
| {'role': 'user', 'content': ( | |
| f'Il codice {_wname} ha problemi strutturali: {"; ".join(_wissues)}.\n' | |
| f'Correggi SOLO i problemi strutturali (tag, graffe, parentesi). ' | |
| f'NON cambiare la logica. Rispondi con la versione corretta completa.' | |
| )}, | |
| ] | |
| try: | |
| _web_repaired = await asyncio.wait_for( | |
| _active_llm.chat(_web_fix_msgs, temperature=0.05, | |
| max_tokens=min(_tok_budget, 4096)), | |
| timeout=10.0, # S434: 20â10s | |
| ) | |
| if _web_repaired and not _web_repaired.startswith('[LLM'): | |
| answer = _web_repaired | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| 'action': 'execution_validator_fix', | |
| 'status': 'done', | |
| 'title': f'Auto-fix {_wname} completato â', | |
| 'explanation': f"Problemi corretti: {'; '.join(_wissues)}", | |
| })) | |
| except Exception: | |
| pass # repair web fallito â usa answer originale | |
| break # un solo blocco per tipo per evitare loop | |
| # R2 S390: critic rimosso â il Verifier (chain-of-verification) è sufficiente. | |
| # Il critic consumava 1 chiamata Groq per ogni risposta con codice/math/>800 chars. | |
| # Rimosso: +3000 req/day Groq liberate, -3-5s su risposte lunghe. | |
| # S195-Robust: success = risposta reale, non placeholder [LLM ...] | |
| # S403-FIX: append della risposta completamente riparata (post verifier/goal_verifier/ | |
| # self-healing). Così "\n\n".join(outputs) riflette il testo finale corretto. | |
| outputs.append(answer) | |
| # S371: sanitize â rimuove monologue interno prima di restituire al frontend | |
| final_output = self._sanitize_agent_output("\n\n".join(outputs).strip()) | |
| success = bool(final_output) and not final_output.startswith("[LLM") | |
| # Sprint 5 ITEM 13: goal_success/fail counters â mai incrementati prima | |
| try: | |
| from api.state import increment_stat as _inc_gs | |
| _inc_gs("goal_success_count" if success else "goal_fail_count") | |
| except Exception as _exc: | |
| _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if self.memory: | |
| await self.memory.save_episode("unified_loop", state.goal, final_output[:1000], | |
| success, tags=["fallback"]) | |
| if on_step: | |
| await _maybe_await(on_step({ | |
| "loop": 2, "action": "fallback", | |
| "status": "done", "success": success, | |
| "title": "Completato" if success else "Risposta parziale", | |
| "explanation": "Risposta elaborata e verificata con successo" if success | |
| else "Risposta completata con limitazioni", | |
| })) | |
| # S285: mostra provider reale usato al posto del generico "fallback" | |
| _engine = getattr(self.llm, 'provider_name', None) or 'llm' | |
| # Sprint 5 ITEM 14: phase_breakdown â medie ultime 10 chiamate per fase | |
| try: | |
| from api.state import _TIMING_STORE as _TS | |
| _phase_bd = {k: _avg10(_TS.get(k, [])) for k in | |
| ["classify_ms", "plan_ms", "coder_ms", "verifier_ms", "browser_ms"]} | |
| except Exception: | |
| _phase_bd = {} | |
| # GAP-3: rollback atomico se task fallisce con write_file parziali | |
| # Ripristina i file al contenuto pre-modifica per evitare stato corrotto | |
| if not success and getattr(self, "_write_snapshots", None): | |
| try: | |
| await self._rollback_writes(on_step) | |
| except Exception: | |
| pass # non-fatal â best effort rollback | |
| return {"success": success, "engine": _engine, "goal": state.goal, | |
| "steps": state.steps, "errors": state.errors, "output": final_output, | |
| "phase_breakdown": _phase_bd} | |
| # ââ Entry point (S193) ââââââââââââââââââââââââââââââââââââââââââââââââââââ | |