File size: 25,610 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
"""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 Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€