"""unified_loop_helpers.py — HelpersMixin: goal compression, replan, reflection, fast-path. Estratto da unified_loop.py (P20-TD1 Fase 3a). Contiene: _compress_goal(): estrae blocchi codice >600 chars come file virtuali [FILE:N] Deps: self._CODE_BLOCK_RE (PromptBuilderMixin), self._guess_filename (PromptBuilderMixin) _budget_replan_check(): GAP-1 probabilistic re-planning trigger su budget critico + errori tool Deps: self._get_fast_llm() (LLMSelectionMixin) _proactive_reflect(): verifica pertinenza tool results prima della sintesi LLM Deps: self._fast_llm / self.llm (instance attrs) _run_fast_path(): S402+S-FAST path leggero per query conversazionali (<3s target) Deps: DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin (tutti via MRO) Nota: chiama self._run_fallback() che resta in UnifiedAgentLoop — ok via MRO Invariante B1: nessun corpo duplicato con unified_loop.py. MRO Python garantisce che tutti i self.xxx riferimenti si risolvano correttamente su UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin). """ from __future__ import annotations import asyncio import re from typing import Any import logging _logger = logging.getLogger("agents.unified_loop_helpers") # Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib) from agents.unified_loop_types import StepCallback, UnifiedLoopState class HelpersMixin: # ── S364+S403: Strategic Recovery reflection ────────────────────────────── async def _reflective_debug(self, goal: str, errors: list, _force_fast: bool = False) -> str: """ S364 + S403: Strategic Recovery — usa ARCHITECT (DeepSeek-R1). S364: Chain-of-Verification dopo 2+ errori (analisi root cause). S403: Limite 3 (criticità 9/10) — recovery strategy reale: errore → diagnosi → NUOVA strategia → ripartenza (non retry:retry:fail). Differenziato per numero di tentativi: - 2 errori: analisi root cause + suggerimento alternativo - 3+ errori: forzatura strategia completamente diversa Ritorna stringa vuota su qualsiasi errore (fire-and-forget safe). """ try: from models.role_router import RoleRouter, Role err_count = len(errors) # S576: err_summary 200→300 — parity con fallback path (S573) # S599: str(e)[:300]→[:500] — errori completi con stack info spesso > 300 chars err_summary = '\n'.join(str(e)[:500] for e in errors[-3:]) # COG-2: record failure pattern per future lesson injection if self.memory and hasattr(self.memory, 'reflection'): try: import asyncio as _asyncio_r _asyncio_r.get_event_loop().call_soon( lambda: self.memory.reflection.record_failure( goal, err_summary[:400], "reflective_debug" ) ) except Exception: pass # recording non blocca mai il recovery # S404: Classifica l'errore prima di invocare l'ARCHITECT # → inject strategia mirata nel context (zero latency, no LLM) classification_hint = "" try: _classify, _fmt = _get_classifier() clf_result = _classify(errors[-4:] if errors else []) classification_hint = _fmt(clf_result) except Exception: pass # classifier failure non blocca il recovery # D6: inietta lezioni precedenti per lo stesso goal — evita ripetere strategie già fallite _lessons_hint = "" if self.memory and hasattr(self.memory, 'reflection'): try: _prev = self.memory.reflection.get_relevant_lessons(goal, n=2) if _prev: _lparts = [] for _l in _prev: if _l.get("type") == "failure": _lparts.append(f"- Già tentato e FALLITO: {_l.get('avoid','')[:200]}") elif _l.get("type") == "success": _lparts.append(f"- Strategia FUNZIONANTE in passato: {_l.get('strategy','')[:200]}") if _lparts: _lessons_hint = "Strategie già tentate (NON ripetere):\n" + "\n".join(_lparts) + "\n\n" except Exception: pass # lesson injection non blocca mai il recovery # B3: bypass deterministico — errori con fix noto non richiedono LLM (1-3s risparmiati). # Conseguenza: import/file/conn/perm errors con 1-2 occorrenze → hint zero-latency. # Zero cons: il fix per ModuleNotFoundError è sempre 'installa dipendenza'. if err_count < 3 and errors: _last_err_str = str(errors[-1])[:600].lower() _B3_DET = [ (r'modulenotfounderror|no module named|importerror|cannot find module|module not found', '\U0001f4a1 Dipendenza mancante: installa il pacchetto con pip/npm prima di riprovare.'), (r'filenotfounderror|no such file or directory|file not found|enoent', '\U0001f4a1 File non trovato: verifica il percorso o crea il file prima di usarlo.'), (r'connectionrefusederror|connection refused|econnrefused|network unreachable', '\U0001f4a1 Connessione rifiutata: verifica che il servizio sia attivo e la porta corretta.'), (r'permissionerror|permission denied|eacces|access denied', '\U0001f4a1 Permessi insufficienti: verifica i permessi o esegui con privilegi appropriati.'), ] import re as _re_b3 for _det_pat, _det_hint in _B3_DET: if _re_b3.search(_det_pat, _last_err_str): return (_det_hint + ('\n\n' + classification_hint if classification_hint else '')).strip() if err_count >= 3 and not _force_fast: # GAP-1.3: ARCHITECT solo # B4: _force_fast=True → fast_llm (strategic ctx già presente, ARCHITECT ridondante) per strategic recovery >= 3 errori (vale la latenza 10-15s) arch = RoleRouter.get_client(Role.ARCHITECT) _rd_timeout = 15.0 # S403 Strategic Recovery: dopo 3+ errori, forza approccio alternativo system_prompt = ( "Sei un senior engineer. L'agente ha fallito 3+ volte con lo stesso approccio. " "Analizza in 4 punti CONCRETI e SPECIFICI:\n" "1. Root cause reale (1 riga)\n" "2. Perché l'approccio usato finora falliva (1 riga)\n" "3. STRATEGIA COMPLETAMENTE DIVERSA da usare ora (2 righe — sii specifico: " "quale libreria, quale pattern, quale struttura dati alternativa)\n" "4. Prima istruzione concreta (1 riga — cosa fare PRIMA di tutto)\n" "No generalità tipo 'prova un altro approccio'. Sii tecnico e diretto." ) label = "STRATEGIA ALTERNATIVA FORZATA" max_tokens = 500 # S586: 350→500 — strategia alternativa spesso >350 tok else: # GAP-1.3: fast path — self.llm invece di ARCHITECT (ms vs 10-15s) per primo errore arch = self.llm _rd_timeout = 8.0 # S364 Chain-of-Verification: dopo 2 errori, analisi + suggerimento system_prompt = ( "Sei un debugger esperto. Analizza in 3 punti:\n" "1. Root cause reale (1 riga)\n" "2. Perché l'approccio precedente falliva (1 riga)\n" "3. Approccio alternativo specifico da provare (1-2 righe)\n" "Solo analisi tecnica — niente codice." ) label = "ANALISI ERRORE PRECEDENTE" max_tokens = 400 # S586: 250→400 — analisi 3 punti necessita più tokens msgs = [ {"role": "system", "content": system_prompt}, # S597: goal 300→500 — più contesto goal nel debug prompt architect {"role": "user", "content": f"{_lessons_hint}Goal: {goal[:500]}\n\nTentativo #{err_count}\nErrori:\n{err_summary}"}, ] analysis = await asyncio.wait_for( arch.chat(msgs, temperature=0.1, max_tokens=max_tokens), timeout=_rd_timeout, ) if analysis and not analysis.startswith('[LLM'): # S404: prepend classificazione deterministica + analisi LLM return f"{classification_hint}\n\n[{label} — tentativo {err_count}]\n{analysis[:600]}" except Exception: pass # S364/S403: ARCHITECT failed — S455-P14: fallback to base LLM # S455-P14: ARCHITECT non disponibile → fallback al modello base (sempre disponibile) try: fallback_msgs = [ {"role": "system", "content": "Sei un debugger esperto. Analizza brevemente l'errore e suggerisci un approccio alternativo specifico in 2-3 righe. Solo analisi tecnica — niente codice."}, # S573: goal 200→300, errors 150→300 — più contesto per il debug fallback # S592: errors[-2:]→[-3:] — più errori nel fallback debug prompt # S597: goal 300→500 — più contesto goal nel debug fallback prompt # S600: str(e)[:300]→[:500] — parity con ARCHITECT path (riga ~271) {"role": "user", "content": f"{_lessons_hint}Goal: {goal[:500]}\n\nErrori ({len(errors)} totali):\n{chr(10).join(str(e)[:500] for e in errors[-3:])}"}, ] fallback_ans = await asyncio.wait_for( # S586: 180→300 — fallback debug risposta 2-3 righe spesso > 180 tok self.llm.chat(fallback_msgs, temperature=0.15, max_tokens=300), timeout=10.0, ) if fallback_ans and not fallback_ans.startswith('[LLM'): return f"{classification_hint}\n\n[FALLBACK DEBUG — tentativo {len(errors)}]\n{fallback_ans[:600]}" # S603: 400→600 except Exception: pass # fallback anche questo fallito — ritorna stringa vuota # S404: se LLM fallisce, almeno ritorna la classificazione deterministica return classification_hint # ── Goal compression (S197/S357) ───────────────────────────────────────── def _compress_goal(self, goal: str) -> tuple[str, str]: """ Estrae blocchi di codice grandi dal goal e li converte in file virtuali. Returns: (goal_compresso, sezione_file_da_iniettare_nel_contesto) Se il codice totale e < _CODE_THRESHOLD, ritorna (goal_originale, ''). """ _CODE_THRESHOLD = 600 # chars — S357: abbassato da 1800 per ridurre token TTFT blocks = list(self._CODE_BLOCK_RE.finditer(goal)) if not blocks: return goal, '' total_code_chars = sum(len(m.group('body')) for m in blocks) if total_code_chars < _CODE_THRESHOLD: return goal, '' # Estrai ogni blocco files_section_lines: list[str] = ['--- CODICE_FORNITO ---'] compressed = goal for idx, m in enumerate(reversed(blocks)): # reverse per preservare offset lang = m.group('lang') or 'txt' body = m.group('body').rstrip() fname = self._guess_filename(lang, len(blocks) - 1 - idx) tag = f'[FILE:{len(blocks) - idx}: {fname}]' start, end = m.start(), m.end() compressed = compressed[:start] + tag + compressed[end:] files_section_lines.insert(1, f'\n[FILE:{len(blocks) - idx}: {fname}]\n```{lang}\n{body}\n```') files_section_lines.append('--- FINE_CODICE_FORNITO ---') return compressed, '\n'.join(files_section_lines) # ── GAP-1: Probabilistic re-planning on budget critical ─────────────────── async def _budget_replan_check( self, state: 'UnifiedLoopState', step_count: int, on_step=None ) -> str: """ GAP-1: Probabilistic Re-planning Trigger. Attivato quando: len(state.errors) >= 2 AND step_count >= 60% max_steps. Genera un piano alternativo leggero usando fast_llm (8B, Groq). Timeout 5s, fail-open — mai blocca il loop principale. Differenza da _reflective_debug: triggera su budget critico + errori tool, non solo su LLM errors. Differenza da COG-1 replan: opera sul single-task loop, non sull'orchestrazione parallela. Ritorna: stringa con nuovo approccio suggerito, o '' su errore/timeout. """ _budget_ratio = step_count / max(state.max_steps, 1) _n_err = len(state.errors) # Guard: solo se errori >= 2 e budget >= 60% consumato if _n_err < 2 or _budget_ratio < 0.6: return '' # Guard dedup: inietta una sola volta per run if '[GAP-1-REPLAN]' in (state.context or ''): return '' try: _fast = self._get_fast_llm() _err_summary = '\n'.join(str(e)[:200] for e in state.errors[-3:]) _done_tools = ', '.join( s.get('tool', s.get('action', '?')) for s in state.steps[-6:] if s.get('action') not in ('llm', 'reflective_debug', 'selfheal_strategy_injection') ) or 'nessuno' _replan_prompt = [ {"role": "system", "content": ( "Sei un re-planning agent. Il piano corrente sta fallendo. " "Genera un approccio alternativo CONCISO (max 200 chars) " "che eviti gli stessi errori. Solo il nuovo approccio, niente altro." )}, {"role": "user", "content": ( f"GOAL: {state.goal[:300]}\n" f"STEP: {step_count}/{state.max_steps} ({_budget_ratio:.0%} budget)\n" f"ERRORI ({_n_err}): {_err_summary}\n" f"TOOL USATI: {_done_tools}\n" f"Suggerisci approccio alternativo:" )}, ] _replan_hint = await asyncio.wait_for( _fast.chat(_replan_prompt, temperature=0.3, max_tokens=120), timeout=5.0, ) if _replan_hint and not _replan_hint.startswith('[LLM') and len(_replan_hint) > 10: _logger.info("GAP-1 budget_replan: step=%d/%d errors=%d hint=%s", step_count, state.max_steps, _n_err, _replan_hint[:80]) if on_step: await _maybe_await(on_step({ "action": "budget_replan", "status": "started", "title": f"♻️ Re-planning (step {step_count}/{state.max_steps})", "explanation": _replan_hint[:200], })) return _replan_hint except Exception: pass # fail-open totale return '' # ── PROACTIVE-REFLECT: tool result validation ────────────────────────────── async def _proactive_reflect(self, goal: str, tool_results: str) -> str: """ PROACTIVE-REFLECT (Gap 1): dopo ogni esecuzione tool, verifica se i risultati sono pertinenti al goal PRIMA della sintesi LLM. Previene allucinazione quando i tool restituiscono dati irrilevanti. Usa fast_llm (8B) con timeout 4s — silent failure totale. Ritorna hint da iniettare in state.context, o "" se risultati ok. Trigger: chiamato in run() dopo direct_results disponibili. Budget: 60 token output, 4s timeout, temperatura 0.1. """ if not tool_results or len(tool_results) < 80: return "" _llm = self._fast_llm or self.llm if not _llm: return "" try: _prompt = ( f"GOAL: {goal[:180]}\n\n" f"RISULTATI TOOL (estratto): {tool_results[:380]}\n\n" "In UNA riga (max 15 parole): questi risultati permettono di rispondere al goal? " "Se sì scrivi 'SUFFICIENTE'. Se no, indica cosa manca." ) _check = await asyncio.wait_for( _llm.chat( [ { "role": "system", "content": ( "Sei un validatore di pertinenza. " "Rispondi SOLO in italiano, massimo 15 parole. " "Mai spiegare — solo valuta." ), }, {"role": "user", "content": _prompt}, ], temperature=0.1, max_tokens=60, ), timeout=4.0, ) _check = (_check or "").strip() if not _check or "SUFFICIENTE" in _check.upper(): return "" # Inietta hint nel context per guidare la sintesi LLM _logger.info("PROACTIVE-REFLECT: tool results parziali — %s", _check[:100]) return f"\n[REFLECT] Tool results parziali: {_check[:120]}\n" except Exception: return "" # silent failure — mai bloccare il loop principale # ── S402+S-FAST: Fast path for conversational queries ───────────────────── async def _run_fast_path(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any]: """S402+S-FAST: Path leggero per query conversazionali (<3s target). Salta: memory lookup, planner, executor, retry loop, verifier, goal_verifier, self-healing Python/HTML. Sistema prompt minimale → meno token → risposta veloce.""" import time as _time _t0 = _time.monotonic() # S-FAST-MATH-EXACT: matematica semplice → calculate tool diretto, nessun LLM. # Bypassa completamente l'LLM per "Calcola 2+2", "15*3", "quanto fa 7+8" # → risposta esatta in <50ms, zero latenza LLM, zero allucinazioni. if hasattr(self, '_SIMPLE_MATH_RE') and self._SIMPLE_MATH_RE.match(state.goal.strip()): try: from tools.registry import TOOL_REGISTRY _math_expr = "" # Prova _extract_calc_expr prima (rimuove prefissi "calcola", "quanto fa") if hasattr(self, '_extract_calc_expr'): _math_expr = self._extract_calc_expr(state.goal) # Fallback: estrae espressione numerica pura (es. solo "2+2" senza prefisso) if not _math_expr: _pure_m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', state.goal) if _pure_m: _math_expr = ( _pure_m.group(0).strip().rstrip('.?! ') .replace('^', '**').replace(',', '.') ) if _math_expr and 'calculate' in TOOL_REGISTRY: _calc_r = await asyncio.wait_for( TOOL_REGISTRY['calculate']['_fn'](expression=_math_expr), timeout=5.0, ) if _calc_r.get('result') is not None: _exact = str(_calc_r['result']) _ms = int((_time.monotonic() - _t0) * 1000) if on_step: await _maybe_await(on_step({ 'loop': 2, 'action': 'fallback', 'status': 'done', 'success': True, 'title': 'Calcolo eseguito', 'explanation': f'{_math_expr} = {_exact}', 'output': _exact, })) return { 'success': True, 'engine': 'calculate', 'ok': True, 'goal': state.goal, 'steps': [{'action': 'math_direct', 'ms': _ms}], 'errors': [], 'output': _exact, 'result': _exact, 'fast_path': True, 'timing_ms': _ms, } except Exception: pass # fallback silenzioso all'LLM standard # Fix-4.1: GREETING-BYPASS — saluti/ping → risposta deterministica 5ms vs 3-4s LLM # Regex copre: ciao, hi, ci sei?, ping, status, sei online?, funziona? # Early-return PRIMA di ogni LLM call — zero token consumati su Safari mobile. _GREETING_RE = re.compile( r'^(ciao|hi|hello|ci\s+sei\??|ping|status|sei\s+online\??|funziona\??|' r'sei\s+l[\u00e0a]\??|sei\s+attivo\??|ok\??|ehi\??|oi|hey|' r'sei\s+disponibile\??|tutto\s+ok\??)[\.!\s]*$', re.IGNORECASE, ) if _GREETING_RE.match(state.goal.strip()): _ms = int((_time.monotonic() - _t0) * 1000) if on_step: await _maybe_await(on_step({ 'action': 'fast_path', 'status': 'done', 'success': True, 'title': 'Risposta diretta', 'explanation': 'Saluto rilevato — risposta deterministica', 'output': '\u2705 Sono online e operativo. Come posso aiutarti?', })) return { 'success': True, 'engine': 'deterministic', 'ok': True, 'goal': state.goal, 'output': '\u2705 Sono online e operativo. Come posso aiutarti?', 'result': '\u2705 Sono online e operativo. Come posso aiutarti?', 'steps': [{'action': 'greeting_bypass', 'ms': _ms}], 'fast_path': True, 'timing_ms': _ms, } fmt_dir = self._classify_format_directive(state.goal) # P27-B2: lingua esplicita invece di "Rispondi nella lingua dell'utente" (vago). # _detect_user_lang() puro (<1ms) → istruzione diretta al modello. _fast_lang = _detect_user_lang(state.goal) _fast_lang_instr = _LANG_INSTRUCTIONS.get(_fast_lang, "Rispondi nella lingua dell'utente.") messages: list[dict] = [ {"role": "system", "content": f"Sei un assistente AI utile e diretto. {_fast_lang_instr}\n{fmt_dir}"}, {"role": "user", "content": state.goal}, ] if on_step: await _maybe_await(on_step({ "loop": 1, "action": "llm", "status": "started", "title": "Risposta rapida", "explanation": "Query semplice — risposta diretta", })) # S-FAST: usa client 8B (Groq) invece del primario (70B) per query semplici _fast_client = self._get_fast_llm() answer = "" try: answer = await asyncio.wait_for( _fast_client.chat(messages, temperature=0.7, max_tokens=512), timeout=8.0, ) except Exception as _exc: _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001 if not answer or answer.startswith("[LLM"): # Degradazione sicura al fallback completo return await self._run_fallback(state, on_step) answer = self._sanitize_agent_output(answer) t_ms = int((_time.monotonic() - _t0) * 1000) engine = getattr(_fast_client, "provider_name", None) or "llm" if on_step: await _maybe_await(on_step({ "loop": 2, "action": "fallback", "status": "done", "success": True, "title": "Completato", "explanation": "Risposta elaborata e verificata con successo", "output": answer, # S-STEP-OUT: espone risposta nel log step per debug frontend })) return { "success": True, "engine": engine, "goal": state.goal, "steps": [{"action": "fast_path", "ms": t_ms}], "errors": [], "output": answer, "fast_path": True, "timing_ms": t_ms, } # R1 S390 + S434: smolagents rimosso — dead code eliminato. # _build_smol_tools / _load_smol_agent / _run_smolagents non chiamati da run(). # ── Fallback deterministico ───────────────────────────────────────────────