Terminal / agents /unified_loop_tools.py
Baida—-
deploy: full backend sync for Node A [Kernel Restore]
8b87770 verified
Raw
History Blame Contribute Delete
54.7 kB
"""unified_loop_tools.py — DirectToolsMixin: tool execution layer.
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
Contiene (nell'ordine originale del file):
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
Invariante B1: nessun corpo duplicato con unified_loop.py.
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
"""
from __future__ import annotations
import asyncio
import os
import re
from typing import Any
import logging
_logger = logging.getLogger("agents.unified_loop_tools")
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
from agents.unified_loop_types import StepCallback
class DirectToolsMixin:
# ── Direct tool execution (S193) ─────────────────────────────────────────
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
# pronta per essere iniettata nel prompt LLM.
_WEATHER_INTENT_RE = re.compile(
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|"
r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|"
r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|"
r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|"
r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|"
r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|"
r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b",
re.IGNORECASE,
)
# S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?"
# S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)'
_CITY_RE = re.compile(
r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|"
r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|"
r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|"
r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|"
r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|"
r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))"
r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)"
# S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now)
r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente"
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
re.IGNORECASE,
)
_CITY_BARE_RE = re.compile(
r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
re.IGNORECASE,
)
_URL_RE = re.compile(r"https?://[^\s\)\"']+")
# NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper
# to avoid false-negative from word-boundary check after non-word char.
_SEARCH_INTENT_RE = re.compile(
r"(?:"
r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera
r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
r"search\s+for\s+|find\s+online\s+)\b"
r"|\bcerca\s*:|\bsearch\s*:"
r")",
re.IGNORECASE,
)
_SEARCH_QUERY_RE = re.compile(
r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
r"web\s+search\s*:?\s*)"
r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
re.IGNORECASE,
)
_CURL_FALLBACK_RE = re.compile(
r"\b(curl|http|request|fetch|api|endpoint|get|post)\b",
re.IGNORECASE,
)
_IMAGE_GEN_INTENT_RE = re.compile(
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
# perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
re.IGNORECASE
)
# S427: aggiunti trigger di calcolo IT/EN comuni
_CALC_INTENT_RE = re.compile(
r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
re.IGNORECASE,
)
_WEB_RESEARCH_INTENT_RE = re.compile(
r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito))",
re.IGNORECASE,
)
_WEB_RESEARCH_TOPIC_RE = re.compile(
r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
re.IGNORECASE,
)
# S764: intent regex per i 3 nuovi fast-path tool (directory_tree / file_search / git_status)
_DIRECTORY_TREE_INTENT_RE = re.compile(
r"\b(directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
r"struttura\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
r"elenca\s+(?:file|cartell[ae]|directory)|lista\s+(?:file|cartell[ae])|"
r"show\s+(?:directory|folder)\s+tree|tree\s+(?:command|cmd|del\s+progetto)|"
r"ls\s+-[lRra]|find\s+\.\s+-type)\b",
re.IGNORECASE,
)
_FILE_SEARCH_INTENT_RE = re.compile(
r"\b(cerca\s+nel\s+(?:codice|progetto|file)|"
r"trova\s+(?:nel\s+codice|nel\s+progetto|nei\s+file)|"
r"grep\s+|file[\s_]search|cerca\s+la\s+stringa|"
r"search\s+in\s+(?:code|files|project)|find\s+in\s+files|"
r"dove\s+[eè]\s+(?:definit[ao]|usato|chiamato)|"
r"occorrenze\s+di|tutte\s+le\s+occorrenze)\b",
re.IGNORECASE,
)
_GIT_INTENT_RE = re.compile(
r"\b(git\s+status|git\s+diff|stato\s+git|stato\s+del\s+repository|"
r"file\s+modificat[i]|modifiche\s+in\s+sospeso|"
r"branch\s+corrente|current\s+branch|ultimi\s+commit|recent\s+commits|"
r"git\s+log|repository\s+status)\b",
re.IGNORECASE,
)
# S766: news intent — attiva _t_get_news fast-path
_NEWS_INTENT_RE = re.compile(
r"\b(notizie|ultime\s+notizie|news|headlines|notiziario|"
r"ultime\s+ore|breaking\s+news|novit\u00e0|"
r"aggiornamenti\s+su|cosa\s+succede|what.s\s+happening)\b",
re.IGNORECASE,
)
_CALC_EXPR_RE = re.compile(
r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
# S390-B-M: aggiunto % (modulo) e // (floor division) al char class
r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
re.IGNORECASE,
)
def _extract_city(self, goal: str) -> str:
m = self._CITY_RE.search(goal)
if m:
return m.group(1).strip()
m2 = self._CITY_BARE_RE.search(goal)
if m2:
city = m2.group(1).strip()
_stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
"meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
if city.lower() not in _stop:
return city
return ""
def _extract_curl_command(self, goal: str) -> str:
# Estrae un comando curl o un URL per il fallback
m = re.search(r"(curl\s+[^\"\'?]+)", goal, re.IGNORECASE)
if m: return m.group(1).strip()
m = re.search(r"(https?://[\w\d\-\./?=&%]+)", goal)
if m: return f"curl -s {m.group(1)}"
return ""
def _extract_search_query(self, goal: str) -> str:
m = self._SEARCH_QUERY_RE.search(goal)
if m:
q = m.group(1).strip().rstrip(".,?!")
if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT'
return q
if self._SEARCH_INTENT_RE.search(goal):
clean = re.sub(
r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
r"web\s+search\s*:?\s*)",
"", goal.strip(), flags=re.IGNORECASE
).strip().rstrip(".,?!")
if len(clean) > 1: # B1: soglia abbassata da >3 a >1
return clean
# B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI')
if len(goal.strip()) > 1:
return goal.strip()[:200] # S579: 120→200 (fallback query usa il goal intero)
return ""
def _extract_calc_expr(self, goal: str) -> str:
m = self._CALC_EXPR_RE.search(goal)
if m:
expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
return expr
return ""
def _extract_dir_path(self, goal: str) -> str:
# Estrae il path della directory dal goal, default '.'
m = re.search(
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
goal, re.IGNORECASE,
)
if m:
candidate = m.group(1).strip().rstrip("/")
if candidate not in {"di", "in", "nel", "nella"}:
return candidate
return "."
def _extract_file_pattern(self, goal: str) -> str:
# Estrae il pattern di ricerca file dal goal
m = re.search(
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
goal, re.IGNORECASE,
)
if m:
return m.group(1).strip()
return ""
def _extract_git_cwd(self, goal: str) -> str:
# Estrae il cwd per git dal goal, default '.'
m = re.search(
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
goal, re.IGNORECASE,
)
if m:
candidate = m.group(1).strip()
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
return candidate
return "."
async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
"""
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
n_called: numero totale di tool chiamati
n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
"""
try:
from tools.registry import TOOL_REGISTRY
except ImportError:
# S649: fix tipo ritorno — run() aspetta 4-tuple, non 2-tuple
return "", 0, 0, 0
results: list[str] = []
# S376/S393: Tool Governor — previene duplicate E supero budget globale per run
_gov_called: set[str] = set()
_gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
# S650: budget adattivo — task complessi necessitano più tool calls
# _max_tokens_for_goal >= 6144 indica app multi-feature → 9 tool calls
# _max_tokens_for_goal >= 4096 indica task singolo complesso → 7 tool calls
# Default: 6 (query semplice, meteo, news, calcolo)
_tok_budget_gov = self._max_tokens_for_goal(goal)
_GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
def _gov_check(tool_name: str, key_arg: str) -> bool:
"""S393 Tool Governor: previene duplicate e supero budget.
Returns True solo se il tool NON è stato già chiamato con questi arg
E il budget totale del run non è esaurito."""
if _gov_total[0] >= _GOV_MAX_CALLS:
return False # budget esaurito — blocca TUTTE le chiamate successive
sig = f"{tool_name}:{key_arg[:150]}" # S608: 80→150
if sig in _gov_called:
return False # chiamata duplicata — skip silenzioso
_gov_called.add(sig)
_gov_total[0] += 1
return True
# Doc2-1a-FIX: helper cache speculativa (S361) — 0ms latency su cache hit.
# get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
# ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
def _spec_hit(tool_name: str, args: dict) -> "str | None":
try:
from api.speculative import get_speculative_result as _gsr
return _gsr(goal, tool_name, args)
except Exception:
return None
# S419: esegui i tool eligible in parallelo con asyncio.gather
# Pre-check intent (sincrono) → costruisce lista coroutine → gather
# Il governor usa stato locale; asyncio è single-threaded → nessuna race condition
url_m = self._URL_RE.search(goal)
async def _t_get_weather() -> str | None:
if not self._WEATHER_INTENT_RE.search(goal):
return None
city = self._extract_city(goal) or "Milano"
if not _gov_check("get_weather", city):
return None
try:
_sc = _spec_hit("get_weather", {"city": city})
if _sc is not None:
return _sc
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"}))
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if "error" not in r:
_wdesc = {
0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
71: "neve leggera", 73: "neve", 75: "neve intensa",
80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
95: "temporale", 96: "temporale con grandine",
}
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
try:
desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D"
except (TypeError, ValueError):
desc = "N/D"
return (
f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n"
f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n"
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
f"Condizioni: {desc}"
)
return f"[get_weather: errore — {r['error'][:300]}]" # S605: 200→300
except asyncio.TimeoutError:
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
except Exception as exc:
return f"[get_weather: errore — {str(exc)[:300]}]" # S605: 200→300
async def _t_read_page() -> str | None:
if not url_m:
return None
url = url_m.group(0)
if not _gov_check("read_page", url):
return None
try:
_sc = _spec_hit("read_page", {"url": url})
if _sc is not None:
return _sc
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"}))
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("content"):
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200→300
except asyncio.TimeoutError:
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
except Exception as exc:
return f"[read_page: errore — {str(exc)[:300]}]" # S605: 200→300
async def _t_calculate() -> str | None:
if url_m or not self._CALC_INTENT_RE.search(goal):
return None
expr = self._extract_calc_expr(goal)
if not expr or not _gov_check("calculate", expr):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"}))
_sc = _spec_hit("calculate", {"expression": expr})
if _sc is not None:
return _sc
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if "result" in r:
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
return f"[calculate: errore — {r.get('error', '?')[:300]}]" # S605: 200→300
except asyncio.TimeoutError:
return "[calculate: timeout]"
except Exception as exc:
return f"[calculate: errore — {str(exc)[:300]}]" # S605: 200→300
async def _t_web_search() -> str | None:
if not self._SEARCH_INTENT_RE.search(goal):
return None
query = self._extract_search_query(goal)
if not query or not _gov_check("web_search", query):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"}))
_sc = _spec_hit("web_search", {"query": query})
if _sc is not None:
return _sc
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
hits = r.get("results", [])
if hits:
snippets = "\n".join(
f"• [{item['title']}] {item['snippet']}"
+ (f"\n URL: {item['url']}" if item.get("url") else "")
for item in hits[:6] # S591: 4→6 — più risultati web nel context
)
return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
# S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM
# ad allucinare training data come se fosse una ricerca reale riuscita.
# Ora è un errore esplicito → contato come _n_errors → _all_errors=True →
# _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:150]}']" # S608: 80→150
except asyncio.TimeoutError:
return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]"
except Exception as exc:
return f"[web_search: errore — {str(exc)[:300]}]" # S605: 200→300
async def _t_curl_fallback() -> str | None:
# S-RECOVERY: fallback se curl è menzionato o implicitamente utile
if not self._CURL_FALLBACK_RE.search(goal):
return None
cmd = self._extract_curl_command(goal)
if not cmd or not _gov_check("execute_shell", cmd):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Fallback: Shell/Curl", "explanation": f"Eseguo fallback: {cmd[:60]}..."}))
r = await asyncio.wait_for(TOOL_REGISTRY["execute_shell"]["_fn"](command=cmd), timeout=15)
if r.get("ok"):
return f"[FALLBACK CURL RIUSCITO]\nOutput:\n{r.get('stdout', '')[:1000]}"
return f"[fallback_curl: errore — {r.get('stderr', '')[:200]}]"
except Exception as exc:
return f"[fallback_curl: eccezione — {str(exc)[:200]}]"
async def _t_generate_image() -> str | None:
if not self._IMAGE_GEN_INTENT_RE.search(goal):
return None
_img_prompt = re.sub(
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
"", goal, flags=re.IGNORECASE
).strip() or goal
if not _gov_check("generate_image", _img_prompt):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400→600
if _sc is not None:
return _sc
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400→600
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
img_url = r.get("url", "")
if img_url:
return (
f"[IMMAGINE AI GENERATA]\n"
f"URL: {img_url}\n"
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100→200
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
)
return "[generate_image: nessun URL restituito]"
except asyncio.TimeoutError:
return "[generate_image: timeout — provider non raggiungibile]"
except Exception as exc:
return f"[generate_image: errore — {str(exc)[:300]}]" # S605: 200→300
async def _t_run_python() -> str | None:
_RUN_CODE_RE = re.compile(
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|"
r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b",
re.IGNORECASE,
)
if not _RUN_CODE_RE.search(goal):
return None
_code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL)
_code = _code_m.group(1).strip() if _code_m else goal
_code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip()
if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
_sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200→400
if _sc is not None:
return _sc
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("returncode", -1) == 0 and r.get("stdout"):
_out = (
"[CODICE PYTHON ESEGUITO]\n"
f"```python\n{_code[:500]}\n```\n"
f"Output:\n```\n{r['stdout'][:1500]}\n```"
)
# S-GAP3: TDD auto-check — solo su codice complesso (>=8 righe, def/class)
try:
from agents.tdd_runner import run_tdd_check as _tdd_chk, _should_test as _tdd_gate
if _tdd_gate(_code):
class _TDDExec:
async def run_tool(self, name, args):
fn = TOOL_REGISTRY.get(name, {}).get("_fn")
return await fn(**args) if fn else {}
from api.state import _get_ai_client as _tdd_ai
_tdd_r = await asyncio.wait_for(_tdd_chk(_code, _TDDExec(), _tdd_ai()), timeout=35.0)
if _tdd_r["ran"]:
_ok = _tdd_r["passed"]
_badge = ("Auto-test: OK" if _ok else f"Auto-test: FAIL\n```\n{_tdd_r['output'][:300]}\n```")
_out += f"\n{_badge}"
# GAP-NEW-2: se TDD FAIL, inietta traceback in exec_warn
# via self._tdd_fail_inject — letto da unified_loop.py
# prima del campionamento StrategicHealer (riga ~2142).
if not _ok:
self._tdd_fail_inject = (
f"[TDD-AUTO-FAIL] traceback del test generato:\n"
f"```\n{_tdd_r['output'][:400]}\n```"
)
except Exception as _exc:
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
return _out
if r.get("error"):
return f"[run_python: errore — {r['error'][:300]}]" # S605: 200→300
if r.get("stderr"):
# S573: 200→400 — stderr spesso contiene tracebacks multi-riga
# S593: 400→600 — tracebacks Python possono superare 400 chars
return f"[run_python: stderr — {r['stderr'][:600]}]" # S593: 400->600
return None
except asyncio.TimeoutError:
return "[run_python: timeout 18s]"
except Exception as exc:
# S593: 200→300 — exception str può includere path + msg
# S600: 300→500 — parity con altri exception handler
return f"[run_python: errore — {str(exc)[:500]}]" # S593: 200->300->500
async def _t_web_research() -> str | None:
if not self._WEB_RESEARCH_INTENT_RE.search(goal):
return None
_topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
_topic = _topic_m.group(1).strip() if _topic_m else re.sub(
r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
"", goal, flags=re.IGNORECASE
).strip()[:200] or goal[:200]
if not _topic or not _gov_check("web_research", _topic):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"}))
_sc = _spec_hit("web_research", {"topic": _topic[:400]})
if _sc is not None:
return _sc
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("ok"):
_synthesis = r.get("synthesis", "")
_sources = r.get("sources", [])
out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
if _synthesis:
out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
if _sources:
for s in _sources[:4]:
out += f"• {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
return out.strip()
return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
except asyncio.TimeoutError:
return "[web_research: timeout 55s]"
except Exception as exc:
return f"[web_research: errore — {str(exc)[:300]}]"
# S766: _t_get_news — notizie in tempo reale tramite TOOL_REGISTRY["get_news"]
async def _t_get_news() -> str | None:
if not self._NEWS_INTENT_RE.search(goal):
return None
_qm = re.search(
r"(?:notizie|news|ultime\s+notizie|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120})(?:\?|$|\.|,)",
goal, re.IGNORECASE,
)
_query = _qm.group(1).strip() if _qm else goal.strip()[:120]
if not _gov_check("get_news", _query):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Ultime notizie", "explanation": f"Cerco notizie: {_query[:60]}\u2026"}))
_sc = _spec_hit("get_news", {"query": _query, "max_results": 5})
if _sc is not None:
return _sc
r = await asyncio.wait_for(
TOOL_REGISTRY["get_news"]["_fn"](query=_query, max_results=5), timeout=20
)
if r.get("ok"):
items = r.get("results", r.get("articles", []))
if items:
out = [f"[NOTIZIE: '{_query[:60]}']"]
for it in items[:5]:
t = it.get("title", it.get("headline", "?"))
s = it.get("source", it.get("publisher", ""))
d = it.get("published_at", it.get("date", ""))
out.append(f"\u2022 {t}" + (f" [{s}]" if s else "") + (f" ({d})" if d else ""))
return "\n".join(out)
return f"[get_news: {r.get('error', 'nessun risultato')[:200]}]"
except asyncio.TimeoutError:
return "[get_news: timeout 20s]"
except Exception as exc:
return f"[get_news: errore — {str(exc)[:200]}]"
# S764: 3 nuovi tool fast-path — directory_tree / file_search / git_status
async def _t_directory_tree() -> str | None:
if not self._DIRECTORY_TREE_INTENT_RE.search(goal):
return None
_path = self._extract_dir_path(goal)
if not _gov_check("directory_tree", _path):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("ok") and r.get("tree"):
return f"[STRUTTURA PROGETTO: '{_path}']\n{r['tree']}"
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
except asyncio.TimeoutError:
return "[directory_tree: timeout 8s]"
except Exception as exc:
return f"[directory_tree: errore — {str(exc)[:300]}]"
async def _t_file_search() -> str | None:
if not self._FILE_SEARCH_INTENT_RE.search(goal):
return None
_pattern = self._extract_file_pattern(goal)
if not _pattern or not _gov_check("file_search", _pattern):
return None
_search_path = self._extract_dir_path(goal)
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Ricerca nel codice", "explanation": f"Cerco '{_pattern[:40]}' nei file..."}))
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("ok"):
_matches = r.get("matches", [])
_count = r.get("count", len(_matches))
out = f"[FILE TROVATI: pattern='{_pattern}', {_count} occorrenze]\n"
for m in _matches[:20]:
out += f"{m.get('file','?')}:{m.get('line','?')}: {m.get('text','')[:120]}\n"
return out.strip()
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
except asyncio.TimeoutError:
return "[file_search: timeout 10s]"
except Exception as exc:
return f"[file_search: errore — {str(exc)[:300]}]"
async def _t_git_status() -> str | None:
if not self._GIT_INTENT_RE.search(goal):
return None
_cwd = self._extract_git_cwd(goal)
if not _gov_check("git_status", _cwd):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Stato Git", "explanation": "Controllo branch e file modificati..."}))
_t0 = asyncio.get_event_loop().time()
r = await asyncio.wait_for(
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
if r.get("ok"):
out = f"[STATO GIT (branch: {r.get('branch', '?')})\n"
if r.get("status"):
out += f"File modificati:\n{r['status'][:600]}\n"
if r.get("log"):
out += f"Ultimi commit:\n{r['log'][:400]}\n"
return out.strip() + "]"
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
except asyncio.TimeoutError:
return "[git_status: timeout 8s]"
except Exception as exc:
return f"[git_status: errore — {str(exc)[:300]}]"
# S419/S734: gather parallelo con Semaphore — limita concorrenza su mobile
# Default 4: max 4 tool simultanei — previene saturazione TCP su iPhone Safari.
# Impatto su goal normali (2-3 tool): ZERO (semaforo mai raggiunto).
# GAP-P3: configurabile via env TOOL_CONCURRENCY_LIMIT per ambienti server/desktop.
_TOOL_CONCURRENCY = int(os.getenv('TOOL_CONCURRENCY_LIMIT', '4'))
_gather_sem = asyncio.Semaphore(_TOOL_CONCURRENCY)
async def _sem_wrap(coro):
async with _gather_sem:
return await coro
# S764: 7->10 tool in gather (Semaphore(4) invariato)
# P30-B1: analisi statica Python — zero exec_engine, <5ms
async def _t_analyze_python() -> str | None:
if not self._ANALYZE_PY_RE.search(goal):
return None
_pm = self._PY_BLOCK_IN_GOAL_RE.search(goal)
if not _pm:
return None
_code = _pm.group(1)
if not _gov_check("python_analyze", _code[:80]):
return None
try:
if on_step:
await _maybe_await(on_step({"action": "tool_start", "status": "running",
"title": "Analisi Python", "explanation": "Analisi statica codice Python (AST)…"}))
_t0 = asyncio.get_event_loop().time()
_r = await asyncio.wait_for(
TOOL_REGISTRY["python_analyze"]["_fn"](code=_code), timeout=5
)
try:
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
except Exception: pass
_out = [f"[ANALISI PYTHON — {_r.get('summary', '?')}]"]
for _e in _r.get("errors", []):
_out.append(f"ERR {_e['type']} riga {_e['line']}: {_e['message']}" + (f" → {_e['text']}" if _e.get('text') else ""))
_c = _r.get("complexity", {})
if _c:
_out.append(
f"Struttura: {_c.get('total_lines',0)} righe, "
f"{_c.get('functions',0)} funzioni, "
f"{_c.get('classes',0)} classi, nesting max {_c.get('max_nesting',0)}"
)
for _s in _r.get("suggestions", []):
_out.append(f"Suggerimento: {_s}")
return "\n".join(_out)
except asyncio.TimeoutError:
return "[python_analyze: timeout]"
except Exception as _exc:
return f"[python_analyze: errore — {str(_exc)[:200]}]"
_parallel_results = await asyncio.gather(
_sem_wrap(_t_get_weather()),
_sem_wrap(_t_read_page()),
_sem_wrap(_t_calculate()),
_sem_wrap(_t_web_search()),
_sem_wrap(_t_generate_image()),
_sem_wrap(_t_run_python()),
_sem_wrap(_t_web_research()),
_sem_wrap(_t_directory_tree()),
_sem_wrap(_t_file_search()),
_sem_wrap(_t_get_news()),
_sem_wrap(_t_git_status()),
_sem_wrap(_t_analyze_python()),
return_exceptions=True,
)
for _pr in _parallel_results:
if isinstance(_pr, str):
results.append(_pr)
# S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo.
# Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e
# "rispondo con dati del training" → contati come successi → _build_messages
# wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale.
# Soluzione: whitelist di prefissi che certificano dati REALI verificati.
_REAL_DATA_PREFIXES = (
"[RICERCA WEB REALE",
"[METEO",
"[CALCOLO REALE",
"[IMMAGINE AI GENERATA",
"[CODICE PYTHON ESEGUITO",
"[PAGINA REALE",
"[DATI REALI",
"[RICERCA APPROFONDITA",
"[STRUTTURA PROGETTO", # S764: directory_tree
"[FILE TROVATI", # S764: file_search
"[NOTIZIE",
"[STATO GIT", # S764: git_status
"[ANALISI PYTHON", # P30-B1: python_analyze
)
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
_n_errors = len(results) - _n_success
# Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima
if _n_errors > 0:
try:
from api.state import increment_stat as _inc_tf
_inc_tf("tool_failure_count")
except Exception as _exc:
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
# P-HARNESS: traccia fallimenti per-tool; warn se threshold raggiunto
try:
from tools.harness_gate import record_failures_from_results as _hg_rec
from tools.registry import _agent_session_id_var as _hg_sid
_hg_n = _hg_rec(_hg_sid.get(), results)
if _hg_n:
_logger.warning(
"[harness_gate] %d tool(s) hit failure threshold — provider switch recommended",
_hg_n,
)
except Exception as _hg_exc: # noqa: BLE001
_logger.debug("[unified_loop_tools] harness silenced: %s", _hg_exc)
return "\n\n".join(results), len(results), _n_success, _n_errors
# ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
# Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato"
# nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
# esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende.
_FALSE_CLAIM_RE = re.compile(
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
re.IGNORECASE,
)
_REALTIME_GOAL_RE = re.compile(
r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
r"weather|meteo|previsioni|temperatura|"
r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
re.IGNORECASE,
)
@staticmethod
def _validate_claims(
response: str,
n_success: int,
n_errors: int,
goal: str,
false_claim_re: "re.Pattern[str]",
realtime_goal_re: "re.Pattern[str]",
) -> str:
"""S428 Sprint1-Fix3: Claim Validation.
Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
Non riscrive la risposta — la estende con una nota visibile all'utente.
"""
if n_success > 0 or n_errors == 0:
return response # dati reali presenti o nessun tool eseguito → ok
if not realtime_goal_re.search(goal):
return response # goal non richiede dati live → ok
if not false_claim_re.search(response):
return response # nessuna false claim → ok
# Rileva false claim + goal realtime + tutti tool falliti
disclaimer = (
"\n\n---\n"
"⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
"dal mio training e potrebbero non essere aggiornate. "
"Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
"o il sito ufficiale della tecnologia."
)
return response + disclaimer
# ── _needs_tools (S193) — regex ampliata ─────────────────────────────────
# S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
_TOOL_NEEDED_RE = re.compile(
r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|"
r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
# S648: email/PDF keyword
r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf|"
# S764: git / npm / pip / file-search / directory-tree keywords
r"git\s+status|git\s+diff|git\s+log|git\s+clone|git\s+commit|"
r"stato\s+git|branch\s+corrente|file\s+modificati|ultimi\s+commit|"
r"npm\s+install|npm\s+run|npm\s+test|npm\s+build|pnpm\s+|yarn\s+add|"
r"pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett|"
r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|"
# R9: webhook/call_api keywords — mancanti da _TOOL_NEEDED_RE
r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|"
r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b",
re.IGNORECASE,
)
def _needs_tools(self, goal: str) -> bool:
return bool(self._TOOL_NEEDED_RE.search(goal))
# ── S402: Fast Path ───────────────────────────────────────────────────────
# Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
# Target: <3s vs 20-60s per il full pipeline.
# S427: aggiunti ack comuni IT/EN per fast path più ampio
_SIMPLE_CONV_RE = re.compile(
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|"
r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|"
r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|"
r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|"
r"giusto|corretto|esattamente|d['\u2019]accordo|"
r"capisco|ho capito|inteso|compreso|ricevuto|"
r"s[iì] grazie|no grazie|va bene|va benissimo|"
r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|"
r"got it|sure|right|agreed|makes sense|correct|good|"
r"good morning|good evening|good night"
r")\.?\s*[!?]?$",
re.IGNORECASE,
)
# S-FAST-MATH: espressioni aritmetiche semplici → fast-path (Groq 8B, ~150ms)
# Override del check _needs_tools: "calcola 2+2" non richiede tool di ricerca web.
# Pattern: prefisso opzionale (calcola/quanto fa) + espressione numerica.
_SIMPLE_MATH_RE = re.compile(
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
r'compute|calculate|what(?:\'s|\s+is)\s+(?:the\s+(?:result\s+of\s+)?)?)\s*)?'
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
re.IGNORECASE,
)
# P30-B1: trigger analisi statica Python (IT + EN)
_ANALYZE_PY_RE = re.compile(
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
r"|check\s+(?:my\s+)?(?:python\s+)?(?:code|syntax|script)"
r"|review\s+(?:my\s+)?(?:python\s+)?(?:code|script)"
r"|syntax\s+check(?:\s+python)?"
r"|verifica\s+(?:la\s+)?(?:sintassi|il\s+codice)(?:\s+python)?"
r"|controlla\s+(?:il\s+)?(?:codice|sintassi)(?:\s+python)?"
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
re.IGNORECASE,
)
# Regex per estrarre blocco python dal goal — P30-B1
_PY_BLOCK_IN_GOAL_RE = re.compile(
r"```(?:python|py)\s*\n([\s\S]+?)```",
re.IGNORECASE,
)
def _is_simple_query(self, goal: str) -> bool:
"""S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent).
S-FAST-MATH: aggiunto check math semplice → fast-path, bypassa _needs_tools.
Attiva il fast path che salta memoria, planner, verifier e self-healing."""
g = goal.strip()
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
return False
# S-FAST-MATH: "calcola 2+2", "quanto fa 15*3" → fast-path (Groq 8B, 150ms)
# Controllo separato da _needs_tools: la matematica pura non richiede tool web.
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
return True
# Percorso originale: greeting/ack con limite 70 chars
if len(g) > 70 or self._needs_tools(g):
return False
return bool(self._SIMPLE_CONV_RE.match(g))