""" unified_loop.py — Unified Agent Loop v4 v5 (S197) — Long-prompt file extraction + never-give-up + adaptive timeout: - _compress_goal(): estrae blocchi codice >1800 chars come file virtuali [FILE:N] → riduce token sul provider, elimina timeout su prompt lunghi. - _build_messages(): inietta CODICE_FORNITO come sezione separata nel contesto. - _run_fallback(): timeout adattivo 1.8x quando ci sono file estratti. - never-give-up: rileva frasi di rifiuto ("non posso", "i cannot", ...) e riprova con forza-risposta al 3° tentativo. - Regola system prompt: MAI dire non posso — problem solver assoluto. v4 (S193) — Fix definitivo tool execution: - _run_direct_tools(): layer deterministico che chiama TOOL_REGISTRY direttamente senza passare per smolagents o LLM per il routing. Copre: get_weather, read_page, calculate, web_search. - Architettura: direct_tools FIRST -> se dati reali -> LLM con dati iniettati. smolagents solo per multi-step complessi senza match diretto. - _needs_tools() regex espansa: copre tutti i pattern reali delle domande utente. - System prompt aggiornato: regole di onesta su self-knowledge e training data. - SMOL_TIMEOUT: leggibile da env UNIFIED_LOOP_TIMEOUT (default 25s, era 12s). """ from __future__ import annotations import asyncio import logging import os import re from agents.watchdog import BidirectionalWatchdog from .grid_rag import get_grid_rag from memory.distiller import MemoryDistiller from contextvars import ContextVar from typing import Any _logger = logging.getLogger("agente_ai") # S624: logger per warning sui fallback silenziosi # Tool execution layer (estratto in split module per ridurre dimensione) from agents.unified_loop_tools import DirectToolsMixin from agents.unified_loop_prompts import PromptBuilderMixin from agents.unified_loop_llm import LLMSelectionMixin from agents.unified_loop_helpers import HelpersMixin # RF-2: context_manager lazy import — skeleton injection per sessioni multi-file (S364/S752-A) # Import lazy per evitare circular deps — chiamato solo al runtime quando necessario def _get_context_manager(): from agents.context_manager import get_context_for_goal as _gcfg return _gcfg LLM_TIMEOUT: float = float(os.getenv("LLM_CALL_TIMEOUT", "60")) # QF-2: default 35→60s TOOL_TIMEOUT: float = float(os.getenv("TOOL_CALL_TIMEOUT", "25")) # QF-2: default 12→25s # Types/helpers/state estratti in unified_loop_types.py (P20-TD1 Fase 1) from agents.unified_loop_types import ( StepCallback, _detect_user_lang, _LANG_INSTRUCTIONS, _TASK_VERBS_RE, _ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding _is_goal_ambiguous, _is_borderline_ambiguous, UnifiedLoopState, _maybe_await, ) # S404: Error Classifier — import lazy per evitare circular import issues def _get_classifier(): from agents.error_classifier import classify_error, format_for_context return classify_error, format_for_context # P17-F2: Upstash REST reader — chiamata dal loop all'avvio per iniettare # le scoperte critiche dei delegate frontend nel context dell'agente backend. # Pattern identico a blackboard.py; duplicato qui per zero import circolare. async def _read_bb_upstash(session_id: str) -> str: """Legge le entry critiche dal blackboard Upstash. Ritorna '' se non disponibile.""" _url = os.getenv("UPSTASH_REDIS_REST_URL", "") _token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") if not _url or not _token or not session_id: return "" import json as _bb_json import httpx as _bb_httpx try: async with _bb_httpx.AsyncClient(timeout=1.5) as _c: _hdr = {"Authorization": f"Bearer {_token}", "Content-Type": "application/json"} _sr = await _c.post( _url, json=["SCAN", "0", "MATCH", f"bb:{session_id}:*", "COUNT", "50"], headers=_hdr, ) _sd = _sr.json() if _sr.is_success else {} _sc = _sd.get("result", []) _keys = _sc[1] if (isinstance(_sc, list) and len(_sc) >= 2 and isinstance(_sc[1], list)) else [] if not _keys: return "" _mr = await _c.post(_url, json=["MGET"] + _keys, headers=_hdr) _md = _mr.json() if _mr.is_success else {} _out = [] for _v in _md.get("result", []): if _v: try: _e = _bb_json.loads(_v) if _e.get("severity") == "critical": _agid = _e.get("agentId", "") _key = _e.get("key", "") _val = str(_e.get("value", ""))[:200] _out.append(f"- [{_agid}] {_key}: {_val}") except Exception as _d_err: _logger.debug("[unified_loop] delegate parse silenced: %s", type(_d_err).__name__) return ("SCOPERTE CRITICHE DAI DELEGATI:\n" + "\n".join(_out)) if _out else "" except Exception as _d_outer_err: _logger.debug("[unified_loop] _get_delegate_findings silenced: %s", type(_d_outer_err).__name__) return "" # ── Nuovi mixin estratti (split 2026-06-30) ────────────────────────────────── from agents.unified_loop_vfs import VFSMixin from agents.unified_loop_delegate import DelegateMixin from agents.unified_loop_routing import RoutingMixin from agents.unified_loop_fallback import FallbackMixin class UnifiedAgentLoop( VFSMixin, DelegateMixin, RoutingMixin, FallbackMixin, DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin, ): """Smolagents-first loop with deterministic direct-tool layer and safe LLM fallback. MRO (sinistra = priorità alta): VFSMixin → _rollback_writes, _vfs_git_backup, _get_vfs_lock DelegateMixin → _reflective_debug, _budget_replan_check, _run_in_loop_delegate RoutingMixin → _CODE_RE, _FILE_BLOCK_RE, _extract_written_files FallbackMixin → _run_fallback DirectToolsMixin → _run_direct_tools, _needs_tools, _is_simple_query PromptBuilderMixin → _build_messages, _compress_goal LLMSelectionMixin → _get_llm_for_goal, _get_fast_llm, _sanitize_agent_output HelpersMixin → _run_fast_path, _proactive_reflect, _budget_replan_check (override) """ def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, critic: Any = None, memory: Any = None, verifier: Any = None) -> None: self.llm = llm_client self.planner = planner self.executor = executor self.critic = critic self.memory = memory self.verifier = verifier self._coder_llm: Any | None = None # S362: lazy-loaded CODER role client self._fast_llm: Any | None = None # S-FAST: lazy-loaded FAST role client (Groq 8B) self._verifier_llm: Any | None = None # P25-B4: cross-model critic — provider diverso dal generatore self._session_files: dict[str, str] = {} # S416-Fix1: path→content dei file scritti nella sessione self._write_snapshots: dict[str, str | None] = {} # GAP-3: contenuto originale pre-write per rollback atomico self._vfs_write_locks: dict[str, asyncio.Lock] = {} # GAP-VFS: per-path lock — previene race condition su scritture parallele self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer # ── GAP-3: Rollback atomico scritture ───────────────────────────────────────── # ── Entry point (S193) ──────────────────────────────────────────────────── async def run(self, goal: str, context: str = "", max_steps: int = 8, on_step: StepCallback | None = None, session_id: str = "") -> dict[str, Any]: # S390-B-L: strip role prefixes che causano prompt injection # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso — input come # "System: User: fai X" diventava "User: fai X" con prefisso residuo. # Fix: loop fino a convergenza per gestire prefix annidati. _strip_role_re = re.compile( r"^\s*(?:system|assistant|ai|human|user|instruction|prompt)\s*[::]\s*", re.IGNORECASE, ) while True: _stripped = _strip_role_re.sub("", goal.strip()) if _stripped == goal: break goal = _stripped # P28-B1: lingua rilevata early — propagata via self._run_lang a _build_messages() self._run_lang = _detect_user_lang(goal) import time as _time _t_run = _time.monotonic() self._t_run_start = _t_run # ttfa_ms: baseline per record_timing in _run_fallback # S568-A: task_id unico per run — previene race condition su sandbox condivisa # quando task paralleli usano lo stesso 'exec_val' hardcoded. self._run_task_id = f"qg_{int(_t_run * 1000) % 999983}" # GAP-3: LoggerAdapter bindato a task_id — Railway: grep qg_XXXXX filtra un singolo task self._log = logging.LoggerAdapter(_logger, {"task_id": self._run_task_id}) # S749-D: imposta ContextVar session_id per isolare sandbox backend-exec per task. # Token permette il reset nel finally anche in presenza di eccezioni — asyncio-safe. try: from tools.registry import _agent_session_id_var as _sid_var _sid_token = _sid_var.set(self._run_task_id) except Exception as _imp_err: _logger.debug("[unified_loop] sid_var import silenced: %s", type(_imp_err).__name__) _sid_token = None # fallback silente — registry usa default "agent_default" # S750-GAP-B: pre-warm sandbox backend-exec — POST /api/session in background. # asyncio.create_task lancia la richiesta senza bloccare il routing: # mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta. try: from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl if _eurl: asyncio.ensure_future( _ce({"session_id": self._run_task_id}, endpoint="/api/session") ) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # S568-B: reset _session_files ogni run — previene memory leak su sessioni lunghe. # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate. self._session_files = {} self._write_snapshots = {} # GAP-3: reset snapshot per ogni run # S568-C: max_steps adattivo — code goals complessi necessitano più step di 8. # Bump a 12 solo se il caller non ha sovrascritto il default (max_steps == 8) # e il goal contiene keyword codice rilevate da _CODE_RE. if max_steps == 8 and self._CODE_RE.search(goal): max_steps = 12 state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id) # S766-GRID: Grid-Enhanced RAG Context grid_rag = get_grid_rag() _grid_context = await grid_rag.prepare_agent_context(state.goal) if _grid_context: state.context += f"\n\n{_grid_context}" # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo) try: from agents.strategic_healer import StrategicHealer as _SHClass self._strategic_healer = _SHClass( getattr(self, 'llm', None) or getattr(self, '_llm', None), state.goal, memory=getattr(self, 'memory', None) or getattr(self, '_memory', None) ) await self._strategic_healer.load_past_failures() _logger.info("GAP-4: StrategicHealer inizializzato per goal '%s'", state.goal[:60]) except Exception as _sh_init_err: self._strategic_healer = None _logger.debug("GAP-4: StrategicHealer init silenced — %s", _sh_init_err) # P17-F2: inject blackboard critical entries at loop start. # I delegate frontend scrivono su Upstash; il loop legge e inietta nel context. if session_id: try: _bb_ctx = await _read_bb_upstash(session_id) if _bb_ctx: state.context = (state.context + "\n\n" + _bb_ctx).strip() if state.context else _bb_ctx _logger.info("[P17-F2] BB ctx injected (%d chars)", len(_bb_ctx)) except Exception as _bb_exc: _logger.debug("[P17-F2] BB read silenced: %s", _bb_exc) # GAP-DECISION-FIX: consulta blacklist prima di eseguire fix già rifiutati try: from api.decision_memory import is_blacklisted as _is_bl _bl_hit, _bl_reason = _is_bl(goal) if _bl_hit: self._log.warning("decision_memory: goal in blacklist — %s", _bl_reason[:100]) if on_step: await _maybe_await(on_step({ "action": "blacklist_warn", "status": "warning", "title": "⚠️ Fix già rifiutato in precedenza", "explanation": _bl_reason[:200], })) # Fail-open: logghiamo e proseguiamo — non blocchiamo task legittimi except Exception as _dm_err: _logger.debug("[unified_loop] decision_memory check silenced: %s", type(_dm_err).__name__) # non disponibile — continua normalmente # P29-B1: gate ambiguità strutturale — _is_goal_ambiguous() era P28-B2 dead code (mai chiamata). # Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing. if _is_goal_ambiguous(goal): _amb_map = { 'en': ( "Your message is too short or doesn't contain a clear action.\n\n" "Try being more specific, for example:\n" "\u2022 'Analyze this code: ...'\n" "\u2022 'Create a function that does X'\n" "\u2022 'Search for information about Y'" ), 'es': ( "Tu mensaje es demasiado corto o no contiene una acci\u00f3n clara.\n\n" "Intenta ser m\u00e1s espec\u00edfico, por ejemplo:\n" "\u2022 'Analiza este c\u00f3digo: ...'\n" "\u2022 'Crea una funci\u00f3n que haga X'" ), 'fr': ( "Votre message est trop court ou ne contient pas d'action claire.\n\n" "Essayez d'\u00eatre plus pr\u00e9cis, par exemple:\n" "\u2022 'Analysez ce code: ...'\n" "\u2022 'Cr\u00e9ez une fonction qui fait X'" ), } _amb_answer = _amb_map.get( getattr(self, '_run_lang', 'auto'), "Il tuo messaggio \u00e8 troppo breve o non contiene un'azione chiara.\n\n" "Prova a essere pi\u00f9 specifico, ad esempio:\n" "\u2022 'Analizza questo codice: ...'\n" "\u2022 'Crea una funzione che fa X'\n" "\u2022 'Cerca informazioni su Y'", ) if on_step: await _maybe_await(on_step({ "action": "ambiguity_gate", "status": "done", "title": "Specifica cosa vuoi fare", "explanation": _amb_answer, })) _r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps} if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) return _r_amb # P29-R1: borderline ambiguity gate — goal con verbo ma oggetto pronominale vago. # Cattura "fix it", "help me with this", "make it better" — goal che passano # _is_goal_ambiguous() perché hanno un verbo, ma mancano di oggetto specifico. # Zero LLM, <0.1ms. Produce domanda mirata in IT/EN/ES/FR (vs. generica P29-B1). _borderline, _bl_pattern = _is_borderline_ambiguous(goal) if _borderline: _lang = getattr(self, '_run_lang', 'auto') _BL_MSGS: dict[str, dict[str, str]] = { 'fix_pronoun': { 'it': ( "Cosa devo fixare? 🔍\n\n" "Per aiutarti ho bisogno di:\n" "\u2022 Il codice o il file da correggere\n" "\u2022 Il messaggio di errore (se presente)\n" "\u2022 Cosa ti aspetti che faccia" ), 'en': ( "What needs fixing? 🔍\n\n" "To help you I need:\n" "\u2022 The code or file to fix\n" "\u2022 The error message (if any)\n" "\u2022 What you expect it to do" ), 'es': ( "\u00bfQué necesita arreglarse? 🔍\n\n" "Para ayudarte necesito:\n" "\u2022 El código o archivo a corregir\n" "\u2022 El mensaje de error (si lo hay)\n" "\u2022 Qué esperas que haga" ), 'fr': ( "Qu'est-ce qui doit être réparé ? 🔍\n\n" "Pour vous aider j'ai besoin de :\n" "\u2022 Le code ou le fichier à corriger\n" "\u2022 Le message d'erreur (s'il y en a un)\n" "\u2022 Ce que vous attendez" ), }, 'help_vague': { 'it': ( "Su cosa posso aiutarti? 💡\n\n" "Descrivimi il task specifico:\n" "\u2022 Cosa stai cercando di fare\n" "\u2022 Qual è il problema attuale\n" "\u2022 Incolla codice/errori rilevanti se ce ne sono" ), 'en': ( "What can I help you with? 💡\n\n" "Describe the specific task:\n" "\u2022 What you're trying to accomplish\n" "\u2022 What the current problem is\n" "\u2022 Paste any relevant code/errors" ), 'es': ( "\u00bfCon qué puedo ayudarte? 💡\n\n" "Describe el task específico:\n" "\u2022 Qué estás intentando hacer\n" "\u2022 Cuál es el problema actual\n" "\u2022 Pega código/errores relevantes si los hay" ), 'fr': ( "Avec quoi puis-je vous aider ? 💡\n\n" "Décrivez la tâche spécifique :\n" "\u2022 Ce que vous essayez d'accomplir\n" "\u2022 Quel est le problème actuel\n" "\u2022 Collez le code/erreurs pertinents s'il y en a" ), }, 'make_vague': { 'it': ( "Cosa vuoi migliorare o far funzionare? \u2699\ufe0f\n\n" "Dimmi:\n" "\u2022 Cosa non funziona o cosa va migliorato\n" "\u2022 Incolla il codice o descrivi il comportamento attuale\n" "\u2022 Qual è il risultato che ti aspetti" ), 'en': ( "What do you want to improve or fix? \u2699\ufe0f\n\n" "Tell me:\n" "\u2022 What's not working or what needs improvement\n" "\u2022 Paste the code or describe the current behavior\n" "\u2022 What result you expect" ), 'es': ( "\u00bfQué quieres mejorar o arreglar? \u2699\ufe0f\n\n" "Dime:\n" "\u2022 Qué no funciona o qué necesita mejora\n" "\u2022 Pega el código o describe el comportamiento actual\n" "\u2022 Qué resultado esperas" ), 'fr': ( "Que voulez-vous améliorer ou réparer ? \u2699\ufe0f\n\n" "Dites-moi :\n" "\u2022 Ce qui ne fonctionne pas ou ce qui doit être amélioré\n" "\u2022 Collez le code ou décrivez le comportement actuel\n" "\u2022 Quel résultat vous attendez" ), }, } _lang_key = _lang if _lang in ('it', 'en', 'es', 'fr') else 'it' _bl_answer = _BL_MSGS.get(_bl_pattern, {}).get( _lang_key, "Puoi essere più specifico? Incolla il codice, l'errore, o descrivi cosa intendi." ) if on_step: await _maybe_await(on_step({ "action": "ambiguity_gate", "status": "done", "title": "Puoi essere più specifico?", "explanation": _bl_answer, })) _r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps} if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) return _r_bl # Sprint 5 ITEM 13: classify_ms — tempo routing/classificazione goal (sync, <1ms) _t0_classify = _time.monotonic() # S402: Fast Path — greeting/ack/identità semplice → bypass tutto l'overhead if self._is_simple_query(goal): try: from api.state import record_timing as _rtc_cls _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _r = await self._run_fast_path(state, on_step) _r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000)) _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # S749-D: reset ContextVar if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) # GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione if self._session_files: asyncio.ensure_future(self._vfs_git_backup()) return _r if not self._needs_tools(goal): try: from api.state import record_timing as _rtc_cls _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # Puro ragionamento — LLM diretto, nessun overhead tool _r = await self._run_fallback(state, on_step) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr _rtc_ttr("ttr_ms", float(_r["timing_ms"])) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # S749-D: reset ContextVar if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) # GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione if self._session_files: asyncio.ensure_future(self._vfs_git_backup()) return _r # S425-BugFix: _SKIP_SMOL_RE chiama direct_tools PRIMA (il commento S371 lo diceva # già — "direct tools + fallback" — ma il codice faceva solo _run_fallback senza tool). # Bug: query meteo/news/cerca non chiamavano mai i tool reali → LLM allucinava i dati # → ResponseVerifier girava su risposta inventata → retry → 20-60s inutili. # B5: query spiegazione pura → _run_fallback diretta (-20-30s risparmio) # Scenari: "cos'è X", "spiegami Y", "how does Z work?", "explain W" # Fail-open: se regex troppo larga → path normale (nessuna perdita) if self._is_pure_explanation(goal): try: from api.state import record_timing as _rtcB5 _rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _rt_err: _logger.debug("[unified_loop] record_timing silenced: %s", type(_rt_err).__name__) _r = await self._run_fallback(state, on_step) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) _r["effective_max_steps"] = state.max_steps if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) if self._session_files: asyncio.ensure_future(self._vfs_git_backup()) return _r # P36: Hybrid Execution Router — Python code analysis fast path. # Se goal contiene keyword analisi + codice Python nel context/goal, # chiama python_analyze direttamente (<5ms) saltando planner+LLM (5-15s). # Latenza: 50-200ms vs 5-15s del percorso normale (-90%). Fail-open. _P36_ANALYZE_RE = re.compile( r'\b(anali[zs]za?|check\s+syntax|syntax\s+check|' r'complessit[\xe0a]\s+cod|nesting\s+max|struttura\s+cod|' r'errori?\s+sintassi|verifica\s+sintass|metriche\s+cod|' r'ast\s+pars|imports?\s+check|funzioni\s+definite)\b', re.IGNORECASE, ) if _P36_ANALYZE_RE.search(goal): _p36_src = (context or "") + "\n" + goal _p36_match = re.search(r'```(?:python|py)?\n([\s\S]*?)```', _p36_src) _p36_code = _p36_match.group(1).strip() if _p36_match else "" if not _p36_code and context: # context puro (no fence) — accetta se sembra Python if re.search(r'\bdef \w+|\bclass \w+|\bimport \w+|\bfor \w+\s+in\b', context): _p36_code = context.strip() if _p36_code and len(_p36_code) >= 20: try: from tools.registry import TOOL_REGISTRY as _P36_TR _p36_r = await asyncio.wait_for( _P36_TR["python_analyze"]["_fn"](code=_p36_code), timeout=5.0, ) _p36_out = [f"[ANALISI PYTHON — {_p36_r.get('summary', '?')}]"] for _p36e in _p36_r.get("errors", [])[:3]: _p36_out.append( "\u274c " + _p36e.get("type", "") + f" riga {_p36e.get('line','?')}: {_p36e.get('message','')}" + (f" \u2192 {_p36e['text']}" if _p36e.get("text") else "") ) _p36_c = _p36_r.get("complexity", {}) if _p36_c and _p36_r.get("syntax_ok"): _p36_out.append( "\u2705 Sintassi OK \u2014 " f"{_p36_c.get('total_lines',0)} righe, " f"{_p36_c.get('functions',0)} funzioni, " f"{_p36_c.get('classes',0)} classi, " f"imports {_p36_c.get('imports',0)}, " f"nesting max {_p36_c.get('max_nesting',0)}" ) for _p36s in _p36_r.get("suggestions", [])[:5]: _p36_out.append(f"\U0001f4a1 {_p36s}") _p36_answer = "\n".join(_p36_out) _p36_ms = int((_time.monotonic() - _t_run) * 1000) try: from api.state import increment_stat as _p36_stat _p36_stat("p36_fast_path_hit") except Exception as _is_err: _logger.debug("[unified_loop] increment_stat P36 silenced: %s", type(_is_err).__name__) _logger.info("P36 fast-path: python_analyze in %dms", _p36_ms) if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) return { "success": True, "answer": _p36_answer, "timing_ms": _p36_ms, "effective_max_steps": state.max_steps, "steps": [{"action": "p36_python_analyze", "status": "done", "output": _p36_answer[:300]}], } except Exception as _p36_exc: _logger.debug("P36 fast-path silenced: %s", _p36_exc) # fail-open: cade nel percorso normale if self._SKIP_SMOL_RE.search(goal): try: from api.state import record_timing as _rtc_cls _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _t_tool = _time.monotonic() direct_results, _tools_count, _exec_success, _exec_errors = \ await self._run_direct_tools(goal, on_step=on_step) _tool_ms = int((_time.monotonic() - _t_tool) * 1000) if direct_results and on_step: await _maybe_await(on_step({ "loop": 0, "action": "direct_tools", "status": "done", "tools_fired": _tools_count, })) _r = await self._run_fallback( state, on_step, preloaded_tool_results=direct_results or None, preloaded_tool_exec_successes=_exec_success, preloaded_tool_exec_errors=_exec_errors, ) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr _rtc_ttr("ttr_ms", float(_r["timing_ms"])) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _r["tool_ms"] = _tool_ms _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # S749-D: reset ContextVar if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) # GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione if self._session_files: asyncio.ensure_future(self._vfs_git_backup()) return _r # Sprint 5 ITEM 13: classify_ms — path normale (tool diretti) try: from api.state import record_timing as _rtc_cls _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # S193: tool diretti PRIMA (deterministici, nessun LLM per routing) # S402: unpack 4-tuple — aggiunto _exec_success/_exec_errors per Tool Integrity Guard _t_tool = _time.monotonic() direct_results, _tools_count, _exec_success, _exec_errors = \ await self._run_direct_tools(goal, on_step=on_step) _tool_ms = int((_time.monotonic() - _t_tool) * 1000) if direct_results: # Dati reali disponibili — LLM risponde con dati iniettati if on_step: await _maybe_await(on_step({ "loop": 0, "action": "direct_tools", "status": "done", "tools_fired": _tools_count, })) _r = await self._run_fallback( state, on_step, preloaded_tool_results=direct_results, preloaded_tool_exec_successes=_exec_success, preloaded_tool_exec_errors=_exec_errors, ) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr _rtc_ttr("ttr_ms", float(_r["timing_ms"])) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _r["tool_ms"] = _tool_ms _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # S749-D: reset ContextVar if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__) # GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione if self._session_files: asyncio.ensure_future(self._vfs_git_backup()) return _r # R1 S390: smolagents rimosso dal run() path. # _run_smolagents() aveva timeout 25s worst-case su task non classificati # e non aggiungeva valore rispetto a _run_fallback con tool results iniettati. # Rimosso: -25s worst case, path sempre: direct_tools → _run_fallback. # Fallback: LLM senza tool results (tool non triggered o tutti skip) _r = await self._run_fallback(state, on_step) _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000) try: from api.state import record_timing as _rtc_ttr _rtc_ttr("ttr_ms", float(_r["timing_ms"])) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 _r["effective_max_steps"] = state.max_steps # GAP-2-FIX # GAP-DECISION-FIX: registra fix falliti per blacklist futura if not _r.get("success", True): try: from api.decision_memory import record_decision as _rec_dec _fail_reason = _r.get("error", "") or str(_r.get("answer", ""))[:200] asyncio.ensure_future(_rec_dec( fix=goal, outcome="rejected", reason=f"run() returned success=False — {_fail_reason[:250]}", )) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 # S749-D: reset ContextVar prima di uscire — libera la sandbox per il GC if _sid_token is not None: try: _sid_var.reset(_sid_token) except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)