Spaces:
Paused
Paused
| """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 âââââââââââââââââââââââââââââââââââââââââââââââ | |