Terminal / agents /unified_loop.py
Baida—-
sync: 149 file da Baida98/AI@37832425 (2026-07-01 10:26 UTC)
cd85ab7 verified
Raw
History Blame Contribute Delete
37.2 kB
"""
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__)