Terminal / agents /unified_loop_helpers.py
Baida-A
Initial clean deploy (Reverse Proxy removed)
28a08e7
Raw
History Blame Contribute Delete
25.6 kB
"""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 ───────────────────────────────────────────────