Spaces:
Configuration error
Configuration error
| """ | |
| 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__) | |