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