github-actions[bot] commited on
Commit
cbe92de
·
0 Parent(s):

Sync backend-only Space export

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +72 -0
  2. .gitignore +8 -0
  3. Dockerfile +41 -0
  4. README.md +42 -0
  5. agents/__init__.py +0 -0
  6. agents/acceptance_criteria.py +122 -0
  7. agents/backend_antiregress.py +110 -0
  8. agents/context_manager.py +450 -0
  9. agents/critic.py +102 -0
  10. agents/dynamic_replanner.py +180 -0
  11. agents/engineering_state.py +255 -0
  12. agents/error_classifier.py +213 -0
  13. agents/escalation_ladder.py +183 -0
  14. agents/executor.py +360 -0
  15. agents/file_conversion.py +163 -0
  16. agents/goal_drift_detector.py +143 -0
  17. agents/goal_verifier.py +580 -0
  18. agents/html_fast_path.py +60 -0
  19. agents/planner.py +397 -0
  20. agents/reasoning_core.py +429 -0
  21. agents/reflection_sidecar.py +216 -0
  22. agents/requirement_engine.py +276 -0
  23. agents/response_verifier.py +367 -0
  24. agents/skill_tracker.py +400 -0
  25. agents/strategic_healer.py +605 -0
  26. agents/tdd_runner.py +178 -0
  27. agents/tool_generator.py +187 -0
  28. agents/unified_loop.py +0 -0
  29. agents/unified_loop_helpers.py +483 -0
  30. agents/unified_loop_llm.py +604 -0
  31. agents/unified_loop_prompts.py +0 -0
  32. agents/unified_loop_tools.py +779 -0
  33. agents/unified_loop_types.py +217 -0
  34. agents/workflow_engine.py +112 -0
  35. api/__init__.py +0 -0
  36. api/admin_state.py +75 -0
  37. api/agent.py +0 -0
  38. api/agent_checkpoint.py +131 -0
  39. api/agent_memory.py +132 -0
  40. api/agent_telemetry.py +230 -0
  41. api/auth_guard.py +401 -0
  42. api/auth_managed.py +545 -0
  43. api/background_tasks.py +53 -0
  44. api/benchmark.py +763 -0
  45. api/benchmark_handler.py +331 -0
  46. api/blackboard.py +131 -0
  47. api/browser.py +1037 -0
  48. api/coding.py +220 -0
  49. api/conversations.py +111 -0
  50. api/daemon_status.py +149 -0
.env.example ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🧠 Agente AI — Template .env Pulito (A-E)
2
+ # ============================================================
3
+ # Copiare in .env per uso locale. NON committare valori reali.
4
+ # Struttura ottimizzata per Quadranti A, B, C, D, E.
5
+ # ============================================================
6
+
7
+ # ── 1. Core Runtime ──────────────────────────────────────────
8
+ PORT=7860
9
+ APP_PROFILE=production_kernel
10
+ INTERNAL_TOKEN= # Bridge HF ↔ CF
11
+ VAULT_KEY= # AES-256 Hex
12
+ NOTIFY_TOKEN= # Notifiche Interne
13
+
14
+ # ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
15
+ BACKEND_URL=https://baida07-terminal.hf.space
16
+ RAILWAY_TOKEN=
17
+ RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
18
+ SUPABASE_URL=
19
+ SUPABASE_SERVICE_ROLE_KEY=
20
+ GITHUB_TOKEN=
21
+ # Hugging Face Router: endpoint OpenAI-compatible per inferenza.
22
+ HF_TOKEN=
23
+ HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct
24
+ # Pool opzionale: [{"profile":"primary","api_key":"...","model":"openai/gpt-oss-120b:fastest"}]
25
+ HF_ROUTER_PROFILES_JSON=
26
+
27
+ # ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
28
+ RAILWAY_TOKEN_B=
29
+ RAILWAY_PROJECT_ID_B=YOUR_RAILWAY_PROJECT_ID_B
30
+ SUPABASE_URL_B=
31
+ SUPABASE_SERVICE_ROLE_KEY_B=
32
+ GITHUB_TOKEN_B=
33
+
34
+ # ── 4. Quadrante C (DAEMON - Telegram) ───────────────────────
35
+ RAILWAY_TOKEN_C=
36
+ RAILWAY_PROJECT_ID_C=YOUR_RAILWAY_PROJECT_ID_C
37
+ SUPABASE_URL_C=
38
+ SUPABASE_SERVICE_ROLE_KEY_C=
39
+
40
+ # ── 5. Quadrante D (AUDIT - Compliance) ──────────────────────
41
+ RAILWAY_TOKEN_D=
42
+ RAILWAY_PROJECT_ID_D=YOUR_RAILWAY_PROJECT_ID_D
43
+ SUPABASE_URL_D=
44
+ SUPABASE_SERVICE_ROLE_KEY_D=
45
+
46
+ # ── 6. Quadrante E (BOT-TG - Dedicated) ──────────────────────
47
+ RAILWAY_TOKEN_E=
48
+ RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E
49
+
50
+ # ── 7. LLM Unified Providers (A-E) ───────────────────────────
51
+ # Configurare nei Secrets del provider hosting (HF/Railway)
52
+ GROQ_API_KEY=
53
+ OPENROUTER_API_KEY=
54
+ # Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
55
+ # Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
56
+ OPENROUTER_PROFILES_JSON=
57
+ GEMINI_API_KEY=
58
+ NVIDIA_API_KEY=
59
+
60
+ # ── 8. Sandboxes & Tools ─────────────────────────────────────
61
+ E2B_API_KEY=
62
+ NOTION_TOKEN=
63
+ TELEGRAM_BOT_TOKEN=
64
+ TELEGRAM_CHAT_ID=
65
+ UPSTASH_REDIS_REST_URL=
66
+ UPSTASH_REDIS_REST_TOKEN=
67
+
68
+ # ── 9. Feature Flags ─────────────────────────────────────────
69
+ VITE_ENABLE_BROWSER_SANDBOX=false
70
+ UNIFIED_LOOP_MAX_STEPS=8
71
+ LLM_MODEL=openai/gpt-oss-20b:free
72
+
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ chroma_db/
6
+ *.egg-info/
7
+ .env
8
+
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PYTHONDONTWRITEBYTECODE=1 \
5
+ PORT=7860 \
6
+ FRONTEND_DIST=/home/user/app/static \
7
+ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
8
+
9
+ WORKDIR /app
10
+
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential curl git nodejs npm \
13
+ libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
14
+ libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
15
+ libxfixes3 libxrandr2 libgbm1 libasound2 \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # hf-sync copia backend/* nella root dello Space — nessun prefisso backend/
19
+ COPY requirements.txt /app/requirements.txt
20
+ RUN pip install --no-cache-dir -r /app/requirements.txt \
21
+ && playwright install chromium \
22
+ && chmod -R 755 /ms-playwright
23
+
24
+ # S356: ttyd — terminale web per accesso da iPhone Safari (binario statico, zero dep)
25
+ ARG TTYD_VERSION=1.7.7
26
+ RUN ARCH=$(uname -m) && \
27
+ TTYD_ARCH=$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x86_64") && \
28
+ curl -fsSL -o /usr/local/bin/ttyd \
29
+ "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.${TTYD_ARCH}" && \
30
+ chmod +x /usr/local/bin/ttyd
31
+
32
+ RUN useradd -m -u 1000 user
33
+ USER user
34
+ ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
35
+
36
+ WORKDIR /home/user/app
37
+ COPY --chown=user . /home/user/app/
38
+
39
+ EXPOSE 7860
40
+
41
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${WEB_CONCURRENCY:-2}"]
README.md ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Agente AI Backend
3
+ emoji: 🤖
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Agente AI — Backend FastAPI
12
+
13
+ Backend Python FastAPI per Agente AI. Streaming LLM, memoria, esecuzione codice, terminal PTY.
14
+
15
+ ## Endpoints principali
16
+
17
+ - `GET /health` — stato backend
18
+ - `GET /api/status` — versione + config
19
+ - `POST /api/reason/loop` — agent reasoning loop
20
+ - `POST /api/exec` — esecuzione codice Python
21
+ - `POST /api/execute-shell` — shell commands
22
+ - `POST /api/search` — web search proxy
23
+ - `POST /api/fetch-page` — page fetch proxy
24
+ - `WS /ws/terminal` — PTY WebSocket terminal
25
+
26
+ ## Stack
27
+
28
+ - Python 3.11 + FastAPI + uvicorn
29
+ - smolagents>=1.14.0 + litellm>=1.40.0
30
+ - supabase (opzionale)
31
+
32
+ ## Variabili ambiente
33
+
34
+ | Variabile | Descrizione |
35
+ |-----------|-------------|
36
+ | GROQ_API_KEY | Groq API key |
37
+ | GEMINI_API_KEY | Google Gemini key |
38
+ | OPENROUTER_API_KEY | OpenRouter key |
39
+ | HF_TOKEN | HuggingFace token |
40
+ | SUPABASE_URL | Supabase URL (opzionale) |
41
+ | SUPABASE_ANON_KEY | Supabase anon key (opzionale) |
42
+ | ALLOWED_ORIGINS | CORS origins comma-separated |
agents/__init__.py ADDED
File without changes
agents/acceptance_criteria.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ acceptance_criteria.py — Sprint 2: Libreria criteri di accettazione standard
3
+
4
+ Zero LLM, dict statico, zero latenza.
5
+ Ogni feature ha una lista di criteri verificabili che il GoalVerifier 2.0 usa
6
+ per valutare se il goal è stato raggiunto per REQUISITO.
7
+
8
+ Integrato con RequirementEngine: ogni requisito riceve criteri automaticamente.
9
+ Integrato con GoalVerifier 2.0: ogni criterio viene verificato separatamente.
10
+
11
+ Invarianti rispettate:
12
+ - Additive-only: importato da requirement_engine.py senza modificare esistente
13
+ - Zero side effects: solo dict statico
14
+ """
15
+
16
+ ACCEPTANCE_CRITERIA: dict[str, list[str]] = {
17
+ "auth": [
18
+ "Utente corretto può autenticarsi (HTTP 200 o redirect autenticato)",
19
+ "Credenziali errate restituiscono errore (HTTP 401/400)",
20
+ "Sessione creata e persistita dopo login",
21
+ "Logout invalida la sessione",
22
+ "Route protette richiedono autenticazione",
23
+ ],
24
+ "crud": [
25
+ "Creazione record: entità inserita nel DB con ID univoco",
26
+ "Lettura lista: endpoint ritorna array con tutti i record",
27
+ "Lettura singola: endpoint ritorna entità per ID",
28
+ "Aggiornamento: modifica persistita nel DB",
29
+ "Cancellazione: record rimosso, non più visibile in lista",
30
+ ],
31
+ "dashboard": [
32
+ "Dati aggregati visibili (contatori, totali, medie)",
33
+ "Componente UI renderizza senza errori console",
34
+ "Dati aggiornati al refresh pagina",
35
+ "Empty state gestito (nessun crash su lista vuota)",
36
+ ],
37
+ "api_rest": [
38
+ "GET /resource → lista entità (200)",
39
+ "POST /resource → crea entità (201)",
40
+ "PUT/PATCH /resource/:id → aggiorna entità (200)",
41
+ "DELETE /resource/:id → rimuove entità (204)",
42
+ "Input non valido → risposta 400 con messaggio errore",
43
+ ],
44
+ "form_validation": [
45
+ "Campi obbligatori mostrano errore se vuoti",
46
+ "Formato email validato",
47
+ "Submit disabilitato finché il form non è valido",
48
+ "Errori server mostrati inline (non solo console)",
49
+ "Submit con dati validi → feedback positivo all'utente",
50
+ ],
51
+ "file_upload": [
52
+ "File selezionato caricato senza errori",
53
+ "Tipo file validato (estensioni accettate)",
54
+ "Dimensione file validata",
55
+ "Progresso upload visibile",
56
+ "File salvato e accessibile dopo upload",
57
+ ],
58
+ "search": [
59
+ "Query restituisce risultati pertinenti",
60
+ "Query vuota → lista completa o empty state",
61
+ "Ricerca case-insensitive",
62
+ "Nessun errore su caratteri speciali",
63
+ ],
64
+ "payments": [
65
+ "Checkout avviato con dati carrello corretti",
66
+ "Pagamento completato → ordine confermato",
67
+ "Pagamento fallito → errore leggibile (non crash)",
68
+ "Ricevuta/conferma mostrata post-pagamento",
69
+ ],
70
+ "notifications": [
71
+ "Notifica inviata in risposta all'evento trigger",
72
+ "Contenuto notifica corretto (destinatario, testo)",
73
+ "Fallimento invio gestito senza crash applicazione",
74
+ ],
75
+ "settings": [
76
+ "Impostazioni salvate persistono dopo refresh",
77
+ "Cambio password richiede verifica vecchia password",
78
+ "Modifica profilo aggiorna dati visibili",
79
+ ],
80
+ "database": [
81
+ "Schema creato senza errori di migrazione",
82
+ "Relazioni tra entità corrette (FK integrità)",
83
+ "Indici su colonne di ricerca/sort presenti",
84
+ "Seed dati iniziali caricati se previsti",
85
+ ],
86
+ "deploy": [
87
+ "Build completata senza errori (exit 0)",
88
+ "App raggiungibile sull'URL di produzione",
89
+ "Variabili d'ambiente necessarie configurate",
90
+ "Health check endpoint risponde 200",
91
+ ],
92
+ "analysis": [
93
+ "Risposta di almeno 150 parole (analisi non superficiale)",
94
+ "Almeno 3 punti distinti argomentati nel testo",
95
+ "Conclusione o sintesi finale presente",
96
+ "Nessuna affermazione generica senza supporto concreto",
97
+ ],
98
+ "comparison": [
99
+ "Almeno 2 dimensioni di confronto esplicite",
100
+ "Ogni opzione trattata in modo bilanciato",
101
+ "Sezione 'Raccomandazione' o conclusione con scelta motivata",
102
+ "Risposta di almeno 150 parole",
103
+ ],
104
+ "explanation": [
105
+ "Definizione chiara del concetto principale",
106
+ "Almeno 1 esempio concreto presente",
107
+ "Linguaggio appropriato al contesto (tecnico o divulgativo)",
108
+ "Risposta di almeno 100 parole",
109
+ ],
110
+ "summarization": [
111
+ "Punti chiave tutti presenti (nessuna omissione critica)",
112
+ "Struttura sintetica e leggibile",
113
+ "Risposta proporzionata alla complessità del materiale originale",
114
+ ],
115
+ "recommendation": [
116
+ "Almeno 3 criteri di valutazione esplicitati",
117
+ "Raccomandazione finale chiara e motivata",
118
+ "Pro/contro menzionati per l'opzione consigliata",
119
+ "Risposta di almeno 150 parole",
120
+ ],
121
+
122
+ }
agents/backend_antiregress.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/agents/backend_antiregress.py
2
+ # S-BACKEND-ANTIREGRESS: Python equivalent of antiRewriteGuard.ts
3
+ #
4
+ # Rileva pattern di regressione nell'output LLM prima che il backend lo restituisca:
5
+ # 1. Import injection — nuove dipendenze esterne non presenti nell'originale
6
+ # 2. Code rewrite — output ha drasticamente meno classi/def dell'originale
7
+ #
8
+ # Chiamato dentro il loop _llm_try di unified_loop.py prima del `break`.
9
+ # Non bloccante: qualsiasi eccezione interna viene silenziata dal caller.
10
+
11
+ from __future__ import annotations
12
+ import re
13
+ from typing import Optional
14
+
15
+ # ── Moduli stdlib Python (top-level) ──────────────────────────────────────────
16
+ _STDLIB = {
17
+ 'abc', 'ast', 'asyncio', 'base64', 'bisect', 'builtins', 'cgi', 'cmath',
18
+ 'collections', 'concurrent', 'contextlib', 'copy', 'copyreg', 'csv',
19
+ 'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'enum', 'errno',
20
+ 'fnmatch', 'fractions', 'functools', 'gc', 'glob', 'gzip', 'hashlib',
21
+ 'heapq', 'hmac', 'html', 'http', 'importlib', 'inspect', 'io', 'itertools',
22
+ 'json', 'keyword', 'linecache', 'locale', 'logging', 'math', 'mimetypes',
23
+ 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'platform', 'pprint',
24
+ 'queue', 'random', 're', 'shutil', 'signal', 'socket', 'sqlite3', 'stat',
25
+ 'string', 'struct', 'subprocess', 'sys', 'tempfile', 'textwrap', 'threading',
26
+ 'time', 'timeit', 'tkinter', 'traceback', 'typing', 'types', 'unittest',
27
+ 'urllib', 'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib',
28
+ # typing extras (importabili da typing_extensions ma canonicamente stdlib)
29
+ 'typing_extensions',
30
+ }
31
+
32
+ # ── Keyword che indicano task di FIX/MODIFICA (non creazione da zero) ─────────
33
+ _FIX_RE = re.compile(
34
+ r'\b(fix|bug|error|correggi|risolvi|modifica|aggiorna|update|repair|'
35
+ r'patch|debug|corretto|sistema|aggiusta|modif|correct|riparare|risolvere)\b',
36
+ re.IGNORECASE,
37
+ )
38
+
39
+ # ── Estrazione import da codice ────────────────────────────────────────────────
40
+ _IMPORT_RE = re.compile(
41
+ r'^(?:import\s+([\w.]+)|from\s+([\w.]+)\s+import)',
42
+ re.MULTILINE,
43
+ )
44
+
45
+ def _extract_imports(text: str) -> set[str]:
46
+ """Estrae set di nomi-modulo top-level da testo (goal o codice)."""
47
+ found: set[str] = set()
48
+ for m in _IMPORT_RE.finditer(text):
49
+ mod = m.group(1) or m.group(2)
50
+ if mod:
51
+ found.add(mod.split('.')[0].lower())
52
+ return found
53
+
54
+ def _extract_code_blocks(text: str) -> str:
55
+ """Restituisce il contenuto concatenato di tutti i code block."""
56
+ return '\n'.join(re.findall(r'```[^\n]*\n([\s\S]*?)```', text))
57
+
58
+ def _count_defs(text: str) -> int:
59
+ """Conta `class X` e `def x` a qualsiasi indentation."""
60
+ return len(re.findall(r'^\s*(?:class|def)\s+\w+', text, re.MULTILINE))
61
+
62
+ # ── API pubblica ───────────────────────────────────────────────────────────────
63
+ def check_regression(goal: str, output: str, context: str = "") -> Optional[str]:
64
+ """
65
+ Controlla se `output` presenta regressioni rispetto al `goal` / `context`.
66
+
67
+ Restituisce una stringa di hint per il retry (da iniettare nel prompt)
68
+ se viene rilevato almeno un pattern; None se l'output sembra ok.
69
+
70
+ Controllato SOLO se:
71
+ - L'output contiene almeno un code block (``` ... ```)
72
+ - Il goal è un task di fix/modifica (non creazione da zero)
73
+ """
74
+ if '```' not in output:
75
+ return None
76
+ if not _FIX_RE.search(goal):
77
+ return None
78
+
79
+ full_code = _extract_code_blocks(output)
80
+ if not full_code.strip():
81
+ return None
82
+
83
+ hints: list[str] = []
84
+
85
+ # ── 1. Import injection ────────────────────────────────────────────────────
86
+ out_imports = _extract_imports(full_code)
87
+ orig_imports = _extract_imports(goal + '\n' + context)
88
+ new_pkgs = out_imports - orig_imports - _STDLIB
89
+ # Rimuovi falsi positivi comuni nei progetti Python
90
+ new_pkgs -= {'typing', 'collections', 'dataclasses', 'abc', 'enum',
91
+ 'pytest', 'unittest', 'mock', 'functools', 'itertools'}
92
+ if new_pkgs:
93
+ hints.append(
94
+ f"NON introdurre nuove dipendenze: {', '.join(sorted(new_pkgs))}. "
95
+ "Usa esclusivamente le librerie già presenti nel codice originale."
96
+ )
97
+
98
+ # ── 2. Code rewrite (classi/funzioni mancanti) ────────────────────────────
99
+ # Conta definizioni nell'originale (nel goal/context) e nell'output
100
+ orig_defs = _count_defs(goal + '\n' + context)
101
+ out_defs = _count_defs(full_code)
102
+ # Segnala solo se l'originale ha 2+ definizioni E l'output ne ha drasticamente meno
103
+ if orig_defs >= 2 and out_defs < max(1, orig_defs - 1):
104
+ hints.append(
105
+ "Il tuo output omette classi/funzioni presenti nell'originale. "
106
+ "Includi TUTTE le strutture originali, modificando SOLO la parte difettosa. "
107
+ "Non riscrivere l'intero file da zero."
108
+ )
109
+
110
+ return ' | '.join(hints) if hints else None
agents/context_manager.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ context_manager.py — Intelligent Context Management (S364)
3
+
4
+ Implementa il "Project Skeleton" approach:
5
+ - Skeleton aggiornato (nomi file + firme funzioni) sempre disponibile
6
+ - Full content solo per file attivamente modificati
7
+ - File "cold" riepilogatati con CONTEXT role (Groq-8b-instant)
8
+
9
+ Risolve il "lost in the middle" problem su sessioni lunghe.
10
+
11
+ Design: stateless per request, tutto I/O fire-and-forget, mai blocca il loop
12
+
13
+ S752-A: aggiunta rank_files_by_relevance() — top-K selezione per rilevanza goal.
14
+ FIX-SKEL-RAG: symbol matching + fuzzy prefix + zero-score filter + path weight 2.0.
15
+ FIX-SYN-EXPAND: synonym expansion IT/EN per copertura semantica senza embeddings.
16
+ """
17
+ from __future__ import annotations
18
+ import asyncio
19
+ import hashlib
20
+ import re
21
+ from typing import Any
22
+
23
+ _FUNC_RE = re.compile(
24
+ r'^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()',
25
+ re.MULTILINE)
26
+ _CLASS_RE = re.compile(r'^(?:export\s+)?class\s+(\w+)', re.MULTILINE)
27
+ _PY_DEF_RE = re.compile(r'^(?: )?(?:async\s+)?def\s+(\w+)\s*\(', re.MULTILINE)
28
+ _PY_CLS_RE = re.compile(r'^class\s+(\w+)', re.MULTILINE)
29
+
30
+ _SUMMARY_CACHE: dict[str, str] = {}
31
+ _MAX_SUMMARY_CACHE = 200
32
+
33
+ # ── S752-A: stopword set per rank_files_by_relevance ──────────────────────────
34
+ _RANK_STOP_IT = {
35
+ 'il','lo','la','i','gli','le','di','del','della','dei','delle',
36
+ 'in','un','una','uno','che','con','per','non','da','si','su','al',
37
+ 'ci','e','a','tra','fra','ma','o','se','ne','ad','ho','ha','è',
38
+ }
39
+ _RANK_STOP_EN = {
40
+ 'the','a','an','in','on','at','to','for','of','and','or','is',
41
+ 'are','was','be','this','that','it','with','as','by','from','about',
42
+ 'can','will','have','has','had','do','does','did','not','but','if',
43
+ }
44
+ _RANK_STOP = _RANK_STOP_IT | _RANK_STOP_EN
45
+
46
+ # Entry-point / config files ottengono un piccolo boost di rilevanza
47
+ _RANK_ENTRY_STEMS = {'main', 'index', 'app', '__init__', 'config', 'settings', 'routes'}
48
+
49
+ # ── FIX-SKEL-RAG: helper per fuzzy prefix matching ────────────────────────────
50
+ _CAMEL_SPLIT_RE = re.compile(r'([a-z])([A-Z])')
51
+
52
+ # ── FIX-SYN-EXPAND: tabella sinonimi tecnici IT↔EN (15 cluster) ───────────────
53
+ # Struttura: ogni entry è un frozenset di termini equivalenti.
54
+ # _expand_tokens() aggiunge tutti i sinonimi di ogni token del goal prima del matching.
55
+ # Scelta design: sinonimi statici (zero LLM, zero latency) coprono l'80% dei task reali.
56
+ # I cluster coprono i domini più frequenti nello sviluppo software.
57
+ _SYN_CLUSTERS: list[frozenset[str]] = [
58
+ # Auth / Sicurezza
59
+ frozenset({'auth', 'autenticazione', 'authentication', 'login', 'signin',
60
+ 'guard', 'middleware', 'jwt', 'token', 'session', 'oauth',
61
+ 'passport', 'credential', 'permission', 'role', 'accesso'}),
62
+ # Pagamenti
63
+ frozenset({'payment', 'pagamento', 'stripe', 'checkout', 'invoice',
64
+ 'billing', 'subscription', 'abbonamento', 'fattura', 'webhook',
65
+ 'price', 'plan', 'tier'}),
66
+ # Database / ORM
67
+ frozenset({'database', 'db', 'schema', 'model', 'migration', 'migrazione',
68
+ 'orm', 'repository', 'query', 'drizzle', 'prisma', 'postgres',
69
+ 'sqlite', 'mysql', 'table', 'tabella', 'record'}),
70
+ # API / Network
71
+ frozenset({'api', 'endpoint', 'route', 'rotta', 'router', 'server',
72
+ 'request', 'response', 'richiesta', 'risposta', 'http',
73
+ 'rest', 'graphql', 'fetch', 'axios', 'client'}),
74
+ # UI / Frontend
75
+ frozenset({'component', 'componente', 'ui', 'interface', 'interfaccia',
76
+ 'button', 'form', 'modal', 'layout', 'page', 'pagina',
77
+ 'style', 'css', 'theme', 'tema', 'render', 'view'}),
78
+ # State Management
79
+ frozenset({'state', 'stato', 'store', 'redux', 'zustand', 'context',
80
+ 'provider', 'hook', 'reducer', 'action', 'dispatch',
81
+ 'observable', 'signal', 'reactive'}),
82
+ # File / Storage
83
+ frozenset({'file', 'upload', 'caricamento', 'storage', 'bucket',
84
+ 'download', 'attachment', 'allegato', 'blob', 'stream',
85
+ 'filesystem', 'directory', 'path', 'percorso'}),
86
+ # Testing
87
+ frozenset({'test', 'testing', 'spec', 'unit', 'integration', 'e2e',
88
+ 'mock', 'stub', 'fixture', 'assert', 'expect', 'coverage',
89
+ 'vitest', 'jest', 'pytest'}),
90
+ # Build / Deploy
91
+ frozenset({'build', 'deploy', 'deployment', 'bundle', 'webpack', 'vite',
92
+ 'esbuild', 'compile', 'dist', 'production', 'staging',
93
+ 'pipeline', 'ci', 'cd', 'docker', 'container'}),
94
+ # Email / Notifiche
95
+ frozenset({'email', 'mail', 'smtp', 'notification', 'notifica', 'alert',
96
+ 'push', 'telegram', 'slack', 'webhook', 'message', 'messaggio',
97
+ 'sendgrid', 'resend', 'mailer'}),
98
+ # AI / ML
99
+ frozenset({'ai', 'llm', 'model', 'prompt', 'embedding', 'rag',
100
+ 'vector', 'semantic', 'chat', 'completion', 'inference',
101
+ 'openai', 'gemini', 'groq', 'anthropic', 'agent', 'agente'}),
102
+ # Errori / Debug
103
+ frozenset({'error', 'errore', 'exception', 'eccezione', 'bug', 'fix',
104
+ 'debug', 'log', 'logging', 'trace', 'stack', 'crash',
105
+ 'fallback', 'retry', 'recover', 'handler', 'catch'}),
106
+ # Configurazione
107
+ frozenset({'config', 'configurazione', 'configuration', 'settings',
108
+ 'impostazioni', 'env', 'environment', 'variable', 'variabile',
109
+ 'secret', 'segreto', 'dotenv', 'constant', 'costante'}),
110
+ # Performance / Cache
111
+ frozenset({'cache', 'performance', 'performanza', 'speed', 'velocità',
112
+ 'optimize', 'ottimizzazione', 'lazy', 'memo', 'debounce',
113
+ 'throttle', 'batch', 'compress', 'compressione'}),
114
+ # Sicurezza / Validazione
115
+ frozenset({'validation', 'validazione', 'validate', 'sanitize',
116
+ 'sanitizzazione', 'schema', 'zod', 'yup', 'joi',
117
+ 'csrf', 'xss', 'injection', 'escape', 'secure'}),
118
+ # Monitoring / Observability (B-GAP-D: cluster mancante — task metriche/dashboard non rankati)
119
+ frozenset({'metrics', 'metric', 'monitoring', 'monitoraggio', 'observability',
120
+ 'prometheus', 'grafana', 'dashboard', 'telemetry', 'telemetria',
121
+ 'tracing', 'trace', 'health', 'healthcheck', 'uptime', 'alerting',
122
+ 'datadog', 'sentry', 'newrelic', 'audit', 'report'}),
123
+ # Scheduling / Background Jobs (B-GAP-D: cluster mancante — task cron/queue/worker)
124
+ frozenset({'cron', 'scheduler', 'pianificatore', 'schedule', 'queue', 'coda',
125
+ 'worker', 'job', 'background', 'celery', 'bull', 'bullmq',
126
+ 'agenda', 'delayed', 'periodic', 'retry', 'backoff', 'redis',
127
+ 'task', 'processo', 'process', 'daemon'}),
128
+ # WebSocket / Realtime (B-GAP-D: cluster mancante — task ws/sse/pubsub)
129
+ frozenset({'websocket', 'ws', 'socket', 'socketio', 'realtime', 'real_time',
130
+ 'sse', 'server_sent', 'pubsub', 'publish', 'subscribe', 'broadcast',
131
+ 'channel', 'canale', 'room', 'event', 'listener', 'emitter',
132
+ 'live', 'push', 'poll', 'long_polling', 'signalr', 'liveview'}),
133
+ ]
134
+
135
+ # Indice inverso: token → frozenset di sinonimi (costruito una volta a import)
136
+ _SYN_INDEX: dict[str, frozenset[str]] = {}
137
+ for _cluster in _SYN_CLUSTERS:
138
+ for _term in _cluster:
139
+ _SYN_INDEX[_term] = _cluster
140
+
141
+
142
+ def _expand_tokens(tokens: list[str]) -> list[str]:
143
+ """
144
+ FIX-SYN-EXPAND: espande ogni token del goal con i sinonimi IT/EN del suo cluster.
145
+
146
+ Esempio:
147
+ ["autenticazione", "aggiungi"] → ["autenticazione", "aggiungi",
148
+ "auth", "login", "guard", "middleware", "jwt", ...]
149
+
150
+ Garanzie:
151
+ - Ordine stabile: token originali prima, sinonimi dopo (preserva priorità)
152
+ - Nessun duplicato (usa set interno)
153
+ - Nessun token < 3 chars, nessuna stopword aggiunta
154
+ - Zero latency (<0.1ms per 20 token), zero LLM calls
155
+ - Mai rilancia eccezioni
156
+ """
157
+ try:
158
+ seen: set[str] = set(tokens)
159
+ expanded = list(tokens)
160
+ for t in tokens:
161
+ cluster = _SYN_INDEX.get(t)
162
+ if cluster:
163
+ for syn in cluster:
164
+ if syn not in seen and len(syn) >= 3 and syn not in _RANK_STOP:
165
+ seen.add(syn)
166
+ expanded.append(syn)
167
+ return expanded
168
+ except Exception:
169
+ return tokens
170
+
171
+
172
+ def _split_camel_snake(text: str) -> list[str]:
173
+ """
174
+ Spezza camelCase/PascalCase/snake_case in token lowercase (min 3 chars).
175
+
176
+ Esempi:
177
+ "contextManager" → ["context", "manager"]
178
+ "rank_files_by_relevance" → ["rank", "files", "relevance"]
179
+ "UnifiedAgentLoop" → ["unified", "agent", "loop"]
180
+ Usato per fuzzy prefix bonus in rank_files_by_relevance.
181
+ """
182
+ try:
183
+ snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text)
184
+ parts = re.split(r'[_\-./]', snake)
185
+ return [p.lower() for p in parts if len(p) >= 3]
186
+ except Exception:
187
+ return []
188
+
189
+
190
+ def rank_files_by_relevance(
191
+ goal: str,
192
+ all_files: list[dict[str, Any]],
193
+ k: int = 5,
194
+ min_score: float = 0.0,
195
+ ) -> list[str]:
196
+ """
197
+ FIX-SKEL-RAG + FIX-SYN-EXPAND: Seleziona i top-K file più rilevanti per il goal.
198
+
199
+ Score composito (normalizzato su max(len(base_tokens), 1)):
200
+ path_hits * 2.0 — keyword del goal (espansi) nel path
201
+ symbol_hits * 1.5 — keyword nei nomi funzione/classe (skeleton RAG)
202
+ content_hits * 1.0 — keyword nei primi 600 chars del contenuto
203
+ prefix_bonus * 0.4 — goal token è prefisso di un split-token path/symbol (fuzzy)
204
+ entry_boost +0.15 — file entry-point/config noti
205
+ lang_boost +0.20 — il linguaggio del file è nel goal
206
+
207
+ FIX-SYN-EXPAND:
208
+ - I token del goal vengono espansi con sinonimi IT/EN prima del matching.
209
+ - Normalizzazione su len(base_tokens) originali (non espansi) per evitare score
210
+ inflazionati su file che matchano solo sinonimi lontani.
211
+ - "autenticazione" → matcha authGuard.ts, middleware.ts, jwt.ts anche senza
212
+ keyword nel path — copertura semantica senza embeddings.
213
+
214
+ Ritorna lista di path ordinata score-desc (top-K, score > min_score).
215
+ Mai rilancia eccezioni — fallback ai primi K file non ranked.
216
+ """
217
+ if not all_files or not goal:
218
+ return []
219
+ try:
220
+ base_tokens = [
221
+ t.lower()
222
+ for t in re.findall(r'\b\w{3,}\b', goal[:500])
223
+ if t.lower() not in _RANK_STOP
224
+ ]
225
+ if not base_tokens:
226
+ return [f.get('path', '') for f in all_files[:k] if f.get('path')]
227
+
228
+ # FIX-SYN-EXPAND: espandi con sinonimi tecnici IT/EN
229
+ tokens = _expand_tokens(base_tokens)
230
+
231
+ goal_lower = goal.lower()
232
+ # Normalizzatore: usa len(base_tokens) non len(tokens) per evitare score inflazionati
233
+ n = max(len(base_tokens), 1)
234
+ scores: list[tuple[float, str]] = []
235
+
236
+ for f in all_files:
237
+ path = f.get('path', '') or ''
238
+ content = (f.get('content', '') or '')[:600]
239
+ lang = (f.get('language', '') or '').lower()
240
+ if not path:
241
+ continue
242
+
243
+ path_lower = path.lower()
244
+ content_lower = content.lower()
245
+
246
+ # FIX-SKEL-RAG: estrai firme funzione/classe
247
+ sigs = _extract_signatures(content, lang)
248
+ symbols_lower = ' '.join(s.split(':', 1)[-1].lower() for s in sigs)
249
+
250
+ # Score primario — matching su token espansi
251
+ path_hits = sum(1 for t in tokens if t in path_lower)
252
+ symbol_hits = sum(1 for t in tokens if t in symbols_lower)
253
+ content_hits = sum(1 for t in tokens if t in content_lower)
254
+ score = (path_hits * 2.0 + symbol_hits * 1.5 + content_hits) / n
255
+
256
+ # Fuzzy prefix bonus (su token base, non espansi — evita falsi positivi)
257
+ filename_stem = re.sub(r'\.[^.]+$', '', path_lower.rsplit('/', 1)[-1])
258
+ split_path = _split_camel_snake(filename_stem)
259
+ split_syms = [t for s in sigs for t in _split_camel_snake(s.split(':', 1)[-1])]
260
+ all_split = split_path + split_syms
261
+ prefix_hits = sum(
262
+ 1 for gt in base_tokens # usa base_tokens: fuzzy su originali
263
+ for st in all_split
264
+ if st != gt and st.startswith(gt)
265
+ )
266
+ if prefix_hits:
267
+ score += (prefix_hits * 0.4) / n
268
+
269
+ # Entry-point boost
270
+ if filename_stem in _RANK_ENTRY_STEMS:
271
+ score += 0.15
272
+
273
+ # Language boost
274
+ if lang and lang in goal_lower:
275
+ score += 0.20
276
+
277
+ if score > min_score:
278
+ scores.append((score, path))
279
+
280
+ # Ordinamento stabile: score desc, poi path asc
281
+ scores.sort(key=lambda x: (-x[0], x[1]))
282
+ return [p for _, p in scores[:k] if p]
283
+ except Exception:
284
+ return [f.get('path', '') for f in all_files[:k] if f.get('path')]
285
+
286
+
287
+ def _extract_signatures(content: str, language: str) -> list[str]:
288
+ """Estrae nomi di funzioni/classi per lo skeleton."""
289
+ try:
290
+ lang = (language or '').lower()
291
+ sigs: list[str] = []
292
+ if lang in ('typescript', 'ts', 'tsx', 'javascript', 'js', 'jsx'):
293
+ for m in _FUNC_RE.finditer(content):
294
+ name = m.group(1) or m.group(2)
295
+ if name:
296
+ sigs.append(f'fn:{name}')
297
+ for m in _CLASS_RE.finditer(content):
298
+ sigs.append(f'class:{m.group(1)}')
299
+ elif lang in ('python', 'py'):
300
+ for m in _PY_DEF_RE.finditer(content):
301
+ sigs.append(f'def:{m.group(1)}')
302
+ for m in _PY_CLS_RE.finditer(content):
303
+ sigs.append(f'class:{m.group(1)}')
304
+ return sigs[:15]
305
+ except Exception:
306
+ return []
307
+
308
+
309
+ def build_file_skeleton(path: str, content: str, language: str) -> str:
310
+ """Costruisce una riga skeleton per un singolo file."""
311
+ sigs = _extract_signatures(content, language)
312
+ line_count = content.count('\n') + 1
313
+ sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)'
314
+ return f' {path} ({line_count}L): {sigs_str}'
315
+
316
+
317
+ async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
318
+ """
319
+ Costruisce lo skeleton compatto da una lista di file VFS.
320
+ Ogni dict ha: path, content, language.
321
+ Ritorna stringa multiriga per iniezione nel contesto agente.
322
+ """
323
+ if not files:
324
+ return ''
325
+ try:
326
+ lines = [f'\U0001f4c1 PROJECT SKELETON ({len(files)} files):']
327
+ for f in sorted(files, key=lambda x: x.get('path', '')):
328
+ path = f.get('path', '?')
329
+ content = f.get('content', '') or ''
330
+ language = f.get('language', '') or ''
331
+ lines.append(build_file_skeleton(path, content, language))
332
+ return '\n'.join(lines)
333
+ except Exception:
334
+ return ''
335
+
336
+
337
+ async def compress_cold_file(path: str, content: str, language: str,
338
+ tester_llm: Any | None = None) -> str:
339
+ """
340
+ Comprime un file 'cold' al suo riepilogo essenziale.
341
+ Usa Groq-8b via CONTEXT role per velocità. Fallback a skeleton.
342
+ Max 8s. Mai rilancia eccezioni.
343
+ """
344
+ # S573: hash() è PYTHONHASHSEED-salted → chiave diversa a ogni restart HF Space
345
+ # → nessun riuso della cache tra restart. hashlib.sha256 è stabile e deterministica.
346
+ _h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16]
347
+ cache_key = f'{path}:{_h}'
348
+ if cache_key in _SUMMARY_CACHE:
349
+ return _SUMMARY_CACHE[cache_key]
350
+
351
+ skeleton = build_file_skeleton(path, content, language)
352
+
353
+ if tester_llm and len(content) > 500:
354
+ try:
355
+ msgs = [
356
+ {"role": "system", "content":
357
+ "Riassumi il file in max 2 righe: scopo, symbols chiave, deps. "
358
+ "Solo facts. Formato: [SCOPO] | [SYMBOLS] | [DEPS]"},
359
+ {"role": "user", "content":
360
+ f"File: {path}\n```{language}\n{content[:2000]}\n```"},
361
+ ]
362
+ summary = await asyncio.wait_for(
363
+ # S587: 120→200 — formato [SCOPO]|[SYMBOLS]|[DEPS] può superare 120 tok
364
+ tester_llm.chat(msgs, temperature=0.0, max_tokens=200),
365
+ timeout=7.0,
366
+ )
367
+ if summary and not summary.startswith('[LLM'):
368
+ result = f' {path}: {summary[:300]}' # S604: 180→300 — summary file LLM spesso 2-3 righe
369
+ if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
370
+ oldest = next(iter(_SUMMARY_CACHE))
371
+ del _SUMMARY_CACHE[oldest]
372
+ _SUMMARY_CACHE[cache_key] = result
373
+ return result
374
+ except Exception:
375
+ pass # S364: fallback a skeleton
376
+
377
+ return skeleton
378
+
379
+
380
+ async def get_context_for_goal(
381
+ goal: str,
382
+ active_files: list[str],
383
+ all_files: list[dict[str, Any]],
384
+ tester_llm: Any | None = None,
385
+ top_k: int = 5,
386
+ ) -> str:
387
+ """
388
+ Contesto intelligente per l'agente:
389
+ - File in active_files: full content (max 1500 chars ciascuno)
390
+ - Altri file: skeleton compatto
391
+ - Output max: ~4000 chars
392
+
393
+ S752-A + FIX-SKEL-RAG + FIX-SYN-EXPAND: se active_files è vuoto o None, usa
394
+ rank_files_by_relevance() (con synonym expansion) per selezionare i top_k file
395
+ più rilevanti per il goal. File con score == 0 esclusi automaticamente.
396
+ """
397
+ if not all_files:
398
+ return ''
399
+ try:
400
+ if not active_files and goal:
401
+ active_files = rank_files_by_relevance(goal, all_files, k=top_k)
402
+
403
+ active_set = set(active_files)
404
+ parts: list[str] = []
405
+ budget = 4000
406
+
407
+ for f in all_files:
408
+ path = f.get('path', '')
409
+ if path not in active_set:
410
+ continue
411
+ content = (f.get('content', '') or '')[:1500]
412
+ language = f.get('language', '') or ''
413
+ chunk = f'[ACTIVE FILE: {path}]\n```{language}\n{content}\n```'
414
+ parts.append(chunk)
415
+ budget -= len(chunk)
416
+ if budget <= 0:
417
+ break
418
+
419
+ cold_files = [f for f in all_files if f.get('path', '') not in active_set]
420
+ if cold_files and budget > 500:
421
+ skeleton = await build_project_skeleton(cold_files)
422
+ if skeleton:
423
+ parts.append(skeleton)
424
+
425
+ return '\n\n'.join(parts) if parts else ''
426
+ except Exception:
427
+ return ''
428
+
429
+ # ── S-CONTEXT-SHARDING: Gestione intelligente del contesto lungo (S482) ──────
430
+ def shard_context(full_context: str, max_shard_size: int = 2000) -> list[str]:
431
+ """Divide il contesto in shard logici basati sulla rilevanza semantica."""
432
+ shards = []
433
+ current_shard = []
434
+ current_size = 0
435
+
436
+ # Dividiamo per blocchi logici (paragrafi o sezioni di codice)
437
+ blocks = re.split(r'\n(?=\s*[A-Z#])', full_context)
438
+
439
+ for block in blocks:
440
+ block_size = len(block)
441
+ if current_size + block_size > max_shard_size and current_shard:
442
+ shards.append("\n".join(current_shard))
443
+ current_shard = []
444
+ current_size = 0
445
+ current_shard.append(block)
446
+ current_size += block_size
447
+
448
+ if current_shard:
449
+ shards.append("\n".join(current_shard))
450
+ return shards
agents/critic.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ critic.py — Critic Model
3
+ Secondo passaggio: verifica output, trova errori, suggerisce miglioramenti.
4
+ Usa AIClient generico (non OllamaClient) per compatibilità HF Space.
5
+ """
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ import logging
11
+ _logger = logging.getLogger("agents.critic")
12
+
13
+
14
+ CRITIC_SYSTEM = """Sei un critico AI. Valuta l'output dato e rispondi SOLO con JSON valido:
15
+ {
16
+ "quality": 0-10,
17
+ "issues": ["lista problemi trovati — solo se GRAVI, non dettagli stilistici"],
18
+ "suggestions": ["lista miglioramenti concreti"],
19
+ "is_complete": true/false,
20
+ "needs_retry": true/false,
21
+ "confidence": 0.0-1.0
22
+ }
23
+
24
+ Criteri:
25
+ - quality 8-10: risposta corretta, completa, con codice/calcoli se richiesti
26
+ - quality 5-7: risposta parziale ma utile, mancano dettagli non essenziali
27
+ - quality 0-4: risposta sbagliata, vuota, o fuori tema → needs_retry: true
28
+ - needs_retry: true SOLO se quality <= 3 (non per risposte corrette ma incomplete)
29
+ - Se la risposta ha codice funzionante, calcoli corretti o dati reali → quality >= 7
30
+
31
+ Nessun testo prima o dopo il JSON."""
32
+
33
+
34
+
35
+ def _extract_json_balanced(raw: str) -> str | None:
36
+ """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'.
37
+ Gestisce oggetti JSON annidati correttamente (es. patch con sub-oggetti).
38
+ """
39
+ depth = 0
40
+ start = -1
41
+ for i, ch in enumerate(raw):
42
+ if ch == '{':
43
+ if depth == 0:
44
+ start = i
45
+ depth += 1
46
+ elif ch == '}':
47
+ depth -= 1
48
+ if depth == 0 and start != -1:
49
+ return raw[start:i + 1]
50
+ return None
51
+
52
+ class Critic:
53
+ def __init__(self, llm_client: Any):
54
+ self.llm = llm_client
55
+
56
+ async def evaluate(self, task: str, output: str, model: str | None = None) -> dict:
57
+ messages = [
58
+ {"role": "system", "content": CRITIC_SYSTEM},
59
+ {
60
+ "role": "user",
61
+ "content": (
62
+ f"Task originale: {task}\n\n"
63
+ f"Output da valutare:\n{output[:2000]}"
64
+ ),
65
+ },
66
+ ]
67
+ try:
68
+ raw = await self.llm.chat(messages, temperature=0.2, max_tokens=512)
69
+ json_match = _extract_json_balanced(raw)
70
+ if json_match:
71
+ result = json.loads(json_match)
72
+ result["_evaluated"] = True
73
+ return result
74
+ except Exception as _exc:
75
+ _logger.debug("[critic] silenced %s", type(_exc).__name__) # noqa: BLE001
76
+
77
+ # Fallback euristico (nessuna chiamata LLM)
78
+ quality = 5
79
+ issues: list[str] = []
80
+
81
+ if len(output) < 50:
82
+ quality -= 3
83
+ issues.append("Output troppo breve")
84
+ if "errore" in output.lower() or "error" in output.lower():
85
+ quality -= 2
86
+ issues.append("Potenziali errori nell'output")
87
+ if len(output) > 100:
88
+ quality += 2
89
+ # Penalizza description leak dei tool
90
+ if "usa il tool" in output.lower() or "esegui il comando" in output.lower():
91
+ quality -= 3
92
+ issues.append("Agente descrive tool invece di usarli")
93
+
94
+ return {
95
+ "quality": max(0, min(10, quality)),
96
+ "issues": issues,
97
+ "suggestions": ["Verifica la completezza della risposta"],
98
+ "is_complete": len(output) > 100,
99
+ "needs_retry": quality < 3, # S192: alzato soglia 4→3 — meno falsi negativi
100
+ "confidence": 0.5,
101
+ "_fallback": True,
102
+ }
agents/dynamic_replanner.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dynamic_replanner.py — COG-1: Dynamic Re-planner on subtask failure.
3
+
4
+ Quando il loop accumula >= 1 subtask falliti con errori reali,
5
+ genera un NUOVO piano con il contesto degli errori iniettato nel goal.
6
+
7
+ Architettura:
8
+ - should_replan(): decision gate — zero latency, no LLM
9
+ - replan(): chiama planner.create_plan() con failure context
10
+ - Max 1 re-plan per run (flag _replanned=True nel piano restituito)
11
+ - Timeout 20s; fallback: None → usa piano originale
12
+
13
+ Integration: chiamato da unified_loop.py dopo il gather dei subtask
14
+ se exec_warn contiene fallimenti reali (non solo risk:high skips).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import re
21
+
22
+ _logger = logging.getLogger("agente_ai.replanner")
23
+
24
+ _FAILURE_RE = re.compile(
25
+ r"(timeout|error|errore|fallito|failed|exception|not found|non trovato"
26
+ r"|AttributeError|TypeError|RuntimeError|ImportError|KeyError"
27
+ r"|404|500|503|ECONNREFUSED|ConnectionError|ModuleNotFoundError)",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+
32
+ # P26-B2: pattern errori transienti — non triggerano replan (si risolvono da soli)
33
+ _TRANSIENT_RE = re.compile(
34
+ r"(429|rate.?limit|too many requests|connection.?reset|connection.?refused"
35
+ r"|network.*timeout|read.*timeout|ssl.*timeout|temporary.*unavailable"
36
+ r"|service.*unavailable|overloaded|quota.*exceeded)",
37
+ re.IGNORECASE,
38
+ )
39
+
40
+ # Pattern per fallimenti critici strutturali (richiedono replan immediato)
41
+ _CRITICAL_RE = re.compile(
42
+ r"(ImportError|ModuleNotFoundError|SyntaxError|TypeError|AttributeError"
43
+ r"|PermissionError|AssertionError|not found|ECONNREFUSED)",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def should_replan(exec_warn: list[str], exec_done: list[str]) -> bool:
49
+ """
50
+ Decision gate: decide se vale la pena re-pianificare.
51
+
52
+ Trigger se:
53
+ - Almeno 1 warning contiene pattern di failure reale (non solo skip risk:high)
54
+ - exec_done ha meno successi dei fallimenti (piano non sta funzionando)
55
+
56
+ P26-B2: errori transienti (429/RateLimit/timeout di rete) NON triggerano
57
+ replan — si risolvono da soli e il replan sarebbe un falso positivo costoso.
58
+ """
59
+ if not exec_warn:
60
+ return False
61
+ real_failures = [w for w in exec_warn if _FAILURE_RE.search(w)]
62
+ if not real_failures:
63
+ return False
64
+ # P26-B2: se TUTTI i fallimenti sono transienti → no replan, lascia retry naturale
65
+ transient = [w for w in real_failures if _TRANSIENT_RE.search(w)]
66
+ if transient and len(transient) == len(real_failures):
67
+ _logger.debug("P26-B2 should_replan=False: tutti i %d fallimenti sono transienti", len(transient))
68
+ return False
69
+ # REASONING-BUG-6: singolo fallimento critico strutturale → re-plan immediato
70
+ critical_failures = [w for w in real_failures if _CRITICAL_RE.search(w)]
71
+ if critical_failures:
72
+ return True # ImportError / SyntaxError / AttributeError → replan subito
73
+ # Re-plan se fallimenti strutturali >= successi (piano non sta funzionando)
74
+ structural = [w for w in real_failures if not _TRANSIENT_RE.search(w)]
75
+ return len(structural) >= max(len(exec_done), 1)
76
+
77
+
78
+ def _find_downstream(subtasks: list, done_descs: set) -> tuple:
79
+ """P25-R1: dato il grafo requires[], ritorna (done_ids, pending_ids).
80
+
81
+ - done_ids : subtask già completati (matched by description in done_descs)
82
+ - pending_ids: subtask non ancora completati (da includere nel re-plan)
83
+
84
+ Logica: matching fuzzy description→done_descs (substring 40 char).
85
+ Pure function, zero I/O, zero LLM — usata solo per filtrare il re-plan scope.
86
+ """
87
+ done_ids: set = set()
88
+ for st in subtasks:
89
+ desc = str(st.get("description", ""))[:40].lower()
90
+ if any(desc and desc in d.lower() for d in done_descs):
91
+ done_ids.add(st.get("id"))
92
+ pending = [st for st in subtasks if st.get("id") not in done_ids]
93
+ return done_ids, pending
94
+
95
+
96
+ async def replan(
97
+ planner: object,
98
+ original_goal: str,
99
+ exec_warn: list[str],
100
+ exec_done: list[str],
101
+ error_context: str = "",
102
+ plan: "dict | None" = None,
103
+ ) -> "dict | None":
104
+ """
105
+ Genera un nuovo piano con il contesto dei fallimenti iniettato nel goal.
106
+
107
+ Il goal arricchito contiene:
108
+ - Subtask già completati (da NON ripetere)
109
+ - Problemi riscontrati (ultimi 3 warning)
110
+ - Analisi errore classificata (se disponibile)
111
+
112
+ Returns: nuovo piano dict con _replanned=True, o None se fallisce.
113
+ """
114
+ if not planner:
115
+ return None
116
+
117
+ # P25-R1: graph-aware scope — se abbiamo il piano corrente, replan solo i subtask
118
+ # pendenti (non quelli già completati). Riduce il re-plan al sottoinsieme necessario.
119
+ _scope_hint = ""
120
+ if plan and plan.get("subtasks"):
121
+ _done_descs = set(exec_done)
122
+ _, _pending = _find_downstream(plan["subtasks"], _done_descs)
123
+ if _pending and len(_pending) < len(plan["subtasks"]):
124
+ _ids = [st.get("id") for st in _pending]
125
+ _scope_hint = f"\nRe-pianifica SOLO i subtask {_ids} (gli altri sono già completati)."
126
+ _logger.debug("P25-R1 scope ridotto: %d/%d subtask da replanare", len(_pending), len(plan["subtasks"]))
127
+
128
+ failures_str = "\n".join(exec_warn[-3:]) if exec_warn else "nessun dettaglio"
129
+ done_str = ", ".join(exec_done[-5:]) if exec_done else "nessuno"
130
+
131
+ # P16-B6: estrai tool/approcci falliti — guida il replanner a evitarli
132
+ # P18: rimosso import re lazy — usa re module-level (già importato riga 20)
133
+ _tool_fails: list[str] = []
134
+ for _w in exec_warn[-5:]:
135
+ _m = re.search(
136
+ r"(web_search|run_python|write_file|read_file|web_fetch|"
137
+ r"trigger_webhook|pip_install|shell_exec|delegate)\w*",
138
+ _w, re.IGNORECASE,
139
+ )
140
+ if _m:
141
+ _tool_fails.append(_m.group(0))
142
+ _avoid_str = ", ".join(set(_tool_fails)) if _tool_fails else ""
143
+
144
+ enriched_goal = (
145
+ f"{original_goal}\n\n"
146
+ f"[CONTESTO RE-PLAN \u2014 tentativo precedente fallito]\n"
147
+ f"Subtask gi\u00e0 completati (NON ripetere): {done_str}.\n"
148
+ f"Problemi riscontrati:\n{failures_str}\n"
149
+ )
150
+ if _avoid_str:
151
+ enriched_goal += f"Tool che hanno fallito (usa ALTERNATIVE): {_avoid_str}.\n"
152
+ if error_context:
153
+ enriched_goal += f"Analisi errore: {error_context[:300]}\n"
154
+ if _scope_hint:
155
+ enriched_goal += _scope_hint
156
+ _avoid_hint = f"Evita: {_avoid_str}. " if _avoid_str else ""
157
+ enriched_goal += (
158
+ "[ISTRUZIONE] Genera un piano ALTERNATIVO che eviti gli stessi problemi. "
159
+ f"{_avoid_hint}"
160
+ "Usa approcci diversi per i subtask falliti. "
161
+ "Se un tool ha fallito, usa un tool alternativo."
162
+ )
163
+ try:
164
+ new_plan = await asyncio.wait_for(
165
+ planner.create_plan(enriched_goal), # type: ignore[attr-defined]
166
+ timeout=20.0,
167
+ )
168
+ if new_plan and new_plan.get("subtasks"):
169
+ _logger.info(
170
+ "COG-1 replan: %d subtask nel nuovo piano (da %d warn, %d done)",
171
+ len(new_plan["subtasks"]), len(exec_warn), len(exec_done),
172
+ )
173
+ new_plan["_replanned"] = True
174
+ return new_plan
175
+ except asyncio.TimeoutError:
176
+ _logger.warning("COG-1 replan timeout 20s — mantengo piano originale")
177
+ except Exception as exc:
178
+ _logger.warning("COG-1 replan error: %s", exc)
179
+
180
+ return None
agents/engineering_state.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Versioned, bounded engineering lifecycle state for the unified agent loop.
2
+
3
+ The module is deliberately dependency-free. It mirrors the legacy lifecycle without
4
+ being authoritative for recovery when the rollout mode is enabled, and it never stores
5
+ raw prompts, credentials, or arbitrary tool output.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any, Mapping
16
+
17
+ SCHEMA_VERSION = 1
18
+ MAX_HISTORY = 64
19
+ MAX_DIAGNOSTICS = 24
20
+ MAX_PREVIEW_CHARS = 256
21
+ MAX_ID_CHARS = 180
22
+
23
+ _SECRET_PATTERNS = (
24
+ re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
25
+ re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
26
+ re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
27
+ re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
28
+ re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
29
+ )
30
+
31
+
32
+ class EngineeringStateMode(str, Enum):
33
+ OFF = "off"
34
+ SHADOW = "shadow"
35
+ CANARY = "canary"
36
+ AUTHORITATIVE = "authoritative"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EngineeringStateConfig:
41
+ """Conservative rollout configuration read once per run."""
42
+
43
+ mode: EngineeringStateMode = EngineeringStateMode.OFF
44
+ canary_rate: float = 0.0
45
+
46
+ @classmethod
47
+ def from_env(cls) -> "EngineeringStateConfig":
48
+ raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
49
+ try:
50
+ mode = EngineeringStateMode(raw_mode)
51
+ except ValueError:
52
+ mode = EngineeringStateMode.OFF
53
+ try:
54
+ rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
55
+ except (TypeError, ValueError):
56
+ rate = 0.0
57
+ return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
58
+
59
+ @property
60
+ def enabled(self) -> bool:
61
+ return self.mode is not EngineeringStateMode.OFF
62
+
63
+ def selects_canary(self, run_id: str, session_id: str) -> bool:
64
+ if self.mode is not EngineeringStateMode.CANARY or not session_id:
65
+ return False
66
+ if self.canary_rate >= 1.0:
67
+ return True
68
+ if self.canary_rate <= 0.0:
69
+ return False
70
+ digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
71
+ bucket = int.from_bytes(digest[:8], "big") / float(2**64)
72
+ return bucket < self.canary_rate
73
+
74
+
75
+ def _bounded_id(value: str | None) -> str:
76
+ return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
77
+
78
+
79
+ def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
80
+ """Redact common credential forms before anything reaches a checkpoint."""
81
+ text = str(value or "")[: max_chars * 4]
82
+ for pattern in _SECRET_PATTERNS:
83
+ if pattern.groups:
84
+ text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
85
+ else:
86
+ text = pattern.sub("[REDACTED]", text)
87
+ return text[:max_chars]
88
+
89
+
90
+ _ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
91
+ "IDLE": frozenset({"CLASSIFYING", "FAILED"}),
92
+ "CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
93
+ "TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
94
+ "THINKING": frozenset({"COMPLETED", "FAILED"}),
95
+ "FAILED": frozenset({"IDLE"}),
96
+ "COMPLETED": frozenset({"IDLE", "FAILED"}),
97
+ }
98
+
99
+
100
+ @dataclass
101
+ class EngineeringState:
102
+ """Bounded state envelope that can be persisted and safely restored."""
103
+
104
+ run_id: str
105
+ session_id: str
106
+ checkpoint_id: str
107
+ goal_digest: str
108
+ goal_preview: str
109
+ current_state: str = "IDLE"
110
+ history: list[dict[str, Any]] = field(default_factory=list)
111
+ diagnostics: list[str] = field(default_factory=list)
112
+ revision: int = 0
113
+ sequence: int = 0
114
+ created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
115
+ updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
116
+
117
+ @classmethod
118
+ def start(
119
+ cls,
120
+ goal: str,
121
+ *,
122
+ run_id: str,
123
+ session_id: str = "",
124
+ checkpoint_id: str | None = None,
125
+ now_ms: int | None = None,
126
+ ) -> "EngineeringState":
127
+ now = int(time.time() * 1000) if now_ms is None else int(now_ms)
128
+ normalized_goal = str(goal or "")
129
+ return cls(
130
+ run_id=_bounded_id(run_id),
131
+ session_id=_bounded_id(session_id),
132
+ checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
133
+ goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
134
+ goal_preview=redact_text(normalized_goal),
135
+ created_at_ms=now,
136
+ updated_at_ms=now,
137
+ )
138
+
139
+ @property
140
+ def status(self) -> str:
141
+ if self.current_state == "COMPLETED":
142
+ return "completed"
143
+ if self.current_state == "FAILED":
144
+ return "failed"
145
+ return "active"
146
+
147
+ def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
148
+ """Apply an idempotent transition; reject illegal transitions deterministically."""
149
+ target = str(next_state)
150
+ if target == self.current_state:
151
+ return False
152
+ allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
153
+ if target not in allowed:
154
+ raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
155
+ now = int(time.time() * 1000) if now_ms is None else int(now_ms)
156
+ self.sequence += 1
157
+ self.revision += 1
158
+ self.history.append({
159
+ "sequence": self.sequence,
160
+ "from_state": self.current_state,
161
+ "to_state": target,
162
+ "at_ms": now,
163
+ })
164
+ if len(self.history) > MAX_HISTORY:
165
+ del self.history[:-MAX_HISTORY]
166
+ self.current_state = target
167
+ self.updated_at_ms = now
168
+ return True
169
+
170
+ def prepare_for_resume(self) -> None:
171
+ """Normalize a restored snapshot before a new loop execution."""
172
+ if self.current_state != "IDLE":
173
+ self.current_state = "IDLE"
174
+ self.revision += 1
175
+ self.updated_at_ms = int(time.time() * 1000)
176
+ self.diagnostic("resume normalized state to IDLE")
177
+
178
+ def diagnostic(self, message: str) -> None:
179
+ value = redact_text(message, 180)
180
+ if not value or value in self.diagnostics:
181
+ return
182
+ self.diagnostics.append(value)
183
+ if len(self.diagnostics) > MAX_DIAGNOSTICS:
184
+ del self.diagnostics[:-MAX_DIAGNOSTICS]
185
+ self.revision += 1
186
+ self.updated_at_ms = int(time.time() * 1000)
187
+
188
+ def snapshot(self) -> dict[str, Any]:
189
+ """Return a bounded JSON-compatible envelope; never expose the raw goal."""
190
+ return {
191
+ "schema_version": SCHEMA_VERSION,
192
+ "run_id": self.run_id,
193
+ "session_id": self.session_id,
194
+ "checkpoint_id": self.checkpoint_id,
195
+ "goal_digest": self.goal_digest,
196
+ "goal_preview": self.goal_preview,
197
+ "status": self.status,
198
+ "current_state": self.current_state,
199
+ "revision": self.revision,
200
+ "sequence": self.sequence,
201
+ "history": list(self.history[-MAX_HISTORY:]),
202
+ "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
203
+ "created_at_ms": self.created_at_ms,
204
+ "updated_at_ms": self.updated_at_ms,
205
+ }
206
+
207
+ def projection(self) -> dict[str, Any]:
208
+ """Small read-only view safe for API/SSE consumers."""
209
+ return {
210
+ "schema_version": SCHEMA_VERSION,
211
+ "status": self.status,
212
+ "current_state": self.current_state,
213
+ "revision": self.revision,
214
+ "sequence": self.sequence,
215
+ "checkpoint_id": self.checkpoint_id,
216
+ "history": [dict(item) for item in self.history[-16:]],
217
+ "diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
218
+ }
219
+
220
+ @classmethod
221
+ def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
222
+ if not isinstance(payload, Mapping):
223
+ raise ValueError("engineering state must be an object")
224
+ if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
225
+ raise ValueError("unsupported engineering state schema")
226
+ history = payload.get("history", [])
227
+ diagnostics = payload.get("diagnostics", [])
228
+ if not isinstance(history, list) or len(history) > MAX_HISTORY:
229
+ raise ValueError("invalid engineering state history")
230
+ if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
231
+ raise ValueError("invalid engineering state diagnostics")
232
+ current = str(payload.get("current_state", ""))
233
+ if current not in _ALLOWED_TRANSITIONS:
234
+ raise ValueError("invalid engineering state current state")
235
+ revision = int(payload.get("revision", -1))
236
+ sequence = int(payload.get("sequence", -1))
237
+ if revision < 0 or sequence < 0 or revision < sequence:
238
+ raise ValueError("invalid engineering state revision")
239
+ state = cls(
240
+ run_id=_bounded_id(str(payload.get("run_id", ""))),
241
+ session_id=_bounded_id(str(payload.get("session_id", ""))),
242
+ checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
243
+ goal_digest=str(payload.get("goal_digest", "")),
244
+ goal_preview=redact_text(payload.get("goal_preview", "")),
245
+ current_state=current,
246
+ history=[dict(item) for item in history if isinstance(item, Mapping)],
247
+ diagnostics=[redact_text(item, 180) for item in diagnostics],
248
+ revision=revision,
249
+ sequence=sequence,
250
+ created_at_ms=int(payload.get("created_at_ms", 0)),
251
+ updated_at_ms=int(payload.get("updated_at_ms", 0)),
252
+ )
253
+ if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
254
+ raise ValueError("invalid engineering state goal digest")
255
+ return state
agents/error_classifier.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ error_classifier.py — S404: Classificazione errori nel repair loop
3
+
4
+ Documento strategico: "Non basta 'vedere un errore'. Devi distinguere almeno:
5
+ selettore sbagliato, pagina diversa dal previsto, login fallito, errore runtime,
6
+ rete lenta, frame non caricato, stato UI incoerente."
7
+
8
+ Implementa la classificazione per consentire repair MIRATI invece di analisi generiche.
9
+
10
+ Integrazione:
11
+ _reflective_debug → classify_error(goal, errors) → ErrorResult
12
+ → inject repair_strategy nel context prima del retry
13
+
14
+ Pipeline classificazione:
15
+ 1. Pattern matching regex (deterministico, zero latenza)
16
+ 2. Fallback: UNKNOWN → generic strategic recovery
17
+
18
+ Non usa LLM per classificare — è sincrono e costa zero latency.
19
+ """
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+ from enum import Enum
24
+
25
+
26
+ class ErrorCategory(str, Enum):
27
+ SELECTOR = "selector" # CSS/DOM selector non trovato
28
+ NAVIGATION = "navigation" # URL sbagliato, pagina non trovata, redirect
29
+ AUTH = "auth" # Login fallito, token scaduto, 401/403
30
+ RUNTIME = "runtime" # TypeError, ReferenceError, is not defined
31
+ NETWORK = "network" # Timeout, ECONNREFUSED, offline, 503
32
+ FRAME = "frame" # iframe non caricato, frame non trovato
33
+ SYNTAX = "syntax" # SyntaxError, IndentationError, parse error
34
+ LOGIC = "logic" # AssertionError, valore atteso ≠ ottenuto
35
+ LIMIT = "limit" # Rate limit, quota, 429, OOM
36
+ DB_ERROR = "db_error" # Sprint 3b: IntegrityError, FK, connection pool, deadlock
37
+ UNKNOWN = "unknown" # Non classificato
38
+
39
+
40
+ # ── Strategie di repair per categoria ─────────────────────────────────────────
41
+ _REPAIR_STRATEGIES: dict[ErrorCategory, str] = {
42
+ ErrorCategory.SELECTOR: (
43
+ "SELETTORE DOM: usa ID espliciti (`#id`), aria-label (`[aria-label='...']`) "
44
+ "o ruoli ARIA (`getByRole('button', {name:'...'})`) — evita classi dinamiche. "
45
+ "Prima leggi il DOM attuale per trovare il selettore corretto."
46
+ ),
47
+ ErrorCategory.NAVIGATION: (
48
+ "NAVIGAZIONE: verifica l'URL esatto prima di navigare. "
49
+ "Attendi `domcontentloaded` o un elemento specifico prima di operare sulla pagina. "
50
+ "Gestisci redirect e pagine di errore (404/500) esplicitamente."
51
+ ),
52
+ ErrorCategory.AUTH: (
53
+ "AUTENTICAZIONE: gestisci il login PRIMA di qualsiasi azione protetta. "
54
+ "Verifica il token, gestisci la scadenza della sessione e i redirect post-login. "
55
+ "Non assumere che la sessione sia già attiva."
56
+ ),
57
+ ErrorCategory.RUNTIME: (
58
+ "ERRORE RUNTIME: controlla undefined/null con optional chaining (`?.`) "
59
+ "o guardie esplicite (`if (x == null) return`). "
60
+ "Verifica i tipi degli argomenti prima di usarli. "
61
+ "Aggiungi try/catch attorno alle operazioni rischiose."
62
+ ),
63
+ ErrorCategory.NETWORK: (
64
+ "ERRORE RETE: aggiungi retry con exponential backoff (max 3 tentativi). "
65
+ "Gestisci offline/timeout con fallback utili. "
66
+ "Non bloccare la UI durante le chiamate — usa stato di loading."
67
+ ),
68
+ ErrorCategory.FRAME: (
69
+ "IFRAME: aspetta che l'iframe sia caricato prima di accedere al suo DOM "
70
+ "(usa `waitForLoadState` o `frameLocator`). "
71
+ "Verifica che il frame esista con `page.frames()` prima di operare."
72
+ ),
73
+ ErrorCategory.SYNTAX: (
74
+ "SINTASSI: correggi l'errore di sintassi PRIMA di qualsiasi altra cosa. "
75
+ "Verifica l'indentazione (Python), le parentesi bilanciate e i punti e virgola (JS). "
76
+ "Usa un linter per identificare tutti gli errori nella stessa sessione."
77
+ ),
78
+ ErrorCategory.LOGIC: (
79
+ "LOGICA: rivedi i casi edge e le assunzioni. "
80
+ "Aggiungi asserzioni esplicite sui valori attesi. "
81
+ "Traccia il flusso di dati passo per passo per trovare dove diverge dal previsto."
82
+ ),
83
+ ErrorCategory.LIMIT: (
84
+ "RATE LIMIT / QUOTA: implementa retry con backoff esponenziale e jitter. "
85
+ "Riduci la frequenza delle chiamate. "
86
+ "Considera caching dei risultati per evitare chiamate ridondanti."
87
+ ),
88
+ ErrorCategory.DB_ERROR: (
89
+ "ERRORE DATABASE: controlla vincoli di integrità (FK, UNIQUE, NOT NULL). "
90
+ "Per IntegrityError: verifica che i dati rispettino i vincoli prima dell'insert. "
91
+ "Per connection pool exhausted: chiudi le connessioni non usate, riduci max_connections. "
92
+ "Per deadlock: usa retry con backoff, considera SKIP LOCKED per job queue. "
93
+ "Per 'relation does not exist': esegui le migrazioni pendenti prima dell'avvio."
94
+ ),
95
+ ErrorCategory.UNKNOWN: (
96
+ "Analizza il contesto completo dell'errore. "
97
+ "Prova un approccio COMPLETAMENTE diverso da quello usato finora."
98
+ ),
99
+ }
100
+
101
+ # ── Pattern regex per classificazione ───────────────────────────────��─────────
102
+ # Ordinati per priorità (il primo match vince)
103
+ _PATTERNS: list[tuple[ErrorCategory, re.Pattern]] = [
104
+ (ErrorCategory.SYNTAX, re.compile(
105
+ r"SyntaxError|IndentationError|ParseError|parse error|"
106
+ r"unexpected token|unexpected EOF|invalid syntax|"
107
+ r"unterminated string|missing \)", re.IGNORECASE,
108
+ )),
109
+ (ErrorCategory.SELECTOR, re.compile(
110
+ r"selector|querySelector|getElementById|no element|"
111
+ r"element not found|locator|aria|getByRole|"
112
+ r"TimeoutError.*waiting for|strict mode violation", re.IGNORECASE,
113
+ )),
114
+ (ErrorCategory.AUTH, re.compile(
115
+ r"401|403|Unauthorized|Forbidden|Authentication|"
116
+ r"token.*expired|session.*expired|login.*failed|"
117
+ r"invalid.*credentials|permission denied", re.IGNORECASE,
118
+ )),
119
+ (ErrorCategory.NETWORK, re.compile(
120
+ r"ECONNREFUSED|ENOTFOUND|ETIMEDOUT|timeout|"
121
+ r"network error|fetch.*failed|connection refused|"
122
+ r"503|502|504|unreachable|offline", re.IGNORECASE,
123
+ )),
124
+ (ErrorCategory.LIMIT, re.compile(
125
+ r"429|rate.?limit|quota.*exceeded|too many requests|"
126
+ r"OOM|out of memory|memory.*error", re.IGNORECASE,
127
+ )),
128
+ (ErrorCategory.FRAME, re.compile(
129
+ r"frame|iframe|frameLocator|frame.*not found|"
130
+ r"cross.?origin|sandboxed.*frame", re.IGNORECASE,
131
+ )),
132
+ (ErrorCategory.NAVIGATION, re.compile(
133
+ r"404|page not found|navigation.*failed|goto.*timeout|"
134
+ r"ERR_NAME_NOT_RESOLVED|net::ERR|redirect.*loop|"
135
+ r"wrong.*page|URL.*invalid", re.IGNORECASE,
136
+ )),
137
+ (ErrorCategory.RUNTIME, re.compile(
138
+ r"TypeError|ReferenceError|is not defined|"
139
+ r"Cannot read|Cannot set|undefined is not|"
140
+ r"null.*property|property.*null|"
141
+ r"is not a function|is not iterable|"
142
+ r"UnhandledPromiseRejection", re.IGNORECASE,
143
+ )),
144
+ (ErrorCategory.LOGIC, re.compile(
145
+ r"AssertionError|assertion.*failed|expected.*got|"
146
+ r"mismatch|wrong.*value|incorrect.*result|"
147
+ r"test.*failed|expected.*received", re.IGNORECASE,
148
+ )),
149
+ # Sprint 3b: DB_ERROR — IntegrityError, FK, connection pool, deadlock
150
+ (ErrorCategory.DB_ERROR, re.compile(
151
+ r"IntegrityError|ForeignKey|ForeignKeyViolation|"
152
+ r"connection pool|deadlock|"
153
+ r"relation.*not.*exist|table.*not.*exist|"
154
+ r"duplicate key|violates.*constraint|"
155
+ r"UniqueViolation|CheckViolation|NotNullViolation|"
156
+ r"OperationalError.*database|psycopg|asyncpg|"
157
+ r"FATAL.*database|could not connect.*database",
158
+ re.IGNORECASE,
159
+ )),
160
+ ]
161
+
162
+
163
+ @dataclass
164
+ class ErrorResult:
165
+ category: ErrorCategory
166
+ repair_strategy: str
167
+ confidence: float # 0.0-1.0 — quanto siamo sicuri della classificazione
168
+ matched_pattern: str # substring che ha fatto match (debug)
169
+
170
+
171
+ def classify_error(errors: list[str]) -> ErrorResult:
172
+ """
173
+ Classifica una lista di messaggi di errore nella categoria più probabile.
174
+
175
+ Algoritmo:
176
+ 1. Concatena gli errori (max 1500 chars)
177
+ 2. Prova i pattern in ordine di priorità (il primo match vince)
178
+ 3. Se nessun match → UNKNOWN con confidence 0.0
179
+
180
+ Non usa LLM — è sincrono e a zero latency.
181
+ Chiamato da _reflective_debug prima di invocare ARCHITECT.
182
+ """
183
+ combined = " | ".join(str(e)[:500] for e in errors[-4:])[:1500] # S604: 400→500 per error string completa
184
+
185
+ for category, pattern in _PATTERNS:
186
+ m = pattern.search(combined)
187
+ if m:
188
+ matched = m.group(0)[:60]
189
+ strategy = _REPAIR_STRATEGIES[category]
190
+ return ErrorResult(
191
+ category = category,
192
+ repair_strategy = strategy,
193
+ confidence = 0.85,
194
+ matched_pattern = matched,
195
+ )
196
+
197
+ return ErrorResult(
198
+ category = ErrorCategory.UNKNOWN,
199
+ repair_strategy = _REPAIR_STRATEGIES[ErrorCategory.UNKNOWN],
200
+ confidence = 0.0,
201
+ matched_pattern = "",
202
+ )
203
+
204
+
205
+ def format_for_context(result: ErrorResult) -> str:
206
+ """
207
+ Produce la stringa da iniettare nel context del loop prima del retry.
208
+ Formato: [ERRORE CLASSIFICATO: <Categoria>] + strategia.
209
+ """
210
+ label = f"ERRORE CLASSIFICATO: {result.category.value.upper()}"
211
+ if result.matched_pattern:
212
+ label += f" (match: \"{result.matched_pattern}\")"
213
+ return f"\n\n[{label}]\n{result.repair_strategy}"
agents/escalation_ladder.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ escalation_ladder.py — GAP-3: Dynamic Cognitive Routing (Escalation Ladder)
3
+
4
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5
+ GAP-3: "The Escalation Ladder" — routing LLM adattivo per tentativi successivi
6
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7
+
8
+ Problema risolto:
9
+ Il sistema corrente assegna lo stesso modello (_get_llm_for_goal) per TUTTI
10
+ i tentativi LLM nel retry loop. Se il modello scelto fallisce (timeout, rifiuto,
11
+ risposta insufficiente), viene riusato identicamente — sprecando budget
12
+ e producendo lo stesso errore N volte.
13
+
14
+ Soluzione:
15
+ EscalationLadder: per ogni retry usa un modello più potente.
16
+
17
+ Attempt 0 → CODER (Llama 4 Scout / OpenRouter — veloce, gratuito, ottimo per codice)
18
+ Attempt 1 → REASONER (Cerebras 120B — 2000+ tok/s, massima qualità reasoning 2026)
19
+ Attempt 2+ → DEFAULT (Gemini 2.5 Flash via AIClient — più capace, fallback finale)
20
+
21
+ Con severity "logic" (errore logico complesso):
22
+ Attempt 0 → REASONER (salta CODER — logica complessa richiede ragionamento)
23
+ Attempt 1+ → DEFAULT (Gemini 2.5 Flash — massima copertura)
24
+
25
+ Integrazione in unified_loop.py (minimal patch):
26
+ PRIMA (una riga FUORI dal loop):
27
+ _active_llm = self._get_llm_for_goal(state.goal)
28
+
29
+ DOPO:
30
+ from agents.escalation_ladder import EscalationLadder as _EscLadder
31
+ _esc_ladder = _EscLadder(base_llm=self.llm, goal=state.goal)
32
+
33
+ DENTRO il for _llm_try loop:
34
+ _active_llm = _esc_ladder.get_llm(_llm_try, _error_severity)
35
+
36
+ Invarianti rispettate:
37
+ - Silent failure totale: qualsiasi eccezione → ritorna base_llm
38
+ - Zero regressioni: attempt 0 per goal di codice = identico a _get_llm_for_goal()
39
+ - Thread-safe: lazy caching per slot, no shared state tra istanze
40
+ - Budget free tier: CODER e REASONER gratuiti; DEFAULT usato solo se necessario
41
+ - Logging: info su ogni escalation (nome modello + motivo)
42
+
43
+ Dipendenze:
44
+ - models.role_router (RoleRouter, Role) — già presente nel progetto
45
+ - logging — stdlib
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import logging
50
+ from typing import Any
51
+
52
+ _logger = logging.getLogger("agente_ai")
53
+
54
+ # ── Escalation schedule ────────────────────────────────────────────────────────
55
+ # Mapping: (attempt_index, error_severity) → lista ordinata di Role da provare
56
+ #
57
+ # Logica:
58
+ # - Per severity "syntax" e "runtime": inizia con CODER (fix procedurale)
59
+ # - Per severity "logic" e "unknown": inizia con REASONER (ragionamento necessario)
60
+ # - Attempt 0 = primo ruolo, attempt 1 = secondo, attempt 2+ = DEFAULT
61
+ #
62
+ # NOTA: i Role sono stringa per evitare import circolare al module-level.
63
+ # RoleRouter viene importato lazy dentro get_llm().
64
+
65
+ _SEVERITY_LADDER: dict[str, list[str]] = {
66
+ # Errori di sintassi: CODER corregge con precisione, poi REASONER se ancora KO
67
+ "syntax": ["coder", "reasoner", "default"],
68
+ # Errori runtime: CODER (approccio alternativo) → REASONER (debugging profondo)
69
+ "runtime": ["coder", "reasoner", "default"],
70
+ # Errori logici: serve ragionamento → salta CODER, parti con REASONER
71
+ "logic": ["reasoner", "default", "default"],
72
+ # Unknown: bilancia velocità e qualità
73
+ "unknown": ["coder", "reasoner", "default"],
74
+ }
75
+
76
+ _DEFAULT_LADDER = _SEVERITY_LADDER["unknown"]
77
+
78
+
79
+ class EscalationLadder:
80
+ """
81
+ Gestisce l'escalation dinamica dei provider LLM durante il retry loop.
82
+
83
+ Crea un'istanza PER RUN (non per sessione) — caching dei client LLM
84
+ valido solo per la durata del retry loop corrente.
85
+
86
+ Usage in unified_loop._run_fallback:
87
+
88
+ # Prima del for loop:
89
+ from agents.escalation_ladder import EscalationLadder
90
+ _esc = EscalationLadder(base_llm=self.llm, goal=state.goal)
91
+
92
+ # Dentro il for _llm_try loop (sostituisce _active_llm=self._get_llm_for_goal()):
93
+ _active_llm = _esc.get_llm(_llm_try, _error_severity)
94
+ """
95
+
96
+ def __init__(self, base_llm: Any, goal: str = "") -> None:
97
+ """
98
+ Args:
99
+ base_llm: Il client LLM di default (self.llm di UnifiedAgentLoop).
100
+ Usato come fallback ultimo livello.
101
+ goal: Il goal corrente — usato per determinare se è un goal di codice
102
+ (e quindi se CODER è appropriato come primo tentativo).
103
+ """
104
+ self._base_llm = base_llm
105
+ self._goal = goal
106
+ # Cache per slot: evita di ri-istanziare RoleRouter per ogni tentativo
107
+ self._cache: dict[str, Any] = {}
108
+
109
+ def get_llm(self, attempt: int, error_severity: str = "unknown") -> Any:
110
+ """
111
+ Restituisce il client LLM appropriato per questo tentativo.
112
+
113
+ Args:
114
+ attempt: Indice del tentativo (0, 1, 2, ...).
115
+ error_severity: Categoria errore da error_classifier
116
+ ("syntax" | "runtime" | "logic" | "unknown").
117
+
118
+ Returns:
119
+ AIClient configurato per il ruolo appropriato.
120
+ MAI lancia eccezioni — sempre ritorna un client valido.
121
+ """
122
+ try:
123
+ ladder = _SEVERITY_LADDER.get(error_severity, _DEFAULT_LADDER)
124
+ # Clamp: attempt >= len(ladder) → usa sempre l'ultimo (DEFAULT)
125
+ slot = ladder[min(attempt, len(ladder) - 1)]
126
+ return self._get_for_slot(slot, attempt, error_severity)
127
+ except Exception as exc:
128
+ _logger.debug("EscalationLadder.get_llm fallback (attempt=%d): %s", attempt, exc)
129
+ return self._base_llm
130
+
131
+ # ── Private ───────────────────────────────────────────────────────────────
132
+
133
+ def _get_for_slot(self, slot: str, attempt: int, severity: str) -> Any:
134
+ """Ottiene (o crea e cacha) il client per uno slot specifico."""
135
+ if slot in self._cache:
136
+ return self._cache[slot]
137
+
138
+ client = self._build_client(slot, attempt, severity)
139
+ self._cache[slot] = client
140
+ return client
141
+
142
+ def _build_client(self, slot: str, attempt: int, severity: str) -> Any:
143
+ """
144
+ Costruisce il client LLM per il ruolo richiesto.
145
+ Ogni eccezione → ritorna base_llm (silent fallback).
146
+ """
147
+ try:
148
+ from models.role_router import RoleRouter, Role
149
+
150
+ if slot == "coder":
151
+ client = RoleRouter.get_client(Role.CODER)
152
+ _logger.info(
153
+ "EscalationLadder[attempt=%d, sev=%s]: CODER (Llama 4 Scout)",
154
+ attempt, severity,
155
+ )
156
+ return client
157
+
158
+ if slot == "reasoner":
159
+ client = RoleRouter.get_client(Role.REASONER)
160
+ _logger.info(
161
+ "EscalationLadder[attempt=%d, sev=%s]: REASONER (Cerebras 120B) — escalation",
162
+ attempt, severity,
163
+ )
164
+ return client
165
+
166
+ if slot == "default":
167
+ # DEFAULT: usa il client base (Gemini 2.5 Flash / primo provider disponibile)
168
+ _logger.info(
169
+ "EscalationLadder[attempt=%d, sev=%s]: DEFAULT (AIClient primary) — max escalation",
170
+ attempt, severity,
171
+ )
172
+ return self._base_llm
173
+
174
+ except Exception as exc:
175
+ _logger.debug("EscalationLadder._build_client[%s] error: %s", slot, exc)
176
+
177
+ return self._base_llm
178
+
179
+ def __repr__(self) -> str:
180
+ return (
181
+ f"EscalationLadder(goal={self._goal[:40]!r}, "
182
+ f"cached_slots={list(self._cache.keys())})"
183
+ )
agents/executor.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ executor.py — Tool Executor con retry, adaptive timeout, circuit breaker e fallback routing.
3
+ Usa AIClient (multi-provider) al posto di OllamaClient (localhost).
4
+
5
+ Architettura adaptive (GAP-SKILL-SYNC v2):
6
+ _AdaptiveTimeoutTracker — P90-based timeout adaptation (sliding window 5 call)
7
+ Circuit Breaker — Wilson score < CIRCUIT_OPEN_THRESHOLD → skip al miglior fallback
8
+ Fallback Execution — TOOL_REGISTRY["fallbacks"] ora eseguiti automaticamente (non solo metadata)
9
+ Recovery Credit — tool circuit-broken retentato ogni RECOVERY_INTERVAL chiamate
10
+ """
11
+ import asyncio
12
+ import collections
13
+ import logging
14
+ import time as _time_mod
15
+
16
+ from models.ai_client import AIClient
17
+ from memory.manager import MemoryManager
18
+ from tools.registry import TOOL_REGISTRY
19
+
20
+ # P17-B1: pre-esecuzione syntax check — fail-open se ast_check non disponibile
21
+ try:
22
+ from tools.ast_check import check_code_syntax as _check_syntax
23
+ _CHECK_SYNTAX_AVAILABLE = True
24
+ except ImportError:
25
+ _CHECK_SYNTAX_AVAILABLE = False
26
+ def _check_syntax(code: str, lang: str): # type: ignore[misc]
27
+ class _Ok:
28
+ ok = True
29
+ error = None
30
+ line = None
31
+ col = None
32
+ return _Ok()
33
+
34
+ _logger = logging.getLogger("agente_ai.executor")
35
+
36
+ # ─── Costanti circuit breaker ────────────────────────────────────────────────
37
+ _CIRCUIT_OPEN_THRESHOLD = 0.15 # Wilson score < soglia AND >= min calls → circuit open
38
+ _MIN_CALLS_FOR_CIRCUIT = 3 # minimo di chiamate prima che il circuit possa aprirsi
39
+ _RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open → tenta il tool primario
40
+
41
+ # ─── S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker ─────────────────────────
42
+ # Sliding window (last 5 durations) per tool — calcola P90 adattivo.
43
+ # Strategia iPhone: rete variabile → se tool è stato lento di recente,
44
+ # aumenta timeout; se è stato veloce, non sprecare tempo.
45
+ class _AdaptiveTimeoutTracker:
46
+ """Tracked P90 per-tool timeout con sliding window di 5 call."""
47
+ _WINDOW = 5
48
+ _MIN = 4.0 # mai sotto 4s — tool veloci non vanno sotto
49
+ _MAX = 55.0 # mai sopra 55s — iPhone connection timeout ~60s
50
+ _MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo
51
+
52
+ def __init__(self) -> None:
53
+ self._times: dict[str, collections.deque] = {}
54
+
55
+ def record(self, tool_name: str, elapsed: float) -> None:
56
+ if tool_name not in self._times:
57
+ self._times[tool_name] = collections.deque(maxlen=self._WINDOW)
58
+ self._times[tool_name].append(elapsed)
59
+
60
+ def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
61
+ """Ritorna timeout adattivo: P90 * 1.5 se dati sufficienti, else base."""
62
+ times = self._times.get(tool_name)
63
+ if not times or len(times) < 2:
64
+ return base_timeout # dati insufficienti → usa base invariato
65
+ sorted_t = sorted(times)
66
+ p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
67
+ adaptive = sorted_t[p90_idx] * self._MULTIPLIER
68
+ return max(self._MIN, min(self._MAX, adaptive))
69
+
70
+ _timeout_tracker = _AdaptiveTimeoutTracker()
71
+
72
+ # P17-B1: mapping tool_name → (argomento_codice, linguaggio) per syntax check
73
+ _CODE_EXEC_TOOLS: dict[str, tuple[str, str]] = {
74
+ "run_python": ("code", "python"),
75
+ "run_code": ("code", "python"),
76
+ }
77
+
78
+
79
+ # ─── Helper: ottieni session_id dal ContextVar (impostato da unified_loop.py) ─
80
+ def _get_session_id() -> str:
81
+ try:
82
+ from tools.registry import _agent_session_id_var
83
+ return _agent_session_id_var.get()
84
+ except Exception:
85
+ return "default"
86
+
87
+
88
+ # ─── Executor ────────────────────────────────────────────────────────────────
89
+
90
+ class Executor:
91
+ def __init__(
92
+ self,
93
+ llm_client: AIClient | None = None,
94
+ memory: MemoryManager | None = None,
95
+ max_retries: int = 2,
96
+ ):
97
+ self.llm = llm_client or AIClient()
98
+ self.memory = memory
99
+ self.max_retries = max_retries
100
+ # GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
101
+ self._circuit_recovery_counts: dict[str, int] = {}
102
+
103
+ # Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
104
+ @classmethod
105
+ def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
106
+ return cls(memory=memory, max_retries=max_retries)
107
+
108
+ # ── Circuit breaker helper ────────────────────────────────────────────────
109
+
110
+ def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
111
+ """True se il circuit breaker deve aprirsi per questo tool in questa sessione.
112
+
113
+ Condizioni (tutte necessarie):
114
+ 1. Wilson score < CIRCUIT_OPEN_THRESHOLD (0.15)
115
+ 2. >= MIN_CALLS_FOR_CIRCUIT (3) chiamate nella sessione
116
+ 3. Il tool ha fallback disponibili in TOOL_REGISTRY
117
+ Recovery credit: ogni RECOVERY_INTERVAL chiamate, il circuit si chiude
118
+ temporaneamente per un tentativo di recovery.
119
+ """
120
+ tool = TOOL_REGISTRY.get(tool_name, {})
121
+ if not tool.get("fallbacks"):
122
+ return False # senza fallback il circuit non può aprirsi
123
+ try:
124
+ from agents.skill_tracker import get_skill_tracker
125
+ stats = get_skill_tracker().get_stats(session_id).get(tool_name)
126
+ except Exception:
127
+ return False
128
+ if not stats:
129
+ return False
130
+ if stats["total_count"] < _MIN_CALLS_FOR_CIRCUIT:
131
+ return False
132
+ if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
133
+ return False
134
+ # Recovery credit: conta le chiamate e apri una finestra ogni RECOVERY_INTERVAL
135
+ count = self._circuit_recovery_counts.get(tool_name, 0) + 1
136
+ self._circuit_recovery_counts[tool_name] = count
137
+ if count % _RECOVERY_INTERVAL == 0:
138
+ _logger.info(
139
+ "[executor] recovery credit: riprovo %s (circuit call #%d)",
140
+ tool_name, count,
141
+ )
142
+ return False # consenti un tentativo di recovery
143
+ return True
144
+
145
+ # ── Fallback execution ────────────────────────────────────────────────────
146
+
147
+ async def _try_fallbacks(
148
+ self,
149
+ primary_name: str,
150
+ inputs: dict,
151
+ timeout: float,
152
+ session_id: str,
153
+ ) -> "dict | None":
154
+ """Tenta i fallback definiti in TOOL_REGISTRY ordinati per Wilson score.
155
+
156
+ Registra ogni tentativo nel skill_tracker sotto il nome del fallback.
157
+ Ritorna il primo risultato con successo, o None se tutti falliscono.
158
+ """
159
+ tool = TOOL_REGISTRY.get(primary_name, {})
160
+ fallbacks = tool.get("fallbacks", [])
161
+ if not fallbacks:
162
+ return None
163
+
164
+ try:
165
+ from agents.skill_tracker import get_skill_tracker
166
+ sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
167
+ except Exception:
168
+ sorted_fbs = fallbacks # ordinamento originale come fallback del fallback
169
+
170
+ for fb_name in sorted_fbs:
171
+ fb_tool = TOOL_REGISTRY.get(fb_name)
172
+ if not fb_tool or not fb_tool.get("_fn"):
173
+ continue
174
+ _logger.info(
175
+ "[executor] %s fallita — provo fallback %s (Wilson-sorted)",
176
+ primary_name, fb_name,
177
+ )
178
+ try:
179
+ _t0 = _time_mod.monotonic()
180
+ _fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
181
+ result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
182
+ _timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
183
+ # Registra il successo del fallback nel skill_tracker
184
+ try:
185
+ from agents.skill_tracker import get_skill_tracker
186
+ get_skill_tracker().record(session_id, fb_name, True)
187
+ except Exception as _skt_err:
188
+ _logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
189
+ return {
190
+ "success": True,
191
+ "tool": fb_name,
192
+ "output": result,
193
+ "via_fallback_from": primary_name,
194
+ "attempt": 1,
195
+ }
196
+ except asyncio.TimeoutError:
197
+ _timeout_tracker.record(fb_name, timeout * 1.2)
198
+ _logger.debug("[executor] fallback %s timeout", fb_name)
199
+ try:
200
+ from agents.skill_tracker import get_skill_tracker
201
+ get_skill_tracker().record(session_id, fb_name, False)
202
+ except Exception as _skt_err:
203
+ _logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
204
+ except Exception as fb_exc:
205
+ _logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
206
+ try:
207
+ from agents.skill_tracker import get_skill_tracker
208
+ get_skill_tracker().record(session_id, fb_name, False)
209
+ except Exception as _skt_err:
210
+ _logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
211
+
212
+ return None # tutti i fallback hanno fallito
213
+
214
+ # ── run_tool ─────────────────────────────────────────────────────────────
215
+
216
+ async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0, worker_hint: str | None = None) -> dict:
217
+ """
218
+ Esegue un tool. Se worker_hint è fornito, tenta l'esecuzione sul worker specifico.
219
+ ARCH-I4.3: Tool Engine evoluto con Capability Resolver.
220
+ """
221
+ tool = TOOL_REGISTRY.get(tool_name)
222
+ if not tool:
223
+ return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
224
+
225
+ # ARCH-E3.2/ARCH-I4.3: Risoluzione dinamica della capability via Kernel
226
+ if not worker_hint:
227
+ try:
228
+ from api.kernel import kernel
229
+ res = await kernel.resolve_capability(tool_name)
230
+ if res.get("status") == "resolved":
231
+ worker_hint = res["worker"]["id"]
232
+ _logger.info(f"[executor] capability '{tool_name}' risolta su worker: {worker_hint}")
233
+ except Exception as e:
234
+ _logger.debug(f"[executor] resolver bypass: {e}")
235
+
236
+ missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
237
+ if missing:
238
+ return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
239
+
240
+ session_id = _get_session_id()
241
+
242
+ # ── GAP-SKILL-SYNC v2: circuit breaker pre-check ──────────────────────
243
+ # Se il tool ha un Wilson score molto basso (< 0.15) con >= 3 dati in sessione,
244
+ # bypassa il tool e vai direttamente al miglior fallback disponibile.
245
+ if self._is_circuit_open(tool_name, session_id):
246
+ _logger.info(
247
+ "[executor] circuit OPEN per %s — routing diretto a fallback (Wilson < %.2f)",
248
+ tool_name, _CIRCUIT_OPEN_THRESHOLD,
249
+ )
250
+ fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
251
+ if fb_result:
252
+ return fb_result
253
+ # Tutti i fallback falliti: procedi con il tool primario (ultima spiaggia)
254
+ _logger.warning(
255
+ "[executor] tutti i fallback di %s hanno fallito — provo comunque il tool primario",
256
+ tool_name,
257
+ )
258
+
259
+ # ── Esecuzione normale con retry ──────────────────────────────────────
260
+ fn = tool.get("_fn")
261
+ if fn is None:
262
+ return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
263
+
264
+ # P17-B1: syntax check pre-esecuzione — intercetta SyntaxError prima che il
265
+ # backend-exec spreci un round-trip su codice già rotto. Fail-open: tool non in
266
+ # mappa, ast_check non importato, o codice vuoto → nessun blocco.
267
+ if tool_name in _CODE_EXEC_TOOLS:
268
+ _code_arg, _code_lang = _CODE_EXEC_TOOLS[tool_name]
269
+ _raw_code = inputs.get(_code_arg, "")
270
+ if isinstance(_raw_code, str) and _raw_code.strip():
271
+ _syn = _check_syntax(_raw_code, _code_lang)
272
+ if not _syn.ok:
273
+ _logger.warning(
274
+ "[executor] P17-B1 syntax check failed per %s: %s",
275
+ tool_name, _syn.error,
276
+ )
277
+ return {
278
+ "success": False,
279
+ "error": (
280
+ f"SyntaxError pre-esecuzione [{_code_lang}]: {_syn.error}"
281
+ + (f" — riga {_syn.line}" if _syn.line else "")
282
+ ),
283
+ "output": None,
284
+ "syntax_check_failed": True,
285
+ }
286
+
287
+ last_error: str = "max_retries"
288
+ for attempt in range(self.max_retries + 1):
289
+ try:
290
+ # S-ORCH-8GAP FIX-GAP2: usa timeout adattivo basato su P90 ultime 5 chiamate
291
+ _adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
292
+ _t0 = _time_mod.monotonic()
293
+ result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
294
+ _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
295
+
296
+ # Il tool ha già prodotto il side effect: la persistenza memoria
297
+ # è osservabilità e non deve riaprire il retry del tool.
298
+ _memory_persisted = True
299
+ _memory_error = None
300
+ if self.memory:
301
+ try:
302
+ # S577→S600: inputs 100→500 — parity con altri handler
303
+ await self.memory.save_episode(
304
+ "tool",
305
+ f"{tool_name}: {str(inputs)[:500]}",
306
+ str(result)[:500],
307
+ True,
308
+ )
309
+ except Exception as _memory_exc:
310
+ _memory_persisted = False
311
+ _memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}"
312
+ _logger.warning(
313
+ "[executor] tool %s completato ma save_episode fallito; "
314
+ "nessun retry del side effect: %s",
315
+ tool_name,
316
+ _memory_error,
317
+ )
318
+ response = {
319
+ "success": True,
320
+ "tool": tool_name,
321
+ "output": result,
322
+ "attempt": attempt + 1,
323
+ "memory_persisted": _memory_persisted,
324
+ }
325
+ if _memory_error:
326
+ response["memory_error"] = _memory_error
327
+ return response
328
+
329
+ except asyncio.TimeoutError:
330
+ # FIX-GAP2: registra il timeout come durata massima per shrink futuro
331
+ _timeout_tracker.record(tool_name, timeout * 1.2)
332
+ last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
333
+ if attempt == self.max_retries:
334
+ # Ultima chance: prova i fallback ordinati per Wilson score
335
+ _logger.info(
336
+ "[executor] %s timeout definitivo — provo fallback Wilson-sorted",
337
+ tool_name,
338
+ )
339
+ fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
340
+ if fb_result:
341
+ return fb_result
342
+ return {"success": False, "error": last_error, "output": None}
343
+ await asyncio.sleep(0.5)
344
+
345
+ except Exception as e:
346
+ last_error = str(e)
347
+ if attempt == self.max_retries:
348
+ # Ultima chance: prova i fallback ordinati per Wilson score
349
+ _logger.info(
350
+ "[executor] %s errore definitivo (%s) — provo fallback Wilson-sorted",
351
+ tool_name, last_error[:60],
352
+ )
353
+ fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
354
+ if fb_result:
355
+ return fb_result
356
+ return {"success": False, "error": last_error, "output": None}
357
+ await asyncio.sleep(0.5)
358
+
359
+ return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
360
+
agents/file_conversion.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversioni tabellari deterministiche per dati CSV espliciti nel goal.
2
+
3
+ Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``.
4
+ Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import io
10
+ import json
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ _ATTACHMENT_RE = re.compile(
16
+ r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
17
+ re.IGNORECASE,
18
+ )
19
+ # Il target può essere espresso come "file chiamato foo.json" oppure come
20
+ # "poi crea foo.json". Il gruppo è limitato a nomi semplici, quindi il parser
21
+ # non accetta path traversal o istruzioni aggiuntive.
22
+ _TARGET_RE = re.compile(
23
+ r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)"
24
+ r"['`\"]?(?P<name>[\w.-]+\.json)\b",
25
+ re.IGNORECASE,
26
+ )
27
+ _CONVERSION_RE = re.compile(
28
+ r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b",
29
+ re.IGNORECASE,
30
+ )
31
+ _INLINE_CSV_RE = re.compile(
32
+ r"\b(?:crea|scrivi)\s+(?P<name>[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*"
33
+ r"(?P<body>[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CsvJsonConversion:
40
+ source_name: str
41
+ target_name: str
42
+ content: str
43
+ row_count: int
44
+ source_content: str
45
+ source_is_inline: bool = False
46
+
47
+
48
+ def _coerce_scalar(value: str) -> Any:
49
+ value = value.strip()
50
+ if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
51
+ return int(value)
52
+ if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value):
53
+ return float(value)
54
+ return value
55
+
56
+
57
+ def _csv_body(raw_body: str) -> str:
58
+ lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n")
59
+ while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")):
60
+ lines.pop(0)
61
+ return "\n".join(lines).strip()
62
+
63
+
64
+ def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None:
65
+ """Legge CSV senza tollerare header/colonne ambigue o righe tronche."""
66
+ try:
67
+ reader = csv.DictReader(io.StringIO(csv_body))
68
+ raw_headers = reader.fieldnames
69
+ if not raw_headers:
70
+ return None
71
+ headers = [str(header or "").strip() for header in raw_headers]
72
+ if any(not header for header in headers) or len(set(headers)) != len(headers):
73
+ return None
74
+
75
+ rows: list[dict[str, Any]] = []
76
+ for raw_row in reader:
77
+ # DictReader usa None per colonne in eccesso e per celle mancanti.
78
+ if None in raw_row or any(raw_row.get(header) is None for header in raw_headers):
79
+ return None
80
+ row = {
81
+ headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "")
82
+ for index in range(len(headers))
83
+ }
84
+ rows.append(row)
85
+ return rows
86
+ except (csv.Error, UnicodeError):
87
+ return None
88
+
89
+
90
+ def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]:
91
+ """Verifica che il JSON sia l’array esatto dei record CSV normalizzati.
92
+
93
+ La verifica è intenzionalmente stretta: stessa cardinalità, stesso ordine,
94
+ stesse chiavi e stessi valori dopo la coercizione deterministica del CSV.
95
+ """
96
+ expected = _parse_csv_rows(_csv_body(csv_content))
97
+ if expected is None:
98
+ return False, "CSV non valido o ambiguo"
99
+ try:
100
+ actual = json.loads(json_content)
101
+ except (TypeError, json.JSONDecodeError):
102
+ return False, "JSON non valido"
103
+ if not isinstance(actual, list):
104
+ return False, "il JSON deve essere un array"
105
+ if any(not isinstance(record, dict) for record in actual):
106
+ return False, "ogni record JSON deve essere un oggetto"
107
+ if actual != expected:
108
+ return False, "i record JSON non corrispondono esattamente al CSV"
109
+ return True, ""
110
+
111
+
112
+ def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None:
113
+ csv_body = _csv_body(raw_body)
114
+ rows = _parse_csv_rows(csv_body)
115
+ if rows is None:
116
+ return None
117
+ content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n"
118
+ is_valid, _reason = validate_csv_json_equivalence(csv_body, content)
119
+ if not is_valid:
120
+ # Difesa di coerenza interna: una conversione diretta non può dichiararsi
121
+ # riuscita se il proprio serializzatore non supera il medesimo contratto.
122
+ return None
123
+ return CsvJsonConversion(
124
+ source_name=source_name.strip(),
125
+ target_name=target_name.strip(),
126
+ content=content,
127
+ row_count=len(rows),
128
+ source_content=csv_body + "\n",
129
+ source_is_inline=source_is_inline,
130
+ )
131
+
132
+
133
+ def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
134
+ """Converte un CSV allegato o esplicitamente incluso nel goal in JSON.
135
+
136
+ Il ritorno è ``None`` quando il goal non definisce una conversione tabellare
137
+ completa: il resto del loop conserva quindi il comportamento esistente.
138
+ """
139
+ if not _CONVERSION_RE.search(goal):
140
+ return None
141
+
142
+ target = _TARGET_RE.search(goal)
143
+ if not target:
144
+ return None
145
+
146
+ inline = _INLINE_CSV_RE.search(goal)
147
+ if inline:
148
+ return _build_conversion(
149
+ inline.group("name"),
150
+ target.group("name"),
151
+ inline.group("body"),
152
+ source_is_inline=True,
153
+ )
154
+
155
+ attachment = _ATTACHMENT_RE.search(goal)
156
+ if not attachment:
157
+ return None
158
+ return _build_conversion(
159
+ attachment.group("name"),
160
+ target.group("name"),
161
+ attachment.group("body"),
162
+ source_is_inline=False,
163
+ )
agents/goal_drift_detector.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ goal_drift_detector.py — COG-5: Goal Drift Detector.
3
+
4
+ Confronta il goal originale con lo stato exec_done ogni N subtask completati.
5
+ Se l'agente si è allontanato dall'obiettivo, emette un segnale di drift
6
+ che il loop principale usa per iniettare una micro-guida correttiva.
7
+
8
+ Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM.
9
+ Zero overhead su task senza drift (guard rapido in should_check_drift).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import logging
15
+ from typing import Any
16
+
17
+ _logger = logging.getLogger("agente_ai.goal_drift")
18
+
19
+ # ── Costanti ──────────────────────────────────────────────────────────────────
20
+ DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati
21
+ DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% → drift
22
+ _MIN_EXEC_DONE: int = 2 # non controlla prima di 2 subtask completati
23
+
24
+ _STOP_WORDS = frozenset({
25
+ # italiano
26
+ "il", "la", "lo", "le", "gli", "un", "una", "uno", "di", "del", "della",
27
+ "dei", "degli", "delle", "al", "alla", "ai", "agli", "alle", "dal",
28
+ "dalla", "dai", "dagli", "dalle", "nel", "nella", "nei", "negli", "nelle",
29
+ "sul", "sulla", "sui", "sugli", "sulle", "con", "per", "tra", "fra",
30
+ "non", "che", "come", "dove", "quando", "chi", "cosa", "quale", "questo",
31
+ "questa", "questi", "queste", "sono", "era", "essere", "fare", "fare",
32
+ # inglese
33
+ "the", "and", "for", "are", "but", "not", "you", "all", "any", "can",
34
+ "had", "her", "was", "one", "our", "out", "get", "has", "him", "his",
35
+ "how", "its", "may", "new", "now", "old", "see", "who", "did", "with",
36
+ "this", "that", "from", "they", "will", "been", "have", "were", "said",
37
+ "each", "she", "which", "their", "time", "than", "then", "into", "your",
38
+ "more", "make", "like", "also", "back", "after", "use", "work", "well",
39
+ "about", "would", "there", "could", "other", "some", "these", "those",
40
+ })
41
+
42
+ _KW_RE = re.compile(r'\b[a-zA-Z\xc0-\xff]{4,}\b')
43
+
44
+
45
+ def _extract_keywords(text: str) -> frozenset[str]:
46
+ """Estrae keyword significative: >= 4 char, no stop-word."""
47
+ words = _KW_RE.findall(text.lower())
48
+ return frozenset(w for w in words if w not in _STOP_WORDS)
49
+
50
+
51
+ def compute_drift_score(goal: str, exec_done: list[str]) -> float:
52
+ """
53
+ Calcola il drift score: 0.0 = nessun drift, 1.0 = drift totale.
54
+
55
+ Args:
56
+ goal: goal originale dell'utente
57
+ exec_done: lista di stringhe "[subtask N — desc]: output"
58
+
59
+ Returns:
60
+ float in [0.0, 1.0] — quanto l'agente si è allontanato dal goal
61
+ """
62
+ if not exec_done:
63
+ return 0.0
64
+ goal_kws = _extract_keywords(goal)
65
+ if not goal_kws:
66
+ return 0.0 # goal senza keyword → nessun drift misurabile
67
+
68
+ exec_text = " ".join(exec_done)
69
+ exec_kws = _extract_keywords(exec_text)
70
+ if not exec_kws:
71
+ return 1.0 # output vuoto di significato → drift massimo
72
+
73
+ overlap = len(goal_kws & exec_kws)
74
+ return max(0.0, 1.0 - overlap / len(goal_kws))
75
+
76
+
77
+ def should_check_drift(step_count: int, last_check: int) -> bool:
78
+ """
79
+ True se è ora di eseguire un drift check.
80
+
81
+ Controlla solo se:
82
+ - step_count >= _MIN_EXEC_DONE (almeno 2 subtask completati)
83
+ - step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step)
84
+ """
85
+ return (
86
+ step_count >= _MIN_EXEC_DONE
87
+ and (step_count - last_check) >= DRIFT_CHECK_EVERY_N
88
+ )
89
+
90
+
91
+ def detect_drift(
92
+ goal: str,
93
+ exec_done: list[str],
94
+ step_count: int,
95
+ last_check: int,
96
+ ) -> dict[str, Any]:
97
+ """
98
+ Punto di accesso principale per il rilevamento drift.
99
+
100
+ Args:
101
+ goal: goal originale
102
+ exec_done: subtask completati (lista stringhe)
103
+ step_count: numero corrente di subtask completati (len(exec_done))
104
+ last_check: step_count dell'ultimo check eseguito
105
+
106
+ Returns: {
107
+ checked: bool — True se la verifica è stata eseguita
108
+ drifted: bool — True se drift rilevato
109
+ score: float — drift score (0.0–1.0)
110
+ reason: str — spiegazione human-readable
111
+ new_last_check: int — aggiornamento del counter
112
+ }
113
+ """
114
+ out: dict[str, Any] = {
115
+ "checked": False,
116
+ "drifted": False,
117
+ "score": 0.0,
118
+ "reason": "",
119
+ "new_last_check": last_check,
120
+ }
121
+
122
+ if not should_check_drift(step_count, last_check):
123
+ return out
124
+
125
+ out["checked"] = True
126
+ out["new_last_check"] = step_count
127
+
128
+ score = compute_drift_score(goal, exec_done)
129
+ out["score"] = round(score, 3)
130
+
131
+ if score > (1.0 - DRIFT_OVERLAP_THRESHOLD):
132
+ out["drifted"] = True
133
+ goal_kws = _extract_keywords(goal)
134
+ exec_kws = _extract_keywords(" ".join(exec_done))
135
+ missing = sorted(goal_kws - exec_kws)[:5]
136
+ out["reason"] = (
137
+ f"score={score:.2f}, keyword goal assenti nell'output: {missing}"
138
+ )
139
+ _logger.info("COG-5 drift rilevato: %s", out["reason"])
140
+ else:
141
+ _logger.debug("COG-5 no drift: score=%.2f step=%d", score, step_count)
142
+
143
+ return out
agents/goal_verifier.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ goal_verifier.py — S403: GoalVerifier semantico + GAP-1 Hard Gate (esecuzione reale)
3
+
4
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5
+ GAP-1: "The Hard Gate" — Execution-based Validation
6
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7
+
8
+ Trasforma la verifica da "ti sembra corretto?" a "funziona davvero?".
9
+
10
+ Nuovo metodo: verify_with_execution()
11
+ 1. Verifica semantica (verify() / verify_v2()) — invariata
12
+ 2. Se semantic PASS + is_code_goal() → estrae il primo blocco Python/JS dalla risposta
13
+ 3. Chiama _call_exec_engine (registry.py — già usato da unified_loop) con timeout 18s
14
+ 4. Se exit_code != 0 → FAIL con traceback REALE come repair_hint (auto-healing)
15
+ 5. Se exit_code == 0 → eleva coverage_score a 0.95 (prova concreta di correttezza)
16
+
17
+ Invarianti rispettate:
18
+ - Silent failure totale: qualsiasi eccezione → ritorna risultato semantico invariato
19
+ - Zero regressioni: i metodi verify() e verify_v2() esistenti NON modificati
20
+ - Se EXEC_ENGINE_URL non configurato → _call_exec_engine ritorna None → fallback silente
21
+ - PR2: backend chiama i propri endpoint (registry._call_exec_engine) — no side-effect frontend
22
+ - B4: nessuna modifica al path SSE/asyncio.Queue
23
+ - Budget: asyncio.wait_for 18s — dentro il budget 20s del chiamante
24
+ """
25
+
26
+ import asyncio
27
+ import re
28
+ import json
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+ from typing import Any
32
+
33
+ import logging
34
+ _logger = logging.getLogger("agents.goal_verifier")
35
+
36
+
37
+ # ── Sprint 1b: GoalVerificationStatus enum ────────────────────────────────────
38
+ class GoalVerificationStatus(str, Enum):
39
+ PASS = "PASS"
40
+ FAIL = "FAIL"
41
+ UNKNOWN = "UNKNOWN"
42
+
43
+ RETRY_THRESHOLD = 0.30 # S-BENCH-FIX: meno punitivo su near-misses
44
+ MAX_GOAL_CHARS = 400
45
+ MAX_ANS_CHARS = 1500
46
+ MAX_HINT_CHARS = 150
47
+
48
+ _COMPLEX_CODE_RE = re.compile(
49
+ r"\b(crea|genera|scrivi|implementa|sviluppa|costruisci|aggiorna|"
50
+ r"create|generate|implement|build|refactor|"
51
+ r"sistema|correggi|debugga|ottimizza|migra|ristruttura|refactorizza|"
52
+ r"patch|rinomina|sostituisci|rimpiazza|converti|trasforma|"
53
+ r"optimize|migrate|patch|rename|replace|convert|transform|restructure|"
54
+ r"app|applicazione|dashboard|api|rest|backend|frontend|"
55
+ r"componente|component|pagina|page|schema|database|db|"
56
+ r"typescript|python|react|vue|flask|fastapi|express|node\.js|"
57
+ r"svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|"
58
+ r"django|rails|laravel|spring|kotlin|rust|go|java|dart|flutter|"
59
+ r"graphql|grpc|websocket|docker|kubernetes|"
60
+ r"prisma|drizzle|sqlalchemy|mongoose|sequelize|"
61
+ r"service|repository|controller|middleware|"
62
+ r"platform|piattaforma|sistema|e.?commerce|chatbot|saas|cms|crm)\b",
63
+ re.IGNORECASE,
64
+ )
65
+
66
+ _SIMPLE_RE = re.compile(
67
+ r"^\s*(ciao|grazie|ok|perfetto|capito|bene|ottimo|esatto|sì|no|"
68
+ r"hi|hello|thanks|got it|yes|no|"
69
+ r"bravo|benissimo|magnifico|fantastico|giusto|corretto|esattamente|"
70
+ r"d['\u2019]accordo|inteso|compreso|capisco|ho capito|"
71
+ r"sì grazie|no grazie|va bene|"
72
+ # B-GAP-D: +IT confirmations mancanti (certo/naturalmente/assolutamente/prego/nessun problema)
73
+ r"certo|certamente|naturalmente|assolutamente|ovviamente|prego|fatto|"
74
+ r"nessun problema|figurati|con piacere|volentieri|pronto|"
75
+ # B-GAP-D: +EN confirmations mancanti (no problem/sounds good/cool/okay/np/awesome)
76
+ r"ty|thx|great|nice|perfect|exactly|understood|sure|right|agreed|"
77
+ r"makes sense|correct|good|no problem|np|sounds good|cool|okay|"
78
+ r"awesome|yep|nope|roger|copy that|will do|got it|works for me)\b",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+ _VERIFIER_SYSTEM = (
83
+ "Sei un valutatore tecnico. Rispondi SOLO con JSON valido, senza testo aggiuntivo.\n"
84
+ "Dato un GOAL e la RISPOSTA dell'agente, valuta:\n"
85
+ "1. score (float 0.0-1.0): quanto la risposta affronta concretamente il goal\n"
86
+ "2. missing (lista, max 3 voci): cosa manca o è incompleto (vuoto se score>0.6)\n"
87
+ "3. hint (stringa, max 15 parole): cosa aggiungere per completare (vuoto se score>0.6)\n\n"
88
+ 'Formato ESATTO: {"score": 0.8, "missing": ["voce1"], "hint": "testo breve"}'
89
+ )
90
+
91
+ # ── GAP-1: regex estrazione code block dalla risposta LLM ─────────────────────
92
+ # Cerca ```python ... ``` o ```js ... ``` — primo blocco valido da eseguire.
93
+ # Fallback: cerca blocco generico ``` ... ``` se nessun linguaggio specificato.
94
+ _CODE_BLOCK_PY_RE = re.compile(
95
+ r"```(?:python|py)\s*\n([\s\S]+?)```",
96
+ re.IGNORECASE,
97
+ )
98
+ _CODE_BLOCK_JS_RE = re.compile(
99
+ r"```(?:javascript|js|node)\s*\n([\s\S]+?)```",
100
+ re.IGNORECASE,
101
+ )
102
+ # GAP-4: TypeScript/TSX regex — 60%+ dei task generano TS/TSX
103
+ _CODE_BLOCK_TS_RE = re.compile(
104
+ r"```(?:typescript|ts|tsx)\s*\n([\s\S]+?)```",
105
+ re.IGNORECASE,
106
+ )
107
+ _CODE_BLOCK_GENERIC_RE = re.compile(
108
+ r"```\w*\s*\n([\s\S]+?)```",
109
+ )
110
+
111
+ def _extract_first_executable_block(response: str) -> tuple[str, str] | None:
112
+ """
113
+ Estrae il primo blocco di codice eseguibile dalla risposta LLM.
114
+
115
+ Returns:
116
+ (code, lang) oppure None se nessun blocco trovato.
117
+ lang: "python" | "javascript"
118
+ """
119
+ m = _CODE_BLOCK_PY_RE.search(response)
120
+ if m:
121
+ code = m.group(1).strip()
122
+ if len(code) > 10:
123
+ return code, "python"
124
+
125
+ # GAP-4: TypeScript/TSX — prima cadevano nel fallback python con ModuleNotFoundError
126
+ m = _CODE_BLOCK_TS_RE.search(response)
127
+ if m:
128
+ code = m.group(1).strip()
129
+ if len(code) > 10:
130
+ return code, "typescript"
131
+
132
+ m = _CODE_BLOCK_JS_RE.search(response)
133
+ if m:
134
+ code = m.group(1).strip()
135
+ if len(code) > 10:
136
+ return code, "javascript"
137
+
138
+ # Fallback blocco generico — assumiamo python (il più comune)
139
+ m = _CODE_BLOCK_GENERIC_RE.search(response)
140
+ if m:
141
+ code = m.group(1).strip()
142
+ if len(code) > 10 and not code.startswith("<"): # esclude HTML
143
+ return code, "python"
144
+
145
+ return None
146
+
147
+
148
+ @dataclass
149
+ class GoalVerifyResult:
150
+ goal_met: bool
151
+ coverage_score: float
152
+ missing_items: list[str] = field(default_factory=list)
153
+ repair_hint: str = ""
154
+ verification_status: GoalVerificationStatus = GoalVerificationStatus.UNKNOWN
155
+ # GAP-1: nuovo campo — True se la validazione è avvenuta via esecuzione reale
156
+ execution_validated: bool = False
157
+
158
+
159
+ class GoalVerifier:
160
+ """
161
+ S403: verifica semantica del goal post-generazione.
162
+ GAP-1: aggiunge verify_with_execution() per validazione via esecuzione reale.
163
+ """
164
+
165
+ _CODE_RE = re.compile(
166
+ r"\b(crea|genera|scrivi|fai|implementa|sviluppa|costruisci|aggiorna|modifica|"
167
+ r"aggiungi|refactoriz|ottimiz|migra|converte?|trasforma|estendi|"
168
+ r"sistema|sistemi|sistemiamo|correggi|corregge|correggere|"
169
+ r"debugga|debuggi|patch|patcha|patchar|rinomina|rinominare|"
170
+ r"sostituisci|sostituire|rimpiazza|rimpiazzare|"
171
+ r"create|generate|write|implement|build|make|update|add|fix|refactor|"
172
+ r"optimize|migrate|convert|transform|extend|scaffold|bootstrap|deploy|"
173
+ r"patch|rename|replace|delete|remove|"
174
+ r"app|sito|website|pagina|page|script|funzion|function|class|component|"
175
+ r"api|endpoint|route|handler|controller|middleware|service|repository|"
176
+ r"html|css|scss|sass|javascript|typescript|python|ruby|go|rust|java|kotlin|swift|"
177
+ r"react|vue|svelte|angular|next\.?js|nuxt|remix|astro|"
178
+ r"flask|fastapi|django|express|nestjs|rails|laravel|"
179
+ r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
180
+ r"database|schema|migration|model|table|index|query|"
181
+ r"test|spec|fixture|mock|unit.*test|integration.*test)\b",
182
+ re.IGNORECASE,
183
+ )
184
+
185
+ _FILE_CONVERSION_RE = re.compile(
186
+ r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}"
187
+ r"\b(?:csv|tsv|xlsx|xls|json|pdf|txt|markdown|md|docx)\b",
188
+ re.IGNORECASE,
189
+ )
190
+ _IMPLEMENTATION_CONTEXT_RE = re.compile(
191
+ r"\b(?:codice|script|funzione|function|class|componente|component|api|endpoint|"
192
+ r"typescript|javascript|python|react|backend|frontend|test\s+unit|test\s+e2e)\b",
193
+ re.IGNORECASE,
194
+ )
195
+
196
+ _EXPLANATION_RE = re.compile(
197
+ r"\b(spiega|spiegami|descr(?:ivi|ivi|izione)|cos.?è|come funziona|"
198
+ r"qual.?è la differenza|differenza tra|confronta|analiz|riassumi|riassunto|"
199
+ r"explain|describe|what is|how does|how do|compare|summarize|summarise|"
200
+ r"difference between|pros and cons|vantaggi|svantaggi|"
201
+ r"cosa significa|cosa vuol dire|significato di)\b",
202
+ re.IGNORECASE,
203
+ )
204
+
205
+ @classmethod
206
+ def is_code_goal(cls, goal: str) -> bool:
207
+ # Gli allegati sono serializzati dopo questo separatore: non devono trasformare
208
+ # una semplice lettura/conversione in un task di sviluppo da riparare.
209
+ user_goal = goal.split("--- **File allegati:**", 1)[0][:500]
210
+ if (cls._FILE_CONVERSION_RE.search(user_goal)
211
+ and not cls._IMPLEMENTATION_CONTEXT_RE.search(user_goal)):
212
+ return False
213
+ return bool(cls._CODE_RE.search(user_goal))
214
+
215
+ @classmethod
216
+ def adaptive_threshold(cls, goal: str) -> float:
217
+ g = goal.strip()
218
+ if _SIMPLE_RE.match(g):
219
+ return 0.28
220
+ if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
221
+ return 0.25
222
+ if _COMPLEX_CODE_RE.search(g[:500]):
223
+ return 0.48 # S-BENCH-FIX: 0.55 -> 0.48 bilanciamento rigore
224
+ if cls._CODE_RE.search(g[:500]):
225
+ return 0.38 # S-BENCH-FIX: 0.42 -> 0.38
226
+ return RETRY_THRESHOLD
227
+
228
+ def __init__(self, llm: Any) -> None:
229
+ self.llm = llm
230
+
231
+ # ── Metodi semantici originali (invariati) ────────────────────────────────
232
+
233
+ async def verify(self, goal: str, response: str) -> GoalVerifyResult:
234
+ goal_short = goal[:MAX_GOAL_CHARS]
235
+ ans_short = response[:MAX_ANS_CHARS]
236
+ msgs = [
237
+ {"role": "system", "content": _VERIFIER_SYSTEM},
238
+ {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
239
+ ]
240
+ try:
241
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
242
+ if not raw or raw.startswith("[LLM"):
243
+ return self._default_ok()
244
+ return self._parse(raw)
245
+ except Exception:
246
+ return self._default_ok()
247
+
248
+ async def verify_v2(
249
+ self,
250
+ goal: str,
251
+ response: str,
252
+ requirements: "list | None" = None,
253
+ ) -> "GoalVerifyResult":
254
+ if not requirements:
255
+ return await self.verify(goal, response)
256
+
257
+ try:
258
+ from api.state import increment_stat as _inc
259
+ _inc("goal_verifier_v2_used")
260
+ except Exception as _exc:
261
+ _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
262
+
263
+ threshold = self.adaptive_threshold(goal)
264
+ ans_short = response[:MAX_ANS_CHARS]
265
+ per_req: dict[str, str] = {}
266
+ failed_reqs: list[str] = []
267
+ failed_hints: list[str] = []
268
+
269
+ for req in requirements[:6]:
270
+ req_id = getattr(req, "id", "unknown")
271
+ req_name = getattr(req, "feature", req_id)
272
+ criteria = getattr(req, "acceptance_criteria", [])
273
+
274
+ if not criteria:
275
+ per_req[req_id] = GoalVerificationStatus.UNKNOWN
276
+ continue
277
+
278
+ criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
279
+ check_prompt = (
280
+ f"Requisito: {req_name}\n"
281
+ f"Criteri:\n{criteria_text}\n\n"
282
+ f"Risposta agente:\n{ans_short[:600]}\n\n"
283
+ "La risposta soddisfa i criteri? Rispondi SOLO: PASS oppure FAIL"
284
+ )
285
+ msgs = [
286
+ {"role": "system", "content":
287
+ "Sei un validatore. Rispondi SOLO con PASS o FAIL — nessun altro testo."},
288
+ {"role": "user", "content": check_prompt},
289
+ ]
290
+ try:
291
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=10)
292
+ verdict = "PASS" if raw and "PASS" in raw.upper() else "FAIL"
293
+ except Exception:
294
+ verdict = "UNKNOWN"
295
+
296
+ per_req[req_id] = verdict
297
+ if verdict == "FAIL":
298
+ failed_reqs.append(req_name)
299
+ if criteria:
300
+ failed_hints.append(f"{req_name}: {criteria[0]}")
301
+
302
+ n_known = sum(1 for v in per_req.values() if v != "UNKNOWN")
303
+ n_pass = sum(1 for v in per_req.values() if v == "PASS")
304
+ score = (n_pass / n_known) if n_known > 0 else 0.5
305
+
306
+ overall_pass = score >= threshold and not failed_reqs
307
+ hint = "; ".join(failed_hints[:4]) if failed_hints else ""
308
+ if failed_reqs:
309
+ hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
310
+
311
+ status = (
312
+ GoalVerificationStatus.PASS if overall_pass
313
+ else GoalVerificationStatus.FAIL if failed_reqs
314
+ else GoalVerificationStatus.UNKNOWN
315
+ )
316
+
317
+ return GoalVerifyResult(
318
+ goal_met = overall_pass,
319
+ coverage_score = round(score, 3),
320
+ missing_items = failed_reqs[:5],
321
+ repair_hint = hint[:MAX_HINT_CHARS],
322
+ verification_status = status,
323
+ )
324
+
325
+ # ── GAP-1: Hard Gate — verify_with_execution ──────────────────────────────
326
+
327
+ async def verify_with_execution(
328
+ self,
329
+ goal: str,
330
+ response: str,
331
+ requirements: "list | None" = None,
332
+ ) -> "GoalVerifyResult":
333
+ """
334
+ GAP-1 Hard Gate: verifica semantica + esecuzione reale del codice generato.
335
+
336
+ Flusso:
337
+ 1. verify_v2() / verify() → semantic check (invariato)
338
+ 2. Se semantic FAIL → return immediatamente (no point in running bad code)
339
+ 3. Se goal NON è di codice → return semantic result (no code to run)
340
+ 4. Estrai primo blocco Python/JS dalla risposta
341
+ 5. Se nessun blocco → return semantic result
342
+ 6. Chiama _call_exec_engine (backend-exec microservice) con timeout 18s
343
+ 7. Se None (non configurato) → return semantic result (silent fallback)
344
+ 8. Se exit_code != 0 → FAIL con traceback REALE come repair_hint
345
+ 9. Se exit_code == 0 → PASS con coverage_score elevato a 0.95
346
+
347
+ Budget: asyncio.wait_for 18s (dentro il budget 20s del chiamante tipico).
348
+ Silent failure: qualsiasi eccezione → ritorna semantic_result invariato.
349
+ Zero regressioni: se exec non disponibile → identico a verify_v2().
350
+ """
351
+ # Step 1: semantic check (invariato — tutte le path esistenti preservate)
352
+ semantic_result = await self.verify_v2(goal, response, requirements)
353
+
354
+ # Step 2: se semantic FAIL → blocco immediato (non eseguire codice scorretto)
355
+ if not semantic_result.goal_met:
356
+ return semantic_result
357
+
358
+ # Step 3: se non è un goal di codice → nessun blocco da estrarre
359
+ if not self.is_code_goal(goal):
360
+ return semantic_result
361
+
362
+ try:
363
+ # Step 4: estrai primo blocco eseguibile
364
+ extracted = _extract_first_executable_block(response)
365
+ if not extracted:
366
+ return semantic_result # nessun code block → solo semantica
367
+
368
+ code_to_run, lang = extracted
369
+
370
+ # Filtra codice che potrebbe essere solo dichiarativo/doc
371
+ # (meno di 2 righe reali = probabilmente un frammento, non eseguibile)
372
+ real_lines = [l for l in code_to_run.splitlines() if l.strip() and not l.strip().startswith('#')]
373
+ if len(real_lines) < 2:
374
+ return semantic_result
375
+
376
+ # GAP-4: TypeScript/TSX — usa _ts_syntax_check (node --check)
377
+ if lang == "typescript":
378
+ try:
379
+ import tempfile as _tmp, os as _os
380
+ from tools.registry import _ts_syntax_check as _tsc
381
+ with _tmp.NamedTemporaryFile(suffix=".ts", mode="w", delete=False) as _tf:
382
+ _tf.write(code_to_run); _tf_path = _tf.name
383
+ try:
384
+ _ts_ok, _ts_err = await asyncio.wait_for(_tsc(_tf_path), timeout=12.0)
385
+ if not _ts_ok:
386
+ return GoalVerifyResult(
387
+ goal_met=False, coverage_score=0.30,
388
+ missing_items=["errore sintassi TypeScript"],
389
+ repair_hint=f"TypeScript error: {_ts_err[:300]}",
390
+ verification_status=GoalVerificationStatus.FAIL,
391
+ execution_validated=True,
392
+ )
393
+ return GoalVerifyResult(
394
+ goal_met=True, coverage_score=0.90, missing_items=[],
395
+ repair_hint="",
396
+ verification_status=GoalVerificationStatus.PASS,
397
+ execution_validated=True,
398
+ )
399
+ finally:
400
+ try: _os.unlink(_tf_path)
401
+ except OSError: pass # temp file cleanup — solo errori OS attesi
402
+ except Exception:
403
+ return semantic_result
404
+
405
+ # P26-B1: Python AST pre-check — compile() built-in, zero latenza, zero roundtrip.
406
+ # Equivalente a GAP-4 (TypeScript node --check). Cattura SyntaxError prima dell'exec engine.
407
+ if lang == "python":
408
+ try:
409
+ compile(code_to_run, "<goal_verifier>", "exec")
410
+ except SyntaxError as _syn:
411
+ _syn_hint = f"[SYNTAX ERROR] {type(_syn).__name__}: {_syn.msg} (riga {_syn.lineno})"
412
+ _logger.debug("P26-B1 Python AST fail: %s", _syn_hint)
413
+ try:
414
+ from api.state import increment_stat as _inc_syn
415
+ _inc_syn("goal_verifier_py_syntax_fail")
416
+ except Exception:
417
+ pass
418
+ return GoalVerifyResult(
419
+ goal_met = False,
420
+ coverage_score = 0.25,
421
+ missing_items = ["errore di sintassi Python nel codice generato"],
422
+ repair_hint = _syn_hint,
423
+ verification_status = GoalVerificationStatus.FAIL,
424
+ execution_validated = True,
425
+ )
426
+
427
+ # Step 5: chiama backend-exec (registry._call_exec_engine)
428
+ try:
429
+ from tools.registry import _call_exec_engine as _exec_fn
430
+ except ImportError:
431
+ return semantic_result # registry non disponibile — silent fallback
432
+
433
+ # Step 6: esecuzione reale con timeout conservativo
434
+ exec_result: dict | None = None
435
+ try:
436
+ exec_result = await asyncio.wait_for(
437
+ _exec_fn({"code": code_to_run, "lang": lang, "timeout": 15}),
438
+ timeout=18.0,
439
+ )
440
+ except (asyncio.TimeoutError, Exception):
441
+ return semantic_result # timeout o errore → silent fallback
442
+
443
+ # Step 7: se exec non configurato → fallback silente
444
+ if exec_result is None:
445
+ return semantic_result
446
+
447
+ # Step 8: analisi risultato esecuzione
448
+ exit_code = exec_result.get("exit_code", -1)
449
+ stdout = (exec_result.get("stdout") or "")[:400]
450
+ stderr = (exec_result.get("stderr") or "")[:400]
451
+ combined = (stdout + stderr).strip()
452
+
453
+ if exit_code != 0:
454
+ # FAIL categorico con traceback reale — feeding self-healing loop
455
+ traceback_hint = f"[EXEC FAIL exit={exit_code}]: {combined[:300]}"
456
+ try:
457
+ from api.state import increment_stat as _inc
458
+ _inc("goal_verifier_exec_fail")
459
+ except Exception as _exc:
460
+ _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
461
+ return GoalVerifyResult(
462
+ goal_met = False,
463
+ coverage_score = 0.0,
464
+ missing_items = ["il codice generato fallisce all'esecuzione"],
465
+ repair_hint = traceback_hint,
466
+ verification_status = GoalVerificationStatus.FAIL,
467
+ execution_validated = True,
468
+ )
469
+
470
+ # Step 9: exit_code == 0 → PASS con prova concreta
471
+ try:
472
+ from api.state import increment_stat as _inc
473
+ _inc("goal_verifier_exec_pass")
474
+ except Exception as _exc:
475
+ _logger.debug("[goal_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
476
+
477
+ return GoalVerifyResult(
478
+ goal_met = True,
479
+ coverage_score = 0.95, # prova reale > verifica semantica
480
+ missing_items = [],
481
+ repair_hint = "",
482
+ verification_status = GoalVerificationStatus.PASS,
483
+ execution_validated = True,
484
+ )
485
+
486
+ except Exception:
487
+ # Qualsiasi eccezione non gestita → silent fallback al risultato semantico
488
+ return semantic_result
489
+
490
+ # ── Parsing ───────────────────────────────────────────────────────────────
491
+
492
+ @staticmethod
493
+ def _parse(raw: str) -> "GoalVerifyResult":
494
+ m = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
495
+ if not m:
496
+ return GoalVerifier._default_ok()
497
+ try:
498
+ d = json.loads(m.group())
499
+ score = float(d.get("score", 1.0))
500
+ score = max(0.0, min(1.0, score))
501
+ missing = [str(x)[:200] for x in (d.get("missing") or [])[:3]]
502
+ hint = str(d.get("hint") or "")[:MAX_HINT_CHARS]
503
+ is_pass = score >= RETRY_THRESHOLD
504
+ return GoalVerifyResult(
505
+ goal_met = is_pass,
506
+ coverage_score = score,
507
+ missing_items = missing,
508
+ repair_hint = hint,
509
+ verification_status = GoalVerificationStatus.PASS if is_pass else GoalVerificationStatus.FAIL,
510
+ )
511
+ except Exception:
512
+ return GoalVerifier._default_ok()
513
+
514
+ @staticmethod
515
+ def _default_ok() -> "GoalVerifyResult":
516
+ return GoalVerifyResult(
517
+ goal_met=False,
518
+ coverage_score=0.5,
519
+ repair_hint="[verifier_unavailable — esito incerto]",
520
+ verification_status=GoalVerificationStatus.UNKNOWN,
521
+ )
522
+
523
+
524
+ # ── S-CRITIC-1: CriticJudge (invariato) ──────────────────────────────────────
525
+
526
+ _CRITIC_SYSTEM = (
527
+ "Sei un validatore tecnico indipendente. Rispondi SOLO con JSON valido, senza testo extra.\n"
528
+ "Dato un GOAL e la RISPOSTA dell'agente AI, giudica se la risposta affronta\n"
529
+ "concretamente e sufficientemente il goal richiesto.\n"
530
+ "Non cercare la perfezione: valuta solo se è utile, pertinente, e sostanzialmente corretta.\n\n"
531
+ 'Formato ESATTO: {"verdict": "PASS", "confidence": 0.8, "reason": "max 10 parole"}\n'
532
+ 'verdict: "PASS" se la risposta soddisfa il goal in modo sufficiente, "FAIL" altrimenti.\n'
533
+ 'confidence: float 0.0–1.0 — certezza del verdetto.\n'
534
+ 'reason: stringa, max 10 parole — motivazione sintetica.'
535
+ )
536
+
537
+
538
+ @dataclass
539
+ class CriticVerdict:
540
+ verdict: str
541
+ confidence: float = 0.0
542
+ reason: str = ""
543
+
544
+
545
+ class CriticJudge:
546
+ """S-CRITIC-1: Critic on-demand — secondo parere LLM quando GoalVerifier è UNKNOWN."""
547
+
548
+ def __init__(self, llm: Any) -> None:
549
+ self.llm = llm
550
+
551
+ async def judge(self, goal: str, response: str) -> CriticVerdict:
552
+ goal_short = goal[:300]
553
+ ans_short = response[:1200]
554
+ msgs = [
555
+ {"role": "system", "content": _CRITIC_SYSTEM},
556
+ {"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
557
+ ]
558
+ try:
559
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=60)
560
+ if not raw or raw.startswith("[LLM"):
561
+ return CriticVerdict(verdict="UNAVAILABLE")
562
+ return self._parse(raw)
563
+ except Exception:
564
+ return CriticVerdict(verdict="UNAVAILABLE")
565
+
566
+ @staticmethod
567
+ def _parse(raw: str) -> CriticVerdict:
568
+ m = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
569
+ if not m:
570
+ return CriticVerdict(verdict="UNAVAILABLE")
571
+ try:
572
+ d = json.loads(m.group())
573
+ verdict = str(d.get("verdict", "UNAVAILABLE")).upper().strip()
574
+ confidence = float(d.get("confidence", 0.5))
575
+ reason = str(d.get("reason", ""))[:100]
576
+ if verdict not in ("PASS", "FAIL"):
577
+ verdict = "UNAVAILABLE"
578
+ return CriticVerdict(verdict=verdict, confidence=max(0.0, min(1.0, confidence)), reason=reason)
579
+ except Exception:
580
+ return CriticVerdict(verdict="UNAVAILABLE")
agents/html_fast_path.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Classificazione locale del fast path per mini-app HTML a file singolo.
2
+
3
+ Il classificatore è deliberatamente conservativo: in caso di dubbio restituisce
4
+ False. Non usa LLM, rete o stato globale e quindi non aggiunge latenza misurabile.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import re
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HtmlFastPathDecision:
14
+ eligible: bool
15
+ reason: str
16
+ path: str = "index.html"
17
+
18
+
19
+ _HTML_RE = re.compile(r"\b(?:html5?|html|pagina\s+web|single[- ]page|landing\s+page)\b", re.I)
20
+ _CREATE_RE = re.compile(r"\b(?:crea|genera|scrivi|realizza|implementa|build|create|generate|make)\b", re.I)
21
+ _SINGLE_FILE_RE = re.compile(
22
+ r"\b(?:un\s+solo\s+file|singolo\s+file|one\s+file|single\s+file|file\s+unico)\b", re.I
23
+ )
24
+ _PATH_RE = re.compile(r"(?<![\w./-])([\w./-]+\.html)(?![\w.-])", re.I)
25
+ _FORBIDDEN_RE = re.compile(
26
+ r"\b(?:deploy|pubblica|publish|rilascia|release|github|git|npm|pnpm|yarn|install|"
27
+ r"api|backend|server|database|db|auth|login|pagamento|payment|webhook|secret|token|"
28
+ r"shell|bash|terminal|esegui\s+comandi|execute\s+commands|multi[- ]file|pi[uù]\s+file|"
29
+ r"react|vue|angular|next(?:\.js)?|vite|typescript|python|sql)\b",
30
+ re.I,
31
+ )
32
+ _EXTERNAL_RE = re.compile(r"\b(?:fetch|axios|websocket|stripe|supabase|firebase|oauth)\b|https?://", re.I)
33
+
34
+
35
+ def classify_html_fast_path(goal: str) -> HtmlFastPathDecision:
36
+ """Return an eligible decision only for a safe, self-contained HTML request."""
37
+ text = " ".join(str(goal or "").split())
38
+ if not text:
39
+ return HtmlFastPathDecision(False, "empty_goal")
40
+ if len(text) > 500:
41
+ return HtmlFastPathDecision(False, "goal_too_long")
42
+ if not _HTML_RE.search(text):
43
+ return HtmlFastPathDecision(False, "not_html_goal")
44
+ if not _CREATE_RE.search(text):
45
+ return HtmlFastPathDecision(False, "not_creation_goal")
46
+ if not _SINGLE_FILE_RE.search(text):
47
+ return HtmlFastPathDecision(False, "single_file_not_explicit")
48
+ if _FORBIDDEN_RE.search(text):
49
+ return HtmlFastPathDecision(False, "contains_project_or_sensitive_operation")
50
+ if _EXTERNAL_RE.search(text):
51
+ return HtmlFastPathDecision(False, "external_dependency_or_network")
52
+
53
+ paths = _PATH_RE.findall(text)
54
+ path = paths[0] if paths else "index.html"
55
+ if "/" in path or path.startswith("."):
56
+ return HtmlFastPathDecision(False, "nested_path_not_allowed", path)
57
+ return HtmlFastPathDecision(True, "self_contained_single_html", path)
58
+
59
+
60
+ __all__ = ["HtmlFastPathDecision", "classify_html_fast_path"]
agents/planner.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ planner.py — Speculative Hybrid Planner (Gap-5: eliminazione bottleneck sequenziale)
3
+
4
+ Architettura:
5
+ FASE 1 Quick-Start Draft < 500ms Cerebras gpt-oss-120b (o Groq 8B fallback)
6
+ Genera 2-3 subtask immediati → agente parte istantaneamente.
7
+ FASE 2 Master Plan background DeepSeek-R1 via OpenRouter
8
+ Piano architetturale completo; raffina i passi successivi.
9
+ FASE 3 Parallel Sub-Graphs — Campo `parallel_groups` nel JSON
10
+ Rami indipendenti (es. Backend vs Frontend) identificati esplicitamente.
11
+
12
+ Compatibilità backward: `create_plan()` restituisce sempre dict con "subtasks".
13
+ Flag `_speculative: True` → piano quick-start; `_speculative: False` → master plan.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import re
21
+ from models.ai_client import AIClient
22
+
23
+ _logger = logging.getLogger("agente_ai")
24
+
25
+ # ── Prompt master (DeepSeek-R1): piano architetturale completo ───────────────
26
+
27
+ PLANNER_SYSTEM = """Sei un planner AI avanzato. Dato un obiettivo, decomponilo in subtask concreti.
28
+
29
+ Rispondi SOLO con JSON valido nel formato:
30
+ {
31
+ "goal": "obiettivo originale",
32
+ "complexity": "low|medium|high",
33
+ "data_model": [
34
+ {"entity": "NomeEntità", "fields": ["id: str", "campo1: tipo", "campo2: tipo"]}
35
+ ],
36
+ "api_contract": [
37
+ {"method": "GET|POST|PUT|DELETE", "path": "/api/risorsa", "body": {}, "response": {"campo": "tipo"}}
38
+ ],
39
+ "subtasks": [
40
+ {
41
+ "id": 1,
42
+ "description": "cosa fare",
43
+ "tool": "<tool>",
44
+ "requires": [],
45
+ "risk": "low|medium|high",
46
+ "priority": "low|medium|high"
47
+ }
48
+ ],
49
+ "parallel_groups": [[1,2],[3,4,5]],
50
+ "estimated_steps": 3,
51
+ "impacted_files": []
52
+ }
53
+
54
+ data_model: lista di entità dati con i loro campi tipizzati (SOLO per goal con entità persistenti).
55
+ - Ogni entità: {"entity": "Nome", "fields": ["campo: tipo", ...]}
56
+ - Esempi di tipi: str, int, float, bool, datetime, list[str], dict.
57
+ - Lascia [] per task senza entità dati (web search, domande, spiegazioni, singole funzioni).
58
+ - P25-B5: definisci data_model PRIMA dei subtask di codice — è il contratto condiviso tra tutti i subtask.
59
+ - I subtask di codice DEVONO referenziare le entità definite qui, MAI inventare nomi diversi on-the-fly.
60
+
61
+ api_contract: endpoint REST/GraphQL con shape request+response (SOLO per goal con interfaccia API).
62
+ - Ogni endpoint: {"method": "GET", "path": "/api/path", "body": {"campo": "tipo"}, "response": {"campo": "tipo"}}
63
+ - Lascia [] per task senza endpoint API (script standalone, funzioni pure, task di analisi).
64
+ - P25-B5: il primo subtask di codice backend DEVE implementare esattamente questo contratto.
65
+ - MAI aggiungere endpoint non dichiarati qui senza aggiornare api_contract nel piano.
66
+
67
+ parallel_groups: lista di liste di id subtask che possono girare in PARALLELO tra loro.
68
+ - Ogni lista interna = un gruppo di subtask eseguibili contemporaneamente (nessuna dipendenza reciproca).
69
+ - Subtask con requires:[] vanno sempre in un gruppo parallelo.
70
+ - Esempio: backend (id:1,2) e frontend (id:3,4) senza dipendenze reciproche → [[1,2],[3,4]].
71
+ - Se tutto è sequenziale: [[1],[2],[3]].
72
+
73
+ impacted_files: lista di path file VFS che potrebbero essere impattati (vuota se non applicabile).
74
+
75
+ Tool disponibili:
76
+ web_search — cerca informazioni online in tempo reale
77
+ code — genera/modifica codice (Python, TS, JS, etc.)
78
+ read_page — legge una pagina web per URL
79
+ memory — accede a dati precedentemente memorizzati
80
+ direct_response — risposta diretta senza tool esterni
81
+ send_email — invia email via Resend
82
+ database_query — esegue query su database PostgreSQL/SQLite
83
+ web_research — ricerca approfondita multi-fonte con sintesi AI
84
+ execute_sql — esegue SQL su dati in-memory
85
+ create_pdf — genera un documento PDF
86
+ call_api — chiama un REST API esterno
87
+ generate_image — genera un'immagine AI
88
+ run_python — esegue codice Python in sandbox sicura
89
+ write_file — scrive un file nel filesystem virtuale (risk: medium)
90
+ read_file — legge un file dal filesystem virtuale (risk: low)
91
+ apply_patch — applica una patch unificata a un file (risk: medium)
92
+ execute_shell — esegue un comando shell in sandbox (risk: high)
93
+ directory_tree — elenca struttura ad albero di una directory (risk: low)
94
+ file_search — cerca pattern nei file con grep (risk: low)
95
+ git_status — mostra branch corrente e file modificati (risk: low)
96
+ git_clone — clona una repo remota (risk: medium)
97
+ git_diff — mostra modifiche in sospeso (risk: low)
98
+ git_commit — esegue add -A + commit (risk: medium)
99
+ npm_install — installa dipendenze node (risk: medium)
100
+ npm_run — esegue script node (risk: medium)
101
+ pip_install — installa pacchetti Python (risk: medium)
102
+ type_check — type check tsc o mypy (risk: low)
103
+ scaffold_project — crea struttura progetto da template (risk: medium)
104
+ delegate_task — delega un sotto-obiettivo a un micro-agente indipendente (risk: low)
105
+
106
+
107
+ REGOLA TASK SEMPLICI (S-ROBUSTNESS): Se il task chiede una singola funzione TypeScript pura
108
+ (sum, add, calculate, map, filter) anche se il prompt ha rumore/distrazioni/noise:
109
+ → piano con 1 SOLO subtask: tool=direct_response
110
+ → MAI run_python, type_check o npm_run per funzioni TypeScript di 1-3 righe
111
+
112
+ REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numerici:
113
+ - Controlla: conversion rate > 100%? conversioni > utenti? → IMPOSSIBILE
114
+ - Se dati impossibili → piano con SOLO 1 subtask: tool=direct_response
115
+ description: "segnala anomalia nei dati: incoerente/impossibile, non calcolare"
116
+ - MAI pianificare run_python/execute_sql su dati statisticamente impossibili
117
+
118
+ REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate,
119
+ DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask.
120
+
121
+ REGOLA ORCHESTRATION (S-GAP9): Per task complessi (>5 passi), includi SEMPRE un subtask finale di "Verifica Integrazione e Test End-to-End".
122
+ Scomponi i rami Backend e Frontend in parallel_groups separati per massimizzare l'efficienza.
123
+
124
+ REGOLA RECOVERY & ROBUSTNESS (S-GAP12, S-GAP7):
125
+ - Se l'obiettivo è ambiguo o i dati sembrano incoerenti, il primo subtask DEVE essere "Analisi Critica e Validazione Requisiti" (tool: direct_response).
126
+ - Per ogni integrazione API, aggiungi un subtask di "Health Check / Verifica Connettività" prima delle operazioni core.
127
+ - Se il task fallisce 2 volte, il piano deve includere un passo di "Debug e Analisi Log" (tool: read_file/execute_shell).
128
+
129
+ REGOLE GRAFO DI DIPENDENZE:
130
+ - requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[]
131
+ - requires:[N] → subtask che dipende dall'output di subtask id N
132
+ - priority:high: subtask bloccante; i dipendenti mettono il suo id in requires
133
+ - priority:low: subtask indipendente; eseguibile in parallelo
134
+ - Identifica SEMPRE rami indipendenti (es. Backend vs Frontend, Read vs Write diversi file)
135
+ - Aggiungi entrambi i rami in parallel_groups per massimizzare il parallelismo"""
136
+
137
+ # ── Prompt quick-start (Cerebras/Groq): 2-3 passi immediati ─────────────────
138
+
139
+ PLANNER_QUICK_SYSTEM = """Sei un planner rapido. Dato un obiettivo, genera SOLO i primi 2-3 passi immediati e concreti.
140
+
141
+ Rispondi SOLO con JSON valido:
142
+ {
143
+ "goal": "obiettivo",
144
+ "complexity": "low|medium|high",
145
+ "subtasks": [
146
+ {"id": 1, "description": "primo passo", "tool": "<tool>", "requires": [], "risk": "low", "priority": "high"},
147
+ {"id": 2, "description": "secondo passo", "tool": "<tool>", "requires": [1], "risk": "low", "priority": "medium"}
148
+ ],
149
+ "parallel_groups": [[1],[2]],
150
+ "estimated_steps": 2,
151
+ "impacted_files": []
152
+ }
153
+
154
+ Regole:
155
+ - MAX 3 subtask — solo le azioni più immediate e ovvie
156
+ - Scegli tool giusto: web_search/read_page per info, run_python per codice, write_file per file
157
+ - Non pianificare l'intero progetto — solo il "prossimo passo" logico
158
+ - requires:[] per passi indipendenti (possono partire subito in parallelo)"""
159
+
160
+
161
+ def _extract_json_balanced(raw: str) -> str | None:
162
+ """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'.
163
+ Gestisce JSON annidati correttamente (piani con subtask oggetti complessi).
164
+ """
165
+ depth = 0
166
+ start = -1
167
+ for i, ch in enumerate(raw):
168
+ if ch == '{':
169
+ if depth == 0:
170
+ start = i
171
+ depth += 1
172
+ elif ch == '}':
173
+ depth -= 1
174
+ if depth == 0 and start != -1:
175
+ return raw[start:i + 1]
176
+ return None
177
+
178
+
179
+ def _parse_plan(raw: str) -> dict | None:
180
+ """Estrae e valida il JSON del piano dalla risposta LLM."""
181
+ if not raw:
182
+ return None
183
+ json_match = _extract_json_balanced(raw)
184
+ if not json_match:
185
+ return None
186
+ try:
187
+ plan = json.loads(json_match)
188
+ if not plan.get("subtasks"):
189
+ return None
190
+ return plan
191
+ except (json.JSONDecodeError, ValueError):
192
+ return None
193
+
194
+
195
+ class Planner:
196
+ def __init__(self, llm_client: AIClient | None = None):
197
+ self._explicit_llm = llm_client is not None
198
+ if llm_client is not None:
199
+ self.llm = llm_client
200
+ else:
201
+ # Master planner: DeepSeek-R1 per deep reasoning
202
+ try:
203
+ from models.role_router import RoleRouter, Role
204
+ self.llm = RoleRouter.get_client(Role.ARCHITECT)
205
+ except Exception:
206
+ self.llm = AIClient()
207
+
208
+ @classmethod
209
+ def from_ollama(cls, ollama=None) -> "Planner":
210
+ return cls()
211
+
212
+ def _get_fast_llm(self) -> AIClient:
213
+ """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
214
+ Fallback: Groq openai/gpt-oss-20b se CEREBRAS_API_KEY assente."""
215
+ if self._explicit_llm:
216
+ return self.llm
217
+ try:
218
+ from models.role_router import RoleRouter, Role
219
+ return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
220
+ except Exception as _exc:
221
+ _logger.debug("[planner] silenced %s", type(_exc).__name__) # noqa: BLE001
222
+ try:
223
+ from models.role_router import RoleRouter, Role
224
+ return RoleRouter.get_client(Role.FAST) # Groq 8B fallback
225
+ except Exception:
226
+ return AIClient()
227
+
228
+ def _build_messages(self, system: str, goal: str,
229
+ context: list | None = None) -> list[dict]:
230
+ msgs = [
231
+ {"role": "system", "content": system},
232
+ {"role": "user", "content": f"Obiettivo: {goal}"},
233
+ ]
234
+ if context:
235
+ ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
236
+ msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
237
+ return msgs
238
+
239
+ async def create_plan(self, goal: str,
240
+ context: list | None = None,
241
+ model: str | None = None) -> dict:
242
+ """
243
+ Gap-5: Speculative Hybrid Planning.
244
+
245
+ Lancia in parallelo:
246
+ 1. Quick-Start (Cerebras/Groq) — risponde in < 500ms con 2-3 subtask immediati
247
+ 2. Master Plan (DeepSeek-R1) — risponde in 8-15s con piano architetturale completo
248
+
249
+ Logica:
250
+ - attende max QUICK_TIMEOUT per il quick-start
251
+ - se arriva → restituisce subito (flag _speculative=True) così l'agente parte
252
+ - se DeepSeek-R1 arriva prima → piano completo (flag _speculative=False)
253
+ - se entrambi timeout → fallback euristico
254
+ """
255
+ QUICK_TIMEOUT = 1.2 # secondi: soglia "fast-first" win
256
+ MASTER_TIMEOUT = 30.0 # secondi: timeout totale DeepSeek-R1
257
+
258
+ msgs_quick = self._build_messages(PLANNER_QUICK_SYSTEM, goal, context)
259
+ msgs_master = self._build_messages(PLANNER_SYSTEM, goal, context)
260
+
261
+ fast_llm = self._get_fast_llm()
262
+
263
+ async def _call_quick() -> dict | None:
264
+ try:
265
+ raw = await asyncio.wait_for(
266
+ fast_llm.chat(msgs_quick, temperature=0.2, max_tokens=512),
267
+ timeout=QUICK_TIMEOUT,
268
+ )
269
+ plan = _parse_plan(raw)
270
+ if plan:
271
+ plan["_speculative"] = True
272
+ plan["_raw"] = raw[:400]
273
+ return plan
274
+ except Exception:
275
+ return None
276
+
277
+ async def _call_master() -> dict | None:
278
+ for _attempt in range(3):
279
+ try:
280
+ raw = await asyncio.wait_for(
281
+ self.llm.chat(msgs_master, temperature=0.3, max_tokens=2048),
282
+ timeout=MASTER_TIMEOUT,
283
+ )
284
+ plan = _parse_plan(raw)
285
+ if plan:
286
+ plan["_speculative"] = False
287
+ plan["_raw"] = raw[:400]
288
+ return plan
289
+ except (asyncio.TimeoutError, TimeoutError):
290
+ if _attempt < 2:
291
+ await asyncio.sleep(1.0 * (2 ** _attempt))
292
+ continue
293
+ break
294
+ except Exception:
295
+ break
296
+ return None
297
+
298
+ # ── Speculative dual-fire ────────────────────────────────────────────
299
+ # Entrambi i modelli partono simultaneamente.
300
+ # asyncio.wait(FIRST_COMPLETED) con soglia QUICK_TIMEOUT:
301
+ # - Se quick-start risponde prima → agente parte subito (< 1s)
302
+ # - Master plan continua in background; il loop lo ignora (non ha callback)
303
+ # - Se master arriva per primo (es. R1 cold-start veloce) → piano completo
304
+ quick_task = asyncio.create_task(_call_quick())
305
+ master_task = asyncio.create_task(_call_master())
306
+
307
+ done, pending = await asyncio.wait(
308
+ {quick_task, master_task},
309
+ timeout=QUICK_TIMEOUT,
310
+ return_when=asyncio.FIRST_COMPLETED,
311
+ )
312
+
313
+ # Caso 1: quick-start ha risposto entro QUICK_TIMEOUT
314
+ if quick_task in done:
315
+ quick_plan = quick_task.result()
316
+ if quick_plan:
317
+ # P25-B3: per goal complessi (app/progetto/sistema), ignora quick-plan con ≤3 subtask
318
+ # e attendi il master plan architetturale. Il quick-plan 2-3 step causa rework
319
+ # sistematico su task multi-file che richiedono schema + contratto API.
320
+ _COMPLEX_APP_RE = re.compile(
321
+ r'\b(crea\s+(?:una\s+)?(?:app|applicazione|sistema|sito|progetto|piattaforma|servizio)|'
322
+ r'build\s+(?:a\s+)?(?:app|system|platform|service|website)|'
323
+ r'sviluppa|realizza|implementa\s+(?:un[ao]\s+)?(?:sistema|app|servizio)|'
324
+ r'full.?stack|backend\s+e\s+frontend|frontend\s+e\s+backend)\b',
325
+ re.IGNORECASE,
326
+ )
327
+ _is_complex_app = bool(_COMPLEX_APP_RE.search(goal)) and len(goal) > 60
328
+ _n_quick_subtasks = len(quick_plan.get("subtasks", []))
329
+ if _is_complex_app and _n_quick_subtasks <= 3:
330
+ _logger.info(
331
+ "P25-B3: goal complesso rilevato (%d subtask quick) — attendo master plan",
332
+ _n_quick_subtasks,
333
+ )
334
+ # Non restituire il quick-plan; lascia cadere al Caso 2 (wait master)
335
+ else:
336
+ _logger.info(
337
+ "Gap-5 speculative: quick-start plan (%d subtask), master in background",
338
+ _n_quick_subtasks,
339
+ )
340
+ # Master task continua in background; risultato non bloccante
341
+ master_task.add_done_callback(
342
+ lambda t: (
343
+ _logger.info(
344
+ "Gap-5 master plan ready (%d subtask) — successiva chiamata beneficerà",
345
+ len((t.result() or {}).get("subtasks", [])) if not t.cancelled() and t.exception() is None else 0,
346
+ )
347
+ if not t.cancelled() and t.exception() is None
348
+ else None
349
+ )
350
+ )
351
+ return quick_plan
352
+
353
+ # Caso 2: nessuno ha risposto in QUICK_TIMEOUT — aspetta il master plan
354
+ if master_task in done:
355
+ master_plan = master_task.result()
356
+ if master_plan:
357
+ quick_task.cancel()
358
+ return master_plan
359
+
360
+ # Caso 3: nessuno ancora pronto — aspetta fino a MASTER_TIMEOUT
361
+ remaining = {t for t in {quick_task, master_task} if not t.done()}
362
+ if remaining:
363
+ done2, _ = await asyncio.wait(remaining, timeout=MASTER_TIMEOUT - QUICK_TIMEOUT)
364
+ for t in done2:
365
+ if t.exception() is None and not t.cancelled():
366
+ result = t.result()
367
+ if result:
368
+ for other in remaining - {t}:
369
+ other.cancel()
370
+ return result
371
+
372
+ # Cancella task pendenti
373
+ for t in {quick_task, master_task}:
374
+ if not t.done():
375
+ t.cancel()
376
+
377
+ # ── Fallback euristico (piano semplice) ──────────────────────────────
378
+ _logger.warning("Gap-5 planner: tutti i modelli in timeout per goal: %s", goal[:80])
379
+ _g = goal.lower()
380
+ _ft, _fr = "direct_response", "low"
381
+ if re.search(r"https?://", _g): _ft = "read_page"
382
+ elif re.search(r"\b(cerca|search|notizie|news)\b", _g): _ft = "web_search"
383
+ elif re.search(r"\b(git|branch|commit|diff)\b", _g): _ft = "git_status"
384
+ elif re.search(r"\b(struttura|directory|tree|elenca)\b", _g): _ft = "directory_tree"
385
+ elif re.search(r"\b(grep|occorrenze|cerca.*codice)\b", _g): _ft = "file_search"
386
+ elif re.search(r"\b(npm|pnpm|yarn)\b", _g): _ft, _fr = "npm_run", "medium"
387
+ elif re.search(r"\b(pip |pip3 |installa pacchett)\b", _g): _ft, _fr = "pip_install", "medium"
388
+ elif re.search(r"\b(codice|python|script)\b", _g): _ft, _fr = "run_python", "medium"
389
+ elif re.search(r"\b(scaffold|bootstrap)\b|crea.*app|crea.*progetto|nuovo.*progetto", _g): _ft, _fr = "scaffold_project", "medium"
390
+ elif re.search(r"\b(genera|crea).*immagine\b", _g): _ft, _fr = "generate_image", "medium"
391
+ return {
392
+ "goal": goal, "complexity": "medium",
393
+ "subtasks": [{"id": 1, "description": goal, "tool": _ft,
394
+ "requires": [], "risk": _fr, "priority": "high"}],
395
+ "parallel_groups": [[1]],
396
+ "estimated_steps": 1, "impacted_files": [], "_fallback": True,
397
+ }
agents/reasoning_core.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reasoning_core.py — MobileMaxAgent Implementation
3
+ Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop.
4
+ """
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from typing import List, Dict, Any, Optional
8
+ import asyncio
9
+ import json, re
10
+ from models.ai_client import AIClient
11
+
12
+ import logging
13
+ _logger = logging.getLogger("agents.reasoning_core")
14
+
15
+
16
+ @dataclass
17
+ class ReasoningResult:
18
+ action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy"
19
+ steps: List[str]
20
+ patch: Optional[str] = None
21
+ reason: str = ""
22
+ confidence: float = 0.5
23
+
24
+
25
+ @dataclass
26
+ class ReasoningState:
27
+ goal: str
28
+ context: str = ""
29
+ last_result: str = ""
30
+ errors: List[str] = field(default_factory=list)
31
+ completed_steps: List[str] = field(default_factory=list)
32
+ loop_count: int = 0
33
+ world_model: Optional[str] = None
34
+ strategy: Optional[str] = None
35
+ project_files: Optional[List[Dict[str, Any]]] = None # GAP-2: file VFS per deep context reasoning
36
+
37
+
38
+ class ReasoningCore:
39
+ """
40
+ MobileMaxAgent — Evoluzione del ReasoningCore.
41
+ Gestisce l'intero ciclo di vita del progetto:
42
+ 1. Analyze (Project Understanding)
43
+ 2. Strategy (Global Decision Making)
44
+ 3. Patch (Multi-file implementation)
45
+ 4. Run & Debug (Auto-repair loop)
46
+ """
47
+ MAX_LOOPS = 15
48
+ MIN_CONFIDENCE = 0.4
49
+
50
+ def __init__(self, llm_client: AIClient | None = None, planner=None, critic=None, executor=None):
51
+ self.llm = llm_client or AIClient()
52
+ self.planner = planner
53
+ self.critic = critic
54
+ self.executor = executor
55
+
56
+ # ── 1. Project Understanding ────────────────────────────────────────────────
57
+ async def analyze_project(self, repo_context: str) -> str:
58
+ prompt = f"""Analyze full software system.
59
+ Return:
60
+ - architecture map
61
+ - dependencies
62
+ - risk zones
63
+ - entry points
64
+ CONTEXT:
65
+ {repo_context}
66
+ """
67
+ # S665: wrap con asyncio.wait_for — analyze_project usava await self.llm.chat() senza timeout
68
+ # → hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py).
69
+ try:
70
+ return await asyncio.wait_for(
71
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
72
+ timeout=45.0,
73
+ )
74
+ except asyncio.TimeoutError:
75
+ return "[reasoning_core] analyze_project: timeout 45s — contesto non disponibile"
76
+
77
+ # ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
78
+ async def develop_strategy(self, state: ReasoningState) -> str:
79
+ prompt = f"""You are an autonomous software engineer.
80
+ WORLD MODEL:
81
+ {state.world_model}
82
+ STATE:
83
+ - goal: {state.goal}
84
+ - errors: {state.errors}
85
+ - completed: {state.completed_steps}
86
+ Decide:
87
+ - what to change
88
+ - why
89
+ - impact
90
+ - risk level
91
+ """
92
+ # S665: timeout anche per develop_strategy
93
+ try:
94
+ return await asyncio.wait_for(
95
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
96
+ timeout=45.0,
97
+ )
98
+ except asyncio.TimeoutError:
99
+ return "[reasoning_core] develop_strategy: timeout 45s — strategia non disponibile"
100
+
101
+ # ── 3. Error Intelligence ───────────────────────────────────────────────────
102
+ async def analyze_error(self, error: str) -> str:
103
+ prompt = f"""Map error to codebase.
104
+ ERROR:
105
+ {error}
106
+ Return:
107
+ - file
108
+ - root cause
109
+ - fix strategy
110
+ """
111
+ # S665: timeout anche per analyze_error
112
+ try:
113
+ return await asyncio.wait_for(
114
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
115
+ timeout=45.0,
116
+ )
117
+ except asyncio.TimeoutError:
118
+ return "[reasoning_core] analyze_error: timeout 45s — analisi non disponibile"
119
+
120
+ # ── Prompt builder ──────────────────────────────────────────────────────────
121
+ def _build_prompt(self, state: ReasoningState) -> str:
122
+ # S590: errors[-3:]→[-5:] — più errori nel contesto per diagnosi più accurata
123
+ # BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati — diagnosi più accurata
124
+ if state.errors:
125
+ import re as _re_err
126
+ _err_all = state.errors
127
+ _err_grouped: dict[str, int] = {}
128
+ for _e in _err_all:
129
+ _ek = _re_err.match(r'(\w+Error|\w+Exception|[A-Z]\w{3,})', _e)
130
+ _ek_str = _ek.group(1) if _ek else "Error"
131
+ _err_grouped[_ek_str] = _err_grouped.get(_ek_str, 0) + 1
132
+ _err_recent = "\n".join(_err_all[-5:])
133
+ _err_summary = ", ".join(f"{k}×{v}" for k, v in _err_grouped.items()) if len(_err_all) > 5 else ""
134
+ errors_str = _err_recent + (f"\n[Riepilogo tipi: {_err_summary}]" if _err_summary else "")
135
+ else:
136
+ errors_str = "nessuno"
137
+ steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
138
+
139
+ _base_prompt = f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
140
+ Analizza lo stato e decidi l'azione successiva.
141
+
142
+ STATO:
143
+ - goal: {state.goal}
144
+ - world_model: {'Presente' if state.world_model else 'Mancante'}
145
+ - strategy: {'Definita' if state.strategy else 'Da definire'}
146
+ - last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300→500
147
+ - errors: {errors_str}
148
+ - loop_count: {state.loop_count}/{self.MAX_LOOPS}
149
+
150
+ Rispondi SOLO con JSON valido:
151
+ {{
152
+ "action": "analyze | strategy | plan | fix | continue | stop",
153
+ "steps": ["prossimo passo tecnico"],
154
+ "patch": "eventuale diff o codice",
155
+ "reason": "perché questa azione?",
156
+ "confidence": 0.0-1.0
157
+ }}
158
+
159
+ Regole:
160
+ 1. Se manca world_model -> "analyze"
161
+ 2. Se manca strategy -> "strategy"
162
+ 3. Se strategy c'è ma serve piano -> "plan"
163
+ 4. Se ci sono errori -> "fix"
164
+ 5. Se tutto ok -> "continue" o "stop" se finito.
165
+ """
166
+
167
+ # GAP-2: Deep Context — inietta skeleton dei file rilevanti per ragionamento multi-file
168
+ _ctx_section = ""
169
+ if state.project_files:
170
+ try:
171
+ from agents.context_manager import rank_files_by_relevance, build_file_skeleton
172
+ _top_paths = set(rank_files_by_relevance(state.goal, state.project_files, k=5))
173
+ _skels = [
174
+ build_file_skeleton(
175
+ f.get("path", ""),
176
+ f.get("content", ""),
177
+ f.get("language", ""),
178
+ )
179
+ for f in state.project_files
180
+ if f.get("path") in _top_paths
181
+ ]
182
+ if _skels:
183
+ # P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare.
184
+ # Zero LLM, zero latenza — stessa logica word-overlap di episodic.py.
185
+ # Garantisce che i blocchi più rilevanti per il goal finiscano PRIMA del taglio.
186
+ _goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set()
187
+ if _goal_kw_ctx:
188
+ _skels.sort(
189
+ key=lambda _s: len(_goal_kw_ctx & set(re.findall(r'\w{4,}', _s.lower()))),
190
+ reverse=True,
191
+ )
192
+ _ctx_raw = "\n".join(_skels)
193
+ # S780-SMART: Smart Chunking — estrae firme funzioni/classi invece di troncare.
194
+ # BUG-SKEL fix: evita allucinazioni su funzioni mancanti nei file complessi.
195
+ if len(_ctx_raw) > 6000:
196
+ import re as _re_sk
197
+ _sig_lines = _re_sk.findall(
198
+ r'^(?:(?:async\s+)?def |class |export\s+(?:default\s+)?'
199
+ r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}',
200
+ _ctx_raw, _re_sk.MULTILINE
201
+ )
202
+ _ctx_smart = '\n'.join(_sig_lines)
203
+ if len(_ctx_smart) >= 500:
204
+ _ctx_raw = (
205
+ f'[SMART CHUNK — {len(_skels)} file — solo firme estratte]\n'
206
+ + _ctx_smart[:10000]
207
+ )
208
+ else:
209
+ _ctx_raw = _ctx_raw[:6000] + '\n… [troncato — usa file_search per dettagli]'
210
+ _ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw
211
+ except Exception:
212
+ pass # non-fatal — degradazione graceful senza deep context
213
+
214
+ return _base_prompt + _ctx_section
215
+
216
+ @staticmethod
217
+ def _extract_json(raw: str) -> str | None:
218
+ """P16-B3: depth-counting bilanciato — sostituisce regex greedy r'{[\s\S]+}'
219
+ che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO }
220
+ producendo JSON malformato → action='continue' per default → agente in loop.
221
+ Pattern identico a safeJsonParse.ts già in produzione sul frontend."""
222
+ depth = 0
223
+ start = -1
224
+ for i, ch in enumerate(raw):
225
+ if ch == '{':
226
+ if depth == 0:
227
+ start = i
228
+ depth += 1
229
+ elif ch == '}':
230
+ depth -= 1
231
+ if depth == 0 and start != -1:
232
+ return raw[start:i + 1]
233
+ return None
234
+
235
+ def _parse(self, raw: str) -> ReasoningResult:
236
+ try:
237
+ candidate = self._extract_json(raw)
238
+ data = json.loads(candidate) if candidate else {}
239
+ except Exception:
240
+ return ReasoningResult(action='continue', steps=[], reason='Parsing error fallback', confidence=0.2)
241
+
242
+ return ReasoningResult(
243
+ action=data.get("action", "continue"),
244
+ steps=data.get("steps", []),
245
+ patch=data.get("patch"),
246
+ reason=data.get("reason", ""),
247
+ confidence=float(data.get("confidence", 0.5))
248
+ )
249
+
250
+ async def decide(self, state: ReasoningState) -> ReasoningResult:
251
+ if state.loop_count >= self.MAX_LOOPS:
252
+ return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
253
+
254
+ prompt = self._build_prompt(state)
255
+ try:
256
+ # S750-GAP-D: asyncio.wait_for — evita hang se LLM provider non risponde
257
+ raw = await asyncio.wait_for(
258
+ self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
259
+ timeout=30.0,
260
+ )
261
+ return self._parse(raw)
262
+ except asyncio.TimeoutError:
263
+ return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3)
264
+ except Exception as e:
265
+ return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3)
266
+
267
+ async def run_loop(self, goal: str, context: str = "", on_step=None,
268
+ project_files: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
269
+ state = ReasoningState(goal=goal, context=context, project_files=project_files)
270
+ results = []
271
+
272
+ while state.loop_count < self.MAX_LOOPS:
273
+ decision = await self.decide(state)
274
+
275
+ if on_step:
276
+ await on_step({
277
+ "loop": state.loop_count,
278
+ "action": decision.action,
279
+ "reason": decision.reason,
280
+ "confidence": decision.confidence
281
+ })
282
+
283
+ if decision.action == "stop":
284
+ break
285
+
286
+ elif decision.action == "analyze":
287
+ state.world_model = await self.analyze_project(context or goal)
288
+ results.append({"action": "analyze", "output": "World model built"})
289
+
290
+ elif decision.action == "strategy":
291
+ state.strategy = await self.develop_strategy(state)
292
+ results.append({"action": "strategy", "output": state.strategy})
293
+
294
+ elif decision.action == "plan" and self.planner:
295
+ plan = await self.planner.create_plan(goal, context=state.strategy)
296
+ state.completed_steps.append("Piano creato")
297
+ state.last_result = "Piano generato"
298
+ results.append({"action": "plan", "result": plan})
299
+
300
+ elif decision.action == "fix":
301
+ if decision.patch:
302
+ # Se c'è una patch, l'executor la applica
303
+ if self.executor:
304
+ res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
305
+ state.last_result = str(res.get("output", ""))
306
+ state.errors = []
307
+ results.append({"action": "fix", "patch": "Applicata"})
308
+ else:
309
+ error_analysis = await self.analyze_error(str(state.errors))
310
+ state.last_result = error_analysis
311
+ results.append({"action": "error_analysis", "output": error_analysis})
312
+
313
+ elif decision.action == "continue":
314
+ # S575: direct_response non esiste nel TOOL_REGISTRY — usa LLM diretto
315
+ if decision.steps:
316
+ try:
317
+ _step_prompt = decision.steps[0]
318
+ _step_ans = await self.llm.chat(
319
+ [{"role": "system", "content":
320
+ "Sei un assistente tecnico. Esegui il passo richiesto in modo conciso."},
321
+ {"role": "user", "content":
322
+ f"Goal: {state.goal}\n\nPasso da eseguire: {_step_prompt}"}],
323
+ temperature=0.2, max_tokens=512,
324
+ )
325
+ state.last_result = _step_ans or ""
326
+ state.completed_steps.append(_step_prompt)
327
+ except Exception:
328
+ state.completed_steps.append(decision.steps[0])
329
+ results.append({"action": "continue", "steps": decision.steps})
330
+
331
+ # Auto-debug check con Critic
332
+ if self.critic and state.last_result and decision.action != "analyze":
333
+ critique = await self.critic.evaluate(goal, state.last_result)
334
+ if critique.get("needs_retry"):
335
+ state.errors.extend(critique.get("issues", []))
336
+
337
+ state.loop_count += 1
338
+
339
+ return {
340
+ "goal": goal,
341
+ "loops": state.loop_count,
342
+ "success": len(state.errors) == 0,
343
+ "results": results,
344
+ "final_state": {
345
+ "has_world_model": state.world_model is not None,
346
+ "has_strategy": state.strategy is not None
347
+ }
348
+ }
349
+
350
+ async def run_loop_to_answer(self, goal: str, context: str = "",
351
+ on_step=None, max_loops: int = 8,
352
+ project_files: Optional[List[Dict[str, Any]]] = None) -> str:
353
+ """S575: Versione di run_loop che ritorna una stringa risposta sintetizzata.
354
+
355
+ Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3.
356
+ Limite max_loops=8 (S701: era 5) — più iterazioni per task profondi.
357
+ Output: stringa di risultati aggregati da passare come contesto extra al LLM finale.
358
+ Mai solleva eccezioni.
359
+ """
360
+ try:
361
+ # GAP-2: deep context — inietta i file VFS nella ReasoningState per rank_files_by_relevance()
362
+ state = ReasoningState(goal=goal, context=context, project_files=project_files)
363
+ parts: List[str] = []
364
+ loop_cap = min(max_loops, self.MAX_LOOPS)
365
+
366
+ while state.loop_count < loop_cap:
367
+ try:
368
+ decision = await self.decide(state)
369
+ except Exception:
370
+ break
371
+
372
+ if on_step:
373
+ try:
374
+ import asyncio as _aio
375
+ coro = on_step({
376
+ "loop": state.loop_count,
377
+ "action": f"reasoning:{decision.action}",
378
+ "reason": decision.reason[:200] if decision.reason else "", # S578: 120→200
379
+ "confidence": decision.confidence,
380
+ })
381
+ if _aio.iscoroutine(coro):
382
+ await coro
383
+ except Exception as _exc:
384
+ _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
385
+
386
+ if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE:
387
+ break
388
+
389
+ elif decision.action == "analyze":
390
+ try:
391
+ state.world_model = await self.analyze_project(context or goal)
392
+ # S593: 400→600 — world_model spesso multi-paragrafo
393
+ parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}")
394
+ except Exception as _exc:
395
+ _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
396
+
397
+ elif decision.action == "strategy":
398
+ try:
399
+ state.strategy = await self.develop_strategy(state)
400
+ # S593: 400→600 — strategy spesso multi-step
401
+ parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}")
402
+ except Exception as _exc:
403
+ _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
404
+
405
+ elif decision.action in ("plan", "continue", "fix"):
406
+ # Esegui passo diretto via LLM
407
+ step_desc = (decision.steps[0] if decision.steps
408
+ else decision.reason or goal)
409
+ try:
410
+ _ans = await self.llm.chat(
411
+ [{"role": "system", "content":
412
+ "Sei un assistente tecnico esperto. "
413
+ "Svolgi il passo richiesto in modo preciso e conciso."},
414
+ {"role": "user", "content":
415
+ f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}],
416
+ temperature=0.2, max_tokens=512,
417
+ )
418
+ if _ans and not _ans.startswith("[LLM"):
419
+ parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}")
420
+ state.last_result = _ans
421
+ state.completed_steps.append(step_desc)
422
+ except Exception as _exc:
423
+ _logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
424
+
425
+ state.loop_count += 1
426
+
427
+ return "\n\n".join(parts) if parts else ""
428
+ except Exception:
429
+ return ""
agents/reflection_sidecar.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reflection_sidecar.py — Reflection Sidecar (Double-Token Innovation)
3
+
4
+ Analizza i log di errore di ogni tool call durante la sessione e aggiorna
5
+ session_rules.md in tempo reale. Questo file viene iniettato nel system prompt
6
+ dell'agente principale per correggere il comportamento on-the-fly.
7
+
8
+ Architettura:
9
+ Token A (agente principale) → esegue tool, chiama log_error()
10
+ Token B (sidecar critic) → analizza pattern, scrive regole
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import re
20
+ import time
21
+ from collections import defaultdict, deque
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ _logger = logging.getLogger("agente_ai.reflection_sidecar")
27
+
28
+ # ── Config ────────────────────────────────────────────────────────────────────
29
+
30
+ _RULES_FILE = Path(os.getenv("SIDECAR_RULES_FILE", "/data/session_rules.md"))
31
+ _MAX_ERRORS_BEFORE_REFLECT = int(os.getenv("SIDECAR_REFLECT_THRESHOLD", "2"))
32
+ _RULE_TTL_S = int(os.getenv("SIDECAR_RULE_TTL_S", "3600")) # 1h
33
+
34
+ # Token B per NVIDIA NIM (Reflection Critic — modello leggero, bassa latenza)
35
+ _NVIDIA_API = "https://integrate.api.nvidia.com/v1"
36
+ _CRITIC_MODEL = os.getenv("NVIDIA_B_MODEL", "meta/llama-3.3-70b-instruct")
37
+ _NVIDIA_KEY_B = os.getenv("NVIDIA_API_KEY_B", "")
38
+
39
+
40
+ # ── Data model ────────────────────────────────────────────────────────────────
41
+
42
+ @dataclass
43
+ class ErrorEvent:
44
+ tool: str
45
+ error: str
46
+ context: str
47
+ ts: float = field(default_factory=time.monotonic)
48
+
49
+
50
+ @dataclass
51
+ class SessionRule:
52
+ pattern: str # cosa ha causato l'errore (regex / descrizione)
53
+ rule: str # istruzione correttiva per l'agente
54
+ tool: str
55
+ created_at: float = field(default_factory=time.time)
56
+ hit_count: int = 0
57
+
58
+
59
+ # ── Sidecar core ─────────────────────────────────────────────────────────────
60
+
61
+ class ReflectionSidecar:
62
+ """
63
+ Singleton per sessione. Riceve errori, li analizza con Token B (NVIDIA),
64
+ aggiorna session_rules.md che viene iniettato nel prompt principale.
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self._errors: list[ErrorEvent] = []
69
+ self._rules: list[SessionRule] = []
70
+ self._tool_error_counts: dict[str, int] = defaultdict(int)
71
+ self._lock = asyncio.Lock()
72
+ self._reflect_task: asyncio.Task | None = None
73
+
74
+ async def log_error(
75
+ self,
76
+ tool: str,
77
+ error: str,
78
+ context: str = "",
79
+ ) -> None:
80
+ """Registra un errore. Se lo stesso tool fallisce >= threshold, avvia reflection."""
81
+ async with self._lock:
82
+ evt = ErrorEvent(tool=tool, error=error[:500], context=context[:300])
83
+ self._errors.append(evt)
84
+ self._tool_error_counts[tool] += 1
85
+ count = self._tool_error_counts[tool]
86
+
87
+ if count >= _MAX_ERRORS_BEFORE_REFLECT:
88
+ # Avvia reflection in background (non blocca l'agente principale)
89
+ if self._reflect_task is None or self._reflect_task.done():
90
+ # BUGFIX: eccezioni di _reflect_and_update erano perse silenziosamente
91
+ def _log_ref_exc(t):
92
+ if not t.cancelled() and t.exception():
93
+ _logger.warning("[reflection_sidecar] reflect task raised: %s", t.exception())
94
+ self._reflect_task = asyncio.create_task(
95
+ self._reflect_and_update(tool, error, context)
96
+ )
97
+ self._reflect_task.add_done_callback(_log_ref_exc)
98
+
99
+ async def _reflect_and_update(
100
+ self, tool: str, last_error: str, context: str
101
+ ) -> None:
102
+ """Token B: analizza gli errori e genera una regola correttiva."""
103
+ if not _NVIDIA_KEY_B:
104
+ _logger.warning("reflection_sidecar: NVIDIA_API_KEY_B non configurato — skip")
105
+ return
106
+
107
+ # Aggrega tutti gli errori del tool
108
+ relevant = [e for e in self._errors if e.tool == tool][-5:]
109
+ error_summary = "\n".join(f"- [{e.tool}] {e.error}" for e in relevant)
110
+
111
+ prompt = f"""Sei un critico di qualità per un agente AI. Analizza questi errori ripetuti:
112
+
113
+ TOOL: {tool}
114
+ ERRORI:
115
+ {error_summary}
116
+
117
+ CONTESTO ULTIMO ERRORE: {context}
118
+
119
+ Scrivi UNA regola correttiva concisa (max 2 righe) che l'agente deve seguire per evitare
120
+ di ripetere questo errore. Formato: "REGOLA [{tool}]: <istruzione diretta all'agente>"
121
+ Rispondi solo con la regola, nessun altro testo."""
122
+
123
+ try:
124
+ import urllib.request
125
+ payload = json.dumps({
126
+ "model": _CRITIC_MODEL,
127
+ "messages": [{"role": "user", "content": prompt}],
128
+ "max_tokens": 120,
129
+ "temperature": 0.1,
130
+ }).encode()
131
+ req = urllib.request.Request(
132
+ f"{_NVIDIA_API}/chat/completions",
133
+ data=payload,
134
+ headers={
135
+ "Authorization": f"Bearer {_NVIDIA_KEY_B}",
136
+ "Content-Type": "application/json",
137
+ },
138
+ method="POST",
139
+ )
140
+ with urllib.request.urlopen(req, timeout=15) as resp:
141
+ data = json.loads(resp.read())
142
+ rule_text = data["choices"][0]["message"]["content"].strip()
143
+
144
+ new_rule = SessionRule(
145
+ pattern=last_error[:100],
146
+ rule=rule_text,
147
+ tool=tool,
148
+ )
149
+ async with self._lock:
150
+ # Dedup: rimuovi regole vecchie per lo stesso tool
151
+ self._rules = [r for r in self._rules if r.tool != tool]
152
+ self._rules.append(new_rule)
153
+ self._tool_error_counts[tool] = 0 # reset counter
154
+
155
+ await self._write_rules_file()
156
+ _logger.info(f"reflection_sidecar: nuova regola generata per {tool}")
157
+
158
+ except Exception as exc:
159
+ _logger.warning(f"reflection_sidecar: reflection fallita — {exc}")
160
+
161
+ async def _write_rules_file(self) -> None:
162
+ """Scrive session_rules.md — viene iniettato nel system prompt principale."""
163
+ now = time.time()
164
+ active = [r for r in self._rules if (now - r.created_at) < _RULE_TTL_S]
165
+ if not active:
166
+ return
167
+ lines = ["# Session Rules (auto-generate dal Reflection Sidecar)\n"]
168
+ lines += [f"- {r.rule}" for r in active]
169
+ lines.append(f"\n_Aggiornato: {time.strftime('%H:%M:%S')}_")
170
+ try:
171
+ _RULES_FILE.parent.mkdir(parents=True, exist_ok=True)
172
+ _RULES_FILE.write_text("\n".join(lines), encoding="utf-8")
173
+ except Exception as exc:
174
+ _logger.warning(f"reflection_sidecar: scrittura rules file fallita — {exc}")
175
+
176
+ def get_rules_for_prompt(self) -> str:
177
+ """Legge session_rules.md per l'iniezione nel system prompt."""
178
+ try:
179
+ if _RULES_FILE.exists():
180
+ content = _RULES_FILE.read_text(encoding="utf-8").strip()
181
+ if content and len(content) > 30:
182
+ return f"\n\n---\n{content}\n---"
183
+ except Exception:
184
+ pass
185
+ return ""
186
+
187
+ def reset(self) -> None:
188
+ """Reset a inizio nuova sessione."""
189
+ self._errors.clear()
190
+ self._rules.clear()
191
+ self._tool_error_counts.clear()
192
+ try:
193
+ _RULES_FILE.unlink(missing_ok=True)
194
+ except Exception:
195
+ pass
196
+
197
+
198
+ # ── Singleton ─────────────────────────────────────────────────────────────────
199
+ _sidecar: ReflectionSidecar | None = None
200
+
201
+
202
+ def get_sidecar() -> ReflectionSidecar:
203
+ global _sidecar
204
+ if _sidecar is None:
205
+ _sidecar = ReflectionSidecar()
206
+ return _sidecar
207
+
208
+
209
+ async def log_tool_error(tool: str, error: str, context: str = "") -> None:
210
+ """Shortcut globale — chiamare dopo ogni tool call fallita."""
211
+ await get_sidecar().log_error(tool, error, context)
212
+
213
+
214
+ def get_session_rules() -> str:
215
+ """Shortcut globale — iniettare nel system prompt principale."""
216
+ return get_sidecar().get_rules_for_prompt()
agents/requirement_engine.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ requirement_engine.py — Sprint 2: Decomposizione semantica del goal in requisiti strutturati
3
+
4
+ Input: goal string (es. "crea un CRM con login e dashboard")
5
+ Output: lista requisiti [{id, feature, description, acceptance_criteria}]
6
+
7
+ Strategia:
8
+ 1. Pattern matching regex deterministico su goal common (zero LLM, zero latenza)
9
+ 2. Fallback LLM (Groq fast, 1 call, 3s timeout) per goal non coperti dai pattern
10
+ 3. Cache locale dict-session per goal già decomposed — niente LLM repeat calls
11
+
12
+ Invarianti rispettate:
13
+ - PR2: non tocca providerChain.ts (questo è solo backend)
14
+ - B1: nessun corpo duplicato
15
+ - Additive-only: fallback a lista vuota su qualsiasi errore — nessuna regressione
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import json
21
+ import asyncio
22
+ import hashlib
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ # ── Cache session-level ────────────────────────────────────────────────────────
27
+ _REQUIREMENT_CACHE: dict[str, list[dict]] = {}
28
+ _CACHE_MAX = 50
29
+
30
+
31
+ def _cache_key(goal: str) -> str:
32
+ return hashlib.md5(goal.strip()[:300].lower().encode()).hexdigest()
33
+
34
+
35
+ # ── Libreria pattern predefiniti ───────────────────────────────────────────────
36
+ # Ogni voce: (regex_pattern, feature_id, feature_name, description)
37
+ _FEATURE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [
38
+ (
39
+ re.compile(r'\b(auth|login|logout|registr|signup|sign.?up|accesso|autenticaz)\b', re.I),
40
+ "auth", "Autenticazione",
41
+ "Login, logout, registrazione utente e gestione sessione",
42
+ ),
43
+ (
44
+ re.compile(r'\b(crud|create|read|update|delete|gestione|manage|list|detail|edit|modifica|elimina)\b', re.I),
45
+ "crud", "Operazioni CRUD",
46
+ "Creazione, lettura, modifica e cancellazione di entità dati",
47
+ ),
48
+ (
49
+ re.compile(r'\b(dashboard|overview|riepilogo|summary|stats|statistiche|kpi|metriche|analytics)\b', re.I),
50
+ "dashboard", "Dashboard",
51
+ "Vista aggregata con metriche, statistiche e KPI principali",
52
+ ),
53
+ (
54
+ re.compile(r'\b(api|rest|endpoint|route|backend|server|fastapi|flask|express|django)\b', re.I),
55
+ "api_rest", "API REST",
56
+ "Endpoint REST con validazione input e gestione errori",
57
+ ),
58
+ (
59
+ re.compile(r'\b(form|validaz|validation|input|campi|fields|submit|invio)\b', re.I),
60
+ "form_validation", "Form e Validazione",
61
+ "Form con validazione lato client e server",
62
+ ),
63
+ (
64
+ re.compile(r'\b(upload|file|immagine|image|media|attachment|allegato)\b', re.I),
65
+ "file_upload", "Upload File",
66
+ "Caricamento e gestione file/media",
67
+ ),
68
+ (
69
+ re.compile(r'\b(search|cerca|ricerca|filtro|filter|sort|ordinamento)\b', re.I),
70
+ "search", "Ricerca e Filtri",
71
+ "Ricerca full-text e filtraggio risultati",
72
+ ),
73
+ (
74
+ re.compile(r'\b(pagament|payment|stripe|checkout|cart|carrello|acquisto|order|ordine)\b', re.I),
75
+ "payments", "Pagamenti",
76
+ "Flusso di checkout e gestione pagamenti",
77
+ ),
78
+ (
79
+ re.compile(r'\b(notif|notification|alert|email|sms|push|avviso|messaggio)\b', re.I),
80
+ "notifications", "Notifiche",
81
+ "Sistema di notifiche e comunicazioni",
82
+ ),
83
+ (
84
+ re.compile(r'\b(setting|impostaz|profilo|profile|account|preferenze|config)\b', re.I),
85
+ "settings", "Impostazioni",
86
+ "Gestione profilo utente e impostazioni applicazione",
87
+ ),
88
+ (
89
+ re.compile(r'\b(database|db|schema|migration|tabella|table|model|entità|entity)\b', re.I),
90
+ "database", "Database",
91
+ "Schema dati, migrazioni e modelli",
92
+ ),
93
+ (
94
+ re.compile(r'\b(deploy|ci.?cd|docker|container|hosting|production|prod)\b', re.I),
95
+ "deploy", "Deploy",
96
+ "Pipeline di build e deploy in produzione",
97
+ ),
98
+
99
+ (
100
+ re.compile(r'\b(analizza|analyze|analisi|analytic|analisi comparativa|analyse)\b', re.I),
101
+ "analysis", "Analisi",
102
+ "Analisi dettagliata con argomentazioni, dati e conclusioni strutturate",
103
+ ),
104
+ (
105
+ re.compile(r'\b(confronta|compare|versus|vs\.?|differenz[ae]|differences?|migliore tra|meglio tra)\b', re.I),
106
+ "comparison", "Confronto",
107
+ "Confronto strutturato con dimensioni esplicite e raccomandazione finale",
108
+ ),
109
+ (
110
+ re.compile(r"\b(spiega|explain|descrivi|describe|cos[\'è ]{1,3}è|what is|how does|come funziona)\b", re.I),
111
+ "explanation", "Spiegazione",
112
+ "Spiegazione chiara con definizione, esempi concreti e contesto d'uso",
113
+ ),
114
+ (
115
+ re.compile(r'\b(riassumi|summarize|sommario|sintesi|riepilog[ao]|riassunto)\b', re.I),
116
+ "summarization", "Sintesi",
117
+ "Sintesi strutturata che mantiene i punti chiave senza perdita di informazioni critiche",
118
+ ),
119
+ (
120
+ re.compile(r'\b(raccomand[ai]|recommend|consigli[ao]|suggerisci|suggest|best practice|cosa sceglier|quale sceglier)\b', re.I),
121
+ "recommendation", "Raccomandazione",
122
+ "Raccomandazione motivata con almeno 3 criteri di valutazione e conclusione esplicita",
123
+ ),
124
+
125
+ ]
126
+
127
+ # Criteri accettazione standard per ogni feature (importati da AcceptanceCriteria)
128
+ from agents.acceptance_criteria import ACCEPTANCE_CRITERIA
129
+
130
+
131
+ @dataclass
132
+ class Requirement:
133
+ id: str
134
+ feature: str
135
+ description: str
136
+ acceptance_criteria: list[str] = field(default_factory=list)
137
+ source: str = "pattern" # "pattern" | "llm"
138
+
139
+
140
+ class RequirementEngine:
141
+ """
142
+ Decompone un goal in lista di requisiti strutturati con criteri di accettazione.
143
+
144
+ Chiamato da _run_fallback() su task complessi (_tok_budget >= 4096).
145
+ Zero LLM per goal coperti dai pattern — fallback LLM solo per goal esotici.
146
+ """
147
+
148
+ def __init__(self, llm: Any = None) -> None:
149
+ self.llm = llm
150
+
151
+ def decompose_sync(self, goal: str) -> list[Requirement]:
152
+ """
153
+ Decomposizione sincrona via pattern matching.
154
+ Usato quando non si è in un contesto async.
155
+ """
156
+ key = _cache_key(goal)
157
+ if key in _REQUIREMENT_CACHE:
158
+ cached = _REQUIREMENT_CACHE[key]
159
+ return [Requirement(**r) for r in cached]
160
+
161
+ reqs = self._match_patterns(goal)
162
+ self._store_cache(key, reqs)
163
+ return reqs
164
+
165
+ async def decompose(self, goal: str) -> list[Requirement]:
166
+ """
167
+ Decomposizione async: pattern first, poi LLM fallback se lista vuota e goal complesso.
168
+ """
169
+ key = _cache_key(goal)
170
+ if key in _REQUIREMENT_CACHE:
171
+ cached = _REQUIREMENT_CACHE[key]
172
+ return [Requirement(**r) for r in cached]
173
+
174
+ reqs = self._match_patterns(goal)
175
+
176
+ # Fallback LLM solo se: nessun pattern matched + goal complesso (>30 chars)
177
+ if not reqs and self.llm and len(goal.strip()) > 30:
178
+ try:
179
+ reqs = await asyncio.wait_for(self._decompose_llm(goal), timeout=3.0)
180
+ except Exception:
181
+ reqs = []
182
+
183
+ self._store_cache(key, reqs)
184
+ return reqs
185
+
186
+ def _match_patterns(self, goal: str) -> list[Requirement]:
187
+ """Pattern matching deterministico — zero latenza."""
188
+ seen: set[str] = set()
189
+ result: list[Requirement] = []
190
+ for pattern, feat_id, feat_name, description in _FEATURE_PATTERNS:
191
+ if feat_id in seen:
192
+ continue
193
+ if pattern.search(goal):
194
+ criteria = ACCEPTANCE_CRITERIA.get(feat_id, [])
195
+ result.append(Requirement(
196
+ id=feat_id,
197
+ feature=feat_name,
198
+ description=description,
199
+ acceptance_criteria=criteria,
200
+ source="pattern",
201
+ ))
202
+ seen.add(feat_id)
203
+ return result
204
+
205
+ async def _decompose_llm(self, goal: str) -> list[Requirement]:
206
+ """
207
+ LLM fallback per goal non coperti dai pattern.
208
+ 1 call, max 3s, output JSON strutturato.
209
+ """
210
+ system = (
211
+ "Sei un analista di requisiti software. Dato un goal, estrai i requisiti "
212
+ "in formato JSON. Rispondi SOLO con JSON valido, nessun testo aggiuntivo.\n"
213
+ 'Formato: [{"id":"snake_case","feature":"Nome","description":"desc breve"}]'
214
+ "\nMax 5 requisiti. Solo requisiti CONCRETI e VERIFICABILI."
215
+ )
216
+ msgs = [
217
+ {"role": "system", "content": system},
218
+ # S596: goal 300→500 — goal complessi superano 300 chars
219
+ {"role": "user", "content": f"Goal: {goal[:500]}"},
220
+ ]
221
+ try:
222
+ # S586: 300→512 — JSON array di requisiti con 3-5 items supera 300 tok
223
+ raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=512)
224
+ if not raw or raw.startswith("[LLM"):
225
+ return []
226
+ m = re.search(r'\[[\s\S]+\]', raw)
227
+ if not m:
228
+ return []
229
+ items = json.loads(m.group())
230
+ result = []
231
+ for item in items[:5]:
232
+ if not isinstance(item, dict):
233
+ continue
234
+ feat_id = str(item.get("id", "unknown"))
235
+ criteria = ACCEPTANCE_CRITERIA.get(feat_id, [])
236
+ result.append(Requirement(
237
+ id=feat_id,
238
+ feature=str(item.get("feature", feat_id)),
239
+ description=str(item.get("description", ""))[:400], # S576: 200→400
240
+ acceptance_criteria=criteria,
241
+ source="llm",
242
+ ))
243
+ return result
244
+ except Exception:
245
+ return []
246
+
247
+ @staticmethod
248
+ def _store_cache(key: str, reqs: list[Requirement]) -> None:
249
+ """Mantieni cache sotto _CACHE_MAX entries."""
250
+ if len(_REQUIREMENT_CACHE) >= _CACHE_MAX:
251
+ oldest = next(iter(_REQUIREMENT_CACHE))
252
+ _REQUIREMENT_CACHE.pop(oldest, None)
253
+ _REQUIREMENT_CACHE[key] = [
254
+ {
255
+ "id": r.id,
256
+ "feature": r.feature,
257
+ "description": r.description,
258
+ "acceptance_criteria": r.acceptance_criteria,
259
+ "source": r.source,
260
+ }
261
+ for r in reqs
262
+ ]
263
+
264
+ @staticmethod
265
+ def format_for_context(reqs: list[Requirement]) -> str:
266
+ """Formatta i requisiti per l'injection nel system prompt."""
267
+ if not reqs:
268
+ return ""
269
+ lines = ["[REQUISITI DECOMPOSED]"]
270
+ for r in reqs:
271
+ lines.append(f"• {r.feature}: {r.description}")
272
+ lines.append(
273
+ "\nIl GoalVerifier verificherà CIASCUN requisito separatamente. "
274
+ "Assicurati che la risposta copra tutti i punti elencati."
275
+ )
276
+ return "\n".join(lines)
agents/response_verifier.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ response_verifier.py — Response Quality Verifier + Repair Pass
3
+
4
+ Verifica e ripara l'output LLM prima che raggiunga l'utente:
5
+ 1. JSON repair — estrae e corregge JSON corrotto (trailing comma, unquoted keys, ecc.)
6
+ 2. Markdown sanitization — chiude code fence aperte, corregge heading malformati
7
+ 3. Coherence check — rileva risposte vuote, description-leak dei tool, risposte fuori tema
8
+ 4. Retry signal — se qualità < soglia, suggerisce retry con hint correttivo
9
+
10
+ Dipendenze: zero (solo stdlib).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import logging
20
+ _logger = logging.getLogger("agents.response_verifier")
21
+
22
+
23
+ # ── Soglie ────────────────────────────────────────────────────────────────────
24
+
25
+ QUALITY_RETRY_THRESHOLD = 0.35 # sotto questa soglia → retry
26
+ QUALITY_WARN_THRESHOLD = 0.55 # sotto questa → repairs loggati ma ok
27
+
28
+
29
+ # ── Patterns ──────────────────────────────────────────────────────────────────
30
+
31
+ # Frasi che indicano che il modello sta descrivendo tool invece di usarli
32
+ _TOOL_DESCRIPTION_PATTERNS = [
33
+ r"puoi (usare|utilizzare|eseguire)\s+(il\s+)?tool",
34
+ r"esegui\s+il\s+comando",
35
+ r"usa\s+il\s+tool\s+\w+",
36
+ r"assicurati di (aver )?installato il tool",
37
+ r"per utilizzare il tool",
38
+ r"il tool ti fornirà",
39
+ r"```(bash|sh)\s*\n\s*get_weather",
40
+ r"```(bash|sh)\s*\n\s*web_search",
41
+ r"```(bash|sh)\s*\n\s*calculate",
42
+ ]
43
+
44
+ # Frasi di resa senza contenuto utile
45
+ _EMPTY_RESPONSE_PATTERNS = [
46
+ r"^non ho informazioni",
47
+ r"^non (posso|riesco) (fornire|darti|aiutarti)",
48
+ r"^mi dispiace,?\s+non",
49
+ r"^purtroppo non",
50
+ r"^come AI non",
51
+ ]
52
+
53
+ _COMPILED_TOOL_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _TOOL_DESCRIPTION_PATTERNS]
54
+ _COMPILED_EMPTY_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _EMPTY_RESPONSE_PATTERNS]
55
+
56
+
57
+ # ── Result ────────────────────────────────────────────────────────────────────
58
+
59
+ @dataclass
60
+ class VerifyResult:
61
+ output: str
62
+ repairs: list[str] = field(default_factory=list)
63
+ quality: float = 1.0
64
+ retry_suggested: bool = False
65
+ retry_hint: str = ""
66
+
67
+
68
+ # ── JSON Repair ───────────────────────────────────────────────────────────────
69
+
70
+ def repair_json(text: str) -> tuple[str, list[str]]:
71
+ """
72
+ Tenta di estrarre e riparare JSON dall'output LLM.
73
+ Restituisce (json_str_riparato_o_originale, lista_riparazioni).
74
+ """
75
+ repairs: list[str] = []
76
+
77
+ # 1. Estrai blocco JSON (con o senza ```json ... ```)
78
+ fenced = re.search(r"```(?:json)?\s*(\{[\s\S]+?\})\s*```", text)
79
+ raw = fenced.group(1) if fenced else None
80
+
81
+ if not raw:
82
+ # P16-B3: depth-counting bilanciato — evita estrazione errata su JSON annidati
83
+ def _depth_extract(s: str) -> str | None:
84
+ depth = 0; start = -1
85
+ for i, ch in enumerate(s):
86
+ if ch == '{':
87
+ if depth == 0: start = i
88
+ depth += 1
89
+ elif ch == '}':
90
+ depth -= 1
91
+ if depth == 0 and start != -1:
92
+ return s[start:i + 1]
93
+ return None
94
+ raw = _depth_extract(text)
95
+
96
+ if not raw:
97
+ return text, repairs
98
+
99
+ # 2. Prova parse diretto
100
+ try:
101
+ json.loads(raw)
102
+ return raw, repairs
103
+ except json.JSONDecodeError as _exc:
104
+ _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
105
+
106
+ fixed = raw
107
+
108
+ # 3. Rimuovi trailing comma prima di } o ]
109
+ fixed, n = re.subn(r",\s*([}\]])", r"\1", fixed)
110
+ if n:
111
+ repairs.append(f"Rimossi {n} trailing comma nel JSON")
112
+
113
+ # 4. Aggiungi virgolette a chiavi non quotate
114
+ fixed, n = re.subn(r'(?<=[{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r' "\1":', fixed)
115
+ if n:
116
+ repairs.append(f"Quotate {n} chiavi JSON non quotate")
117
+
118
+ # 5. Sostituisci apici singoli con doppi (solo nelle stringhe)
119
+ if "'" in fixed and '"' not in fixed:
120
+ fixed = fixed.replace("'", '"')
121
+ repairs.append("Convertiti apici singoli → doppi nel JSON")
122
+
123
+ # 6. Prova di nuovo
124
+ try:
125
+ json.loads(fixed)
126
+ repairs.append("JSON riparato con successo")
127
+ return fixed, repairs
128
+ except json.JSONDecodeError as _exc:
129
+ _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001
130
+
131
+ # 7. Non riparabile — restituisci originale
132
+ return text, repairs
133
+
134
+
135
+ # ── Markdown Sanitization ─────────────────────────────────────────────────────
136
+
137
+ def sanitize_markdown(text: str) -> tuple[str, list[str]]:
138
+ """
139
+ Chiude code fence aperte e corregge markdown malformato.
140
+ """
141
+ repairs: list[str] = []
142
+ lines = text.split("\n")
143
+
144
+ # 1. Conta code fence aperte
145
+ fence_count = sum(1 for l in lines if re.match(r"^```", l))
146
+ if fence_count % 2 != 0:
147
+ text = text + "\n```"
148
+ repairs.append("Chiusa code fence aperta")
149
+
150
+ # 2. Correggi heading senza spazio (es. "##Titolo" → "## Titolo")
151
+ fixed, n = re.subn(r"^(#{1,6})([^#\s])", r"\1 \2", text, flags=re.MULTILINE)
152
+ if n:
153
+ text = fixed
154
+ repairs.append(f"Corretti {n} heading Markdown malformati")
155
+
156
+ # 3. Rimuovi backtick tripli isolati su riga vuota alla fine
157
+ text = re.sub(r"\n```\s*$", "\n```", text)
158
+
159
+ return text, repairs
160
+
161
+
162
+ # ── Coherence Check ───────────────────────────────────────────────────────────
163
+
164
+ def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]:
165
+ """
166
+ Verifica la coerenza della risposta rispetto al goal.
167
+ Ritorna (quality_score 0-1, issues[], retry_hint).
168
+ """
169
+ issues: list[str] = []
170
+ score = 1.0
171
+ hint = ""
172
+
173
+ stripped = response.strip()
174
+
175
+ # 1. Risposta vuota
176
+ if not stripped or len(stripped) < 20:
177
+ issues.append("Risposta troppo breve o vuota")
178
+ return 0.0, issues, "Rispondi in modo completo e diretto. Non restituire testo vuoto."
179
+
180
+ # 2. Tool description leak — l'agente descrive tool invece di usarli
181
+ for pat in _COMPILED_TOOL_PATTERNS:
182
+ if pat.search(stripped):
183
+ issues.append("L'agente sta descrivendo tool invece di usarli")
184
+ score -= 0.5
185
+ hint = (
186
+ "NON descrivere come usare tool o comandi. "
187
+ "I dati devono essere già stati recuperati. "
188
+ "Rispondi direttamente con le informazioni richieste."
189
+ )
190
+ break
191
+
192
+ # 3. Resa senza contenuto
193
+ for pat in _COMPILED_EMPTY_PATTERNS:
194
+ if pat.match(stripped):
195
+ issues.append("Risposta di resa senza contenuto utile")
196
+ score -= 0.4
197
+ if not hint:
198
+ # S577: 100→200 — più contesto nell'hint di repair
199
+ # S589: goal 200→300 — hint repair più dettagliato
200
+ # S597: 300→500 — goal lunghi tagliati
201
+ hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}"
202
+ break
203
+
204
+ # 4. Risposta in lingua sbagliata (controllo leggero)
205
+ italian_markers = ["è", "sono", "non", "per", "con", "che", "della", "una", "questo"]
206
+ english_markers = ["the", "is", "are", "for", "with", "that", "this", "have"]
207
+ italian_score = sum(1 for w in italian_markers if f" {w} " in stripped.lower())
208
+ english_score = sum(1 for w in english_markers if f" {w} " in stripped.lower())
209
+ if english_score > italian_score + 3:
210
+ issues.append("Risposta in inglese invece di italiano")
211
+ score -= 0.2
212
+ if not hint:
213
+ hint = "Rispondi SEMPRE in italiano."
214
+
215
+ # 5. Risposta troppo corta per il tipo di richiesta
216
+ is_complex = any(k in goal.lower() for k in ["spiega", "analizza", "descrivi", "come funziona"])
217
+ if is_complex and len(stripped) < 100:
218
+ issues.append("Risposta troppo breve per una richiesta complessa")
219
+ score -= 0.2
220
+ if not hint:
221
+ hint = "Fornisci una risposta più dettagliata e completa."
222
+
223
+ # 6. HTML/JS structural issues — detect broken markup in code blocks
224
+ if "```html" in stripped.lower():
225
+ html_issues = _check_html_structure(stripped)
226
+ if html_issues:
227
+ issues.extend(html_issues)
228
+ score -= 0.15
229
+ if not hint:
230
+ hint = f"Il codice HTML ha problemi strutturali: {'; '.join(html_issues[:2])}. Correggi la struttura."
231
+
232
+ # 7. JS unbalanced braces in code blocks
233
+ if "```javascript" in stripped.lower() or "```js" in stripped.lower():
234
+ js_issues = _check_js_structure(stripped)
235
+ if js_issues:
236
+ issues.extend(js_issues)
237
+ score -= 0.10
238
+ if not hint:
239
+ hint = f"Il codice JavaScript ha problemi strutturali: {'; '.join(js_issues[:2])}."
240
+
241
+ # 8. Mancanza executive summary per risposte lunghe (GAP-UX-1 — regola 20)
242
+ # Penalità leggera: incoraggia formato **[EMOJI] Esito** all'inizio
243
+ if len(stripped) > 200:
244
+ import re as _re2
245
+ first_line = stripped.split("\n")[0].strip()
246
+ has_bold_summary = bool(_re2.match(r'^\*\*[^*].{2,}\*\*', first_line))
247
+ if not has_bold_summary:
248
+ issues.append("Risposta senza executive summary in grassetto (regola 20 — GAP-UX-1)")
249
+ score -= 0.10
250
+ if not hint:
251
+ hint = (
252
+ "Inizia la risposta con **[EMOJI] [Esito conciso max 8 parole]** "
253
+ "come da regola 20. Es: **✅ Completato** — spiegazione breve."
254
+ )
255
+
256
+ return max(0.0, score), issues, hint
257
+
258
+
259
+ # ── HTML/JS Structure Checks (S401) ──────────────────────────────────────────
260
+
261
+ def _check_html_structure(text: str) -> list[str]:
262
+ """Rileva problemi strutturali in blocchi HTML."""
263
+ issues: list[str] = []
264
+ import re
265
+
266
+ # Estrai blocchi HTML
267
+ blocks = re.findall(r"```html\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
268
+ for block in blocks[:1]:
269
+ # Tag non bilanciati (esclusi void elements)
270
+ void_tags = {"area","base","br","col","embed","hr","img","input",
271
+ "link","meta","param","source","track","wbr"}
272
+ open_tags = re.findall(r"<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>", block)
273
+ close_tags = re.findall(r"</([a-zA-Z][a-zA-Z0-9]*)>", block)
274
+ open_count: dict[str, int] = {}
275
+ for t in open_tags:
276
+ tl = t.lower()
277
+ if tl not in void_tags:
278
+ open_count[tl] = open_count.get(tl, 0) + 1
279
+ for t in close_tags:
280
+ tl = t.lower()
281
+ open_count[tl] = open_count.get(tl, 0) - 1
282
+ unbalanced = [t for t, c in open_count.items() if c != 0]
283
+ if unbalanced:
284
+ # S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report
285
+ issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}")
286
+
287
+ # Script/style non chiusi
288
+ if block.count("<script") != block.count("</script>"):
289
+ issues.append("Tag <script> non bilanciato")
290
+ if block.count("<style") != block.count("</style>"):
291
+ issues.append("Tag <style> non bilanciato")
292
+
293
+ return issues
294
+
295
+
296
+ def _check_js_structure(text: str) -> list[str]:
297
+ """Rileva problemi strutturali in blocchi JavaScript."""
298
+ issues: list[str] = []
299
+ import re
300
+
301
+ blocks = re.findall(r"```(?:javascript|js)\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
302
+ for block in blocks[:1]:
303
+ # Parentesi graffe sbilanciate (escluse stringhe e commenti — approssimazione)
304
+ stripped = re.sub(r"//[^\n]*", "", block) # rimuovi commenti riga
305
+ stripped = re.sub(r"/\*.*?\*/", "", stripped, flags=re.DOTALL) # commenti blocco
306
+ stripped = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', '""', stripped) # stringhe
307
+ stripped = re.sub(r"'[^'\\]*(?:\\.[^'\\]*)*'", "''", stripped)
308
+
309
+ braces = stripped.count("{") - stripped.count("}")
310
+ parens = stripped.count("(") - stripped.count(")")
311
+ if abs(braces) > 0:
312
+ issues.append(f"Parentesi graffe sbilanciate ({braces:+d})")
313
+ if abs(parens) > 0:
314
+ issues.append(f"Parentesi tonde sbilanciate ({parens:+d})")
315
+
316
+ return issues
317
+
318
+
319
+ # ── Main Verifier Class ───────────────────────────────────────────────────────
320
+
321
+ class ResponseVerifier:
322
+ """
323
+ Verifica e ripara l'output LLM.
324
+ Usato da UnifiedAgentLoop dopo ogni risposta LLM.
325
+ """
326
+
327
+ def verify_and_repair(self, goal: str, output: str) -> VerifyResult:
328
+ """
329
+ Esegue tutti i repair pass e restituisce VerifyResult.
330
+ Non-blocking, non richiede LLM.
331
+ """
332
+ current = output
333
+ all_repairs: list[str] = []
334
+
335
+ # 1. Markdown sanitization
336
+ current, md_repairs = sanitize_markdown(current)
337
+ all_repairs.extend(md_repairs)
338
+
339
+ # 2. JSON repair (solo se sembra JSON)
340
+ if "{" in current and "}" in current:
341
+ current, json_repairs = repair_json(current)
342
+ all_repairs.extend(json_repairs)
343
+
344
+ # 3. Coherence check
345
+ quality, issues, retry_hint = check_coherence(goal, current)
346
+ all_repairs.extend(issues)
347
+
348
+ retry_suggested = quality < QUALITY_RETRY_THRESHOLD
349
+
350
+ return VerifyResult(
351
+ output=current,
352
+ repairs=all_repairs,
353
+ quality=quality,
354
+ retry_suggested=retry_suggested,
355
+ retry_hint=retry_hint,
356
+ )
357
+
358
+ def build_retry_prompt(self, goal: str, bad_output: str, hint: str) -> str:
359
+ """Costruisce prompt migliorato per il retry."""
360
+ return (
361
+ f"La risposta precedente non era soddisfacente.\n"
362
+ f"PROBLEMA: {hint}\n\n"
363
+ # S592: bad_output 300→500 — più contesto della risposta precedente per retry
364
+ f"Risposta precedente (non usare):\n{bad_output[:500]}...\n\n"
365
+ f"Obiettivo originale: {goal}\n\n"
366
+ f"Ora rispondi correttamente, in italiano, in modo diretto e completo."
367
+ )
agents/skill_tracker.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ skill_tracker.py — GAP-SKILL-SYNC: Session-scoped adaptive tool success/failure tracker.
3
+ GAP-SUP1: Supabase persistence — skill stats sopravvivono ai riavvii del backend.
4
+
5
+ Design originale:
6
+ - In-memory Dict[session_id, Dict[tool_name, SkillStats]] — zero DB dep, zero latency
7
+ - SkillStats: success_count, fail_count, last_used, total_latency_ms
8
+ - record() sincrono — GIL-safe in CPython, asyncio single-threaded
9
+ - get_sorted_fallbacks() — Wilson score lower bound (95% CI) per robustezza su n piccoli
10
+ - get_stats() — JSON-serializable per /api/agent/skill-stats endpoint
11
+ - clear_session() — cleanup opzionale fine task (evita memory leak su run lunghissimi)
12
+
13
+ GAP-SUP1 (Supabase persistence):
14
+ - _supabase_upsert(): httpx POST → PostgREST /rest/v1/skill_stats (upsert conflict)
15
+ - _supabase_load_session(): httpx GET → ripristina sessione precedente al boot
16
+ - record() fire-and-forget ogni _SYNC_EVERY_N chiamate per tool/sessione
17
+ - load_session_from_cloud(): chiamato da unified_loop al boot sessione
18
+ - Fallback silente se SUPABASE_URL/SUPABASE_ANON_KEY assenti → comportamento invariato
19
+ - Timeout conservativo 8s — mai blocca il loop principale
20
+ - Schema SQL: backend/migrations/gap1_skill_stats.sql
21
+
22
+ Integrazione:
23
+ - unified_loop.py: record() dopo ogni executor.run_tool() — registra successo/fallimento
24
+ - unified_loop.py: load_session_from_cloud() al boot sessione (se Supabase abilitato)
25
+ - api/agent.py: GET /api/agent/skill-stats/{session_id} per merge Dexie frontend
26
+
27
+ Singleton: get_skill_tracker() restituisce sempre lo stesso SkillTracker globale.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import math
33
+ import os
34
+ import time
35
+ import logging
36
+ from collections import defaultdict
37
+ from dataclasses import dataclass
38
+ from typing import Any
39
+
40
+ import httpx
41
+
42
+ _logger = logging.getLogger("agente_ai.skill_tracker")
43
+
44
+
45
+ # ─── Supabase config (GAP-SUP1) ───────────────────────────────────────────────
46
+
47
+ _SUPA_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
48
+ _SUPA_KEY = os.getenv("SUPABASE_ANON_KEY", "")
49
+ _SUPA_ENABLED = bool(_SUPA_URL and _SUPA_KEY)
50
+ _SUPA_TABLE = "skill_stats" # tabella PostgREST — vedi gap1_skill_stats.sql
51
+ _SYNC_EVERY_N = 5 # upsert ogni N record() per tool/sessione (throttle)
52
+
53
+ if _SUPA_ENABLED:
54
+ _logger.info("[skill_tracker] Supabase persistence ABILITATA → %s/rest/v1/%s", _SUPA_URL, _SUPA_TABLE)
55
+ else:
56
+ _logger.debug("[skill_tracker] Supabase non configurato — solo in-memory")
57
+
58
+
59
+ # ─── SkillStats ───────────────────────────────────────────────────────────────
60
+
61
+ @dataclass
62
+ class SkillStats:
63
+ success_count: int = 0
64
+ fail_count: int = 0
65
+ last_used: float = 0.0
66
+ total_latency_ms: float = 0.0
67
+
68
+ @property
69
+ def total_count(self) -> int:
70
+ return self.success_count + self.fail_count
71
+
72
+ @property
73
+ def success_rate(self) -> float:
74
+ if not self.total_count:
75
+ return 1.0 # ottimismo iniziale — tool mai usato
76
+ return self.success_count / self.total_count
77
+
78
+ @property
79
+ def avg_latency_ms(self) -> float:
80
+ if not self.total_count:
81
+ return 0.0
82
+ return self.total_latency_ms / self.total_count
83
+
84
+ def wilson_score(self) -> float:
85
+ """Wilson score lower bound (95% CI).
86
+
87
+ Bilanciamento statisticamente robusto tra success rate e confidenza.
88
+ Esempio: tool 1/1 (score ≈ 0.21) vs tool 10/11 (score ≈ 0.68) —
89
+ il secondo viene preferito anche se il primo ha 100% raw rate.
90
+ Usato da get_sorted_fallbacks() per ordinamento adattivo.
91
+ """
92
+ n = self.total_count
93
+ if n == 0:
94
+ return 0.5 # prior neutro su tool mai usati in questa sessione
95
+ p = self.success_count / n
96
+ z = 1.96 # 95% confidence interval
97
+ num = p + z * z / (2 * n) - z * math.sqrt((p * (1 - p) + z * z / (4 * n)) / n)
98
+ den = 1 + z * z / n
99
+ return num / den
100
+
101
+ def to_supabase_row(self, session_id: str, tool_name: str) -> dict:
102
+ """Serializza per upsert PostgREST."""
103
+ return {
104
+ "session_id": session_id,
105
+ "tool_name": tool_name,
106
+ "success_count": self.success_count,
107
+ "fail_count": self.fail_count,
108
+ "last_used": self.last_used,
109
+ "total_latency_ms": self.total_latency_ms,
110
+ }
111
+
112
+ @classmethod
113
+ def from_supabase_row(cls, row: dict) -> "SkillStats":
114
+ """Deserializza da riga PostgREST."""
115
+ return cls(
116
+ success_count = int(row.get("success_count", 0)),
117
+ fail_count = int(row.get("fail_count", 0)),
118
+ last_used = float(row.get("last_used", 0.0)),
119
+ total_latency_ms = float(row.get("total_latency_ms", 0.0)),
120
+ )
121
+
122
+
123
+ # ─── Supabase helpers (GAP-SUP1) ──────────────────────────────────────────────
124
+
125
+ async def _supabase_upsert(session_id: str, tool_name: str, stats: SkillStats) -> None:
126
+ """Fire-and-forget: upsert riga skill_stats su Supabase (PostgREST).
127
+
128
+ Fallback silente su qualsiasi errore — mai blocca il loop principale.
129
+ Timeout 8s conservativo.
130
+ """
131
+ if not _SUPA_ENABLED:
132
+ return
133
+ row = stats.to_supabase_row(session_id, tool_name)
134
+ try:
135
+ async with httpx.AsyncClient(timeout=8.0) as client:
136
+ resp = await client.post(
137
+ f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
138
+ json=row,
139
+ headers={
140
+ "apikey": _SUPA_KEY,
141
+ "Authorization": f"Bearer {_SUPA_KEY}",
142
+ "Content-Type": "application/json",
143
+ "Prefer": "resolution=merge-duplicates,return=minimal",
144
+ },
145
+ )
146
+ if resp.status_code not in (200, 201, 204):
147
+ _logger.debug(
148
+ "[skill_tracker] supabase upsert %s: HTTP %d %s",
149
+ tool_name[:20], resp.status_code, resp.text[:120],
150
+ )
151
+ except Exception as exc: # noqa: BLE001
152
+ _logger.debug("[skill_tracker] supabase upsert silenced: %s", type(exc).__name__)
153
+
154
+
155
+ async def _supabase_load_session(session_id: str) -> dict[str, SkillStats]:
156
+ """Carica tutti i tool stats di una sessione da Supabase.
157
+
158
+ Ritorna dict vuoto su qualsiasi errore (fallback silente).
159
+ Chiamato da load_session_from_cloud() al boot sessione.
160
+ """
161
+ if not _SUPA_ENABLED:
162
+ return {}
163
+ try:
164
+ async with httpx.AsyncClient(timeout=8.0) as client:
165
+ resp = await client.get(
166
+ f"{_SUPA_URL}/rest/v1/{_SUPA_TABLE}",
167
+ params={"session_id": f"eq.{session_id}", "select": "*"},
168
+ headers={
169
+ "apikey": _SUPA_KEY,
170
+ "Authorization": f"Bearer {_SUPA_KEY}",
171
+ },
172
+ )
173
+ if resp.status_code != 200:
174
+ _logger.debug(
175
+ "[skill_tracker] supabase load %s: HTTP %d",
176
+ session_id[:12], resp.status_code,
177
+ )
178
+ return {}
179
+ rows: list[dict] = resp.json()
180
+ loaded = {
181
+ row["tool_name"]: SkillStats.from_supabase_row(row)
182
+ for row in rows
183
+ if "tool_name" in row
184
+ }
185
+ if loaded:
186
+ _logger.info(
187
+ "[skill_tracker] GAP-SUP1: ripristinati %d tool stats per sessione %s",
188
+ len(loaded), session_id[:12],
189
+ )
190
+ return loaded
191
+ except Exception as exc: # noqa: BLE001
192
+ _logger.debug("[skill_tracker] supabase load silenced: %s", type(exc).__name__)
193
+ return {}
194
+
195
+
196
+ # ─── SkillTracker ─────────────────────────────────────────────────────────────
197
+
198
+ class SkillTracker:
199
+ """Singleton session-scoped tracker: impara quali tool funzionano per ogni sessione.
200
+
201
+ GAP-SUP1: i dati persistono su Supabase e vengono ripristinati al boot sessione.
202
+ """
203
+
204
+ def __init__(self) -> None:
205
+ self._sessions: dict[str, dict[str, SkillStats]] = defaultdict(
206
+ lambda: defaultdict(SkillStats)
207
+ )
208
+ # Contatori per throttle upsert (session_id → tool_name → count_since_last_sync)
209
+ self._sync_counters: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
210
+
211
+ # ── Write ─────────────────────────────────────────────────────────────────
212
+
213
+ def record(
214
+ self,
215
+ session_id: str,
216
+ tool_name: str,
217
+ success: bool,
218
+ latency_ms: float = 0.0,
219
+ ) -> None:
220
+ """Registra il risultato di una chiamata tool (sincrono, GIL-safe).
221
+
222
+ Chiamato da unified_loop.py dopo ogni executor.run_tool().
223
+ GAP-SUP1: fire-and-forget upsert Supabase ogni _SYNC_EVERY_N chiamate.
224
+ """
225
+ s = self._sessions[session_id][tool_name]
226
+ if success:
227
+ s.success_count += 1
228
+ else:
229
+ s.fail_count += 1
230
+ s.last_used = time.monotonic()
231
+ s.total_latency_ms += latency_ms
232
+
233
+ # GAP-SUP1: sync throttled — ogni _SYNC_EVERY_N record per questo tool/sessione
234
+ if _SUPA_ENABLED:
235
+ self._sync_counters[session_id][tool_name] += 1
236
+ if self._sync_counters[session_id][tool_name] >= _SYNC_EVERY_N:
237
+ self._sync_counters[session_id][tool_name] = 0
238
+ try:
239
+ loop = asyncio.get_running_loop()
240
+ loop.create_task(
241
+ _supabase_upsert(session_id, tool_name, s),
242
+ name=f"skill_sync_{tool_name[:20]}",
243
+ )
244
+ except RuntimeError:
245
+ pass # no running loop (test context) — silente
246
+
247
+ # ── Cloud bootstrap (GAP-SUP1) ────────────────────────────────────────────
248
+
249
+ async def load_session_from_cloud(self, session_id: str) -> int:
250
+ """Ripristina stats precedenti da Supabase per la sessione (chiamare al boot task).
251
+
252
+ Merge con in-memory: somma i contatori (in-memory è vuoto al boot, ma sicuro).
253
+ Ritorna numero di tool ripristinati (0 se Supabase non configurato).
254
+ Idempotente: chiamate multiple sommano i dati — chiamare una sola volta per sessione.
255
+ """
256
+ loaded = await _supabase_load_session(session_id)
257
+ if not loaded:
258
+ return 0
259
+ session = self._sessions[session_id]
260
+ for tool_name, cloud_stats in loaded.items():
261
+ mem = session[tool_name]
262
+ # Merge additivo — in-memory è tipicamente vuoto al boot
263
+ mem.success_count += cloud_stats.success_count
264
+ mem.fail_count += cloud_stats.fail_count
265
+ mem.total_latency_ms += cloud_stats.total_latency_ms
266
+ # last_used: prendi il più recente
267
+ if cloud_stats.last_used > mem.last_used:
268
+ mem.last_used = cloud_stats.last_used
269
+ return len(loaded)
270
+
271
+ async def flush_session_to_cloud(self, session_id: str) -> int:
272
+ """Forza upsert di tutti i tool di una sessione su Supabase (chiamare a fine task).
273
+
274
+ Ritorna numero di tool sincronizzati. Fallback silente su errori.
275
+ """
276
+ if not _SUPA_ENABLED:
277
+ return 0
278
+ session = self._sessions.get(session_id, {})
279
+ tasks = [
280
+ _supabase_upsert(session_id, tool_name, stats)
281
+ for tool_name, stats in session.items()
282
+ ]
283
+ if tasks:
284
+ await asyncio.gather(*tasks, return_exceptions=True)
285
+ _logger.info(
286
+ "[skill_tracker] GAP-SUP1: flush %d tool stats per sessione %s",
287
+ len(tasks), session_id[:12],
288
+ )
289
+ return len(tasks)
290
+
291
+ # ── Read / routing ────────────────────────────────────────────────────────
292
+
293
+ def get_sorted_fallbacks(
294
+ self,
295
+ session_id: str,
296
+ candidates: list[str],
297
+ ) -> list[str]:
298
+ """Riordina i candidati tool per Wilson score decrescente.
299
+
300
+ Tool mai usati in questa sessione → prior neutro 0.5, non penalizzati.
301
+ Uso tipico: reordina la lista fallback prima di provarli.
302
+ """
303
+ session = self._sessions.get(session_id, {})
304
+ return sorted(
305
+ candidates,
306
+ key=lambda t: session[t].wilson_score() if t in session else 0.5,
307
+ reverse=True,
308
+ )
309
+
310
+ def get_stats(self, session_id: str) -> dict[str, dict]:
311
+ """Statistiche JSON-serializable per una sessione (ordinato per Wilson score desc)."""
312
+ session = self._sessions.get(session_id, {})
313
+ return {
314
+ tool: {
315
+ "success_count": s.success_count,
316
+ "fail_count": s.fail_count,
317
+ "total_count": s.total_count,
318
+ "success_rate": round(s.success_rate, 3),
319
+ "wilson_score": round(s.wilson_score(), 3),
320
+ "avg_latency_ms": round(s.avg_latency_ms, 1),
321
+ "last_used": round(s.last_used, 3),
322
+ }
323
+ for tool, s in sorted(
324
+ session.items(),
325
+ key=lambda kv: kv[1].wilson_score(),
326
+ reverse=True,
327
+ )
328
+ }
329
+
330
+ def get_all_sessions(self) -> dict[str, Any]:
331
+ """Debug: panoramica tutte le sessioni attive."""
332
+ return {
333
+ sid: {
334
+ "tool_count": len(tools),
335
+ "total_calls": sum(s.total_count for s in tools.values()),
336
+ "tools": list(tools.keys()),
337
+ }
338
+ for sid, tools in self._sessions.items()
339
+ }
340
+
341
+ def clear_session(self, session_id: str) -> None:
342
+ """Libera memoria per una sessione terminata."""
343
+ removed = self._sessions.pop(session_id, None)
344
+ self._sync_counters.pop(session_id, None)
345
+ if removed is not None:
346
+ _logger.debug(
347
+ "[skill_tracker] cleared session %s (%d tools tracked)",
348
+ session_id[:12],
349
+ len(removed),
350
+ )
351
+
352
+
353
+ # ─── Singleton globale ───────────────────────────────────────────��────────────
354
+
355
+ _skill_tracker = SkillTracker()
356
+
357
+
358
+ def get_skill_tracker() -> SkillTracker:
359
+ """Restituisce il singleton SkillTracker. Thread-safe in CPython (GIL)."""
360
+ return _skill_tracker
361
+
362
+
363
+ # ─── P17-B2: FastAPI router per sync frontend ─────────────────────────────────
364
+ # Endpoint REST che permette al frontend (Dexie) di leggere e scrivere skill stats.
365
+ # Montato in backend/main.py tramite _on_startup() se importato.
366
+
367
+ try:
368
+ from fastapi import APIRouter as _APIRouter
369
+ from pydantic import BaseModel as _BM
370
+
371
+ skill_router = _APIRouter(prefix="/api/agent", tags=["skill-tracker"])
372
+
373
+ class _SkillRecordBody(_BM):
374
+ success: bool
375
+ latency_ms: float = 0.0
376
+ error_msg: str = ""
377
+
378
+ @skill_router.get("/skill-stats/{session_id}")
379
+ async def api_get_skill_stats(session_id: str):
380
+ """Restituisce stats tool per sessione — per merge con Dexie frontend."""
381
+ return get_skill_tracker().get_stats(session_id)
382
+
383
+ @skill_router.post("/skill-record/{session_id}/{tool_name}")
384
+ async def api_record_skill(session_id: str, tool_name: str, body: _SkillRecordBody):
385
+ """Registra un risultato tool dal frontend (es. tool chiamato via browser)."""
386
+ get_skill_tracker().record(
387
+ session_id, tool_name,
388
+ success=body.success,
389
+ latency_ms=body.latency_ms,
390
+ )
391
+ return {"ok": True, "session_id": session_id, "tool": tool_name}
392
+
393
+ @skill_router.delete("/skill-stats/{session_id}")
394
+ async def api_clear_skill_session(session_id: str):
395
+ """Pulisce la sessione skill tracker alla fine del task."""
396
+ get_skill_tracker().clear_session(session_id)
397
+ return {"ok": True}
398
+
399
+ except ImportError:
400
+ skill_router = None # type: ignore[assignment] # FastAPI non disponibile (unit test env)
agents/strategic_healer.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ strategic_healer.py — GAP-SELFHEAL v3: Cognitive Self-Healing Loop
3
+
4
+ Principio fondamentale: ogni fallimento deve insegnare qualcosa di PRECISO.
5
+ Niente retry statici, niente hint precompilati.
6
+ Ogni transizione di strategia emerge da ragionamento sul contesto specifico:
7
+
8
+ Strategia 1 tentata
9
+ → PERCHÉ ha fallito (ragione strutturale, non solo l'errore)
10
+ → Strategia 2 ragionata (diversa per costruzione, non per caso)
11
+ Strategia 2 tentata
12
+ → PERCHÉ anche lei ha fallito (incorpora conoscenza dei due fallimenti)
13
+ → Strategia 3 ortogonale a entrambe
14
+ ...
15
+ Dopo MAX_ATTEMPTS: diagnosi finale strutturale, non altro retry
16
+
17
+ Usa la memoria episodica della sessione per non ripetere pattern già falliti
18
+ in run precedenti sullo stesso goal (o goal simili).
19
+
20
+ Integrazione in unified_loop.py:
21
+ # All'inizio del run (una volta per goal):
22
+ healer = StrategicHealer(self.llm, state.goal, memory=self.memory)
23
+ await healer.load_past_failures()
24
+
25
+ # Dopo ogni ciclo subtask con fallimenti:
26
+ decision = await healer.analyze_and_decide(errors_list, exec_context_str)
27
+ if decision.should_stop:
28
+ exec_warn.insert(0, decision.strategy_prompt) # diagnosi finale
29
+ else:
30
+ exec_warn.insert(0, decision.strategy_prompt) # nuova strategia ragionata
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import logging
36
+ import time
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Optional
39
+
40
+ _logger = logging.getLogger("agente_ai.healer")
41
+
42
+
43
+ # ── Strutture dati ─────────────────────────────────────────────────────────────
44
+
45
+ @dataclass
46
+ class AttemptRecord:
47
+ """Registro completo e immutabile di un tentativo fallito."""
48
+ attempt_num: int
49
+ strategy_brief: str # Cosa è stato tentato (estratto dal contesto, 1-2 righe)
50
+ errors: list[str] # Errori grezzi dell'iterazione
51
+ error_category: str # Categoria da error_classifier (es. "syntax", "logic")
52
+ why_failed: str # Ragionamento LLM: PERCHÉ strutturalmente questo approccio non poteva funzionare
53
+ next_direction: str # Direzione LLM per il prossimo tentativo (specifica, non generica)
54
+ timestamp: float = field(default_factory=time.time)
55
+
56
+
57
+ @dataclass
58
+ class StrategyDecision:
59
+ """Decisione sulla prossima strategia da iniettare nel contesto del loop."""
60
+ strategy_prompt: str # Testo pronto da inserire in exec_warn[0]
61
+ confidence: float # 0.0–1.0: quanto il healer è sicuro della decisione
62
+ rationale: str # Spiegazione interna (per logging/debugging)
63
+ should_stop: bool = False # True se abbiamo esaurito le idee ragionevoli
64
+ stop_reason: str = "" # Diagnosi finale se should_stop=True
65
+
66
+
67
+ # ── Healer principale ──────────────────────────────────────────────────────────
68
+
69
+ class StrategicHealer:
70
+
71
+ # ── S-DYNAMIC-TOOL-HEALING: Fallback dinamico per tool (S512) ────────────
72
+ async def get_tool_fallback_strategy(self, tool_name: str, error: str) -> str:
73
+ """Determina una strategia alternativa se un tool specifico fallisce."""
74
+ fallbacks = {
75
+ "google_search": "Il tool di ricerca web è instabile. Usa 'webpage_extract' direttamente sugli URL noti o tenta una ricerca mirata su GitHub/Wikipedia via shell.",
76
+ "web_fetch": "L'estrazione fallisce. Usa 'curl -s' via shell per ottenere il contenuto grezzo e analizzalo con regex.",
77
+ "python_exec": "L'esecuzione Python ha fallito. Tenta di risolvere il task tramite logica shell (bc, awk, sed) o semplifica lo script."
78
+ }
79
+ return fallbacks.get(tool_name, f"Il tool {tool_name} ha fallito. Analizza l'errore {error} e cambia approccio.")
80
+
81
+ """
82
+ Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
83
+
84
+ Ogni chiamata ad analyze_and_decide():
85
+ 1. Classifica l'errore (deterministico, zero latenza)
86
+ 2. Ragiona su PERCHÉ l'approccio corrente era sbagliato (LLM)
87
+ 3. Incorpora la storia di TUTTI i tentativi precedenti della sessione
88
+ 4. Consulta la memoria episodica per pattern già noti come fallaci
89
+ 5. Produce una strategia ortogonale a tutto ciò già tentato
90
+
91
+ Non usa hint statici precompilati.
92
+ Non ripete lo stesso ragionamento — ogni risposta deve aggiungere informazione.
93
+ """
94
+
95
+ MAX_ATTEMPTS = 5 # oltre questo, diagnosi finale e stop
96
+
97
+ # GAP-DIVERSITY: quando una categoria fallisce >=2 volte, forza alternativa
98
+ # DETERMINISTICA — non dipende dall'LLM free tier (es. Llama 3.1 8B).
99
+ _CATEGORY_ALTERNATIVES: dict = {
100
+ "syntax": ["logic", "runtime", "modulenotfounderror"],
101
+ "logic": ["syntax", "valueerror", "typeerror"],
102
+ "runtime": ["logic", "importerror", "typeerror"],
103
+ "network": ["logic", "runtime", "valueerror"],
104
+ "importerror": ["runtime", "logic", "syntax"],
105
+ "modulenotfounderror": ["importerror", "runtime", "logic"],
106
+ "typeerror": ["logic", "valueerror", "syntax"],
107
+ "valueerror": ["typeerror", "logic", "runtime"],
108
+ "unknown": ["syntax", "logic", "runtime"],
109
+ }
110
+ _FORCED_STRATEGIES: dict = {
111
+ "syntax": [
112
+ "Genera codice in blocchi atomici (<10 righe), verifica sintassi ognuno.",
113
+ "Scrivi solo scheletro (firme+stub), riempi un metodo alla volta.",
114
+ ],
115
+ "logic": [
116
+ "Scrivi casi test attesi (TDD inverso), poi implementa per soddisfarli.",
117
+ "Decomponi in 3 funzioni pure, verifica ognuna con input/output espliciti.",
118
+ ],
119
+ "runtime": [
120
+ "Usa try/except granulari attorno ogni operazione I/O o accesso a chiave.",
121
+ "Verifica esistenza ogni risorsa prima: if key in dict, if path.exists().",
122
+ ],
123
+ "network": [
124
+ "Usa dati locali/mock, aggiungi timeout=10 e retry backoff esponenziale.",
125
+ ],
126
+ "importerror": ["Usa solo stdlib Python: json, csv, re, pathlib, collections."],
127
+ "modulenotfounderror": ["Usa solo stdlib Python. Reimplementa senza dipendenze."],
128
+ "typeerror": ["Aggiungi conversioni esplicite str/int/list prima di ogni op."],
129
+ "valueerror": ["Valida e normalizza ogni input: None, stringa vuota, range."],
130
+ "unknown": ["Decomponi in 3 sotto-problemi indipendenti, risolvi il più semplice."],
131
+ }
132
+
133
+ def __init__(self, llm_client: Any, goal: str, memory: Any = None):
134
+ self.llm = llm_client
135
+ self.goal = goal
136
+ self.memory = memory
137
+ self.history: list[AttemptRecord] = []
138
+ self._past_session_patterns: list[str] = [] # da memoria episodica
139
+ self._goal_hash = goal[:80] # per matching memoria
140
+ # GAP-DIVERSITY: tracking deterministico — indipendente qualità LLM
141
+ self._category_frequency: dict[str, int] = {}
142
+ self._category_strategies: dict[str, list[str]] = {}
143
+
144
+ # ── Interfaccia pubblica ───────────────────────────────────────────────────
145
+
146
+ async def load_past_failures(self) -> None:
147
+ """
148
+ Carica pattern di fallimento da sessioni precedenti.
149
+ Permette di non ripetere strategie già dimostrate inefficaci.
150
+ """
151
+ if not self.memory:
152
+ return
153
+ try:
154
+ if hasattr(self.memory, 'episodic') and hasattr(self.memory.episodic, 'search'):
155
+ episodes = await asyncio.wait_for(
156
+ self.memory.episodic.search(self._goal_hash, limit=5),
157
+ timeout=3.0,
158
+ )
159
+ failed_eps = [e for e in (episodes or []) if not getattr(e, 'success', True)]
160
+ self._past_session_patterns = [
161
+ getattr(e, 'content', '')[:200] for e in failed_eps[:3]
162
+ ]
163
+ if self._past_session_patterns:
164
+ _logger.debug(
165
+ "StrategicHealer: caricati %d pattern falliti da sessioni precedenti",
166
+ len(self._past_session_patterns),
167
+ )
168
+ except Exception as _e:
169
+ _logger.debug("StrategicHealer.load_past_failures: %s", str(_e)[:60])
170
+
171
+ async def analyze_and_decide(
172
+ self,
173
+ current_errors: list[str],
174
+ exec_context: str = "",
175
+ ) -> StrategyDecision:
176
+ """
177
+ Punto di ingresso principale.
178
+
179
+ Args:
180
+ current_errors: errori dell'iterazione appena fallita
181
+ exec_context: stringa con piano/subtask eseguiti (per capire cosa si è tentato)
182
+
183
+ Returns:
184
+ StrategyDecision con strategy_prompt pronto per exec_warn.insert(0, ...)
185
+ """
186
+ attempt_num = len(self.history) + 1
187
+
188
+ # Diagnosi finale se abbiamo raggiunto il limite
189
+ if attempt_num > self.MAX_ATTEMPTS:
190
+ return await self._final_diagnosis()
191
+
192
+ # ── Step 1: Classificazione deterministica (zero latenza) ──────────────
193
+ error_category = self._classify(current_errors)
194
+
195
+ # ── Step 1b: GAP-DIVERSITY — forza ortogonalità se categoria ripetuta >=2v # SH-BUG-1
196
+ _forced = self._get_forced_direction(error_category)
197
+
198
+ # ── Step 2: Ragionamento LLM su PERCHÉ questo approccio era sbagliato ─
199
+ why_failed, next_direction = await self._reason_why_failed(
200
+ current_errors, error_category, exec_context, attempt_num,
201
+ forced_direction=_forced,
202
+ )
203
+
204
+ # ── Step 3: Registra il tentativo ──────────────────────────────────────
205
+ record = AttemptRecord(
206
+ attempt_num = attempt_num,
207
+ strategy_brief= self._extract_strategy_brief(exec_context),
208
+ errors = [str(e)[:300] for e in current_errors[-3:]],
209
+ error_category= error_category,
210
+ why_failed = why_failed,
211
+ next_direction= next_direction,
212
+ )
213
+ self.history.append(record)
214
+ # GAP-DIVERSITY: aggiorna tracking
215
+ self._category_frequency[error_category] = self._category_frequency.get(error_category, 0) + 1
216
+ self._category_strategies.setdefault(error_category, []).append(next_direction[:120])
217
+
218
+ # ── Step 4: Costruisci la decisione per il prossimo ciclo ──────────────
219
+ if attempt_num == 1:
220
+ # Primo fallimento: prompt diretto dal ragionamento sul singolo errore
221
+ return self._first_failure_decision(record)
222
+ else:
223
+ # 2+ fallimenti: sintesi cross-attempt con ARCHITECT
224
+ return await self._cross_attempt_synthesis()
225
+
226
+ # ── Implementazione ────────────────────────────────────────────────────────
227
+
228
+ def _classify(self, errors: list[str]) -> str:
229
+ """Classificazione deterministica: zero latenza, mai blocca il loop."""
230
+ try:
231
+ from agents.error_classifier import classify_error
232
+ result = classify_error([str(e) for e in errors[-4:]])
233
+ return result.category.value
234
+ except Exception:
235
+ return "unknown"
236
+
237
+ def _extract_strategy_brief(self, exec_context: str) -> str:
238
+ """Estrae una descrizione concisa di cosa è stato tentato nell'iterazione."""
239
+ if not exec_context:
240
+ return f"tentativo {len(self.history) + 1} (nessun contesto)"
241
+ lines = [l.strip() for l in exec_context.split('\n') if l.strip()]
242
+ meaningful = [l for l in lines if len(l) > 10 and not l.startswith('#')]
243
+ return ' | '.join(meaningful[:2])[:160] if meaningful else lines[0][:160]
244
+
245
+ def _get_forced_direction(self, category: str) -> "str | None":
246
+ """
247
+ GAP-DIVERSITY fix: se categoria fallita >=2 volte ritorna strategia
248
+ DETERMINISTICA — bypassa LLM per garantire ortogonalità reale.
249
+ """
250
+ freq = self._category_frequency.get(category, 0)
251
+ if freq < 2:
252
+ return None
253
+ strategies = self._FORCED_STRATEGIES.get(category, self._FORCED_STRATEGIES["unknown"])
254
+ used = set(s[:40] for s in self._category_strategies.get(category, []))
255
+ for s in strategies:
256
+ if s[:40] not in used:
257
+ _logger.info("StrategicHealer DIVERSITY_FORCE cat=%s freq=%d", category, freq)
258
+ return s
259
+ for alt in self._CATEGORY_ALTERNATIVES.get(category, ["unknown"]):
260
+ alts = self._FORCED_STRATEGIES.get(alt, [])
261
+ if alts:
262
+ _logger.info("StrategicHealer DIVERSITY_FORCE exhausted→alt_cat=%s", alt)
263
+ return f"[CAMBIO APPROCCIO {category}→{alt}] {alts[0]}"
264
+ return None
265
+
266
+ async def _reason_why_failed(
267
+ self,
268
+ errors: list[str],
269
+ category: str,
270
+ exec_context: str,
271
+ attempt_num: int,
272
+ forced_direction: "str | None" = None,
273
+ ) -> tuple[str, str]:
274
+ """
275
+ Usa l'LLM per ragionare su PERCHÉ strutturalmente questo approccio
276
+ non poteva funzionare, e suggerire UNA direzione specifica alternativa.
277
+
278
+ Non chiede "cosa ha sbagliato" (già noto dagli errori),
279
+ ma "perché questo approccio era intrinsecamente inadeguato al goal".
280
+
281
+ Ritorna: (why_failed: str, next_direction: str)
282
+ """
283
+ history_ctx = self._format_history(verbose=False)
284
+ past_ctx = ""
285
+ if self._past_session_patterns:
286
+ past_ctx = "\nPattern già falliti in sessioni precedenti:\n" + \
287
+ "\n".join(f"• {p}" for p in self._past_session_patterns[:2])
288
+
289
+ err_text = '\n'.join(str(e)[:400] for e in errors[-3:])
290
+
291
+ system = (
292
+ "Sei un senior engineer che esamina il fallimento di un agente AI autonomo.\n"
293
+ "Il tuo compito NON è descrivere l'errore (già noto), ma capire "
294
+ "la RAGIONE STRUTTURALE per cui questo approccio era inadeguato.\n\n"
295
+ "Rispondi ESATTAMENTE in questo formato (due righe, senza altro):\n"
296
+ "PERCHE_FALLITO: <1-2 frasi: ragione strutturale per cui questo metodo non "
297
+ "poteva funzionare per questo goal, non solo l'errore di superficie>\n"
298
+ "STRATEGIA_SUCCESSIVA: <2-3 frasi: quale approccio SPECIFICO provare dopo "
299
+ "— nomina librerie, pattern, strutture dati, decomposizione alternativa. "
300
+ "Mai 'prova diversamente' o 'usa un altro approccio'>"
301
+ )
302
+
303
+ history_section = f"\nTentativi già falliti in questa sessione:\n{history_ctx}" if history_ctx else ""
304
+ user = (
305
+ f"Goal: {self.goal[:400]}\n\n"
306
+ f"Tentativo #{attempt_num} — categoria errore: {category}\n"
307
+ f"Errori:\n{err_text}"
308
+ f"{history_section}"
309
+ f"{past_ctx}"
310
+ )
311
+ if forced_direction:
312
+ freq = self._category_frequency.get(category, 0)
313
+ user += (
314
+ f"\n\n⛔ VINCOLO HARD (cat '{category}' fallita {freq}v): "
315
+ f"la STRATEGIA_SUCCESSIVA DEVE implementare: {forced_direction}\n"
316
+ f"Non proporre varianti della stessa categoria."
317
+ )
318
+ if exec_context:
319
+ user += f"\n\nContesto esecuzione (cosa si è tentato):\n{exec_context[:350]}"
320
+
321
+ try:
322
+ resp = await asyncio.wait_for(
323
+ self.llm.chat(
324
+ [{"role": "system", "content": system},
325
+ {"role": "user", "content": user}],
326
+ temperature=0.1,
327
+ max_tokens=380,
328
+ ),
329
+ timeout=12.0,
330
+ )
331
+
332
+ if resp and not resp.startswith('[LLM'):
333
+ why, direction = "", ""
334
+ for line in resp.split('\n'):
335
+ stripped = line.strip()
336
+ if stripped.startswith('PERCHE_FALLITO:') or stripped.startswith('PERCHÉ_FALLITO:'):
337
+ why = stripped.split(':', 1)[-1].strip()
338
+ elif stripped.startswith('STRATEGIA_SUCCESSIVA:'):
339
+ direction = stripped.split(':', 1)[-1].strip()
340
+ # Fallback se il formato non è rispettato esattamente
341
+ if not why and not direction:
342
+ mid = len(resp) // 2
343
+ why = resp[:mid].strip()[:250]
344
+ direction = resp[mid:].strip()[:250]
345
+ elif not direction:
346
+ direction = why[len(why)//2:].strip() or "Decomponi il problema in passi atomici verificabili."
347
+ return why or resp[:200], direction or resp[-200:]
348
+
349
+ except Exception as _e:
350
+ _logger.debug("StrategicHealer._reason_why_failed LLM error: %s", str(_e)[:60])
351
+
352
+ # Fallback deterministico per categoria
353
+ return self._fallback_reasoning(category)
354
+
355
+ def _fallback_reasoning(self, category: str) -> tuple[str, str]:
356
+ """Ragionamento deterministico quando l'LLM non è disponibile."""
357
+ _MAP = {
358
+ "syntax": ("Il codice generato ha errori sintattici che lo rendono non parsabile.",
359
+ "Genera codice in blocchi minimali, verifica la sintassi di ogni blocco prima di procedere."),
360
+ "runtime": ("L'approccio presuppone uno stato dell'ambiente che non esiste.",
361
+ "Verifica lo stato prima di ogni operazione: usa try/except granulari, controlla None/empty, isola ogni effetto collaterale."),
362
+ "logic": ("La logica implementata non mappa correttamente i requisiti del goal.",
363
+ "Scrivi prima i casi di test attesi, poi implementa la logica che li soddisfa — TDD inverso."),
364
+ "network": ("L'endpoint o la connessione non è raggiungibile con questo approccio.",
365
+ "Usa dati locali/cached, verifica l'URL base, implementa circuit breaker con fallback su dati statici."),
366
+ "modulenotfounderror": ("La libreria richiesta non è installata nell'ambiente.",
367
+ "Usa solo la stdlib Python (json, csv, re, pathlib, collections, itertools) o verifica cosa è già installato con pkg_resources."),
368
+ "importerror": ("L'import fallisce perché il modulo o il percorso non esiste.",
369
+ "Verifica il path di import, usa importlib per import condizionale, o refactora senza quel modulo."),
370
+ "typeerror": ("I tipi degli argomenti non corrispondono a quanto atteso.",
371
+ "Aggiungi conversioni esplicite (str/int/list/dict) e valida il tipo degli input prima di ogni operazione."),
372
+ "valueerror": ("Il valore passato è fuori dal range o formato atteso.",
373
+ "Valida e normalizza ogni input (None, stringa vuota, tipo errato) prima di processarlo."),
374
+ }
375
+ _UNKNOWN = ("L'approccio è incompatibile con il goal per ragioni non classificabili.",
376
+ "Decomponi il goal in 3 sotto-problemi indipendenti e risolvi il più semplice prima.")
377
+ return _MAP.get(category, _UNKNOWN)
378
+
379
+ def _first_failure_decision(self, record: AttemptRecord) -> StrategyDecision:
380
+ """Primo fallimento: prompt costruito dal ragionamento sul singolo errore."""
381
+ prompt = (
382
+ f"⚠️ CAMBIO STRATEGIA [{record.error_category}·tentativo 1]: "
383
+ f"{record.why_failed} "
384
+ f"→ {record.next_direction} "
385
+ "NON ripetere l'approccio precedente in nessuna forma."
386
+ )
387
+ return StrategyDecision(
388
+ strategy_prompt=prompt,
389
+ confidence=0.70,
390
+ rationale=record.why_failed,
391
+ )
392
+
393
+ async def _cross_attempt_synthesis(self) -> StrategyDecision:
394
+ """
395
+ 2+ fallimenti: l'ARCHITECT sintetizza l'intera storia e propone
396
+ una strategia ORTOGONALE a tutto ciò già tentato.
397
+
398
+ La sintesi non può limitarsi all'ultimo errore — deve ragionare
399
+ sul pattern comune tra i fallimenti e identificare lo spazio
400
+ delle soluzioni ancora inesplorato.
401
+ """
402
+ history_verbose = self._format_history(verbose=True)
403
+ n = len(self.history)
404
+
405
+ system = (
406
+ "Sei un senior architect. Un agente AI ha fallito più volte sullo stesso goal.\n"
407
+ "Analizza la sequenza di tutti i tentativi falliti e ragiona:\n\n"
408
+ "PATTERN_COMUNE: <1-2 frasi: qual è la caratteristica comune nei fallimenti? "
409
+ "Non descrivere i singoli errori — trova la causa radice trasversale>\n"
410
+ "SPAZIO_INESPLORATO: <1-2 frasi: quale classe di approcci NON è stata ancora tentata? "
411
+ "Qual è il punto cieco?>\n"
412
+ "STRATEGIA_ORTOGONALE: <2-3 frasi: strategia concreta che non condivide "
413
+ "nessuna assunzione con i tentativi precedenti — nomina librerie, pattern, "
414
+ "struttura dati, o decomposizione specifica>\n"
415
+ "PRIMA_AZIONE: <1 frase: la prima cosa concreta da fare, immediatamente eseguibile>"
416
+ )
417
+
418
+ past_block = ""
419
+ if self._past_session_patterns:
420
+ past_block = "\nPattern già falliti in sessioni precedenti (da escludere):\n" + \
421
+ "\n".join(f"• {p}" for p in self._past_session_patterns[:2])
422
+
423
+ user = (
424
+ f"Goal: {self.goal[:400]}\n\n"
425
+ f"Sequenza di {n} fallimenti:\n{history_verbose}"
426
+ f"{past_block}"
427
+ )
428
+
429
+ try:
430
+ # Usa ARCHITECT se disponibile — ha il reasoning migliore per sintesi cross-attempt
431
+ try:
432
+ from models.role_router import RoleRouter, Role
433
+ llm = RoleRouter.get_client(Role.ARCHITECT)
434
+ timeout = 20.0
435
+ source = "ARCHITECT"
436
+ except Exception:
437
+ llm = self.llm
438
+ timeout = 14.0
439
+ source = "BASE"
440
+
441
+ resp = await asyncio.wait_for(
442
+ llm.chat(
443
+ [{"role": "system", "content": system},
444
+ {"role": "user", "content": user}],
445
+ temperature=0.05, # massima determinismo — non vogliamo creatività casuale
446
+ max_tokens=500,
447
+ ),
448
+ timeout=timeout,
449
+ )
450
+
451
+ if resp and not resp.startswith('[LLM'):
452
+ # Estrai le sezioni dal formato strutturato
453
+ sections: dict[str, str] = {}
454
+ current_key = None
455
+ for line in resp.split('\n'):
456
+ stripped = line.strip()
457
+ for key in ('PATTERN_COMUNE', 'SPAZIO_INESPLORATO', 'STRATEGIA_ORTOGONALE', 'PRIMA_AZIONE'):
458
+ if stripped.startswith(key + ':'):
459
+ current_key = key
460
+ sections[key] = stripped.split(':', 1)[-1].strip()
461
+ break
462
+ else:
463
+ if current_key and stripped:
464
+ sections[current_key] = sections.get(current_key, '') + ' ' + stripped
465
+
466
+ # Costruisci il prompt di strategia dalle sezioni
467
+ parts = []
468
+ if sections.get('PATTERN_COMUNE'):
469
+ parts.append(f"Pattern comune nei fallimenti: {sections['PATTERN_COMUNE']}")
470
+ if sections.get('SPAZIO_INESPLORATO'):
471
+ parts.append(f"Spazio ancora inesplorato: {sections['SPAZIO_INESPLORATO']}")
472
+ if sections.get('STRATEGIA_ORTOGONALE'):
473
+ parts.append(f"Strategia da usare ora: {sections['STRATEGIA_ORTOGONALE']}")
474
+ if sections.get('PRIMA_AZIONE'):
475
+ parts.append(f"Prima azione: {sections['PRIMA_AZIONE']}")
476
+
477
+ if parts:
478
+ prompt = (
479
+ f"⛔ ANALISI CROSS-FALLIMENTO [{source}·{n} tentativi]: "
480
+ + " | ".join(parts)
481
+ + " — Non condividere NESSUNA assunzione coi tentativi precedenti."
482
+ )
483
+ return StrategyDecision(
484
+ strategy_prompt=prompt,
485
+ confidence=0.85,
486
+ rationale=f"Sintesi {source} su {n} tentativi: {sections.get('PATTERN_COMUNE', '')[:80]}",
487
+ )
488
+
489
+ # Formato non rispettato: usa tutta la risposta
490
+ prompt = (
491
+ f"⛔ STRATEGIA ORTOGONALE [{n} fallimenti]: "
492
+ f"{resp[:450]} "
493
+ "— Nessuna assunzione condivisa coi tentativi precedenti."
494
+ )
495
+ return StrategyDecision(
496
+ strategy_prompt=prompt,
497
+ confidence=0.75,
498
+ rationale=f"Risposta {source} non strutturata — usata grezza",
499
+ )
500
+
501
+ except Exception as _e:
502
+ _logger.debug("StrategicHealer._cross_attempt_synthesis error: %s", str(_e)[:80])
503
+
504
+ # Fallback deterministico: sintetizza dalla storia senza LLM
505
+ return self._fallback_cross_synthesis(n)
506
+
507
+ def _fallback_cross_synthesis(self, n: int) -> StrategyDecision:
508
+ """Sintesi cross-attempt senza LLM: usa la storia registrata."""
509
+ categories = [r.error_category for r in self.history]
510
+ why_list = [r.why_failed[:80] for r in self.history[-3:]]
511
+ tried = "; ".join(f"[{r.error_category}] {r.strategy_brief[:50]}" for r in self.history[-3:])
512
+
513
+ prompt = (
514
+ f"⛔ STRATEGIA COMPLETAMENTE DIVERSA [{n} fallimenti, "
515
+ f"categorie: {', '.join(set(categories))}]: "
516
+ f"Ragioni dei fallimenti: {' | '.join(why_list)}. "
517
+ f"Approcci già esclusi: {tried}. "
518
+ "Usa metodo, libreria e struttura dati completamente diversi da quelli tentati."
519
+ )
520
+ return StrategyDecision(
521
+ strategy_prompt=prompt,
522
+ confidence=0.50,
523
+ rationale="Fallback deterministico — LLM non disponibile",
524
+ )
525
+
526
+ async def _final_diagnosis(self) -> StrategyDecision:
527
+ """
528
+ Dopo MAX_ATTEMPTS: produce una diagnosi strutturale finale.
529
+ Non è un nuovo tentativo — è una spiegazione onesta di cosa non funziona
530
+ e perché, utile per l'utente e per la memoria episodica.
531
+ """
532
+ history_verbose = self._format_history(verbose=True)
533
+
534
+ try:
535
+ resp = await asyncio.wait_for(
536
+ self.llm.chat(
537
+ [
538
+ {"role": "system", "content": (
539
+ "Un agente AI ha esaurito i tentativi su un goal. "
540
+ "Scrivi una diagnosi finale in 3 parti:\n"
541
+ "PROBLEMA_STRUTTURALE: <perché questo goal è difficile/impossibile con gli strumenti disponibili>\n"
542
+ "COSA_RICHIEDEREBBE: <cosa servirebbe per risolverlo — accesso esterno, libreria specifica, input mancante>\n"
543
+ "WORKAROUND_POSSIBILE: <cosa si può fare ADESSO con ciò che c'è — anche parziale>"
544
+ )},
545
+ {"role": "user", "content": (
546
+ f"Goal: {self.goal[:300]}\n\n"
547
+ f"Storico {len(self.history)} tentativi:\n{history_verbose}"
548
+ )},
549
+ ],
550
+ temperature=0.1,
551
+ max_tokens=280,
552
+ ),
553
+ timeout=10.0,
554
+ )
555
+ diagnosis = resp[:400] if (resp and not resp.startswith('[LLM')) else ""
556
+ except Exception:
557
+ diagnosis = ""
558
+
559
+ if not diagnosis:
560
+ cats = ", ".join(set(r.error_category for r in self.history))
561
+ diagnosis = (
562
+ f"Problema strutturale ({cats}): il goal richiede capacità o accesso "
563
+ "non disponibili nell'ambiente corrente."
564
+ )
565
+
566
+ prompt = (
567
+ f"⛔ LIMITE RAGGIUNTO ({self.MAX_ATTEMPTS} tentativi): {diagnosis} "
568
+ "Comunica onestamente all'utente cosa non è stato possibile fare e perché."
569
+ )
570
+
571
+ return StrategyDecision(
572
+ strategy_prompt=prompt,
573
+ confidence=0.0,
574
+ rationale=diagnosis,
575
+ should_stop=True,
576
+ stop_reason=diagnosis,
577
+ )
578
+
579
+ def _format_history(self, verbose: bool = False) -> str:
580
+ """Formatta la storia dei tentativi per il contesto LLM."""
581
+ if not self.history:
582
+ return ""
583
+ lines = []
584
+ for r in self.history:
585
+ if verbose:
586
+ lines.append(
587
+ f"Tentativo {r.attempt_num} [{r.error_category}]\n"
588
+ f" Cosa si è tentato: {r.strategy_brief[:120]}\n"
589
+ f" Perché era sbagliato: {r.why_failed[:200]}\n"
590
+ f" Errori principali: {'; '.join(e[:80] for e in r.errors[:2])}"
591
+ )
592
+ else:
593
+ lines.append(
594
+ f"T{r.attempt_num}[{r.error_category}]: {r.why_failed[:120]}"
595
+ )
596
+ return "\n".join(lines)
597
+
598
+ def get_summary(self) -> dict:
599
+ """Summary per logging/telemetry."""
600
+ return {
601
+ "attempts": len(self.history),
602
+ "categories": [r.error_category for r in self.history],
603
+ "goal_excerpt": self.goal[:80],
604
+ "stopped_at_max": len(self.history) >= self.MAX_ATTEMPTS,
605
+ }
agents/tdd_runner.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tdd_runner.py — S-GAP3: Auto-Testing post-code execution.
3
+
4
+ Dopo ogni run_python con codice complesso (>=8 righe, def/class presente),
5
+ l'agente genera un test minimale, lo esegue, e verifica PASS/FAIL.
6
+ Questo chiude il ciclo: codice scritto → codice verificato.
7
+
8
+ Integrazione: chiamato da unified_loop_tools.py dopo run_python.
9
+ Zero overhead su task semplici (direct_response, query, output non-code).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import re
16
+ from typing import Any
17
+
18
+ _logger = logging.getLogger("agente_ai.tdd")
19
+
20
+ _MIN_LINES = 8
21
+ _CODE_RE = re.compile(r"\b(def |class |function |return |async def )", re.MULTILINE)
22
+ _SUPPORTED = {"python", "py", ""}
23
+
24
+
25
+ def _should_test(code: str, language: str = "python") -> bool:
26
+ if language.lower() not in _SUPPORTED:
27
+ return False
28
+ lines = [l for l in code.splitlines() if l.strip() and not l.strip().startswith("#")]
29
+ return len(lines) >= _MIN_LINES and bool(_CODE_RE.search(code))
30
+
31
+
32
+ async def _gen_test(code: str, llm: Any) -> str | None:
33
+ try:
34
+ from models.role_router import RoleRouter, Role
35
+ coder = RoleRouter.get_client(Role.CODER)
36
+ except Exception:
37
+ coder = llm
38
+ prompt = (
39
+ "Dato questo codice Python, scrivi un micro-test con assert statements "
40
+ "(NON pytest/unittest — solo assert + print). "
41
+ "Verifica il comportamento principale. Rispondi SOLO con il codice.\n\n"
42
+ f"```python\n{code[:1800]}\n```"
43
+ )
44
+ try:
45
+ raw = await asyncio.wait_for(
46
+ coder.chat(
47
+ [
48
+ {"role": "system", "content": "Sei un tester Python. Solo codice, niente testo."},
49
+ {"role": "user", "content": prompt},
50
+ ],
51
+ temperature=0.1, max_tokens=400,
52
+ ),
53
+ timeout=18.0,
54
+ )
55
+ m = re.search(r"```(?:python)?\n?([\s\S]+?)```", raw)
56
+ return m.group(1).strip() if m else raw.strip()
57
+ except Exception as e:
58
+ _logger.warning("TDD gen failed: %s", e)
59
+ return None
60
+
61
+
62
+ async def run_tdd_check(code: str, executor: Any, llm: Any, language: str = "python") -> dict:
63
+ """
64
+ Ciclo TDD completo.
65
+ Returns: { ran, passed, output, test_code }
66
+ """
67
+ out = {"ran": False, "passed": False, "output": "", "test_code": ""}
68
+ if not _should_test(code, language):
69
+ return out
70
+
71
+ test_code = await _gen_test(code, llm)
72
+ if not test_code:
73
+ return out
74
+
75
+ out["ran"] = True
76
+ out["test_code"] = test_code
77
+ combined = f"{code}\n\n# === AUTO-TEST S-GAP3 ===\n{test_code}"
78
+
79
+ try:
80
+ r = await asyncio.wait_for(
81
+ executor.run_tool("run_python", {"code": combined}),
82
+ timeout=28.0,
83
+ )
84
+ stdout = r.get("output", r.get("stdout", ""))
85
+ stderr = r.get("stderr", "")
86
+ ec = r.get("exit_code", -1)
87
+ passed = ec == 0 and not stderr.strip()
88
+ out["passed"] = passed
89
+ out["output"] = (stdout or "")[:400]
90
+ if stderr:
91
+ out["output"] += f"\n[stderr] {stderr[:150]}"
92
+ _logger.info("TDD: passed=%s exit=%s", passed, ec)
93
+ except asyncio.TimeoutError:
94
+ out["output"] = "⏱ timeout 28s"
95
+ except Exception as e:
96
+ out["output"] = f"⚠️ {e}"
97
+ return out
98
+
99
+ # ── COG-3: TypeScript TDD ─────────────────────────────────────────────────────
100
+
101
+ _TS_FILE_RE = re.compile(r'\.(ts|tsx)$', re.IGNORECASE)
102
+ _TS_CODE_RE = re.compile(
103
+ r'(interface |type |const |function |class |export |import |=>|async )',
104
+ re.MULTILINE,
105
+ )
106
+
107
+
108
+ def _should_test_ts(content: str, path: str = '') -> bool:
109
+ """COG-3: decides whether to type-check a TypeScript file change."""
110
+ if path and not _TS_FILE_RE.search(path):
111
+ return False
112
+ lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith('//')]
113
+ return len(lines) >= 5 and bool(_TS_CODE_RE.search(content))
114
+
115
+
116
+ async def run_tdd_check_ts(
117
+ content: str,
118
+ path: str,
119
+ executor: Any,
120
+ on_warn: Any = None,
121
+ ) -> dict:
122
+ """
123
+ COG-3: TypeScript TDD — esegue type_check dopo apply_patch / write_file su .ts/.tsx.
124
+
125
+ Pipeline:
126
+ 1. _should_test_ts() — guard rapido, zero overhead su file non-TS
127
+ 2. executor.run_tool('type_check', {'path': path}) — tsc --noEmit
128
+ 3. Se errori TypeScript rilevati: emette warning via on_warn callback
129
+
130
+ Returns: { ran, passed, output, path }
131
+ """
132
+ out = {'ran': False, 'passed': False, 'output': '', 'path': path}
133
+
134
+ if not _should_test_ts(content, path):
135
+ return out
136
+
137
+ if not executor:
138
+ return out
139
+
140
+ out['ran'] = True
141
+ try:
142
+ r = await asyncio.wait_for(
143
+ executor.run_tool('type_check', {'path': path}),
144
+ timeout=20.0,
145
+ )
146
+ raw_out = r.get('output', {})
147
+ stdout = raw_out.get('stdout', '') if isinstance(raw_out, dict) else str(raw_out)
148
+ stderr = raw_out.get('stderr', '') if isinstance(raw_out, dict) else ''
149
+ ec = r.get('exit_code', r.get('output', {}).get('exit_code', -1) if isinstance(r.get('output'), dict) else -1)
150
+
151
+ has_errors = (
152
+ 'error TS' in (stdout + stderr)
153
+ or (isinstance(ec, int) and ec != 0)
154
+ )
155
+ out['passed'] = not has_errors
156
+ out['output'] = (stdout + stderr)[:500]
157
+
158
+ if not out['passed'] and on_warn:
159
+ ts_errors = [l for l in (stdout + stderr).splitlines() if 'error TS' in l][:5]
160
+ warn_msg = f'TypeScript errors in {path}:\n' + '\n'.join(ts_errors)
161
+ try:
162
+ import asyncio as _asyncio
163
+ _v = on_warn({'action': 'tdd_ts', 'status': 'warn', 'output': warn_msg})
164
+ if _asyncio.iscoroutine(_v):
165
+ await _v
166
+ except Exception as _exc:
167
+ _logger.debug("[tdd_runner] silenced %s", type(_exc).__name__) # noqa: BLE001
168
+
169
+ _logger.info('COG-3 TS TDD: path=%s passed=%s ec=%s', path, out['passed'], ec)
170
+ except asyncio.TimeoutError:
171
+ out['output'] = 'TS type_check timeout 20s'
172
+ _logger.warning('COG-3 TS TDD timeout for %s', path)
173
+ except Exception as exc:
174
+ out['output'] = f'TS TDD error: {exc}'
175
+ _logger.warning('COG-3 TS TDD exception: %s', exc)
176
+
177
+ return out
178
+
agents/tool_generator.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tool_generator.py — COG-4: Dynamic Tool Generation at runtime.
3
+
4
+ Quando l'executor non trova un tool adatto per un subtask,
5
+ chiede all'LLM di scrivere una piccola funzione Python async,
6
+ la valida in sandbox (run_python), e la registra nel TOOL_REGISTRY
7
+ per la durata della sessione corrente.
8
+
9
+ Security constraints:
10
+ - Blocca import di rete (requests, httpx, socket, urllib, subprocess)
11
+ - Blocca accesso filesystem in write (open() write mode)
12
+ - Timeout 12s generazione + 10s validazione
13
+ - Max 5 tool generati per sessione (evita context explosion)
14
+ - Tool generati hanno prefisso "_dyn_" per identificazione e pulizia
15
+
16
+ Integration: chiamato da unified_loop.py quando _TOOL_MAP.get(tool) == (None, None)
17
+ e needs_dynamic_tool() restituisce True.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import re
24
+ from typing import Any
25
+
26
+ _logger = logging.getLogger("agente_ai.tool_gen")
27
+
28
+ _GENERATED_COUNT = 0
29
+ _MAX_GENERATED = 5
30
+
31
+ _BLOCKED_RE = re.compile(
32
+ r"\b(import\s+(requests|httpx|socket|urllib|subprocess|paramiko|ftplib)"
33
+ r"|os\.system\s*\(|os\.popen\s*\(|eval\s*\(|exec\s*\("
34
+ r"|__import__\s*\(|open\s*\([^)]*['\"][wa][+bt]*['\"])",
35
+ re.IGNORECASE | re.MULTILINE,
36
+ )
37
+ _TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{2,39}$")
38
+
39
+ _GEN_SYSTEM = """\
40
+ Sei un tool engineer Python. Scrivi UNA funzione Python asincrona che risolve il task specificato.
41
+
42
+ REGOLE ASSOLUTE:
43
+ 1. Firma obbligatoria: `async def tool_fn(**kwargs) -> dict`
44
+ 2. Ritorna sempre: `{"success": True/False, "output": <risultato>, "error": ""}`
45
+ 3. ZERO import di rete (no requests, httpx, socket, urllib, subprocess)
46
+ 4. ZERO filesystem esterno (no open() in write mode)
47
+ 5. Solo stdlib: json, re, math, datetime, collections, itertools, hashlib, base64, urllib.parse
48
+ 6. Max 30 righe
49
+ 7. Rispondi SOLO con il blocco ```python ... ``` — zero testo extra
50
+
51
+ Esempio:
52
+ ```python
53
+ import re
54
+ async def tool_fn(**kwargs) -> dict:
55
+ text = kwargs.get("text", "")
56
+ words = re.findall(r"\\b\\w+\\b", text.lower())
57
+ freq = {}
58
+ for w in words:
59
+ freq[w] = freq.get(w, 0) + 1
60
+ return {"success": True, "output": sorted(freq.items(), key=lambda x: -x[1])[:10], "error": ""}
61
+ ```\
62
+ """
63
+
64
+
65
+ def needs_dynamic_tool(tool_name: str, description: str) -> bool:
66
+ """
67
+ Decide se generare un tool dinamico per questo subtask.
68
+
69
+ Condizioni positive:
70
+ - La descrizione suggerisce un'operazione computazionale locale
71
+ - Non siamo al limite di tool generati per sessione
72
+
73
+ Non genera tool per: operazioni di rete, filesystem esterno, browser.
74
+ """
75
+ global _GENERATED_COUNT
76
+ if _GENERATED_COUNT >= _MAX_GENERATED:
77
+ return False
78
+
79
+ _NETWORK_RE = re.compile(
80
+ r"\b(http|url|fetch|scrape|download|upload|api|request|socket)\b",
81
+ re.IGNORECASE,
82
+ )
83
+ if _NETWORK_RE.search(description):
84
+ return False # operazioni di rete → non generare tool locale
85
+
86
+ _COMPUTE_RE = re.compile(
87
+ r"\b(calcola|converti|trasforma|analizza|estrai|filtra|ordina|conta"
88
+ r"|parse|format|encode|decode|compress|hash|valida|validate"
89
+ r"|split|merge|aggregate|summarize|riassumi|statistiche)\b",
90
+ re.IGNORECASE,
91
+ )
92
+ return bool(_COMPUTE_RE.search(description))
93
+
94
+
95
+ async def generate_and_register(
96
+ description: str,
97
+ tool_name: str,
98
+ llm: Any,
99
+ executor: Any,
100
+ ) -> "tuple[bool, str]":
101
+ """
102
+ Genera un tool Python via LLM, lo valida in sandbox, lo registra.
103
+
104
+ Returns: (success: bool, dyn_name: str)
105
+ """
106
+ global _GENERATED_COUNT
107
+ if _GENERATED_COUNT >= _MAX_GENERATED:
108
+ return False, ""
109
+
110
+ safe_name = re.sub(r"[^a-z0-9_]", "_", tool_name.lower())[:36]
111
+ if not _TOOL_NAME_RE.match(safe_name):
112
+ safe_name = f"gen{_GENERATED_COUNT + 1}"
113
+ dyn_name = f"_dyn_{safe_name}"
114
+
115
+ # Step 1: genera il codice via LLM
116
+ try:
117
+ raw = await asyncio.wait_for(
118
+ llm.chat(
119
+ [
120
+ {"role": "system", "content": _GEN_SYSTEM},
121
+ {"role": "user", "content": f"Scrivi un tool per: {description[:400]}"},
122
+ ],
123
+ temperature=0.1,
124
+ max_tokens=600,
125
+ ),
126
+ timeout=12.0,
127
+ )
128
+ except Exception as exc:
129
+ _logger.warning("COG-4 gen LLM failed: %s", exc)
130
+ return False, ""
131
+
132
+ # Step 2: estrai blocco codice
133
+ m = re.search(r"```(?:python)?\n?([\s\S]+?)```", raw)
134
+ code = m.group(1).strip() if m else raw.strip()
135
+
136
+ # Step 3: security gate
137
+ if _BLOCKED_RE.search(code):
138
+ _logger.warning("COG-4 blocked unsafe import in generated tool: %s", dyn_name)
139
+ return False, ""
140
+
141
+ # Step 4: valida in sandbox
142
+ validation = (
143
+ f"{code}\n\n"
144
+ "import asyncio as _asyncio\n"
145
+ "_r = _asyncio.run(tool_fn())\n"
146
+ "assert isinstance(_r, dict), 'must return dict'\n"
147
+ "assert 'output' in _r, 'must have output key'\n"
148
+ "print('COG4_OK')\n"
149
+ )
150
+ try:
151
+ val = await asyncio.wait_for(
152
+ executor.run_tool("run_python", {"code": validation}),
153
+ timeout=10.0,
154
+ )
155
+ out = val.get("output", {})
156
+ stdout = out.get("stdout", "") if isinstance(out, dict) else str(out)
157
+ if "COG4_OK" not in stdout:
158
+ _logger.warning("COG-4 validation failed: %s", stdout[:200])
159
+ return False, ""
160
+ except Exception as exc:
161
+ _logger.warning("COG-4 sandbox error: %s", exc)
162
+ return False, ""
163
+
164
+ # Step 5: registra nel TOOL_REGISTRY (runtime only, non persiste)
165
+ try:
166
+ from tools.registry import TOOL_REGISTRY
167
+ ns: dict = {}
168
+ exec(compile(code, f"<dyn:{dyn_name}>", "exec"), ns) # noqa: S102
169
+ fn = ns.get("tool_fn")
170
+ if not fn or not asyncio.iscoroutinefunction(fn):
171
+ _logger.warning("COG-4 tool_fn missing or not async")
172
+ return False, ""
173
+ TOOL_REGISTRY[dyn_name] = {
174
+ "description": f"[DYN] {description[:200]}",
175
+ "required_inputs": [],
176
+ "_fn": fn,
177
+ "_generated": True,
178
+ }
179
+ _GENERATED_COUNT += 1
180
+ _logger.info(
181
+ "COG-4 registered '%s' (%d/%d total dynamic tools)",
182
+ dyn_name, _GENERATED_COUNT, _MAX_GENERATED,
183
+ )
184
+ return True, dyn_name
185
+ except Exception as exc:
186
+ _logger.warning("COG-4 registration failed: %s", exc)
187
+ return False, ""
agents/unified_loop.py ADDED
The diff for this file is too large to render. See raw diff
 
agents/unified_loop_helpers.py ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_helpers.py — HelpersMixin: goal compression, replan, reflection, fast-path.
2
+
3
+ Estratto da unified_loop.py (P20-TD1 Fase 3a).
4
+
5
+ Contiene:
6
+ _compress_goal(): estrae blocchi codice >600 chars come file virtuali [FILE:N]
7
+ Deps: self._CODE_BLOCK_RE (PromptBuilderMixin), self._guess_filename (PromptBuilderMixin)
8
+ _budget_replan_check(): GAP-1 probabilistic re-planning trigger su budget critico + errori tool
9
+ Deps: self._get_fast_llm() (LLMSelectionMixin)
10
+ _proactive_reflect(): verifica pertinenza tool results prima della sintesi LLM
11
+ Deps: self._fast_llm / self.llm (instance attrs)
12
+ _run_fast_path(): S402+S-FAST path leggero per query conversazionali (<3s target)
13
+ Deps: DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin (tutti via MRO)
14
+ Nota: chiama self._run_fallback() che resta in UnifiedAgentLoop — ok via MRO
15
+
16
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
17
+ MRO Python garantisce che tutti i self.xxx riferimenti si risolvano correttamente
18
+ su UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin, HelpersMixin).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import re
24
+ from typing import Any
25
+
26
+ import logging
27
+ _logger = logging.getLogger("agents.unified_loop_helpers")
28
+
29
+ # Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
30
+ from agents.unified_loop_types import (
31
+ StepCallback,
32
+ UnifiedLoopState,
33
+ _LANG_INSTRUCTIONS,
34
+ _detect_user_lang,
35
+ _maybe_await,
36
+ )
37
+
38
+
39
+ def _get_classifier():
40
+ """Load the error classifier lazily, avoiding import cycles."""
41
+ from agents.error_classifier import classify_error, format_for_context
42
+ return classify_error, format_for_context
43
+
44
+
45
+ class HelpersMixin:
46
+ # ── S364+S403: Strategic Recovery reflection ──────────────────────────────
47
+
48
+ async def _reflective_debug(self, goal: str, errors: list,
49
+ _force_fast: bool = False) -> str:
50
+ """
51
+ S364 + S403: Strategic Recovery — usa ARCHITECT (DeepSeek-R1).
52
+
53
+ S364: Chain-of-Verification dopo 2+ errori (analisi root cause).
54
+ S403: Limite 3 (criticità 9/10) — recovery strategy reale:
55
+ errore → diagnosi → NUOVA strategia → ripartenza
56
+ (non retry:retry:fail).
57
+
58
+ Differenziato per numero di tentativi:
59
+ - 2 errori: analisi root cause + suggerimento alternativo
60
+ - 3+ errori: forzatura strategia completamente diversa
61
+
62
+ Ritorna stringa vuota su qualsiasi errore (fire-and-forget safe).
63
+ """
64
+ try:
65
+ from models.role_router import RoleRouter, Role
66
+ err_count = len(errors)
67
+ # S576: err_summary 200→300 — parity con fallback path (S573)
68
+ # S599: str(e)[:300]→[:500] — errori completi con stack info spesso > 300 chars
69
+ err_summary = '\n'.join(str(e)[:500] for e in errors[-3:])
70
+
71
+ # COG-2: record failure pattern per future lesson injection
72
+ if self.memory and hasattr(self.memory, 'reflection'):
73
+ try:
74
+ import asyncio as _asyncio_r
75
+ _asyncio_r.get_event_loop().call_soon(
76
+ lambda: self.memory.reflection.record_failure(
77
+ goal, err_summary[:400], "reflective_debug"
78
+ )
79
+ )
80
+ except Exception:
81
+ pass # recording non blocca mai il recovery
82
+
83
+ # S404: Classifica l'errore prima di invocare l'ARCHITECT
84
+ # → inject strategia mirata nel context (zero latency, no LLM)
85
+ classification_hint = ""
86
+ try:
87
+ _classify, _fmt = _get_classifier()
88
+ clf_result = _classify(errors[-4:] if errors else [])
89
+ classification_hint = _fmt(clf_result)
90
+ except Exception:
91
+ pass # classifier failure non blocca il recovery
92
+
93
+ # D6: inietta lezioni precedenti per lo stesso goal — evita ripetere strategie già fallite
94
+ _lessons_hint = ""
95
+ if self.memory and hasattr(self.memory, 'reflection'):
96
+ try:
97
+ _prev = self.memory.reflection.get_relevant_lessons(goal, n=2)
98
+ if _prev:
99
+ _lparts = []
100
+ for _l in _prev:
101
+ if _l.get("type") == "failure":
102
+ _lparts.append(f"- Già tentato e FALLITO: {_l.get('avoid','')[:200]}")
103
+ elif _l.get("type") == "success":
104
+ _lparts.append(f"- Strategia FUNZIONANTE in passato: {_l.get('strategy','')[:200]}")
105
+ if _lparts:
106
+ _lessons_hint = "Strategie già tentate (NON ripetere):\n" + "\n".join(_lparts) + "\n\n"
107
+ except Exception:
108
+ pass # lesson injection non blocca mai il recovery
109
+
110
+ # B3: bypass deterministico — errori con fix noto non richiedono LLM (1-3s risparmiati).
111
+ # Conseguenza: import/file/conn/perm errors con 1-2 occorrenze → hint zero-latency.
112
+ # Zero cons: il fix per ModuleNotFoundError è sempre 'installa dipendenza'.
113
+ if err_count < 3 and errors:
114
+ _last_err_str = str(errors[-1])[:600].lower()
115
+ _B3_DET = [
116
+ (r'modulenotfounderror|no module named|importerror|cannot find module|module not found',
117
+ '\U0001f4a1 Dipendenza mancante: installa il pacchetto con pip/npm prima di riprovare.'),
118
+ (r'filenotfounderror|no such file or directory|file not found|enoent',
119
+ '\U0001f4a1 File non trovato: verifica il percorso o crea il file prima di usarlo.'),
120
+ (r'connectionrefusederror|connection refused|econnrefused|network unreachable',
121
+ '\U0001f4a1 Connessione rifiutata: verifica che il servizio sia attivo e la porta corretta.'),
122
+ (r'permissionerror|permission denied|eacces|access denied',
123
+ '\U0001f4a1 Permessi insufficienti: verifica i permessi o esegui con privilegi appropriati.'),
124
+ ]
125
+ import re as _re_b3
126
+ for _det_pat, _det_hint in _B3_DET:
127
+ if _re_b3.search(_det_pat, _last_err_str):
128
+ return (_det_hint + ('\n\n' + classification_hint if classification_hint else '')).strip()
129
+
130
+ if err_count >= 3 and not _force_fast:
131
+ # GAP-1.3: ARCHITECT solo
132
+ # B4: _force_fast=True → fast_llm (strategic ctx già presente, ARCHITECT ridondante) per strategic recovery >= 3 errori (vale la latenza 10-15s)
133
+ arch = RoleRouter.get_client(Role.ARCHITECT)
134
+ _rd_timeout = 15.0
135
+ # S403 Strategic Recovery: dopo 3+ errori, forza approccio alternativo
136
+ system_prompt = (
137
+ "Sei un senior engineer. L'agente ha fallito 3+ volte con lo stesso approccio. "
138
+ "Analizza in 4 punti CONCRETI e SPECIFICI:\n"
139
+ "1. Root cause reale (1 riga)\n"
140
+ "2. Perché l'approccio usato finora falliva (1 riga)\n"
141
+ "3. STRATEGIA COMPLETAMENTE DIVERSA da usare ora (2 righe — sii specifico: "
142
+ "quale libreria, quale pattern, quale struttura dati alternativa)\n"
143
+ "4. Prima istruzione concreta (1 riga — cosa fare PRIMA di tutto)\n"
144
+ "No generalità tipo 'prova un altro approccio'. Sii tecnico e diretto."
145
+ )
146
+ label = "STRATEGIA ALTERNATIVA FORZATA"
147
+ max_tokens = 500 # S586: 350→500 — strategia alternativa spesso >350 tok
148
+ else:
149
+ # GAP-1.3: fast path — self.llm invece di ARCHITECT (ms vs 10-15s) per primo errore
150
+ arch = self.llm
151
+ _rd_timeout = 8.0
152
+ # S364 Chain-of-Verification: dopo 2 errori, analisi + suggerimento
153
+ system_prompt = (
154
+ "Sei un debugger esperto. Analizza in 3 punti:\n"
155
+ "1. Root cause reale (1 riga)\n"
156
+ "2. Perché l'approccio precedente falliva (1 riga)\n"
157
+ "3. Approccio alternativo specifico da provare (1-2 righe)\n"
158
+ "Solo analisi tecnica — niente codice."
159
+ )
160
+ label = "ANALISI ERRORE PRECEDENTE"
161
+ max_tokens = 400 # S586: 250→400 — analisi 3 punti necessita più tokens
162
+
163
+ msgs = [
164
+ {"role": "system", "content": system_prompt},
165
+ # S597: goal 300→500 — più contesto goal nel debug prompt architect
166
+ {"role": "user", "content": f"{_lessons_hint}Goal: {goal[:500]}\n\nTentativo #{err_count}\nErrori:\n{err_summary}"},
167
+ ]
168
+ analysis = await asyncio.wait_for(
169
+ arch.chat(msgs, temperature=0.1, max_tokens=max_tokens),
170
+ timeout=_rd_timeout,
171
+ )
172
+ if analysis and not analysis.startswith('[LLM'):
173
+ # S404: prepend classificazione deterministica + analisi LLM
174
+ return f"{classification_hint}\n\n[{label} — tentativo {err_count}]\n{analysis[:600]}"
175
+ except Exception:
176
+ pass # S364/S403: ARCHITECT failed — S455-P14: fallback to base LLM
177
+ # S455-P14: ARCHITECT non disponibile → fallback al modello base (sempre disponibile)
178
+ try:
179
+ fallback_msgs = [
180
+ {"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."},
181
+ # S573: goal 200→300, errors 150→300 — più contesto per il debug fallback
182
+ # S592: errors[-2:]→[-3:] — più errori nel fallback debug prompt
183
+ # S597: goal 300→500 — più contesto goal nel debug fallback prompt
184
+ # S600: str(e)[:300]→[:500] — parity con ARCHITECT path (riga ~271)
185
+ {"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:])}"},
186
+ ]
187
+ fallback_ans = await asyncio.wait_for(
188
+ # S586: 180→300 — fallback debug risposta 2-3 righe spesso > 180 tok
189
+ self.llm.chat(fallback_msgs, temperature=0.15, max_tokens=300),
190
+ timeout=10.0,
191
+ )
192
+ if fallback_ans and not fallback_ans.startswith('[LLM'):
193
+ return f"{classification_hint}\n\n[FALLBACK DEBUG — tentativo {len(errors)}]\n{fallback_ans[:600]}" # S603: 400→600
194
+ except Exception:
195
+ pass # fallback anche questo fallito — ritorna stringa vuota
196
+ # S404: se LLM fallisce, almeno ritorna la classificazione deterministica
197
+ return classification_hint
198
+
199
+
200
+
201
+ # ── Goal compression (S197/S357) ─────────────────────────────────────────
202
+
203
+ def _compress_goal(self, goal: str) -> tuple[str, str]:
204
+ """
205
+ Estrae blocchi di codice grandi dal goal e li converte in file virtuali.
206
+ Returns: (goal_compresso, sezione_file_da_iniettare_nel_contesto)
207
+ Se il codice totale e < _CODE_THRESHOLD, ritorna (goal_originale, '').
208
+ """
209
+ _CODE_THRESHOLD = 600 # chars — S357: abbassato da 1800 per ridurre token TTFT
210
+ blocks = list(self._CODE_BLOCK_RE.finditer(goal))
211
+ if not blocks:
212
+ return goal, ''
213
+ total_code_chars = sum(len(m.group('body')) for m in blocks)
214
+ if total_code_chars < _CODE_THRESHOLD:
215
+ return goal, ''
216
+
217
+ # Estrai ogni blocco
218
+ files_section_lines: list[str] = ['--- CODICE_FORNITO ---']
219
+ compressed = goal
220
+ for idx, m in enumerate(reversed(blocks)): # reverse per preservare offset
221
+ lang = m.group('lang') or 'txt'
222
+ body = m.group('body').rstrip()
223
+ fname = self._guess_filename(lang, len(blocks) - 1 - idx)
224
+ tag = f'[FILE:{len(blocks) - idx}: {fname}]'
225
+ start, end = m.start(), m.end()
226
+ compressed = compressed[:start] + tag + compressed[end:]
227
+ files_section_lines.insert(1, f'\n[FILE:{len(blocks) - idx}: {fname}]\n```{lang}\n{body}\n```')
228
+
229
+ files_section_lines.append('--- FINE_CODICE_FORNITO ---')
230
+ return compressed, '\n'.join(files_section_lines)
231
+
232
+
233
+ # ── GAP-1: Probabilistic re-planning on budget critical ───────────────────
234
+
235
+ async def _budget_replan_check(
236
+ self, state: 'UnifiedLoopState', step_count: int, on_step=None
237
+ ) -> str:
238
+ """
239
+ GAP-1: Probabilistic Re-planning Trigger.
240
+
241
+ Attivato quando: len(state.errors) >= 2 AND step_count >= 60% max_steps.
242
+ Genera un piano alternativo leggero usando fast_llm (8B, Groq).
243
+ Timeout 5s, fail-open — mai blocca il loop principale.
244
+
245
+ Differenza da _reflective_debug: triggera su budget critico + errori tool,
246
+ non solo su LLM errors. Differenza da COG-1 replan: opera sul single-task loop,
247
+ non sull'orchestrazione parallela.
248
+
249
+ Ritorna: stringa con nuovo approccio suggerito, o '' su errore/timeout.
250
+ """
251
+ _budget_ratio = step_count / max(state.max_steps, 1)
252
+ _n_err = len(state.errors)
253
+ # Guard: solo se errori >= 2 e budget >= 60% consumato
254
+ if _n_err < 2 or _budget_ratio < 0.6:
255
+ return ''
256
+ # Guard dedup: inietta una sola volta per run
257
+ if '[GAP-1-REPLAN]' in (state.context or ''):
258
+ return ''
259
+ try:
260
+ _fast = self._get_fast_llm()
261
+ _err_summary = '\n'.join(str(e)[:200] for e in state.errors[-3:])
262
+ _done_tools = ', '.join(
263
+ s.get('tool', s.get('action', '?'))
264
+ for s in state.steps[-6:]
265
+ if s.get('action') not in ('llm', 'reflective_debug', 'selfheal_strategy_injection')
266
+ ) or 'nessuno'
267
+ _replan_prompt = [
268
+ {"role": "system", "content": (
269
+ "Sei un re-planning agent. Il piano corrente sta fallendo. "
270
+ "Genera un approccio alternativo CONCISO (max 200 chars) "
271
+ "che eviti gli stessi errori. Solo il nuovo approccio, niente altro."
272
+ )},
273
+ {"role": "user", "content": (
274
+ f"GOAL: {state.goal[:300]}\n"
275
+ f"STEP: {step_count}/{state.max_steps} ({_budget_ratio:.0%} budget)\n"
276
+ f"ERRORI ({_n_err}): {_err_summary}\n"
277
+ f"TOOL USATI: {_done_tools}\n"
278
+ f"Suggerisci approccio alternativo:"
279
+ )},
280
+ ]
281
+ _replan_hint = await asyncio.wait_for(
282
+ _fast.chat(_replan_prompt, temperature=0.3, max_tokens=120),
283
+ timeout=5.0,
284
+ )
285
+ if _replan_hint and not _replan_hint.startswith('[LLM') and len(_replan_hint) > 10:
286
+ _logger.info("GAP-1 budget_replan: step=%d/%d errors=%d hint=%s",
287
+ step_count, state.max_steps, _n_err, _replan_hint[:80])
288
+ if on_step:
289
+ await _maybe_await(on_step({
290
+ "action": "budget_replan",
291
+ "status": "started",
292
+ "title": f"♻️ Re-planning (step {step_count}/{state.max_steps})",
293
+ "explanation": _replan_hint[:200],
294
+ }))
295
+ return _replan_hint
296
+ except Exception:
297
+ pass # fail-open totale
298
+ return ''
299
+
300
+
301
+ # ── PROACTIVE-REFLECT: tool result validation ──────────────────────────────
302
+
303
+ async def _proactive_reflect(self, goal: str, tool_results: str) -> str:
304
+ """
305
+ PROACTIVE-REFLECT (Gap 1): dopo ogni esecuzione tool, verifica se
306
+ i risultati sono pertinenti al goal PRIMA della sintesi LLM.
307
+
308
+ Previene allucinazione quando i tool restituiscono dati irrilevanti.
309
+ Usa fast_llm (8B) con timeout 4s — silent failure totale.
310
+ Ritorna hint da iniettare in state.context, o "" se risultati ok.
311
+
312
+ Trigger: chiamato in run() dopo direct_results disponibili.
313
+ Budget: 60 token output, 4s timeout, temperatura 0.1.
314
+ """
315
+ if not tool_results or len(tool_results) < 80:
316
+ return ""
317
+ _llm = self._fast_llm or self.llm
318
+ if not _llm:
319
+ return ""
320
+ try:
321
+ _prompt = (
322
+ f"GOAL: {goal[:180]}\n\n"
323
+ f"RISULTATI TOOL (estratto): {tool_results[:380]}\n\n"
324
+ "In UNA riga (max 15 parole): questi risultati permettono di rispondere al goal? "
325
+ "Se sì scrivi 'SUFFICIENTE'. Se no, indica cosa manca."
326
+ )
327
+ _check = await asyncio.wait_for(
328
+ _llm.chat(
329
+ [
330
+ {
331
+ "role": "system",
332
+ "content": (
333
+ "Sei un validatore di pertinenza. "
334
+ "Rispondi SOLO in italiano, massimo 15 parole. "
335
+ "Mai spiegare — solo valuta."
336
+ ),
337
+ },
338
+ {"role": "user", "content": _prompt},
339
+ ],
340
+ temperature=0.1,
341
+ max_tokens=60,
342
+ ),
343
+ timeout=4.0,
344
+ )
345
+ _check = (_check or "").strip()
346
+ if not _check or "SUFFICIENTE" in _check.upper():
347
+ return ""
348
+ # Inietta hint nel context per guidare la sintesi LLM
349
+ _logger.info("PROACTIVE-REFLECT: tool results parziali — %s", _check[:100])
350
+ return f"\n[REFLECT] Tool results parziali: {_check[:120]}\n"
351
+ except Exception:
352
+ return "" # silent failure — mai bloccare il loop principale
353
+
354
+
355
+ # ── S402+S-FAST: Fast path for conversational queries ─────────────────────
356
+
357
+ async def _run_fast_path(self, state: UnifiedLoopState,
358
+ on_step: StepCallback | None) -> dict[str, Any]:
359
+ """S402+S-FAST: Path leggero per query conversazionali (<3s target).
360
+ Salta: memory lookup, planner, executor, retry loop, verifier, goal_verifier,
361
+ self-healing Python/HTML. Sistema prompt minimale → meno token → risposta veloce."""
362
+ import time as _time
363
+ _t0 = _time.monotonic()
364
+
365
+ # S-FAST-MATH-EXACT: matematica semplice → calculate tool diretto, nessun LLM.
366
+ # Bypassa completamente l'LLM per "Calcola 2+2", "15*3", "quanto fa 7+8"
367
+ # → risposta esatta in <50ms, zero latenza LLM, zero allucinazioni.
368
+ if hasattr(self, '_SIMPLE_MATH_RE') and self._SIMPLE_MATH_RE.match(state.goal.strip()):
369
+ try:
370
+ from tools.registry import TOOL_REGISTRY
371
+ _math_expr = ""
372
+ # Prova _extract_calc_expr prima (rimuove prefissi "calcola", "quanto fa")
373
+ if hasattr(self, '_extract_calc_expr'):
374
+ _math_expr = self._extract_calc_expr(state.goal)
375
+ # Fallback: estrae espressione numerica pura (es. solo "2+2" senza prefisso)
376
+ if not _math_expr:
377
+ _pure_m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', state.goal)
378
+ if _pure_m:
379
+ _math_expr = (
380
+ _pure_m.group(0).strip().rstrip('.?! ')
381
+ .replace('^', '**').replace(',', '.')
382
+ )
383
+ if _math_expr and 'calculate' in TOOL_REGISTRY:
384
+ _calc_r = await asyncio.wait_for(
385
+ TOOL_REGISTRY['calculate']['_fn'](expression=_math_expr),
386
+ timeout=5.0,
387
+ )
388
+ if _calc_r.get('result') is not None:
389
+ _exact = str(_calc_r['result'])
390
+ _ms = int((_time.monotonic() - _t0) * 1000)
391
+ if on_step:
392
+ await _maybe_await(on_step({
393
+ 'loop': 2, 'action': 'fallback', 'status': 'done',
394
+ 'success': True, 'title': 'Calcolo eseguito',
395
+ 'explanation': f'{_math_expr} = {_exact}',
396
+ 'output': _exact,
397
+ }))
398
+ return {
399
+ 'success': True, 'engine': 'calculate', 'ok': True,
400
+ 'goal': state.goal, 'steps': [{'action': 'math_direct', 'ms': _ms}],
401
+ 'errors': [], 'output': _exact, 'result': _exact,
402
+ 'fast_path': True, 'timing_ms': _ms,
403
+ }
404
+ except Exception:
405
+ pass # fallback silenzioso all'LLM standard
406
+ # Fix-4.1: GREETING-BYPASS — saluti/ping → risposta deterministica 5ms vs 3-4s LLM
407
+ # Regex copre: ciao, hi, ci sei?, ping, status, sei online?, funziona?
408
+ # Early-return PRIMA di ogni LLM call — zero token consumati su Safari mobile.
409
+ _GREETING_RE = re.compile(
410
+ r'^(ciao|hi|hello|ci\s+sei\??|ping|status|sei\s+online\??|funziona\??|'
411
+ r'sei\s+l[\u00e0a]\??|sei\s+attivo\??|ok\??|ehi\??|oi|hey|'
412
+ r'sei\s+disponibile\??|tutto\s+ok\??)[\.!\s]*$',
413
+ re.IGNORECASE,
414
+ )
415
+ if _GREETING_RE.match(state.goal.strip()):
416
+ _ms = int((_time.monotonic() - _t0) * 1000)
417
+ if on_step:
418
+ await _maybe_await(on_step({
419
+ 'action': 'fast_path', 'status': 'done', 'success': True,
420
+ 'title': 'Risposta diretta',
421
+ 'explanation': 'Saluto rilevato — risposta deterministica',
422
+ 'output': '\u2705 Sono online e operativo. Come posso aiutarti?',
423
+ }))
424
+ return {
425
+ 'success': True, 'engine': 'deterministic', 'ok': True,
426
+ 'goal': state.goal,
427
+ 'output': '\u2705 Sono online e operativo. Come posso aiutarti?',
428
+ 'result': '\u2705 Sono online e operativo. Come posso aiutarti?',
429
+ 'steps': [{'action': 'greeting_bypass', 'ms': _ms}],
430
+ 'fast_path': True, 'timing_ms': _ms,
431
+ }
432
+
433
+ fmt_dir = self._classify_format_directive(state.goal)
434
+ # P27-B2: lingua esplicita invece di "Rispondi nella lingua dell'utente" (vago).
435
+ # _detect_user_lang() puro (<1ms) → istruzione diretta al modello.
436
+ _fast_lang = _detect_user_lang(state.goal)
437
+ _fast_lang_instr = _LANG_INSTRUCTIONS.get(_fast_lang, "Rispondi nella lingua dell'utente.")
438
+ messages: list[dict] = [
439
+ {"role": "system", "content":
440
+ f"Sei un assistente AI utile e diretto. {_fast_lang_instr}\n{fmt_dir}"},
441
+ {"role": "user", "content": state.goal},
442
+ ]
443
+ if on_step:
444
+ await _maybe_await(on_step({
445
+ "loop": 1, "action": "llm", "status": "started",
446
+ "title": "Risposta rapida",
447
+ "explanation": "Query semplice — risposta diretta",
448
+ }))
449
+ # S-FAST: usa client 8B (Groq) invece del primario (70B) per query semplici
450
+ _fast_client = self._get_fast_llm()
451
+ answer = ""
452
+ try:
453
+ answer = await asyncio.wait_for(
454
+ _fast_client.chat(messages, temperature=0.7, max_tokens=512),
455
+ timeout=8.0,
456
+ )
457
+ except Exception as _exc:
458
+ _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
459
+ if not answer or answer.startswith("[LLM"):
460
+ # Degradazione sicura al fallback completo
461
+ return await self._run_fallback(state, on_step)
462
+ answer = self._sanitize_agent_output(answer)
463
+ t_ms = int((_time.monotonic() - _t0) * 1000)
464
+ engine = getattr(_fast_client, "provider_name", None) or "llm"
465
+ if on_step:
466
+ await _maybe_await(on_step({
467
+ "loop": 2, "action": "fallback", "status": "done", "success": True,
468
+ "title": "Completato",
469
+ "explanation": "Risposta elaborata e verificata con successo",
470
+ "output": answer, # S-STEP-OUT: espone risposta nel log step per debug frontend
471
+ }))
472
+ return {
473
+ "success": True, "engine": engine, "goal": state.goal,
474
+ "steps": [{"action": "fast_path", "ms": t_ms}],
475
+ "errors": [], "output": answer,
476
+ "fast_path": True, "timing_ms": t_ms,
477
+ }
478
+
479
+ # R1 S390 + S434: smolagents rimosso — dead code eliminato.
480
+ # _build_smol_tools / _load_smol_agent / _run_smolagents non chiamati da run().
481
+
482
+ # ── Fallback deterministico ───────────────────────────────────────────────
483
+
agents/unified_loop_llm.py ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_llm.py — LLMSelectionMixin: selezione LLM, routing e output sanitization.
2
+
3
+ Estratto da unified_loop.py (P20-TD1 Fase 2).
4
+
5
+ Contiene:
6
+ Block A — LLM selection + routing class attrs:
7
+ _SKIP_SMOL_RE, _COMPLEX_APP_RE, _MULTI_FEATURE_RE, _CODE_TASK_RE, _NEEDS_PLAN_RE
8
+ _get_llm_for_goal(): CODER vs default LLM selection (S362/S416)
9
+ _get_fast_llm(): lazy fast LLM cache (S-FAST)
10
+ _get_verifier_llm(): P25-B4 cross-model critic selection
11
+ _is_pure_explanation(): conceptual query detection (B5)
12
+ _max_tokens_for_goal(): token budget estimation (S373/B13)
13
+
14
+ Block B — Output sanitization + explanation regex attrs:
15
+ _sanitize_agent_output(): staticmethod rimozione monologue interno (S371)
16
+ _PURE_EXPLANATION_RE, _EXPL_ACTION_RE, _EXPL_FILE_REF_RE: regex per _is_pure_explanation
17
+
18
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
19
+ MRO Python garantisce self._SKIP_SMOL_RE / self._sanitize_agent_output()
20
+ funzionino da qualsiasi metodo di UnifiedAgentLoop.
21
+ Importato da: unified_loop.py (solo per ereditarietà LLMSelectionMixin)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Any
27
+
28
+ import logging
29
+ _logger = logging.getLogger("agents.unified_loop_llm")
30
+
31
+
32
+ class LLMSelectionMixin:
33
+ # ── Block A: LLM selection + routing ─────────────────────────────────────
34
+
35
+ def _get_llm_for_goal(self, goal: str) -> Any:
36
+ """S362: return CODER-role LLM for code-heavy goals, default otherwise.
37
+ GAP-ROUT: route SQL/Reasoning/MMLU to REASONER role (Cerebras 120B).
38
+ S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
39
+ anche se _CODE_RE non matcha — garantisce qualità su app multi-file."""
40
+ g = goal[:500]
41
+ _is_code = bool(self._CODE_GOAL_RE.search(g))
42
+ _is_reasoning = bool(self._REASONING_GOAL_RE.search(g)) or \
43
+ bool(self._SQL_GOAL_RE.search(g)) or \
44
+ bool(self._MMLU_GOAL_RE.search(g))
45
+
46
+ _tok = self._max_tokens_for_goal(goal)
47
+ _needs_heavy = _is_code or _is_reasoning or _tok >= 6144
48
+
49
+ if not _needs_heavy:
50
+ return self.llm
51
+
52
+ if _is_reasoning:
53
+ try:
54
+ from models.role_router import RoleRouter, Role
55
+ return RoleRouter.get_client(Role.REASONER)
56
+ except Exception:
57
+ pass
58
+
59
+ if self._coder_llm is None:
60
+ try:
61
+ from models.role_router import RoleRouter, Role
62
+ self._coder_llm = RoleRouter.get_client(Role.CODER)
63
+ except Exception:
64
+ self._coder_llm = self.llm
65
+ return self._coder_llm
66
+
67
+ def _get_fast_llm(self) -> Any:
68
+ """S-FAST: return Role.FAST client (Groq openai/gpt-oss-20b) per query semplici.
69
+ Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
70
+ Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
71
+ if self._fast_llm is None:
72
+ try:
73
+ from models.role_router import RoleRouter, Role
74
+ self._fast_llm = RoleRouter.get_client(Role.FAST)
75
+ except Exception:
76
+ self._fast_llm = self.llm
77
+ return self._fast_llm
78
+
79
+ def _get_verifier_llm(self) -> Any:
80
+ """P25-B4: Cross-model critic — restituisce un provider DIVERSO da self.llm per la verifica.
81
+ Elimina il bias di conferma: lo stesso modello che ha generato la risposta
82
+ non dovrebbe giudicare se è corretta.
83
+
84
+ Strategia di selezione:
85
+ - Generatore Groq → Verifier Gemini RESEARCHER (ragionamento diverso)
86
+ - Generatore Gemini → Verifier Groq CODER (modello diverso)
87
+ - Generatore altri → Verifier Gemini RESEARCHER → fallback Groq CODER
88
+ - Qualsiasi errore → fallback self.llm (comportamento invariato, zero regressioni)
89
+
90
+ Cache lazy (self._verifier_llm) — caricato una volta per sessione.
91
+ """
92
+ if self._verifier_llm is not None:
93
+ return self._verifier_llm
94
+ try:
95
+ from models.role_router import RoleRouter as _RRv, Role as _Rolev
96
+ _gen_prov = getattr(self.llm, 'provider_name', '') or ''
97
+ if 'groq' in _gen_prov:
98
+ # Generatore Groq → Verifier Gemini (diverso reasoning)
99
+ self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
100
+ elif 'gemini' in _gen_prov:
101
+ # Generatore Gemini → Verifier Groq CODER (70B, diverso modello)
102
+ self._verifier_llm = _RRv.get_client(_Rolev.CODER)
103
+ elif 'cerebras' in _gen_prov:
104
+ # Generatore Cerebras → Verifier Gemini
105
+ self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
106
+ elif 'nvidia' in _gen_prov:
107
+ # Generatore NVIDIA → Verifier Groq CODER (per diversificare)
108
+ self._verifier_llm = _RRv.get_client(_Rolev.CODER)
109
+ else:
110
+ # OpenRouter / SambaNova / altri → Verifier Groq CODER come default cross-model
111
+ self._verifier_llm = _RRv.get_client(_Rolev.CODER)
112
+ _logger.debug(
113
+ "P25-B4 verifier_llm: gen_prov=%s → verifier=%s",
114
+ _gen_prov, getattr(self._verifier_llm, 'provider_name', '?'),
115
+ )
116
+ except Exception as _exc:
117
+ _logger.debug("P25-B4 _get_verifier_llm fallback: %s", _exc)
118
+ self._verifier_llm = self.llm # fallback: invariato
119
+ return self._verifier_llm
120
+
121
+ def _is_pure_explanation(self, goal: str) -> bool:
122
+ """True per una spiegazione concettuale completa che non richiede mutazioni.
123
+
124
+ I riferimenti nominali contestuali, per esempio ``dopo una modifica al
125
+ codice``, non trasformano una domanda esplicativa in un task operativo.
126
+ Una seconda azione imperativa resta invece un percorso operativo.
127
+ """
128
+ if len(goal) > 300: return False
129
+ text = goal[:200]
130
+ if not self._PURE_EXPLANATION_RE.search(text): return False
131
+ if self._EXPL_FILE_REF_RE.search(text): return False
132
+ if self._EXPL_REALTIME_RE.search(text): return False
133
+ if self._EXPL_TUTORIAL_RE.search(text): return True
134
+ contextual_spans = [
135
+ match.span()
136
+ for match in self._EXPL_CONTEXTUAL_ACTION_REF_RE.finditer(text)
137
+ ]
138
+ for action in self._EXPL_ACTION_RE.finditer(text):
139
+ if not any(
140
+ start <= action.start() < end
141
+ for start, end in contextual_spans
142
+ ):
143
+ return False
144
+ return True
145
+
146
+ # S371: _SKIP_SMOL_RE — skippa smolagents per query semplici (notizie, cerca) → direct tools
147
+ # S427: ampliato — più query bypassano smolagents → direct tools (più veloce).
148
+ _SKIP_SMOL_RE = re.compile(
149
+ r'\b(notizie|news|ultime notizie|cerca|ricerca|meteo|tempo|previsioni|'
150
+ r'cerca online|cerca su|trova online|guarda su|vai su|'
151
+ # S427: meteo/clima ampliato
152
+ r'weather|forecast|temperatura|clima|piove|nevica|temporale|umidità|vento|'
153
+ # S427: valute/crypto
154
+ r'valuta|cambio|tasso|euro|dollaro|bitcoin|ethereum|crypto|yen|sterlina|'
155
+ r'exchange rate|currency|'
156
+ # S427: knowledge lookup
157
+ r'wikipedia|enciclopedia|chi [eè]|chi era|storia di|'
158
+ # S427: calcoli diretti
159
+ r'calcola|quanto fa|quant[oei]\s+fa|risultato di|computa|'
160
+ # S427: data/ora
161
+ r'che ora|che giorno|data di oggi|ora attuale|orario|timezone|'
162
+ # S427: traduzione
163
+ r'traduci|traduzione|translate|translation|'
164
+ # EN-DIRECT: English patterns — bypass planner (save 5-15s latency) for simple EN queries
165
+ r'calculate|compute|how much is \d|what time is it|current time|today.s date|'
166
+ r'what.s the (?:time|date|day|weather)|who (?:is|was|are|were) |what is the (?:weather|capital|population)|'
167
+ r'stock price|crypto price|price of (?:bitcoin|ethereum|gold)|'
168
+ r'weather in|forecast for|temperature in|'
169
+ r'convert \d|how many \w+ in|exchange rate (?:of|for|from)|'
170
+ r'latest news (?:about|on)|search (?:for |on )?wikipedia|look up |'
171
+ # B7: unit conversion, timezone, date calc, IP — direct tools, skip planner
172
+ r'converti\s+\d+\s+\w+\s+(?:in|to)\s+\w+|'
173
+ r'quanti\s+giorni\s+(?:tra|fino|mancano)|'
174
+ r'che\s+ora\s+[e\xe8]\s+a\s+\w+|what\s+time\s+is\s+it\s+in\s+\w+|'
175
+ r'(?:mio\s+ip|my\s+ip|ip\s+address)\s*\??)\b',
176
+ re.IGNORECASE,
177
+ )
178
+
179
+ # ── S373: pattern per stima budget token output ────────────────────────
180
+ # S427: aggiunti più trigger per app/sistemi complessi + more token budget
181
+ _COMPLEX_APP_RE = re.compile(
182
+ r'\b(crea|scrivi|implementa|build|create|write|implement|'
183
+ r'sviluppa|costruisci|progetta|genera|scaffold|deploy|develop)\b.{0,80}'
184
+ r'\b(app|applicazione|application|progetto|project|website|sito|'
185
+ r'dashboard|api|backend|frontend|server|service|platform|piattaforma|'
186
+ r'sistema|e.?commerce|chatbot|bot|game|gioco|portfolio|blog|crm|cms|'
187
+ r'saas|marketplace|admin|panel|landing|cli|tool|library|sdk)\b',
188
+ re.IGNORECASE | re.DOTALL,
189
+ )
190
+ # S427: indicatori di complessità multi-feature ampliati
191
+ _MULTI_FEATURE_RE = re.compile(
192
+ r'\b(completo|completa|full.?stack|multi.?file|con\s+test|con\s+typescript|'
193
+ r'con\s+animazioni?|con\s+filtri?|con\s+localStorag|tipizzato|'
194
+ r'multiple\s+components?|più\s+component|separati|con\s+routing|'
195
+ r'con\s+auth(?:entication)?|con\s+login|con\s+database|con\s+deploy|'
196
+ r'con\s+pagament[io]|con\s+api|con\s+websocket|con\s+i18n|'
197
+ r'con\s+dark\s+mode|con\s+responsive|multi.?pagina|multi.?step|'
198
+ r'end.?to.?end|production.?ready|scalabile|enterprise|'
199
+ r'con\s+docker|con\s+ci.?cd|con\s+testing|con\s+validation)\b',
200
+ re.IGNORECASE,
201
+ )
202
+ # S427: tipi di artefatti codice ampliati per token budget
203
+ _CODE_TASK_RE = re.compile(
204
+ r'\b(codice|funzione|function|classe|class|componente|component|'
205
+ r'modulo|module|script|algoritmo|algorithm|hook|utility|helper|'
206
+ r'type|interface|enum|decorator|middleware|service|repository|'
207
+ r'controller|handler|resolver|store|reducer|action|mutation|'
208
+ r'schema|dto|validator|mapper|factory|builder|'
209
+ r'context|provider|consumer|wrapper|composable|mixin)\b',
210
+ re.IGNORECASE,
211
+ )
212
+
213
+ # B7: planner richiesto solo per task di progettazione/implementazione complessa
214
+ # S427: aggiunti verbi che richiedono pianificazione (patch/debug/ottimizza/ecc.)
215
+ _NEEDS_PLAN_RE = re.compile(
216
+ # F18: esteso con pattern IT mancanti — installa/nuovo/clona/avvia/esegui/testa
217
+ r'\b(crea|implementa|scrivi|refactor|progetta|costruisci|'
218
+ r'sistema|architettura|deploy|migra|sviluppa|build|create|'
219
+ r'implement|design|architect|'
220
+ r'patch|fix|debug|ottimizza|optimize|rinomina|rename|'
221
+ r'aggiungi|aggiorna|update|integra|integrate|'
222
+ r'scaffold|bootstrap|genera|generate|'
223
+ r'ristruttura|restructure|refactorizza|converti|convert|'
224
+ r'installa|clona|avvia|esegui|testa|verifica|configura|'
225
+ r'install|clone|run|test|verify|setup|configure)\b',
226
+ re.IGNORECASE,
227
+ )
228
+
229
+ @classmethod # B13: era @staticmethod — usa class attrs già compilati invece di re.search inline
230
+ def _max_tokens_for_goal(cls, goal: str) -> int:
231
+ """S373: stima budget max_tokens in base alla complessità del goal.
232
+ Groq llama-3.3-70b supporta 32768 output token, Gemini 2.5-flash 8192.
233
+ Default 2048 solo per Q&A semplice; per codice si scala fino a 8192.
234
+ B13: usa class attrs _COMPLEX_APP_RE/_MULTI_FEATURE_RE/_CODE_TASK_RE già compilati.
235
+ """
236
+ # B6: fast-fix task → risposta atomica breve → 512 token max (-30-60% LLM time)
237
+ # Conseguenza: "aggiungi import X" non richiede 4096 tokens — 50-100 bastano.
238
+ if cls._FAST_FIX_RE.search(goal[:180]) and len(goal) < 180:
239
+ return 512
240
+
241
+ g = goal[:600]
242
+ is_app = bool(cls._COMPLEX_APP_RE.search(g))
243
+ is_complex = bool(cls._MULTI_FEATURE_RE.search(g))
244
+ is_code = bool(cls._CODE_TASK_RE.search(g))
245
+ if is_app and is_complex:
246
+ return 8192 # app multi-feature → output completo garantito
247
+ if is_app or is_complex:
248
+ return 6144 # app semplice o feature complessa
249
+ if is_code:
250
+ return 4096 # singola funzione/componente
251
+ return 2048 # Q&A, spiegazioni, risposte brevi
252
+
253
+
254
+ # ── Block B: Output sanitization + explanation regex attrs ────────────────
255
+
256
+ @staticmethod
257
+ def _sanitize_agent_output(text: str) -> str:
258
+ """S371: Rimuove monologue interno che può leakare da smolagents/LLM.
259
+ Patterns rimossi: GOAL:/DONE_WHEN:/OUT_OF_SCOPE: blocks, Proceed.We need to output...,
260
+ Thought:/Code:/Observation: lines, raw JSON tool call arrays.
261
+ S390: aggiunto stripping <think> blocks (Qwen3/DeepSeek-R1 reasoning leaks).
262
+ B12: fast-path — skip tutti i regex se nessun segnale di monologue (risparmia 50-200ms).
263
+ """
264
+ if not text:
265
+ return text
266
+ # S390: strip <think> sempre — Qwen3 e DeepSeek-R1 li iniettano indipendentemente dagli altri segnali
267
+ import re as _re # BV-3: hoisted — stdlib, always in sys.modules
268
+ if '<think>' in text:
269
+ text = _re.sub(r'<think>[\s\S]*?</think>', '', text, flags=_re.IGNORECASE) # blocco chiuso
270
+ text = _re.sub(r'<think>[\s\S]*', '', text, flags=_re.IGNORECASE) # blocco aperto (troncato)
271
+ text = text.strip()
272
+ if not text:
273
+ return text
274
+ # B12: fast-path exit — ~95% delle risposte normali non contengono questi segnali
275
+ _MONOLOGUE_SIGNALS = ('GOAL:', 'DONE_WHEN:', 'OUT_OF_SCOPE:', 'Proceed.', 'Thought:', 'Code:', '[{"action"')
276
+ if not any(s in text for s in _MONOLOGUE_SIGNALS):
277
+ return text.strip()
278
+ # BV-3: _re already imported at function top
279
+ # Strip smolagents internal prompt blocks (GOAL/DONE_WHEN/OUT_OF_SCOPE)
280
+ text = _re.sub(
281
+ r'(?m)^(?:GOAL|DONE_WHEN|OUT_OF_SCOPE):\s*.*(?:\n(?!\n)[^\n]*)*',
282
+ '', text
283
+ )
284
+ # Strip "Proceed. We need to output the tool call now. [{"action":...}]"
285
+ text = _re.sub(
286
+ r'Proceed\.?\s*We\s+need\s+to\s+output\s+the\s+tool\s+call\s+now\.?\s*\[.*?\]',
287
+ '', text, flags=_re.DOTALL
288
+ )
289
+ # Strip raw JSON tool call arrays at line start
290
+ text = _re.sub(r'^\s*\[\s*\{["\']*action["\']*\s*:', '', text, flags=_re.MULTILINE)
291
+ # Strip smolagents Thought:/Code:/Observation: line prefixes (when not inside code blocks)
292
+ text = _re.sub(r'^(?:Thought|Code|Observation|Action Input):\s*', '', text, flags=_re.MULTILINE)
293
+ # Strip [STEP N/M] markers
294
+ text = _re.sub(r'\[STEP\s+\d+/\d+\]\s*', '', text)
295
+ return text.strip()
296
+
297
+ # S375: Format directive classifier — speculare al frontend formatClassifier.ts
298
+ # Iniettato in _build_messages() per garantire formattazione consistente
299
+ # anche sui task tool-heavy gestiti interamente dal backend.
300
+ _FORMAT_DIRECTIVE_CODE = (
301
+ "FORMATO RISPOSTA OBBLIGATORIO — CODICE:\n"
302
+ "• Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
303
+ "• Per una richiesta di singolo snippet, emetti ESATTAMENTE un blocco nel linguaggio richiesto; "
304
+ "non sostituirlo con pseudocodice, analisi o un blocco generico.\n"
305
+ "• Il blocco deve contenere la soluzione completa, autonoma ed eseguibile senza modifiche; "
306
+ "mantieni gli export e la firma richiesti.\n"
307
+ "• Prima di rispondere applica il CONTROLLO FINALE: codice compilabile, nessun placeholder/TODO, "
308
+ "nessun simbolo non definito, tipi espliciti.\n"
309
+ "• Per codice async con handler indipendenti: includi `async`, `await` e `try/catch` oppure "
310
+ "`Promise.allSettled` per isolare ogni errore.\n"
311
+ "• Per correzioni React useEffect: preserva la struttura, usa AbortController o una guardia di annullamento "
312
+ "e restituisci sempre cleanup (`return () => ...`).\n"
313
+ "• Aggiungi commenti inline solo per la logica non ovvia. Se multi-file: mostra ogni file in un blocco separato "
314
+ "con il nome come titolo; formato titolo: ### src/nomefile.tsx."
315
+ )
316
+ _FORMAT_DIRECTIVE_MARKDOWN = (
317
+ "FORMATO RISPOSTA OBBLIGATORIO — STRUTTURATO:\n"
318
+ "• Usa titoli (##), liste puntate, grassetto per punti chiave\n"
319
+ "• Max 3 livelli di gerarchia — non annidare troppo\n"
320
+ "• Tabelle markdown per confronti (3+ elementi)\n"
321
+ "• Paragrafi brevi (2-3 righe) per leggibilità mobile\n"
322
+ "Su mobile, tabelle 3+ colonne: usa lista chiave-valore o ### + punti (scroll orizzontale non usabile su iPhone)"
323
+ )
324
+ _FORMAT_DIRECTIVE_CONVERSATIONAL = (
325
+ "FORMATO RISPOSTA OBBLIGATORIO — CONVERSAZIONALE:\n"
326
+ "• Tono diretto e naturale, senza formalismi eccessivi\n"
327
+ "• Niente strutture markdown pesanti per domande semplici\n"
328
+ "• Rispondi in 1-3 paragrafi se la domanda è semplice\n"
329
+ "• Usa grassetto solo per termini chiave critici"
330
+ )
331
+ _FORMAT_DIRECTIVE_MATH = (
332
+ "FORMATO RISPOSTA OBBLIGATORIO — MATEMATICA:\n"
333
+ "• Mostra SEMPRE i calcoli passo per passo numerati\n"
334
+ "• Usa notazione chiara: P(A|B), E[X], Σ, ecc.\n"
335
+ "• Risultato finale in riga separata con grassetto\n"
336
+ "• Usa il PUNTO come separatore decimale (non virgola)\n"
337
+ "• Esprimi probabilità sia come frazione che come percentuale"
338
+ )
339
+
340
+ # S-FMT-MOBILE: 4 nuove direttive specializzate iPhone (2026-06-12)
341
+ _FORMAT_DIRECTIVE_RESEARCH = (
342
+ "FORMATO RISPOSTA OBBLIGATORIO - RICERCA:\n"
343
+ "- Struttura ogni fonte: Fonte -> sintesi 1-2 righe -> punto chiave\n"
344
+ "- Max 4 fonti, priorita qualita su quantita\n"
345
+ "- Separazione netta fatti verificati vs interpretazioni\n"
346
+ "- Termina con sezione VERDETTO (1 paragrafo, risposta diretta)\n"
347
+ "- Nessuna fonte disponibile: dichiaralo esplicitamente"
348
+ )
349
+ _FORMAT_DIRECTIVE_DEBUG = (
350
+ "FORMATO RISPOSTA OBBLIGATORIO - DEBUG:\n"
351
+ "- Struttura FISSA 4 sezioni: Errore -> Causa -> Fix (codice) -> Prevenzione\n"
352
+ "- Sezione Fix: codice completo pronto da copiare, non frammenti\n"
353
+ "- Sezione Causa: spiega PERCHE accade, non solo cosa accade\n"
354
+ "- Sezione Prevenzione: max 2 punti concreti\n"
355
+ "- Bug multipli: numera ogni set Errore/Causa/Fix/Prevenzione"
356
+ )
357
+ _FORMAT_DIRECTIVE_MEDIA = (
358
+ "FORMATO RISPOSTA OBBLIGATORIO - MEDIA/IMMAGINE:\n"
359
+ "- URL immagine come link cliccabile: [Visualizza immagine](URL)\n"
360
+ "- URL in riga separata per copy-paste\n"
361
+ "- Breve descrizione di cosa raffigura l' immagine generata\n"
362
+ "- Se non disponibile: fornisci URL Pollinations come fallback esplicito"
363
+ )
364
+ _FORMAT_DIRECTIVE_MOBILE_COMPACT = (
365
+ "FORMATO RISPOSTA OBBLIGATORIO - MOBILE RAPIDO:\n"
366
+ "- Risposta MASSIMO 150 parole, vai dritto al punto\n"
367
+ "- Prima riga = verdetto/azione (no preamboli)\n"
368
+ "- Usa emoji come icone stato: OK fatto, WARN attenzione, ERR errore, TIP suggerimento\n"
369
+ "- Zero spiegazioni non richieste, solo l' essenziale\n"
370
+ "- Se servono dettagli: offri follow-up"
371
+ )
372
+
373
+ # GAP-A: narrazione pre-tool — spiega all'utente PERCHÉ l'agente usa quel tool.
374
+ # Usato nel _run_subtask (reason field) e come text_chunk prima del gather.
375
+ # Zero latency: nessuna chiamata LLM — solo lookup di dizionario.
376
+ _TOOL_NARRATION: dict[str, str] = {
377
+ "web_search": "🔍 Cerco informazioni aggiornate sul web",
378
+ "read_page": "🌐 Leggo la pagina web per estrarre i dati",
379
+ "write_file": "✍️ Implemento il codice richiesto",
380
+ "read_file": "📂 Leggo il file per analizzare la struttura attuale",
381
+ "apply_patch": "🔧 Applico la patch mirata al file",
382
+ "run_python": "⚙️ Eseguo il codice Python per verificare il risultato",
383
+ "execute_shell": "🖥️ Eseguo il comando shell",
384
+ "execute_sql": "🗄️ Eseguo la query SQL",
385
+ "database_query": "🗄️ Eseguo la query sul database",
386
+ "git_push": "🚀 Invio le modifiche al repository",
387
+ "git_commit": "💾 Salvo le modifiche con un commit",
388
+ "git_status": "📊 Verifico lo stato del repository",
389
+ "git_diff": "🔎 Confronto le modifiche in staging",
390
+ "git_clone": "📥 Clono il repository",
391
+ "npm_install": "📦 Installo le dipendenze Node.js",
392
+ "npm_run": "▶️ Avvio lo script npm",
393
+ "pip_install": "📦 Installo i pacchetti Python",
394
+ "type_check": "✅ Verifico i tipi TypeScript",
395
+ "lint_code": "🔍 Analizzo la qualità del codice",
396
+ "get_weather": "🌤️ Recupero le previsioni meteo",
397
+ "get_news": "📰 Recupero le ultime notizie",
398
+ "recall": "🧠 Consulto la memoria dell'agente",
399
+ "list_files": "📁 Elenco i file del progetto",
400
+ "directory_tree": "🗂️ Analizzo la struttura del progetto",
401
+ "file_search": "🔎 Cerco nel codice del progetto",
402
+ "create_project": "🏗️ Creo la struttura del progetto",
403
+ "scaffold_project": "🏗️ Genero la struttura del progetto da template",
404
+ # Tool planner-only (non in TOOL_REGISTRY) — narrazione pre-gather
405
+ "code": "💻 Implemento la soluzione richiesta",
406
+ "send_email": "📧 Invio l'email tramite Resend",
407
+ "create_pdf": "📄 Genero il documento PDF",
408
+ "call_api": "🔗 Chiamo l'API esterna",
409
+ "browser_navigate": "🌐 Navigo verso la pagina",
410
+ "browser_session_open": "🌐 Apro una sessione browser",
411
+ "browser_session_act": "🖱️ Interagisco con la pagina",
412
+ "get_image": "🖼️ Recupero l'immagine",
413
+ "create_chart": "📊 Creo il grafico",
414
+ "diff_text": "🔎 Confronto i testi",
415
+ "validate_json": "✅ Valido il JSON",
416
+ "calculate": "🔢 Calcolo il risultato matematico",
417
+ "image": "🎨 Genero un'immagine con AI",
418
+ "generate_image": "🎨 Genero un'immagine con AI",
419
+ "delegate_task": "🤝 Delego a un micro-agente specializzato",
420
+ "browser_session_open": "🖱️ Apro una sessione browser autonoma",
421
+ "browser_session_act": "🖱️ Interagisco con la pagina nel browser",
422
+ "web_research": "🔍 Ricerca approfondita multi-fonte sul web",
423
+ }
424
+ _TOOL_NARRATION_DEFAULT = "⚙️ Eseguo l'operazione"
425
+
426
+ # GAP-B scaffold preview: albero file mostrato in real-time PRIMA che il tool scriva.
427
+ # Sincronizzato con i template in registry.py/_scaffold_project — aggiorna entrambi.
428
+ _SCAFFOLD_FILE_TREE: dict[str, list[str]] = {
429
+ "react": ["package.json", "index.html", "vite.config.ts", "src/main.tsx", "src/App.tsx", "src/index.css"],
430
+ "nextjs": ["package.json", "next.config.mjs", "app/layout.tsx", "app/page.tsx"],
431
+ "fastapi": ["main.py", "requirements.txt", "Dockerfile", ".gitignore"],
432
+ "flask": ["app.py", "requirements.txt", ".gitignore"],
433
+ "django": ["manage.py", "requirements.txt", "config/settings.py", "config/urls.py", "api/views.py", "api/urls.py"],
434
+ "express": ["package.json", "src/index.js", ".gitignore"],
435
+ }
436
+
437
+ # S427: ampliato con verbi IT/EN + tecnologie — stesso set di _CODE_RE
438
+ _CODE_GOAL_RE = re.compile(
439
+ r'\b(scrivi|crea|genera|implementa|refactor|codice|funzione|classe|componente|'
440
+ r'script|algoritmo|api|endpoint|hook|store|tipo|interface|migration|query|schema|'
441
+ r'write|create|generate|implement|code|function|class|component|backend|frontend|'
442
+ r'sistema|correggi|debugga|patch|rinomina|sostituisci|rimpiazza|ottimizza|'
443
+ r'rename|replace|remove|delete|fix|debug|optimize|deploy|scaffold|'
444
+ r'typescript|javascript|python|react|vue|svelte|angular|next\.?js|nuxt|'
445
+ r'fastapi|flask|django|express|nest\.?js|rails|laravel|'
446
+ r'service|repository|controller|middleware|utility|helper|'
447
+ r'css|scss|html|sql|graphql|dockerfile|prisma|drizzle)\b',
448
+ re.IGNORECASE,
449
+ )
450
+ # S427: ampliato con più concetti matematici IT/EN
451
+ _MATH_GOAL_RE = re.compile(
452
+ r'\b(calcola|calcolare|probabilit|bayes|integra|derivat|statistic|media|'
453
+ r'varianza|percentuale|equazione|formula|risolvi|calculate|probability|'
454
+ r'integral|derivative|statistic|mean|variance|equation|solve|'
455
+ r'somma|prodotto|divisione|divisore|multiplo|mcd|mcm|modulo|quoziente|'
456
+ r'logaritmo|radice|potenza|fattoriale|fibonacci|'
457
+ r'trigonometria|seno|coseno|tangente|algebra|geometria|aritmetica|'
458
+ r'matrice|determinante|vettore|'
459
+ r'sum|product|division|lcm|gcd|remainder|quotient|'
460
+ r'logarithm|sqrt|square\s*root|factorial|power|'
461
+ r'trigonometry|sine|cosine|tangent|matrix|determinant|vector|'
462
+ r'fraction|frazioni|decimali|decimal|percentag)\b',
463
+ re.IGNORECASE,
464
+ )
465
+ # S427: ampliato con più trigger per risposta strutturata markdown
466
+ _MARKDOWN_GOAL_RE = re.compile(
467
+ r'\b(elenca|confronta|spiega|differenz|vantaggi|svantaggi|guida|tutorial|'
468
+ r'passaggi|step|pros?|contro|list|compare|explain|differences?|advantages?|'
469
+ r'disadvantages?|guide|steps?|pros?|cons?|'
470
+ r'riassumi|riassunto|summarize|summary|overview|panoramica|'
471
+ r'tabella|table|sezioni|sections|categorizza|categorie|'
472
+ r'elencami|dammi una lista|i migliori|le migliori|i principali|le principali|'
473
+ r'tipi di|types\s+of|examples?\s+of|esempi\s+di|'
474
+ r'struttura|structure|breakdown|analisi|analysis)\b',
475
+ re.IGNORECASE,
476
+ )
477
+
478
+ # S-FMT-MOBILE: regex per direttive specializzate iPhone (2026-06-12)
479
+ _RESEARCH_GOAL_RE = re.compile(
480
+ r'\b(ricerca|research|trova\s+info|notizie|news|articoli|fonti|'
481
+ r'find\s+information|when\s+did|storia\s+di|'
482
+ r'ultima\s+notizia|latest\s+on|trending|fact.?check)\b',
483
+ re.IGNORECASE,
484
+ )
485
+ _DEBUG_GOAL_RE = re.compile(
486
+ r'\b(errore|error|traceback|exception|stacktrace|stack\s+trace|'
487
+ r'debug|debugga|crash|fallisce|non\s+funziona|si\s+rompe|'
488
+ r'undefined\s+is\s+not|cannot\s+read|type\s+error|runtime\s+error|'
489
+ r'fix\s+(?:the\s+)?(?:bug|error|crash|issue|problem))\b',
490
+ re.IGNORECASE,
491
+ )
492
+ _MEDIA_GOAL_RE = re.compile(
493
+ r'\b(genera\s+immagine|generate\s+image|crea\s+immagine|create\s+image|'
494
+ r'immagine\s+di|image\s+of|foto\s+di|picture\s+of|'
495
+ r'disegna|draw|illustra|illustrate|render|genera\s+foto|'
496
+ r'flux|dall.?e|midjourney|stable\s+diffusion|pollinations)\b',
497
+ re.IGNORECASE,
498
+ )
499
+ _MOBILE_COMPACT_RE = re.compile(
500
+ r'\b(quick|veloce|breve|dimmi\s+solo|solo\s+il\s+risultato|verdetto|'
501
+ r'in\s+breve|tl;?dr|sintesi\s+rapida|recap|riassumi\s+in\s+poche|'
502
+ r'risposta\s+breve|brief\s+answer|short\s+answer)\b',
503
+ re.IGNORECASE,
504
+ )
505
+ # GAP-ROUT: routing specializzato per benchmark (SQL, Reasoning, MMLU)
506
+ _SQL_GOAL_RE = re.compile(
507
+ r'\b(sql|postgresql|cte ricorsiva|recursive cte|with recursive|'
508
+ r'window functions?|over\(|partition by|rank\(|row_number\(|'
509
+ r'gerarchia|parent_id|manager_id|recursive)\b',
510
+ re.IGNORECASE,
511
+ )
512
+ _REASONING_GOAL_RE = re.compile(
513
+ r'\b(reasoning|gsm8k|math|matematica|logica|ragionamento|'
514
+ r'ted the t-rex|calcola|calcolare|probabilit|bayes|frazioni|percentuale)\b',
515
+ re.IGNORECASE,
516
+ )
517
+ _MMLU_GOAL_RE = re.compile(
518
+ r'\b(mmlu|computer science|informatica|architettura|os|networking|'
519
+ r'database|complessità|p vs np|modello osi|acid properties)\b',
520
+ re.IGNORECASE,
521
+ )
522
+ # S-FMT-ORCH: fast-fix detector per bypass ARCHITECT su singola operazione (<180 chars)
523
+ # B1: espansa con 10 operazioni atomiche — guardata da len(goal)<180 nel chiamante.
524
+ # Conseguenze: skip ARCHITECT (-15s) per operazioni single-step unambiguamente chiare.
525
+ # Zero cons: la guard len<180 esclude goal multi-step; il piano sintetico è sufficiente.
526
+ _FAST_FIX_RE = re.compile(
527
+ r'\b(typos?|rinomina\s+\w|rename\s+\w|'
528
+ r'cambia\s+.{1,40}\s+in\s+|change\s+.{1,40}\s+to\s+|'
529
+ r'sostituisci\s+.{1,40}\s+con\s+|replace\s+.{1,40}\s+with\s+|'
530
+ r'aggiungi\s+commento|add\s+comment|correggi\s+il\s+typo|fix\s+typo|'
531
+ r'rimuovi\s+riga|delete\s+line|bump\s+version|update\s+version\s+to|'
532
+ # Fix-4.2: import banali non hanno bisogno di 10-15s Architect
533
+ r'aggiungi\s+import|add\s+import|manca\s+import|import\s+missing|'
534
+ r'install\s+package|installa\s+pacchetto|aggiungi\s+dipendenza|'
535
+ # B1-a: parametri / campi / proprietà — operazioni single-step su firme
536
+ r'aggiungi\s+(?:un\s+)?(?:parametro|param|argomento|campo|field|propert\w+)\b|'
537
+ r'rimuovi\s+(?:il\s+|la\s+|questo\s+|questa\s+)?(?:parametro|param|campo|field|prop|propert\w+)\b|'
538
+ # B1-b: tipi / annotazioni — aggiunta tipo/interface senza logica
539
+ r'aggiungi\s+(?:il\s+|un\s+)?(?:tipo|type|annotation|return\s+type|tipo\s+di\s+ritorno)\b|'
540
+ r'(?:aggiorna|update)\s+(?:il\s+)?(?:tipo|type|interface|schema|signature)\b|'
541
+ # B1-c: export — aggiunta/rimozione senza logica
542
+ r'aggiungi\s+(?:il\s+|un\s+)?(?:export|export\s+default|default\s+export)\b|'
543
+ r'rimuovi\s+(?:il\s+)?(?:export|default\s+export)\b|'
544
+ # B1-d: log / debug — aggiungi o rimuovi singola istruzione
545
+ r'aggiungi\s+(?:un\s+)?(?:console\.log|print|log|debug)\b|'
546
+ r'(?:togli|elimina|cancella|rimuovi)\s+(?:il\s+|i\s+)?(?:console\.log|log\s+di\s+debug|debug\s+log|print)\b|'
547
+ # B1-e: formattazione / indentazione — nessuna logica, solo stile
548
+ r'correggi\s+(?:l[a\']?\s+)?(?:indentazione|indent|spaziatura|spacing|formatt\w+)\b|'
549
+ r'(?:formatta|format)\s+(?:il\s+)?(?:codice|file|questo)\b|'
550
+ # B1-f: conversioni semplici — async/arrow/const senza cambiare logica
551
+ r'(?:converti|convert)\s+.{1,50}\s+(?:in|to|a)\s+(?:async|arrow\s+function|const|let)\b)\b',
552
+ re.IGNORECASE,
553
+ )
554
+
555
+ # B5: pure-explanation bypass — nessun tool, LLM diretto (risparmio -20-30s).
556
+ # Fix storia: cos'?[eè] (char class ['è] era sbagliata: matchava 1 char, non seq 'è).
557
+ # Fail-open: 4 guard — len<300, pattern, no action verb, no file ref.
558
+ _PURE_EXPLANATION_RE = re.compile(
559
+ r"^\s*(?:"
560
+ r"(?:cos'?[e\xe8]\s+)"
561
+ r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
562
+ r"|(?:spiega(?:mi)?\b)"
563
+ r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
564
+ r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
565
+ r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
566
+ r"|(?:cosa\s+(?:fa|significa|vuol\s+dire|rappresenta)\s+)"
567
+ r"|(?:a\s+cosa\s+serve\s+)"
568
+ r"|(?:perch[e\xe8]\s+(?:si\s+usa|viene\s+usato|[e\xe8]\s+utile|esiste)\s+)"
569
+ r"|(?:what\s+(?:is|are|does|means?)\s+(?!my\b|the\s+output\b|this\s+code\b))"
570
+ r"|(?:how\s+does\s+(?!my\b|this\b|the\s+code\b|it\s+work\s+in\b))"
571
+ r"|(?:explain\s+(?:me\s+)?(?:briefly\s+)?(?:what|how|why|the)\s+)"
572
+ r")",
573
+ re.IGNORECASE | re.DOTALL,
574
+ )
575
+ _EXPL_REALTIME_RE = re.compile(
576
+ r"\b(oggi|adesso|ora|live|real.?time|notizie|news|ultime|recenti|"
577
+ r"aggiornamenti|previsioni|meteo|prezzo|quotazione|borsa|trend)\b",
578
+ re.IGNORECASE,
579
+ )
580
+ _EXPL_TUTORIAL_RE = re.compile(
581
+ r"^\s*spiega(?:mi)?\b.{0,80}?\b(?:e\s+)?poi\s+"
582
+ r"(?:indica|descrivi|elenca)\s+(?:i\s+)?(?:passaggi|step)\s+"
583
+ r"(?:per\s+)?(?:modificare|correggere|configurare|aggiornare)\b",
584
+ re.IGNORECASE | re.DOTALL,
585
+ )
586
+ _EXPL_CONTEXTUAL_ACTION_REF_RE = re.compile(
587
+ r"\b(?:dopo|prima|durante|in seguito a|a seguito di)\s+una\s+modifica\s+"
588
+ r"(?:al|del|nel)\s+(?:codice|file|progetto)\b",
589
+ re.IGNORECASE,
590
+ )
591
+ _EXPL_ACTION_RE = re.compile(
592
+ r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
593
+ r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
594
+ r"aggiungi|rimuovi|modifica|refactor|build|pubblica|manda|invia|upload|"
595
+ r"poi|then|e\s+poi|and\s+then|quindi|dopo|successivamente)\b",
596
+ re.IGNORECASE,
597
+ )
598
+ _EXPL_FILE_REF_RE = re.compile(
599
+ r"\b(nel\s+codice|in\s+questo\s+file|nel\s+file|nel\s+progetto|"
600
+ r"qui\s+sopra|il\s+codice\s+che|in\s+the\s+code|"
601
+ r"in\s+this\s+file|this\s+function|questa\s+funzione)\b",
602
+ re.IGNORECASE,
603
+ )
604
+
agents/unified_loop_prompts.py ADDED
The diff for this file is too large to render. See raw diff
 
agents/unified_loop_tools.py ADDED
@@ -0,0 +1,779 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_tools.py — DirectToolsMixin: tool execution layer.
2
+ Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
3
+ Contiene (nell'ordine originale del file):
4
+ - Regex class attrs: meteo, URL, ricerca, immagini, calcolo
5
+ - Helper: _extract_city / _extract_search_query / _extract_calc_expr
6
+ - _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
7
+ - _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
8
+ - _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
9
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
10
+ Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
11
+ """
12
+ from __future__ import annotations
13
+ import asyncio
14
+ import hashlib
15
+ import os
16
+ import re
17
+ from typing import Any
18
+ import logging
19
+ try:
20
+ from api.state import record_timing as _rtc_global # telemetria tool call
21
+ except ImportError:
22
+ _rtc_global = None # state module non ancora disponibile al boot
23
+
24
+ _logger = logging.getLogger("agents.unified_loop_tools")
25
+ # StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
26
+ # S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
27
+ from agents.unified_loop_types import StepCallback, _maybe_await
28
+ from agents.file_conversion import convert_csv_attachment_to_json, validate_csv_json_equivalence
29
+ class DirectToolsMixin:
30
+ # ── Direct tool execution (S193) ─────────────────────────────────────────
31
+ # Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
32
+ # Deterministico, veloce, testabile. Restituisce i risultati come stringa
33
+ # pronta per essere iniettata nel prompt LLM.
34
+ _WEATHER_INTENT_RE = re.compile(
35
+ # S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
36
+ # S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
37
+ r"\b(meteo|temperatura|temperature|temp\s*a\b|clima|weather|previsioni|forecast|"
38
+ r"che\s+tempo\s+fa|quanto\s+fa\s+freddo|quanto\s+fa\s+caldo|gradi\s+a\b|"
39
+ r"piove|sta\s+piovendo|nevica|neve|pioggia|temporale|grandine|"
40
+ r"nebbia|umidità|vento|allerta\s+meteo|allerta\s+rossa|allerta\s+arancione|"
41
+ r"ondata\s+di\s+caldo|ondata\s+di\s+freddo|gelate|gelo|"
42
+ r"rain|raining|snow|snowing|fog|humid|wind|windy|storm|thunderstorm|hail|"
43
+ r"sunny|cloudy|overcast|uv\s+index|heat\s+wave|cold\s+snap|frost)\b",
44
+ re.IGNORECASE,
45
+ )
46
+ # S385: improved city extraction — catches bare patterns like "che tempo fa a Roma?"
47
+ # S390-B-O: aggiunti trigger 'temperature\s+in' e 'weather\s+(?:forecast\s+)?(?:in|at|for)'
48
+ _CITY_RE = re.compile(
49
+ r"(?:meteo|temperatura|temperature|temp(?:eratura)?\s+(?:a|in)|"
50
+ r"(?:che\s+)?tempo\s+(?:\w+\s+){0,2}(?:fa\s+)?(?:a|in)|"
51
+ r"com['\u2019]è\s+il\s+tempo\s+a|com['\u2019]è\s+il\s+meteo\s+a|"
52
+ r"clima\s+(?:a|in)|weather\s+(?:forecast\s+)?(?:in|at|for)|"
53
+ r"temperature\s+(?:in|at|for)|forecast\s+(?:for|in)|"
54
+ r"previsioni\s+(?:per|a|in)|gradi\s+(?:a|in))"
55
+ r"\s+(?:a\s+|in\s+|per\s+)?([A-Za-z\xc0-\xff][A-Za-z\xc0-\xff\s]{1,25}?)"
56
+ # S390-B-I: aggiunti terminatori inglesi (today/now/tomorrow/currently/right now)
57
+ r"(?:\?|$|\s*[,\.]|\s+adesso|\s+ora|\s+oggi|\s+domani|\s+attuale|\s+corrente"
58
+ r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
59
+ re.IGNORECASE,
60
+ )
61
+ _URL_RE = re.compile(r"https?://[^\s\)\}\]>]+", re.IGNORECASE)
62
+ _SEARCH_INTENT_RE = re.compile(
63
+ r"\b(cerca|search|trova|find|googla|google|duckduckgo|bing|research|investiga|indaga|"
64
+ r"fammi\s+sapere|dimmi\s+di\s+più\s+su|informazioni\s+su|info\s+su|news\s+su|notizie\s+su|"
65
+ r"chi\s+è|cos['\u2019]è|dove\s+si\s+trova|quando\s+è\s+successo|perché\s+il|storia\s+di|"
66
+ r"tell\s+me\s+about|who\s+is|what\s+is|where\s+is|when\s+did|why\s+is|history\s+of|"
67
+ r"latest\s+on|ultime\s+su|prezzo\s+di|valore\s+di|quotazione\s+di|stock\s+price\s+of|"
68
+ r"crypto|bitcoin|ethereum|market\s+cap|capitalizzazione)\b",
69
+ re.IGNORECASE,
70
+ )
71
+ # Un verbo generico come «crea» non autorizza un artefatto visivo: richiedi
72
+ # un output immagine esplicito, oppure un verbo artistico inequivoco. Questo
73
+ # evita che «Crea un piano…» diventi una generazione Pollinations/VFS.
74
+ _IMAGE_INTENT_RE = re.compile(
75
+ r"(?:\b(?:genera|crea|fai|mostra|visualizza|produce|generate|create|make)\b.{0,40}\b"
76
+ r"(?:immagine|foto|illustrazione|ritratto|paesaggio|logo|icona|disegno|grafica|"
77
+ r"image|photo|picture|illustration|portrait|landscape|drawing|graphic|art|artwork)\b)"
78
+ r"|(?:^\s*(?:disegna|illustra|dipingi|render|paint|sketch)\b"
79
+ r"(?!\s+(?:(?:un|una|il|lo|la|the|a|an)\s+)?(?:diagramma|grafico|chart|schema|ui|ux|figma)\b))",
80
+ re.IGNORECASE,
81
+ )
82
+ _CALC_INTENT_RE = re.compile(
83
+ r"\b(calcola|quanto\s+fa|risultato\s+di|compute|calculate|math|matematica|operazione|"
84
+ r"somma|sottrai|moltiplica|dividi|percentuale|radice|potenza|"
85
+ r"sum|add|subtract|multiply|divide|percentage|root|power)\b",
86
+ re.IGNORECASE,
87
+ )
88
+ def _extract_city(self, goal: str) -> str:
89
+ m = self._CITY_RE.search(goal)
90
+ if m:
91
+ candidate = m.group(1).strip()
92
+ if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
93
+ return candidate
94
+ return "."
95
+ def _extract_search_query(self, goal: str) -> str:
96
+ q = re.sub(self._SEARCH_INTENT_RE, "", goal, flags=re.IGNORECASE).strip()
97
+ return q or goal
98
+ def _extract_calc_expr(self, goal: str) -> str:
99
+ m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', goal)
100
+ return m.group(0).strip() if m else ""
101
+
102
+ def _extract_dir_path(self, goal: str) -> str:
103
+ """Extract a safe relative directory path, defaulting to the tool root."""
104
+ m = re.search(
105
+ r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
106
+ r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
107
+ goal, re.IGNORECASE,
108
+ )
109
+ if m:
110
+ candidate = m.group(1).strip().rstrip("/")
111
+ if candidate not in {"di", "in", "nel", "nella"}:
112
+ return candidate
113
+ return "."
114
+
115
+ def _extract_file_pattern(self, goal: str) -> str:
116
+ """Extract the search pattern without changing the registry's FS jail."""
117
+ m = re.search(
118
+ r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
119
+ r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
120
+ goal, re.IGNORECASE,
121
+ )
122
+ return m.group(1).strip() if m else ""
123
+
124
+ def _extract_git_cwd(self, goal: str) -> str:
125
+ """Extract the requested git working directory, defaulting to root."""
126
+ m = re.search(
127
+ r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
128
+ goal, re.IGNORECASE,
129
+ )
130
+ if m:
131
+ candidate = m.group(1).strip()
132
+ if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
133
+ return candidate
134
+ return "."
135
+ async def _run_direct_tools(
136
+ self,
137
+ goal: str,
138
+ on_step: StepCallback | None = None,
139
+ *,
140
+ local_csv_only: bool = False,
141
+ ) -> tuple[str, int, int, int]:
142
+ """
143
+ S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
144
+ Returns: 4-tuple (results_str, n_called, n_success, n_errors).
145
+ results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
146
+ n_called: numero totale di tool chiamati
147
+ n_success: numero di tool completati con successo
148
+ n_errors: numero di tool falliti
149
+ """
150
+ # FIX-TOOL-01: usare i package reali del backend; i moduli indicati dal
151
+ # precedente restore non esistono e interrompevano il direct-tools layer.
152
+ from tools.registry import TOOL_REGISTRY
153
+ from api.speculative import get_speculative_result as _speculative_result
154
+
155
+ results: list[str] = []
156
+ n_called = 0
157
+ n_success = 0
158
+ n_errors = 0
159
+ TOOL_TIMEOUT = 25
160
+
161
+ # Governor per singolo run: conserva budget adattivo e deduplicazione.
162
+ _gov_called: set[str] = set()
163
+ _gov_total = 0
164
+ _tok_budget_gov = self._max_tokens_for_goal(goal)
165
+ _gov_max_calls = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
166
+
167
+ def _gov_check(tool_name: str, key_arg: str) -> bool:
168
+ nonlocal _gov_total
169
+ if _gov_total >= _gov_max_calls:
170
+ return False
171
+ signature = f"{tool_name}:{key_arg[:150]}"
172
+ if signature in _gov_called:
173
+ return False
174
+ _gov_called.add(signature)
175
+ _gov_total += 1
176
+ return True
177
+
178
+ def _spec_hit(tool_name: str, args: dict[str, Any]) -> str | None:
179
+ try:
180
+ return _speculative_result(goal, tool_name, args)
181
+ except Exception:
182
+ # Cache speculativa opzionale: mai bloccare l'esecuzione reale.
183
+ return None
184
+
185
+ # S419: esegui i tool eligible in parallelo con asyncio.gather
186
+ # Pre-check intent (sincrono) → costruisce lista coroutine → gather
187
+ url_m = self._URL_RE.search(goal)
188
+ async def _t_get_weather() -> str | None:
189
+ if not self._WEATHER_INTENT_RE.search(goal):
190
+ return None
191
+ city = self._extract_city(goal)
192
+ if not _gov_check("get_weather", city):
193
+ return None
194
+ try:
195
+ if on_step:
196
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
197
+ "title": "Meteo", "explanation": f"Recupero meteo per {city}…"}))
198
+ _sc = _spec_hit("get_weather", {"city": city})
199
+ if _sc is not None:
200
+ return _sc
201
+ _t0 = asyncio.get_event_loop().time()
202
+ r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
203
+ try:
204
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
205
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
206
+ if "temp_c" in r:
207
+ _wdesc = {
208
+ 0: "cielo sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso", 3: "coperto",
209
+ 45: "nebbia", 48: "nebbia con brina", 51: "pioviggine leggera", 53: "pioviggine moderata",
210
+ 55: "pioviggine intensa", 61: "pioggia leggera", 63: "pioggia moderata", 65: "pioggia forte",
211
+ 71: "nevicata leggera", 73: "nevicata moderata", 75: "nevicata forte", 80: "rovesci leggeri",
212
+ 81: "rovesci moderati", 82: "rovesci violenti", 95: "temporale", 96: "temporale con grandine",
213
+ }
214
+ wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
215
+ try:
216
+ desc = _wdesc.get(int(wcode), f"codice {wcode}") if wcode is not None else "N/D"
217
+ except (TypeError, ValueError):
218
+ desc = "N/D"
219
+ return (
220
+ f"[METEO REALE — {r['city']}, {r.get('country', '')}]\n"
221
+ f"Temperatura attuale: {f'{temp_c}°C' if temp_c is not None else 'N/D'}\n"
222
+ f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
223
+ f"Condizioni: {desc}"
224
+ )
225
+ return f"[get_weather: errore — {r['error'][:300]}]"
226
+ except asyncio.TimeoutError:
227
+ return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
228
+ except Exception as exc:
229
+ return f"[get_weather: errore — {str(exc)[:300]}]"
230
+ async def _t_read_page() -> str | None:
231
+ if not url_m:
232
+ return None
233
+ url = url_m.group(0)
234
+ if not _gov_check("read_page", url):
235
+ return None
236
+ try:
237
+ _sc = _spec_hit("read_page", {"url": url})
238
+ if _sc is not None:
239
+ return _sc
240
+ if on_step:
241
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
242
+ "title": "Lettura pagina", "explanation": f"Leggo {url[:60]}…"}))
243
+ _t0 = asyncio.get_event_loop().time()
244
+ r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
245
+ try:
246
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
247
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
248
+ if r.get("content"):
249
+ return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
250
+ return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]"
251
+ except asyncio.TimeoutError:
252
+ return f"[read_page: timeout {TOOL_TIMEOUT}s]"
253
+ except Exception as exc:
254
+ return f"[read_page: errore — {str(exc)[:300]}]"
255
+ async def _t_calculate() -> str | None:
256
+ if url_m or not self._CALC_INTENT_RE.search(goal):
257
+ return None
258
+ expr = self._extract_calc_expr(goal)
259
+ if not expr or not _gov_check("calculate", expr):
260
+ return None
261
+ try:
262
+ if on_step:
263
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
264
+ "title": "Calcolo", "explanation": f"Calcolo: {expr[:60]}"}))
265
+ _sc = _spec_hit("calculate", {"expression": expr})
266
+ if _sc is not None:
267
+ return _sc
268
+ _t0 = asyncio.get_event_loop().time()
269
+ r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
270
+ try:
271
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
272
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
273
+ if "result" in r:
274
+ return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
275
+ return f"[calculate: errore — {r.get('error', '?')[:300]}]"
276
+ except asyncio.TimeoutError:
277
+ return "[calculate: timeout]"
278
+ except Exception as exc:
279
+ return f"[calculate: errore — {str(exc)[:300]}]"
280
+ async def _t_web_search() -> str | None:
281
+ if not self._SEARCH_INTENT_RE.search(goal):
282
+ return None
283
+ query = self._extract_search_query(goal)
284
+ if not query or not _gov_check("web_search", query):
285
+ return None
286
+ try:
287
+ if on_step:
288
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
289
+ "title": "Ricerca web", "explanation": f"Cerco: {query[:60]}…"}))
290
+ _sc = _spec_hit("web_search", {"query": query})
291
+ if _sc is not None:
292
+ return _sc
293
+ _t0 = asyncio.get_event_loop().time()
294
+ r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
295
+ try:
296
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
297
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
298
+ hits = r.get("results", [])
299
+ if hits:
300
+ _out = [f"[RICERCA WEB REALE: {query}]"]
301
+ for h in hits:
302
+ _out.append(f"• {h['title']} ({h['url']}): {h['snippet']}")
303
+ return "\n".join(_out)
304
+ return f"[web_search: nessun risultato per '{query}']"
305
+ except asyncio.TimeoutError:
306
+ return f"[web_search: timeout {TOOL_TIMEOUT}s]"
307
+ except Exception as exc:
308
+ return f"[web_search: errore — {str(exc)[:300]}]"
309
+ async def _t_convert_csv_attachment() -> str | None:
310
+ conversion = convert_csv_attachment_to_json(goal)
311
+ if conversion is None:
312
+ return None
313
+ if not _gov_check("convert_csv_to_json", conversion.target_name):
314
+ return None
315
+
316
+ # Il successo diretto è consentito solo dopo il confronto semantico
317
+ # record-per-record. Questo blocca cataloghi generici/allucinati prima
318
+ # che il loop possa dichiarare una conversione corretta.
319
+ is_valid, validation_error = validate_csv_json_equivalence(
320
+ conversion.source_content,
321
+ conversion.content,
322
+ )
323
+ if not is_valid:
324
+ return f"[convert_csv_to_json: validazione fallita — {validation_error}]"
325
+
326
+ async def _write(path: str, content: str) -> dict[str, Any]:
327
+ return await asyncio.wait_for(
328
+ TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
329
+ timeout=TOOL_TIMEOUT,
330
+ )
331
+
332
+ try:
333
+ if on_step:
334
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
335
+ "title": "Conversione CSV in JSON",
336
+ "explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
337
+ _t0 = asyncio.get_event_loop().time()
338
+
339
+ # Per il CSV inline il goal richiede esplicitamente entrambi gli
340
+ # artefatti. Gli allegati conservano il comportamento esistente:
341
+ # viene scritto soltanto il JSON, poiché la fonte è già disponibile.
342
+ written_paths: list[str] = []
343
+ if conversion.source_is_inline:
344
+ source_written = await _write(conversion.source_name, conversion.source_content)
345
+ if not source_written.get("ok"):
346
+ return f"[convert_csv_to_json: errore sorgente — {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
347
+ written_paths.append(conversion.source_name)
348
+ if on_step:
349
+ await _maybe_await(on_step({
350
+ "action": "file_written", "status": "done",
351
+ "path": conversion.source_name, "content": conversion.source_content,
352
+ "title": "File CSV creato",
353
+ "explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
354
+ }))
355
+
356
+ target_written = await _write(conversion.target_name, conversion.content)
357
+ if not target_written.get("ok"):
358
+ return f"[convert_csv_to_json: errore JSON — {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
359
+ written_paths.append(conversion.target_name)
360
+ if on_step:
361
+ await _maybe_await(on_step({
362
+ "action": "file_written", "status": "done",
363
+ "path": conversion.target_name, "content": conversion.content,
364
+ "title": "File JSON creato",
365
+ "explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
366
+ }))
367
+ try:
368
+ from api.state import record_timing as _rtc
369
+ _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
370
+ except Exception as _e:
371
+ _logger.debug('[timing/record_timing] %s', _e)
372
+ paths = ", ".join(f"`{path}`" for path in written_paths)
373
+ return (
374
+ "[DIRECT_TERMINAL]\n"
375
+ f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
376
+ f"e `{conversion.target_name}`.\n\n"
377
+ f"File workspace salvati: {paths}."
378
+ )
379
+ except asyncio.TimeoutError:
380
+ return "[convert_csv_to_json: timeout]"
381
+ except Exception as exc:
382
+ return f"[convert_csv_to_json: errore — {str(exc)[:300]}]"
383
+
384
+ async def _t_generate_image() -> str | None:
385
+ if not self._IMAGE_INTENT_RE.search(goal):
386
+ return None
387
+ _img_prompt = re.sub(
388
+ r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
389
+ "", goal, flags=re.IGNORECASE
390
+ ).strip() or goal
391
+ if not _gov_check("generate_image", _img_prompt):
392
+ return None
393
+ try:
394
+ if on_step:
395
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
396
+ "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
397
+ _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
398
+ if _sc is not None:
399
+ return _sc
400
+ _img_prompt = _img_prompt[:600]
401
+ # Il provider autenticato rimane lato server. Il fallback storico
402
+ # resta solo per garantire la creazione gratuita se il secret non è
403
+ # ancora disponibile durante un riavvio del runtime.
404
+ try:
405
+ from api.image_provider import generate_pollinations_image
406
+ remote = await generate_pollinations_image(_img_prompt, width=512, height=512)
407
+ img_url = remote.url
408
+ img_mime = remote.mime_type
409
+ except Exception as provider_exc:
410
+ _logger.info("image provider unavailable; using free URL fallback (%s)", type(provider_exc).__name__)
411
+ from urllib.parse import quote
412
+ _img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
413
+ img_url = (
414
+ f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
415
+ f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
416
+ )
417
+ img_mime = "image/jpeg"
418
+ _artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
419
+ _artifact_path = f"generated-image-{_artifact_id}.jpg"
420
+ if on_step:
421
+ await _maybe_await(on_step({
422
+ "action": "file_written",
423
+ "status": "done",
424
+ "path": _artifact_path,
425
+ "source_url": img_url,
426
+ "mime_type": img_mime,
427
+ "title": "Immagine salvata nel workspace",
428
+ "explanation": f"Salvo {_artifact_path} nel VFS…",
429
+ }))
430
+ return (
431
+ "[DIRECT_TERMINAL]\n"
432
+ f"![Immagine generata]({img_url})\n\n"
433
+ "E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
434
+ f"[Apri o scarica l’immagine]({img_url}).\n\n"
435
+ f"File VFS: `{_artifact_path}`\n"
436
+ f"Prompt usato: {_img_prompt[:200]}\n"
437
+ "Dimensioni: 512x512 px"
438
+ )
439
+ except asyncio.TimeoutError:
440
+ return "[generate_image: timeout — provider non raggiungibile]"
441
+ except Exception as exc:
442
+ return f"[generate_image: errore — {str(exc)[:300]}]"
443
+ async def _t_run_python() -> str | None:
444
+ _RUN_CODE_RE = re.compile(
445
+ r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
446
+ r"execute\s+(?:python\s+)?code|lancia\s+(?:il\s+)?codice|"
447
+ r"esegui\s+(?:questo\s+|il\s+)?(?:script|programma))\b",
448
+ re.IGNORECASE,
449
+ )
450
+ if not _RUN_CODE_RE.search(goal):
451
+ return None
452
+ _code_m = re.search(r"[::]\s*(.+)$", goal, re.DOTALL)
453
+ _code = _code_m.group(1).strip() if _code_m else goal
454
+ _code = re.sub(r"^```(?:python)?\s*|\s*```$", "", _code.strip(), flags=re.DOTALL).strip()
455
+ if not _code or len(_code) <= 3 or not _gov_check("run_python", _code[:80]):
456
+ return None
457
+ try:
458
+ if on_step:
459
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
460
+ "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
461
+ _sc = _spec_hit("run_python", {"code": _code[:400]})
462
+ if _sc is not None:
463
+ return _sc
464
+ _t0 = asyncio.get_event_loop().time()
465
+ r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
466
+ try:
467
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
468
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
469
+ if r.get("returncode", -1) == 0 and r.get("stdout"):
470
+ _out = (
471
+ "[CODICE PYTHON ESEGUITO]\n"
472
+ f"```python\n{_code[:500]}\n```\n"
473
+ f"Output:\n```\n{r['stdout'][:1500]}\n```"
474
+ )
475
+ return _out
476
+ return f"[run_python: errore — {r.get('stderr', 'ignoto')[:300]}]"
477
+ except asyncio.TimeoutError:
478
+ return "[run_python: timeout 18s]"
479
+ except Exception as exc:
480
+ return f"[run_python: errore — {str(exc)[:300]}]"
481
+ async def _t_web_research() -> str | None:
482
+ _RESEARCH_RE = re.compile(r"\b(ricerca\s+approfondita|deep\s+research|investigazione|analisi\s+dettagliata)\b", re.IGNORECASE)
483
+ if not _RESEARCH_RE.search(goal):
484
+ return None
485
+ query = self._extract_search_query(goal)
486
+ if not _gov_check("web_research", query):
487
+ return None
488
+ try:
489
+ if on_step:
490
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
491
+ "title": "Ricerca approfondita", "explanation": f"Analisi dettagliata su: {query[:60]}…"}))
492
+ _t0 = asyncio.get_event_loop().time()
493
+ r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](query=query), timeout=45)
494
+ try:
495
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
496
+ except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
497
+ if r.get("report"):
498
+ return f"[RICERCA APPROFONDITA REALE: {query}]\n\n{r['report'][:4000]}"
499
+ return f"[web_research: errore — {r.get('error', 'nessun report')[:300]}]"
500
+ except asyncio.TimeoutError:
501
+ return "[web_research: timeout 45s]"
502
+ except Exception as exc:
503
+ return f"[web_research: errore — {str(exc)[:300]}]"
504
+ async def _t_directory_tree() -> str | None:
505
+ _TREE_RE = re.compile(r"\b(albero|struttura|directory\s+tree|files?|cartell[ae])\b", re.IGNORECASE)
506
+ if not _TREE_RE.search(goal):
507
+ return None
508
+ _path = self._extract_dir_path(goal)
509
+ if not _gov_check("directory_tree", _path):
510
+ return None
511
+ try:
512
+ if on_step:
513
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
514
+ "title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
515
+ r = await asyncio.wait_for(
516
+ TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
517
+ )
518
+ if r.get("ok") and r.get("tree"):
519
+ return f"[STRUTTURA PROGETTO REALE: '{_path}']\n{r['tree'][:2000]}"
520
+ return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
521
+ except Exception as exc:
522
+ return f"[directory_tree: errore — {str(exc)[:200]}]"
523
+ async def _t_file_search() -> str | None:
524
+ _SEARCH_RE = re.compile(r"\b(cerca\s+file|find\s+file|grep)\b", re.IGNORECASE)
525
+ if not _SEARCH_RE.search(goal):
526
+ return None
527
+ _pattern = self._extract_file_pattern(goal)
528
+ if not _pattern or not _gov_check("file_search", _pattern):
529
+ return None
530
+ _search_path = self._extract_dir_path(goal)
531
+ try:
532
+ if on_step:
533
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
534
+ "title": "Ricerca file", "explanation": f"Cerco '{_pattern}' nel codice…"}))
535
+ r = await asyncio.wait_for(
536
+ TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
537
+ )
538
+ if r.get("ok"):
539
+ _matches = r.get("matches", [])
540
+ _out = [f"[FILE TROVATI: pattern='{_pattern}', {r.get('count', len(_matches))} occorrenze]"]
541
+ for match in _matches[:20]:
542
+ _out.append(f"{match.get('file', '?')}:{match.get('line', '?')}: {match.get('text', '')[:120]}")
543
+ return "\n".join(_out)
544
+ return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
545
+ except Exception as exc:
546
+ return f"[file_search: errore — {str(exc)[:200]}]"
547
+ async def _t_get_news() -> str | None:
548
+ _NEWS_RE = re.compile(r"\b(news|notizie|ultim[ae]\s+ora|breaking)\b", re.IGNORECASE)
549
+ if not _NEWS_RE.search(goal):
550
+ return None
551
+ query = self._extract_search_query(goal)
552
+ try:
553
+ if on_step:
554
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
555
+ "title": "Notizie", "explanation": f"Cerco notizie su: {query[:60]}…"}))
556
+ r = await asyncio.wait_for(TOOL_REGISTRY["get_news"]["_fn"](query=query), timeout=15)
557
+ if r.get("news"):
558
+ _out = [f"[NOTIZIE REALI: {query}]"]
559
+ for n in r["news"][:5]:
560
+ _out.append(f"• {n['title']} ({n.get('source', '?')}): {n.get('description', '')[:150]}")
561
+ return "\n".join(_out)
562
+ return "[get_news: nessuna notizia trovata]"
563
+ except Exception as exc:
564
+ return f"[get_news: errore — {str(exc)[:200]}]"
565
+ async def _t_git_status() -> str | None:
566
+ _GIT_RE = re.compile(r"\b(git|status|commit|branch|repo)\b", re.IGNORECASE)
567
+ if not _GIT_RE.search(goal):
568
+ return None
569
+ _cwd = self._extract_git_cwd(goal)
570
+ if not _gov_check("git_status", _cwd):
571
+ return None
572
+ try:
573
+ if on_step:
574
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
575
+ "title": "Stato Git", "explanation": f"Controllo la repo in {_cwd}…"}))
576
+ r = await asyncio.wait_for(
577
+ TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
578
+ )
579
+ if r.get("ok"):
580
+ _out = [f"[STATO GIT REALE (branch: {r.get('branch', '?')})]"]
581
+ if r.get("status"):
582
+ _out.append(f"File modificati:\n{r['status'][:600]}")
583
+ if r.get("log"):
584
+ _out.append(f"Ultimi commit:\n{r['log'][:400]}")
585
+ return "\n".join(_out)
586
+ return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
587
+ except Exception as exc:
588
+ return f"[git_status: errore — {str(exc)[:200]}]"
589
+ async def _t_analyze_python() -> str | None:
590
+ # P30-B1: Analisi statica Python integrata nel tool layer
591
+ if not self._ANALYZE_PY_RE.search(goal):
592
+ return None
593
+ _code = ""
594
+ _m = self._PY_BLOCK_IN_GOAL_RE.search(goal)
595
+ if _m: _code = _m.group(1).strip()
596
+ if not _code: return None
597
+ try:
598
+ if on_step:
599
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
600
+ "title": "Analisi codice Python", "explanation": "Controllo sintassi e best practices…"}))
601
+ from scripts.gap_map import analyze_python_code as _apc
602
+ r = await asyncio.wait_for(_apc(_code), timeout=15)
603
+ _out = ["[ANALISI PYTHON REALE]"]
604
+ if r.get("errors"):
605
+ _out.append("❌ Errori rilevati:")
606
+ for _e in r["errors"]: _out.append(f" - {_e}")
607
+ else:
608
+ _out.append("✅ Nessun errore di sintassi rilevato.")
609
+ if r.get("suggestions"):
610
+ _out.append("\n💡 Suggerimenti:")
611
+ for _s in r["suggestions"]: _out.append(f" - {_s}")
612
+ return "\n".join(_out)
613
+ except asyncio.TimeoutError:
614
+ return "[python_analyze: timeout]"
615
+ except Exception as _exc:
616
+ return f"[python_analyze: errore — {str(_exc)[:200]}]"
617
+ # Conversione e immagine sono artefatti terminali: eseguirli prima del
618
+ # fan-out evita risultati accessori e, soprattutto, una successiva chiamata LLM.
619
+ _terminal_conversion = await _t_convert_csv_attachment()
620
+ if _terminal_conversion is not None:
621
+ return (_terminal_conversion, 1,
622
+ int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
623
+ int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
624
+ # Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
625
+ # ammessi altri direct tool, né fallback impliciti a immagine/rete.
626
+ if local_csv_only:
627
+ return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
628
+ _terminal_image = await _t_generate_image()
629
+ if _terminal_image is not None:
630
+ return (_terminal_image, 1,
631
+ int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
632
+ int(": errore" in _terminal_image or ": timeout" in _terminal_image))
633
+
634
+ # Esecuzione parallela per i tool non terminali.
635
+ _sem = asyncio.Semaphore(3)
636
+ async def _sem_wrap(coro):
637
+ if coro is None: return None
638
+ async with _sem: return await coro
639
+ _parallel_results = await asyncio.gather(
640
+ _sem_wrap(_t_get_weather()),
641
+ _sem_wrap(_t_read_page()),
642
+ _sem_wrap(_t_calculate()),
643
+ _sem_wrap(_t_web_search()),
644
+ _sem_wrap(_t_run_python()),
645
+ _sem_wrap(_t_web_research()),
646
+ _sem_wrap(_t_directory_tree()),
647
+ _sem_wrap(_t_file_search()),
648
+ _sem_wrap(_t_get_news()),
649
+ _sem_wrap(_t_git_status()),
650
+ _sem_wrap(_t_analyze_python()),
651
+ return_exceptions=True,
652
+ )
653
+ for _pr in _parallel_results:
654
+ if isinstance(_pr, str):
655
+ results.append(_pr)
656
+ # S428 Sprint1-Fix1: Tool Success Contract
657
+ _REAL_DATA_PREFIXES = (
658
+ "[RICERCA WEB REALE", "[METEO REALE", "[PAGINA REALE", "[CALCOLO REALE",
659
+ "[IMMAGINE AI GENERATA", "[DIRECT_TERMINAL]", "[CODICE PYTHON ESEGUITO", "[RICERCA APPROFONDITA REALE",
660
+ "[STRUTTURA PROGETTO REALE", "[RICERCA FILE REALE", "[NOTIZIE REALI",
661
+ "[STATO GIT REALE", "[ANALISI PYTHON REALE"
662
+ )
663
+ for r_str in results:
664
+ n_called += 1
665
+ if any(r_str.startswith(p) for p in _REAL_DATA_PREFIXES):
666
+ n_success += 1
667
+ elif ": errore" in r_str or ": timeout" in r_str:
668
+ n_errors += 1
669
+ return ("\n\n".join(results), n_called, n_success, n_errors)
670
+ # ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
671
+ # A failed live tool must never be represented as a successful live lookup.
672
+ _FALSE_CLAIM_RE = re.compile(
673
+ r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
674
+ r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
675
+ r"i\s+risultati\s+(?:mostrano|indicano|confermano)|"
676
+ r"la\s+ricerca\s+ha\s+(?:trovato|restituito)|"
677
+ r"secondo\s+i\s+risultati|dalle\s+mie\s+ricerche|"
678
+ r"I\s+found|the\s+results?\s+show|based\s+on\s+(?:the\s+)?results?|"
679
+ r"according\s+to\s+(?:the\s+)?(?:search\s+)?results?)\b",
680
+ re.IGNORECASE,
681
+ )
682
+ _REALTIME_GOAL_RE = re.compile(
683
+ r"\b(notizie|news|ultime\s+notizie|cerca|ricerca\s+web|"
684
+ r"weather|meteo|previsioni|temperatura|"
685
+ r"bitcoin|ethereum|cambio\s+valuta|tasso|crypto|"
686
+ r"versione\s+(?:attuale|corrente|recente)|aggiornamenti\s+su|release)\b",
687
+ re.IGNORECASE,
688
+ )
689
+
690
+ @staticmethod
691
+ def _validate_claims(
692
+ response: str,
693
+ n_success: int,
694
+ n_errors: int,
695
+ goal: str,
696
+ false_claim_re: "re.Pattern[str]",
697
+ realtime_goal_re: "re.Pattern[str]",
698
+ ) -> str:
699
+ """Add transparency when failed live tools are presented as successful."""
700
+ if n_success > 0 or n_errors == 0:
701
+ return response
702
+ if not realtime_goal_re.search(goal):
703
+ return response
704
+ if not false_claim_re.search(response):
705
+ return response
706
+ disclaimer = (
707
+ "\n\n---\n"
708
+ "**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
709
+ "raggiungibili durante questa risposta. Le informazioni sopra provengono "
710
+ "dal mio training e potrebbero non essere aggiornate. "
711
+ "Per dati live consulta una fonte ufficiale."
712
+ )
713
+ return response + disclaimer
714
+ _TOOL_NEEDED_RE = re.compile(
715
+ r"\b(meteo|temperatura|weather|forecast|cerca|search|trova|find|googla|google|"
716
+ r"immagine|foto|photo|image|disegna|draw|genera|create|calcola|calculate|math|"
717
+ r"news|notizie|prezzo|quotazione|stock|crypto|bitcoin|albero|struttura|directory|"
718
+ r"file|cartella|grep|python|esegui|run|execute|script|webhook|api|http|zapier|n8n)\b",
719
+ re.IGNORECASE,
720
+ )
721
+ def _needs_tools(self, goal: str) -> bool:
722
+ # S-BENCH-FIX: abbassata soglia a 50 per catturare task di benchmark complessi
723
+ if len(goal) > 50: return True
724
+ if bool(self._TOOL_NEEDED_RE.search(goal)): return True
725
+ # Aggiunto 'benchmark', 'test', 'codice' per forzare tool su task tecnici
726
+ tech_keywords = ['file', 'directory', 'folder', 'script', 'api', 'json', 'data', 'analisi', 'fix', 'bug', 'benchmark', 'test', 'codice']
727
+ if any(kw in goal.lower() for kw in tech_keywords): return True
728
+ # Se sembra un goal di codice, attiva i tool
729
+ if bool(self._CODE_GOAL_RE.search(goal)): return True
730
+ return False
731
+ _SIMPLE_CONV_RE = re.compile(
732
+ r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
733
+ r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
734
+ r"come stai\??|come va\??|stai bene\??|chi sei\??|cosa sei\??|"
735
+ r"sei (?:un[ao']?\s+)?(?:ai|bot|intelligenza artificiale|assistente)\??|"
736
+ r"cosa (?:puoi fare|sai fare)\??|dimmi qualcosa di te|"
737
+ r"bravo|benissimo|magnifico|fantastico|geniale|ottima risposta|"
738
+ r"giusto|corretto|esattamente|d['\u2019]accordo|"
739
+ r"capisco|ho capito|inteso|compreso|ricevuto|"
740
+ r"s[iì] grazie|no grazie|va bene|va benissimo|"
741
+ r"thanks|thank you|ty|thx|great|nice|perfect|exactly|understood|"
742
+ r"got it|sure|right|agreed|makes sense|correct|good|"
743
+ r"good morning|good evening|good night"
744
+ r")\.?\s*[!?]?$",
745
+ re.IGNORECASE,
746
+ )
747
+ _SIMPLE_MATH_RE = re.compile(
748
+ r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
749
+ r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
750
+ r'compute|calculate|what(?:\'s|\s+is)\s+(?:the\s+(?:result\s+of\s+)?)?)\s*)?'
751
+ r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
752
+ re.IGNORECASE,
753
+ )
754
+ _ANALYZE_PY_RE = re.compile(
755
+ r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
756
+ r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
757
+ r"|check\s+(?:my\s+)?(?:python\s+)?(?:code|syntax|script)"
758
+ r"|review\s+(?:my\s+)?(?:python\s+)?(?:code|script)"
759
+ r"|syntax\s+check(?:\s+python)?"
760
+ r"|verifica\s+(?:la\s+)?(?:sintassi|il\s+codice)(?:\s+python)?"
761
+ r"|controlla\s+(?:il\s+)?(?:codice|sintassi)(?:\s+python)?"
762
+ r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
763
+ re.IGNORECASE,
764
+ )
765
+ _PY_BLOCK_IN_GOAL_RE = re.compile(
766
+ r"```(?:python|py)\s*\n([\s\S]+?)```",
767
+ re.IGNORECASE,
768
+ )
769
+ _CODE_GOAL_RE = re.compile(r"\b(codice|script|programma|funzione|classe|modulo|libreria|package|repository|repo|git|github|branch|commit|pull\s+request|pr|merge|conflitto|conflict|test|unit\s+test|benchmark|profiling|debug|fix|bug|issue|refactor|ottimizzazione|optimization|typescript|javascript|python|rust|go|java|c\+\+|html|css|react|vue|angular|svelte|nextjs|vite|webpack|babel|eslint|prettier|npm|pnpm|yarn|docker|kubernetes|k8s|aws|gcp|azure|vercel|netlify|railway|supabase|firebase|database|sql|nosql|mongodb|postgresql|mysql|redis|api|rest|graphql|grpc|websocket|oauth|jwt|auth|sicurezza|security|crittografia|encryption|ai|llm|agente|agent|transformer|pytorch|tensorflow|scikit-learn|pandas|numpy|matplotlib|seaborn|plotly|fastapi|flask|django|express|koa|nest|spring|laravel|rails|symfony|phoenix|elixir|erlang|clojure|haskell|scala|kotlin|swift|objective-c|dart|flutter|react-native|expo|electron|tauri|capacitor|cordova|ionic|wasm|webassembly)\b", re.IGNORECASE)
770
+ _CODE_RE = re.compile(r"```[\s\S]*?```")
771
+ def _is_simple_query(self, goal: str) -> bool:
772
+ g = goal.strip()
773
+ if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
774
+ return False
775
+ if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
776
+ return True
777
+ if len(g) > 70 or self._needs_tools(g):
778
+ return False
779
+ return bool(self._SIMPLE_CONV_RE.match(g))
agents/unified_loop_types.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_types.py — Tipi condivisi e helper leggeri per UnifiedAgentLoop.
2
+
3
+ Estratto da unified_loop.py (P20-TD1 Fase 1).
4
+
5
+ Contiene:
6
+ - StepCallback: type alias condiviso (era duplicato in unified_loop_tools.py)
7
+ - _detect_user_lang(): rilevamento lingua (P27-B2), zero LLM, <1ms
8
+ - _LANG_INSTRUCTIONS: dizionario prompt per lingua
9
+ - _TASK_VERBS_RE: regex verbi azione (P28-B2)
10
+ - _is_goal_ambiguous(): gate ambiguità strutturale (P28-B2, wired P29-B1)
11
+ - UnifiedLoopState: dataclass stato del loop
12
+ - _maybe_await(): helper coroutine-safe
13
+
14
+ Invariante: nessuna dipendenza da altri moduli agents/ — solo stdlib.
15
+ Importato da: unified_loop.py, unified_loop_tools.py, unified_loop_prompts.py
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from enum import Enum
23
+ from typing import Any, Awaitable, Callable
24
+
25
+ StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
26
+
27
+ class AgentState(str, Enum):
28
+ """Lifecycle states for one UnifiedAgentLoop execution."""
29
+
30
+ IDLE = "IDLE"
31
+ CLASSIFYING = "CLASSIFYING"
32
+ TOOL_EXECUTING = "TOOL_EXECUTING"
33
+ THINKING = "THINKING"
34
+ FAILED = "FAILED"
35
+ COMPLETED = "COMPLETED"
36
+
37
+
38
+ _AGENT_STATE_TRANSITIONS: dict[AgentState, frozenset[AgentState]] = {
39
+ AgentState.IDLE: frozenset({AgentState.CLASSIFYING, AgentState.FAILED}),
40
+ AgentState.CLASSIFYING: frozenset({
41
+ AgentState.TOOL_EXECUTING, AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
42
+ }),
43
+ AgentState.TOOL_EXECUTING: frozenset({
44
+ AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
45
+ }),
46
+ AgentState.THINKING: frozenset({AgentState.COMPLETED, AgentState.FAILED}),
47
+ AgentState.FAILED: frozenset({AgentState.IDLE}),
48
+ # Exceptional finalization errors must be able to surface as FAILED.
49
+ AgentState.COMPLETED: frozenset({AgentState.IDLE, AgentState.FAILED}),
50
+ }
51
+
52
+
53
+ class AgentLoopStateMachine:
54
+ """Deterministic lifecycle machine owned by one loop invocation."""
55
+
56
+ def __init__(self) -> None:
57
+ self.current: AgentState = AgentState.IDLE
58
+ self.history: list[AgentState] = [AgentState.IDLE]
59
+
60
+ def transition(self, next_state: AgentState) -> None:
61
+ if next_state == self.current:
62
+ return
63
+ if next_state not in _AGENT_STATE_TRANSITIONS[self.current]:
64
+ raise ValueError(
65
+ f"Invalid AgentLoop transition: {self.current.value} -> {next_state.value}"
66
+ )
67
+ self.current = next_state
68
+ self.history.append(next_state)
69
+
70
+ def snapshot(self) -> dict[str, Any]:
71
+ return {
72
+ "agent_state": self.current.value,
73
+ "state_history": [state.value for state in self.history],
74
+ }
75
+
76
+
77
+
78
+ def _detect_user_lang(goal: str) -> str:
79
+ """P27-B2: rilevamento lingua leggero — zero I/O, zero LLM, <1ms.
80
+
81
+ Conta token ad alta frequenza per lingua sui primi 200 char del goal.
82
+ Se nessuna lingua raggiunge soglia minima (≥2 token) → 'auto'.
83
+
84
+ Lingue supportate: it, en, es, fr, de.
85
+ Usata per iniettare istruzione esplicita nel system prompt invece del
86
+ generico "Rispondi nella lingua dell'utente" che il modello può ignorare.
87
+ """
88
+ import re as _re
89
+ g = goal.lower()[:200]
90
+ _EN = len(_re.findall(
91
+ r'\b(the|is|are|was|were|can|please|make|create|how|what|write|get|set|add|'
92
+ r'fix|find|show|give|build|help|need|want|check|update|remove|delete|use|'
93
+ r'with|from|that|this|have|will|your|you|i|me|my|do|does|did)\b', g))
94
+ _IT = len(_re.findall(
95
+ r'\b(il|lo|la|le|gli|dei|del|della|delle|degli|nella|nel|crea|scrivi|fai|'
96
+ r'dammi|mostra|aggiungi|rimuovi|correggi|sistema|puoi|devo|voglio|come|'
97
+ r'cosa|quale|quando|perché|senza|con|una|uno|ho|hai|ha|sono|sei|è)\b', g))
98
+ _ES = len(_re.findall(
99
+ r'\b(el|la|los|las|del|como|qué|cómo|crear|hacer|dame|muestra|agrega|'
100
+ r'corrige|necesito|quiero|puedes|tengo|tienes|tiene|es|son|una|uno)\b', g))
101
+ _FR = len(_re.findall(
102
+ r'\b(le|la|les|des|du|une|comment|créer|faire|donne|montre|ajoute|'
103
+ r'corrige|besoin|veux|peux|je|tu|il|elle|nous|vous|est|sont|un)\b', g))
104
+ _DE = len(_re.findall(
105
+ r'\b(der|die|das|den|dem|wie|was|erstelle|schreibe|mache|zeige|füge|'
106
+ r'korrigiere|brauche|kann|bitte|ich|du|er|sie|wir|ihr|ist|sind|ein)\b', g))
107
+ scores = {'en': _EN, 'it': _IT, 'es': _ES, 'fr': _FR, 'de': _DE}
108
+ best = max(scores, key=scores.get)
109
+ return best if scores[best] >= 2 else 'auto'
110
+
111
+
112
+ _LANG_INSTRUCTIONS: dict[str, str] = {
113
+ 'en': 'Respond in English.',
114
+ 'it': 'Rispondi in italiano.',
115
+ 'es': 'Responde en español.',
116
+ 'fr': 'Réponds en français.',
117
+ 'de': 'Antworte auf Deutsch.',
118
+ }
119
+
120
+ _TASK_VERBS_RE = re.compile(
121
+ r'\b(fix|create|crea|analyze|analizza|write|scrivi|find|trova|show|mostra|'
122
+ r'check|controlla|help|aiuta|build|costruisci|make|fai|get|ottieni|set|imposta|'
123
+ r'add|aggiungi|remove|rimuovi|update|aggiorna|run|esegui|execute|test|'
124
+ r'generate|genera|explain|spiega|summarize|riassumi|translate|traduci|'
125
+ r'calculate|calcola|compare|confronta|search|cerca|fetch|scarica|deploy|'
126
+ r'install|installa|debug|refactor|optimize|ottimizza|review|list|elenco|'
127
+ r'arregla|haz|muestra|busca|dame|explica)\b',
128
+ re.IGNORECASE,
129
+ )
130
+
131
+ # MIN-LENGTH-GATE (Item 1/5): verbi analitici che richiedono risposta elaborata.
132
+ # Sottoinsieme di _TASK_VERBS_RE — solo verbi non-coding orientati alla prosa.
133
+ # Usato da: unified_loop.py (min-length gate + _fast_pass non-coding branch).
134
+ _ANALYTICAL_VERBS_RE = re.compile(
135
+ r'\b(analizza|analyze|analysis|analisi|spiega|explain|descrivi|describe|'
136
+ r'confronta|compare|riassumi|summarize|valuta|evaluate|discuss|discuti|'
137
+ r'illustra|illustrate|approfondisci|elaborate|'
138
+ r'pros.*cons|vantaggi.*svantaggi|differenz|difference|'
139
+ r'cosa.*meglio|quale.*migliore)\b',
140
+ re.IGNORECASE,
141
+ )
142
+
143
+
144
+
145
+ def _is_goal_ambiguous(goal: str) -> bool:
146
+ """P28-B2: heuristic gate — rileva goal strutturalmente ambigui (zero LLM, <0.1ms).
147
+
148
+ Un goal e ambiguo se ha meno di 5 parole reali E nessun verbo task riconoscibile.
149
+ Complementare a S-BENCH-REC-AMB: cattura goal brevi come help, aiutami, fix it.
150
+ """
151
+ words = re.findall(r'\w+', goal)
152
+ if len(words) >= 5:
153
+ return False
154
+ return not bool(_TASK_VERBS_RE.search(goal))
155
+
156
+
157
+ # ── P29-R1: borderline ambiguity patterns ────────────────────────────────────
158
+ # Cattura goal che PASSANO _is_goal_ambiguous() (hanno un verbo) ma sono
159
+ # semanticamente vaghi perché l'oggetto è solo un pronome ("it","this","quello").
160
+
161
+ _BORDERLINE_FIX_RE = re.compile(
162
+ r"^(?:fix|debug|repair|check|sistemo?|aggiust[aao]|arregla|fixe|corrige|r[eé]pare)\s+"
163
+ r"(?:it|this|that|them|those|these|quello|questa|questo|esto|ça|lo|la|das)\s*[.!?]?\s*$",
164
+ re.IGNORECASE,
165
+ )
166
+ _BORDERLINE_HELP_RE = re.compile(
167
+ r"^(?:help(?:\s+me)?|aiutami|ayúdame|aide[z-]?\s*moi|hilf\s+mir)"
168
+ r"(?:\s+(?:with|con|avec|mit)\s+(?:it|this|that|questo|eso|cela|das))?\s*[.!?]?\s*$",
169
+ re.IGNORECASE,
170
+ )
171
+ _BORDERLINE_MAKE_RE = re.compile(
172
+ r"^(?:make|render|rendi|haz|fais|mach(?:e)?)\s+"
173
+ r"(?:it|this|that|quello|esto|ça|das)\s+"
174
+ r"(?:better|work|faster|good|bello|buono|mejor|bien|besser|gut)\s*[.!?]?\s*$",
175
+ re.IGNORECASE,
176
+ )
177
+
178
+
179
+ def _is_borderline_ambiguous(goal: str) -> tuple[bool, str]:
180
+ """P29-R1: rileva goal 'borderline' — verbo presente ma oggetto pronominale vago.
181
+
182
+ Ritorna (True, pattern_key) se borderline, (False, '') altrimenti.
183
+ Zero LLM, <0.1ms. Chiamata DOPO _is_goal_ambiguous() (che gestisce goal senza verbo).
184
+ Solo su goal 3-60 char — oltre sono abbastanza specifici da non richiedere chiarimento.
185
+
186
+ Pattern catturati:
187
+ - "fix it/this/that/quello" → 'fix_pronoun'
188
+ - "help me / help me with this" → 'help_vague'
189
+ - "make it better/work" → 'make_vague'
190
+ """
191
+ g = goal.strip()
192
+ if len(g) < 3 or len(g) > 60:
193
+ return False, ''
194
+ if _BORDERLINE_FIX_RE.match(g):
195
+ return True, 'fix_pronoun'
196
+ if _BORDERLINE_HELP_RE.match(g):
197
+ return True, 'help_vague'
198
+ if _BORDERLINE_MAKE_RE.match(g):
199
+ return True, 'make_vague'
200
+ return False, ''
201
+
202
+
203
+ @dataclass
204
+ class UnifiedLoopState:
205
+ goal: str
206
+ context: str = ""
207
+ max_steps: int = 8
208
+ steps: list[dict[str, Any]] = field(default_factory=list)
209
+ errors: list[str] = field(default_factory=list)
210
+ has_files: bool = False # B10: flag separato — evita di inquinare il context string
211
+ session_id: str = "" # P17-F2: blackboard session key per sync Upstash
212
+ state_machine: AgentLoopStateMachine = field(default_factory=AgentLoopStateMachine)
213
+
214
+
215
+ async def _maybe_await(val: Any) -> None:
216
+ if asyncio.iscoroutine(val) or asyncio.isfuture(val):
217
+ await val
agents/workflow_engine.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import time
3
+ import uuid
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ _logger = logging.getLogger("agents.workflow_engine")
9
+
10
+
11
+ class WorkflowStep(BaseModel):
12
+ step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
13
+ tool_name: str
14
+ args: Dict[str, Any]
15
+ status: str = "pending" # pending, running, completed, failed
16
+ result: Any = None
17
+ error: Optional[str] = None
18
+ started_at: Optional[float] = None
19
+ finished_at: Optional[float] = None
20
+
21
+
22
+ class Workflow(BaseModel):
23
+ workflow_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
24
+ name: str
25
+ steps: List[WorkflowStep]
26
+ status: str = "pending"
27
+ created_at: float = Field(default_factory=time.time)
28
+ metadata: Dict[str, Any] = Field(default_factory=dict)
29
+
30
+
31
+ class WorkflowExecutor:
32
+ """
33
+ ARCH-I4.3: Workflow Engine.
34
+
35
+ Coordina workflow in-memory step-by-step tramite Kernel ed Executor. La
36
+ persistenza o il resume inter-processo non sono garantiti da questo motore;
37
+ i caller possono consultare lo stato del workflow corrente tramite
38
+ ``get_workflow``.
39
+ """
40
+
41
+ def __init__(self, kernel: Any, executor: Any):
42
+ self.kernel = kernel
43
+ self.executor = executor
44
+ self.active_workflows: Dict[str, Workflow] = {}
45
+
46
+ def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
47
+ """Ritorna il workflow noto, inclusi gli stati terminali in memoria."""
48
+ return self.active_workflows.get(workflow_id)
49
+
50
+ async def execute_workflow(self, workflow: Workflow) -> Workflow:
51
+ """Esegue un workflow step-by-step, mantenendo il fallback locale."""
52
+ self.active_workflows[workflow.workflow_id] = workflow
53
+ workflow.status = "running"
54
+ _logger.info("Avvio workflow: %s (%s)", workflow.name, workflow.workflow_id)
55
+
56
+ for step in workflow.steps:
57
+ step.status = "running"
58
+ step.started_at = time.time()
59
+ _logger.info(
60
+ "Esecuzione step: %s in workflow %s",
61
+ step.tool_name,
62
+ workflow.workflow_id,
63
+ )
64
+ try:
65
+ # ARCH-I4.3: il Kernel risolve la capability senza esporre
66
+ # l'infrastruttura al workflow.
67
+ resolution = await self.kernel.resolve_capability(step.tool_name)
68
+ if resolution.get("status") == "resolved":
69
+ worker = resolution["worker"]
70
+ worker_id = worker.id if hasattr(worker, "id") else worker["id"]
71
+ _logger.info(
72
+ "Step %s risolto su worker: %s",
73
+ step.tool_name,
74
+ worker_id,
75
+ )
76
+ result = await self.executor.run_tool(
77
+ tool_name=step.tool_name,
78
+ inputs=step.args,
79
+ worker_hint=worker_id,
80
+ )
81
+ else:
82
+ # Nessun worker registrato: il comportamento storico resta
83
+ # l'esecuzione locale tramite lo stesso Executor.
84
+ _logger.warning(
85
+ "Nessun worker per %s, provo esecuzione locale",
86
+ step.tool_name,
87
+ )
88
+ result = await self.executor.run_tool(
89
+ tool_name=step.tool_name,
90
+ inputs=step.args,
91
+ )
92
+
93
+ step.result = result
94
+ if isinstance(result, dict) and result.get("success") is False:
95
+ step.status = "failed"
96
+ step.error = str(result.get("error", "Tool execution failed"))
97
+ workflow.status = "failed"
98
+ break
99
+ step.status = "completed"
100
+ except Exception as exc:
101
+ step.status = "failed"
102
+ step.error = str(exc)
103
+ workflow.status = "failed"
104
+ _logger.error("Step %s fallito: %s", step.tool_name, exc)
105
+ break
106
+ finally:
107
+ step.finished_at = time.time()
108
+
109
+ if workflow.status == "running":
110
+ workflow.status = "completed"
111
+ _logger.info("Workflow %s terminato con stato: %s", workflow.name, workflow.status)
112
+ return workflow
api/__init__.py ADDED
File without changes
api/admin_state.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stato operativo amministrativo protetto da JWT Supabase admin."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+
9
+ from .auth_guard import require_admin_user
10
+ from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
11
+
12
+ router = APIRouter(
13
+ prefix="/api/admin/state",
14
+ tags=["admin"],
15
+ dependencies=[Depends(require_admin_user)],
16
+ )
17
+
18
+
19
+ @router.get("/sessions")
20
+ async def admin_sessions(
21
+ max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
22
+ limit: int = Query(default=100, ge=1, le=200),
23
+ ) -> dict[str, object]:
24
+ cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
25
+
26
+ def operation(client: Any):
27
+ return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
28
+
29
+ result = await _call(operation)
30
+ sessions = []
31
+ for row in result.data or []:
32
+ context = _json_object(row.get("context"))
33
+ session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
34
+ if not session_id:
35
+ continue
36
+ claimed = context.get("claimedFiles")
37
+ sessions.append({
38
+ "session_id": session_id,
39
+ "session_name": str(context.get("sessionName") or session_id)[:160],
40
+ "sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
41
+ "claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
42
+ "last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
43
+ "current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
44
+ })
45
+ return {"sessions": sessions}
46
+
47
+
48
+ @router.get("/tasks")
49
+ async def admin_tasks(
50
+ limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
51
+ offset: int = Query(default=0, ge=0, le=10_000),
52
+ status: str | None = Query(default=None, max_length=64),
53
+ ) -> dict[str, object]:
54
+ normalized_status = status.strip().upper() if status else ""
55
+
56
+ def operation(client: Any):
57
+ query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
58
+ if normalized_status:
59
+ query = query.eq("status", normalized_status)
60
+ page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
61
+ all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
62
+ return page, all_statuses
63
+
64
+ page, all_statuses = await _call(operation)
65
+ counts: dict[str, int] = {}
66
+ for row in all_statuses.data or []:
67
+ key = str(row.get("status") or "UNKNOWN").upper()
68
+ counts[key] = counts.get(key, 0) + 1
69
+ tasks = [{
70
+ "task_id": str(row.get("task_id") or ""),
71
+ "goal": str(row.get("goal") or "")[:1_000],
72
+ "status": str(row.get("status") or "UNKNOWN"),
73
+ "updated_at": _as_epoch_ms(row.get("updated_at")),
74
+ } for row in page.data or []]
75
+ return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
api/agent.py ADDED
The diff for this file is too large to render. See raw diff
 
api/agent_checkpoint.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/agent_checkpoint.py — Simplified checkpoint endpoints (ARCH-K2.3)
3
+
4
+ Aggiunge alias /api/agent/checkpoint (senza task_id nella path) per uso diretto dal frontend:
5
+ GET /api/agent/checkpoint — lista tutti i checkpoint attivi in memoria
6
+ POST /api/agent/checkpoint — salva checkpoint (taskId opzionale nel body)
7
+ GET /api/agent/checkpoint/{task_id} — recupera checkpoint specifico
8
+ DELETE /api/agent/checkpoint/{task_id} — elimina checkpoint
9
+
10
+ I checkpoint per-task esistono già su /api/agent/tasks/{id}/checkpoint (agent.py).
11
+ Questi alias sono più comodi quando il frontend non ha un task_id esplicito
12
+ (es. salvataggio periodico dello stato dell'agente, resume dopo refresh).
13
+
14
+ ROUTING CF PAGES: /api/agent/* → HANDS (Space B) via HANDS_PATTERNS[0].
15
+ Nessuna modifica a [[catchall]].ts necessaria.
16
+
17
+ NOTA: Import da api.agent e api.persistence sono LAZY (dentro le funzioni)
18
+ per evitare import circolari — agent.py importa già molti altri moduli.
19
+ """
20
+ import time
21
+ import asyncio
22
+ import logging
23
+ from typing import Optional
24
+
25
+ from fastapi import APIRouter, Depends, HTTPException
26
+ from pydantic import BaseModel
27
+
28
+ from .auth_guard import require_role, AuthRole
29
+
30
+ _logger = logging.getLogger("api.agent_checkpoint")
31
+
32
+ router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
33
+
34
+
35
+ class CheckpointBody(BaseModel):
36
+ taskId: Optional[str] = None # se omesso → usa "default"
37
+ step: int = 0
38
+ goal: str = ""
39
+ plan: list = []
40
+ logs: list[str] = []
41
+ artifacts: list[str] = []
42
+ retryCount: int = 0
43
+ extra: dict = {}
44
+
45
+
46
+ # ── GET /api/agent/checkpoint ─────────────────────────────────────────────────
47
+ @router.get("/api/agent/checkpoint")
48
+ async def list_checkpoints_alias():
49
+ """
50
+ Lista tutti i checkpoint attivi in memoria.
51
+ Alias leggero per /api/agent/checkpoints (agent.py).
52
+ """
53
+ # Import lazy — evita circolarità
54
+ from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
55
+
56
+ _prune_checkpoints()
57
+ now = int(time.time() * 1000)
58
+ return {
59
+ "count": len(_task_checkpoints),
60
+ "checkpoints": [
61
+ {
62
+ "taskId": k,
63
+ "step": v.get("step", 0),
64
+ "goal": v.get("goal", "")[:300],
65
+ "age_ms": now - v.get("savedAt", now),
66
+ }
67
+ for k, v in _task_checkpoints.items()
68
+ ],
69
+ }
70
+
71
+
72
+ # ── POST /api/agent/checkpoint ────────────────────────────────────────────────
73
+ @router.post("/api/agent/checkpoint")
74
+ async def save_checkpoint_alias(body: CheckpointBody):
75
+ """
76
+ Salva un checkpoint. taskId opzionale: se omesso usa 'default'.
77
+ Replica la logica di /api/agent/tasks/{id}/checkpoint con Supabase persist.
78
+ """
79
+ from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
80
+ from api.persistence import sb_save_checkpoint # type: ignore[import]
81
+
82
+ _prune_checkpoints()
83
+ task_id = body.taskId or "default"
84
+
85
+ cp: dict = {
86
+ "taskId": task_id,
87
+ "step": body.step,
88
+ "goal": body.goal,
89
+ "plan": body.plan,
90
+ "logs": body.logs[-50:], # mantieni solo gli ultimi 50 log
91
+ "artifacts": body.artifacts,
92
+ "retryCount": body.retryCount,
93
+ "extra": body.extra,
94
+ "savedAt": int(time.time() * 1000),
95
+ }
96
+ _task_checkpoints[task_id] = cp
97
+ # Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
98
+ asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
99
+ return {"saved": True, "taskId": task_id, "step": body.step}
100
+
101
+
102
+ # ── GET /api/agent/checkpoint/{task_id} ──────────────────────────────────────
103
+ @router.get("/api/agent/checkpoint/{task_id}")
104
+ async def get_checkpoint_alias(task_id: str):
105
+ """
106
+ Recupera il checkpoint per un task specifico.
107
+ Cerca prima in memoria (_task_checkpoints), poi su Supabase via sb_get_checkpoint.
108
+ """
109
+ from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
110
+ from api.persistence import sb_get_checkpoint # type: ignore[import]
111
+
112
+ _prune_checkpoints()
113
+ cp = _task_checkpoints.get(task_id)
114
+ if not cp:
115
+ cp = await sb_get_checkpoint(task_id)
116
+ if not cp:
117
+ raise HTTPException(
118
+ status_code=404,
119
+ detail={"error": "checkpoint_not_found", "taskId": task_id},
120
+ )
121
+ return cp
122
+
123
+
124
+ # ── DELETE /api/agent/checkpoint/{task_id} ───────────────────────────────────
125
+ @router.delete("/api/agent/checkpoint/{task_id}")
126
+ async def delete_checkpoint_alias(task_id: str):
127
+ """Rimuove il checkpoint da memoria in-process (non elimina da Supabase)."""
128
+ from api.agent import _task_checkpoints # type: ignore[import]
129
+
130
+ _task_checkpoints.pop(task_id, None)
131
+ return {"deleted": task_id}
api/agent_memory.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/agent_memory.py — Agent memory CRUD (S354).
3
+ GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase.
4
+ GAP-SENSITIVE-FIX: implementato masking per le chiavi definite in SENSITIVE.
5
+ """
6
+ import time, asyncio
7
+ from typing import Any
8
+ from fastapi import APIRouter, Depends
9
+ from .auth_guard import require_role, AuthRole
10
+ from pydantic import BaseModel
11
+ from .state import _sb, _mem_fallback, SENSITIVE
12
+ import logging
13
+
14
+ _logger = logging.getLogger("api.agent_memory")
15
+
16
+ # Router protetto a livello MACHINE — richiede X-Internal-Token
17
+ router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
18
+
19
+ class MemoryEntry(BaseModel):
20
+ key: str
21
+ value: str
22
+ category: str = 'general'
23
+ createdAt: int = 0
24
+ updatedAt: int = 0
25
+
26
+ def _mask_value(key: str, value: Any) -> Any:
27
+ """Maschera il valore se la chiave è presente nel set SENSITIVE."""
28
+ if key in SENSITIVE and value:
29
+ return "[REDACTED]"
30
+ return value
31
+
32
+ async def _reconcile_fallback() -> int:
33
+ """GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase."""
34
+ if not _sb or not _mem_fallback:
35
+ return 0
36
+ synced = 0
37
+ for key, entry in list(_mem_fallback.items()):
38
+ try:
39
+ _sb.table('agent_memory').upsert({
40
+ 'key': entry['key'],
41
+ 'value': entry['value'],
42
+ 'category': entry.get('category', 'general'),
43
+ 'created_at': entry.get('createdAt', 0),
44
+ 'updated_at': entry.get('updatedAt', 0),
45
+ }, on_conflict='key').execute()
46
+ synced += 1
47
+ except Exception as _e:
48
+ _logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
49
+ break
50
+ if synced:
51
+ _logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
52
+ return synced
53
+
54
+ @router.get('/api/memory/agent')
55
+ async def list_agent_memory():
56
+ """Lista le voci di memoria, mascherando i segreti."""
57
+ if _sb:
58
+ try:
59
+ data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).limit(500).execute()
60
+ entries = [
61
+ {
62
+ 'key': r['key'],
63
+ 'value': _mask_value(r['key'], r['value']),
64
+ 'category': r.get('category', 'general'),
65
+ 'createdAt': r.get('created_at', 0),
66
+ 'updatedAt': r.get('updated_at', 0)
67
+ }
68
+ for r in (data.data or [])
69
+ ]
70
+ return {'entries': entries}
71
+ except Exception as e:
72
+ _logger.warning('[memory] Supabase list error: %s', e)
73
+
74
+ entries = [
75
+ {
76
+ 'key': v['key'],
77
+ 'value': _mask_value(v['key'], v['value']),
78
+ 'category': v.get('category', 'general'),
79
+ 'createdAt': v.get('createdAt', 0),
80
+ 'updatedAt': v.get('updatedAt', 0)
81
+ }
82
+ for v in _mem_fallback.values()
83
+ ]
84
+ return {'entries': entries}
85
+
86
+ @router.get('/api/memory/agent/{key}')
87
+ async def get_agent_memory(key: str):
88
+ """Recupera una singola voce di memoria, mascherando se sensibile."""
89
+ val = None
90
+ if _sb:
91
+ try:
92
+ data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
93
+ if data.data:
94
+ val = data.data[0]['value']
95
+ except Exception as e:
96
+ _logger.warning('[memory] Supabase get error: %s', e)
97
+
98
+ if val is None:
99
+ entry = _mem_fallback.get(key)
100
+ val = entry['value'] if entry else None
101
+
102
+ return {'value': _mask_value(key, val)}
103
+
104
+ @router.post('/api/memory/agent')
105
+ async def set_agent_memory(entry: MemoryEntry):
106
+ now = int(time.time() * 1000)
107
+ record = {
108
+ 'key': entry.key, 'value': entry.value, 'category': entry.category,
109
+ 'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
110
+ }
111
+ _mem_fallback[entry.key] = record
112
+ if _sb:
113
+ try:
114
+ _sb.table('agent_memory').upsert({
115
+ 'key': entry.key, 'value': entry.value, 'category': entry.category,
116
+ 'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
117
+ }, on_conflict='key').execute()
118
+ if len(_mem_fallback) > 1:
119
+ asyncio.create_task(_reconcile_fallback())
120
+ except Exception as _e:
121
+ _logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
122
+ return {'ok': True, 'key': entry.key}
123
+
124
+ @router.delete('/api/memory/agent/{key}')
125
+ async def delete_agent_memory(key: str):
126
+ if _sb:
127
+ try:
128
+ _sb.table('agent_memory').delete().eq('key', key).execute()
129
+ except Exception as _exc:
130
+ _logger.debug("[agent_memory] silenced %s", type(_exc).__name__)
131
+ _mem_fallback.pop(key, None)
132
+ return {'deleted': key}
api/agent_telemetry.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/agent_telemetry.py — Sync verdetti agentTelemetry.ts cross-session/device.
3
+
4
+ Gap N4: agentTelemetry.ts usava solo localStorage → dati di calibrazione persi su
5
+ altri device o dopo clear della cache. Questo endpoint li persiste su /data/ del
6
+ volume HF Space (stesso usato dalla memoria episodica — mai si perde tra restart).
7
+
8
+ Endpoints:
9
+ POST /api/agent-telemetry/sync — riceve TelemetryStore dal client, merge server,
10
+ persiste, ritorna il merged aggiornato.
11
+ GET /api/agent-telemetry/sync — ritorna store server-side (load al boot del client).
12
+ DELETE /api/agent-telemetry/sync — reset admin/debug.
13
+
14
+ Auth: nessuna (dati aggregati, zero PII — stesso pattern di /api/telemetry esistente).
15
+ Rate limit: middleware globale 120 req/min/IP già applicato da main.py.
16
+ Merge: additive — per ogni (system, verdict) prende max(count) e max(lastSeenMs).
17
+ I contatori non diminuiscono mai (protezione da client con dati parziali).
18
+ """
19
+ import os, json, logging, time, asyncio
20
+ from pathlib import Path
21
+ from fastapi import APIRouter, Depends
22
+ from .auth_guard import require_role, AuthRole
23
+ from fastapi.responses import JSONResponse
24
+ from pydantic import BaseModel
25
+
26
+ router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
27
+ _logger = logging.getLogger("agente_ai")
28
+
29
+ # ─── Storage ──────────────────────────────────────────────────────────────────
30
+ _DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
31
+ _TEL_FILE = _DATA_DIR / "agent_telemetry.json"
32
+ _MAX_STORE = 500 # max entry totali prima del pruning
33
+ # Serializza write concorrenti: due POST simultanei da device diversi leggerebbero
34
+ # lo stesso store e si sovrascriverebbero. asyncio.Lock() è safe a livello di modulo
35
+ # in Python 3.10+ (non richiede event loop attivo all'init del modulo).
36
+ _store_lock = asyncio.Lock()
37
+ _RUNTIME_PHASES = ('auth', 'queue', 'provider', 'tool', 'persistence')
38
+ _RUNTIME_MAX_SAMPLES = 200
39
+ _runtime_lock = asyncio.Lock()
40
+ _runtime_store: dict[str, dict] = {
41
+ phase: {'samples_ms': [], 'ok': 0, 'errors': {}}
42
+ for phase in _RUNTIME_PHASES
43
+ }
44
+
45
+
46
+ async def record_runtime_phase(phase: str, duration_ms: float = 0.0,
47
+ outcome: str = 'ok', error_class: str | None = None) -> None:
48
+ if phase not in _runtime_store:
49
+ return
50
+ async with _runtime_lock:
51
+ bucket = _runtime_store[phase]
52
+ samples = bucket['samples_ms']
53
+ samples.append(max(0.0, round(float(duration_ms), 2)))
54
+ if len(samples) > _RUNTIME_MAX_SAMPLES:
55
+ del samples[:-_RUNTIME_MAX_SAMPLES]
56
+ if outcome == 'ok':
57
+ bucket['ok'] += 1
58
+ else:
59
+ key = error_class or outcome or 'unknown'
60
+ bucket['errors'][key] = bucket['errors'].get(key, 0) + 1
61
+
62
+
63
+ def _percentile(samples: list[float], percentile: float) -> float:
64
+ if not samples:
65
+ return 0.0
66
+ ordered = sorted(samples)
67
+ index = min(len(ordered) - 1, int(round((percentile / 100) * (len(ordered) - 1))))
68
+ return ordered[index]
69
+
70
+
71
+ async def runtime_snapshot() -> dict[str, dict]:
72
+ async with _runtime_lock:
73
+ return {
74
+ phase: {
75
+ 'count': len(bucket['samples_ms']),
76
+ 'ok': bucket['ok'],
77
+ 'errors': dict(bucket['errors']),
78
+ 'p50_ms': _percentile(bucket['samples_ms'], 50),
79
+ 'p95_ms': _percentile(bucket['samples_ms'], 95),
80
+ }
81
+ for phase, bucket in _runtime_store.items()
82
+ }
83
+
84
+ # ─── Store helpers ────────────────────────────────────────────────────────────
85
+
86
+ def _load_store() -> dict:
87
+ """Carica /data/agent_telemetry.json. Ritorna {} in caso di errore."""
88
+ try:
89
+ if _TEL_FILE.exists():
90
+ return json.loads(_TEL_FILE.read_text("utf-8"))
91
+ except Exception as exc:
92
+ _logger.warning("agent_telemetry: load error — %s", exc)
93
+ return {}
94
+
95
+
96
+ def _save_store(store: dict) -> None:
97
+ """Persiste store su disco. Fail-open."""
98
+ try:
99
+ _DATA_DIR.mkdir(parents=True, exist_ok=True)
100
+ _TEL_FILE.write_text(json.dumps(store, separators=(",", ":")), "utf-8")
101
+ except Exception as exc:
102
+ _logger.warning("agent_telemetry: save error — %s", exc)
103
+
104
+
105
+ def _merge(server: dict, client: dict) -> dict:
106
+ """
107
+ Merge additive cross-device.
108
+ Regola: per ogni (system, verdict) prende max(count) e max(lastSeenMs).
109
+ Un client con dati parziali non può mai ridurre i contatori server.
110
+ """
111
+ merged: dict = {k: dict(v) for k, v in server.items()}
112
+ for sys_name, verdicts in client.items():
113
+ if not isinstance(verdicts, dict):
114
+ continue
115
+ if sys_name not in merged:
116
+ merged[sys_name] = {}
117
+ srv_sys = merged[sys_name]
118
+ for verdict, stats in verdicts.items():
119
+ if not isinstance(stats, dict):
120
+ continue
121
+ c_count = int(stats.get("count", 0))
122
+ c_ts = int(stats.get("lastSeenMs", 0))
123
+ prev = srv_sys.get(verdict, {"count": 0, "lastSeenMs": 0})
124
+ srv_sys[verdict] = {
125
+ "count": max(int(prev.get("count", 0)), c_count),
126
+ "lastSeenMs": max(int(prev.get("lastSeenMs", 0)), c_ts),
127
+ }
128
+ return merged
129
+
130
+
131
+ def _prune(store: dict) -> dict:
132
+ """Se totale entry > _MAX_STORE, sacrifica i sistemi meno usati."""
133
+ total = sum(len(v) for v in store.values())
134
+ if total <= _MAX_STORE:
135
+ return store
136
+ sorted_sys = sorted(
137
+ store.items(),
138
+ key=lambda kv: sum(s.get("count", 0) for s in kv[1].values()),
139
+ reverse=True,
140
+ )
141
+ pruned: dict = {}
142
+ kept = 0
143
+ for sys_name, verdicts in sorted_sys:
144
+ n = len(verdicts)
145
+ if kept + n > _MAX_STORE:
146
+ break
147
+ pruned[sys_name] = verdicts
148
+ kept += n
149
+ return pruned
150
+
151
+
152
+ # ─── Pydantic models ──────────────────────────────────────────────────────────
153
+
154
+ class TelemetrySyncBody(BaseModel):
155
+ """
156
+ Payload POST dal client — struttura identica a TelemetryStore di agentTelemetry.ts:
157
+ { "crossCritic": { "pass": {"count": 5, "lastSeenMs": 1718000000000} }, ... }
158
+ """
159
+ data: dict
160
+
161
+
162
+ # ─── Endpoints ────────────────────────────────────────────────────────────────
163
+
164
+ @router.get("/api/agent-telemetry/runtime")
165
+ async def get_runtime_telemetry() -> JSONResponse:
166
+ return JSONResponse({'ok': True, 'phases': await runtime_snapshot(), 'server_ts': int(time.time() * 1000)})
167
+
168
+
169
+ @router.post("/api/agent-telemetry/sync")
170
+ async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
171
+ """
172
+ POST /api/agent-telemetry/sync
173
+
174
+ Riceve il TelemetryStore locale del client (agentTelemetry.ts localStorage).
175
+ Lo merge con lo store server-side (additive — max count), lo persiste su
176
+ /data/agent_telemetry.json, e ritorna il merged.
177
+
178
+ Il client sostituisce il suo localStorage con il merged ricevuto:
179
+ i dati da altri device sono ora disponibili localmente.
180
+ """
181
+ try:
182
+ async with _store_lock:
183
+ server = _load_store()
184
+ merged = _prune(_merge(server, body.data))
185
+ _save_store(merged)
186
+ return JSONResponse({
187
+ "ok": True,
188
+ "merged": merged,
189
+ "server_ts": int(time.time() * 1000),
190
+ })
191
+ except Exception as exc:
192
+ _logger.error("agent_telemetry POST error: %s", exc)
193
+ return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
194
+
195
+
196
+ @router.get("/api/agent-telemetry/sync")
197
+ async def get_agent_telemetry() -> JSONResponse:
198
+ """
199
+ GET /api/agent-telemetry/sync
200
+
201
+ Ritorna lo store server-side. Il client lo carica all'avvio e lo mergia
202
+ con il localStorage locale (additive — max count) prima di iniziare
203
+ a registrare nuovi eventi.
204
+ """
205
+ try:
206
+ store = _load_store()
207
+ return JSONResponse({
208
+ "ok": True,
209
+ "data": store,
210
+ "server_ts": int(time.time() * 1000),
211
+ })
212
+ except Exception as exc:
213
+ _logger.error("agent_telemetry GET error: %s", exc)
214
+ return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
215
+
216
+
217
+ @router.delete("/api/agent-telemetry/sync")
218
+ async def delete_agent_telemetry() -> JSONResponse:
219
+ """
220
+ DELETE /api/agent-telemetry/sync — solo per admin/debug.
221
+ Cancella lo store server-side. Non tocca il localStorage dei client.
222
+ """
223
+ try:
224
+ async with _store_lock:
225
+ if _TEL_FILE.exists():
226
+ _TEL_FILE.unlink()
227
+ return JSONResponse({"ok": True, "deleted": True})
228
+ except Exception as exc:
229
+ _logger.error("agent_telemetry DELETE error: %s", exc)
230
+ return JSONResponse({"ok": False, "error": str(exc)[:120]}, status_code=500)
api/auth_guard.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/auth_guard.py — GAP-A6: Authorization model granulare.
2
+
3
+ Definisce ruoli gerarchici + dependency FastAPI per proteggere gli endpoint.
4
+
5
+ Ruoli (IntEnum, gerarchici):
6
+ USER — nessuna auth (endpoint pubblici, chiamate frontend)
7
+ MACHINE — X-Internal-Token header = INTERNAL_TOKEN env var (backend↔backend)
8
+ OPERATOR — X-Operator-Token header = OPERATOR_TOKEN env var (monitoring, trigger)
9
+ ADMIN — X-Admin-Token header = ADMIN_TOKEN env var (operazioni distruttive)
10
+
11
+ Utilizzo:
12
+ from api.auth_guard import require_role, AuthRole
13
+
14
+ @router.delete("/tasks/{id}")
15
+ async def delete_task(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
16
+ ...
17
+
18
+ @router.post("/incidents/purge")
19
+ async def purge(role: AuthRole = Depends(require_role(AuthRole.ADMIN))):
20
+ ...
21
+
22
+ Token env vars (Railway):
23
+ INTERNAL_TOKEN — già usato in main.py (generato al boot se assente)
24
+ OPERATOR_TOKEN — opzionale; se assente, endpoint OPERATOR bloccati
25
+ ADMIN_TOKEN — opzionale; se assente, endpoint ADMIN bloccati
26
+
27
+ NOTA: endpoint USER non richiedono alcun header.
28
+ Se OPERATOR_TOKEN non è impostato, gli endpoint OPERATOR ritornano 503
29
+ invece di 403 (distinzione "non configurato" vs "token errato").
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import os
35
+ from enum import IntEnum
36
+ from typing import Optional, Any
37
+
38
+ from fastapi import Depends, Header, HTTPException, Request
39
+
40
+ logger = logging.getLogger("agente_ai.auth_guard")
41
+
42
+
43
+ # ── Rate limiter sliding window (RATE-LIMIT-FIX + GAP-AUTH-FIX) ──────────────
44
+ # In-memory, per token hash (sicuro: non salva il token in chiaro).
45
+ # Limiti per ruolo: USER=10/min, MACHINE=30/min, OPERATOR=60/min, ADMIN=illimitato.
46
+ # Thread-safe in asyncio (GIL + nessun await interno).
47
+ #
48
+ # GAP-AUTH-FIX: per USER (role=0) la chiave usa client_ip invece di 'anonymous'.
49
+ # Prima del fix: tutti gli utenti pubblici condividevano sha256("0:anonymous") →
50
+ # un singolo client poteva svuotare il bucket per tutti gli utenti al mondo.
51
+ # Fix: sha256("0:{client_ip}") — bucket separato per IP.
52
+ # Fallback: 'unknown' se IP non rilevabile (raro su Railway/HF con proxy).
53
+
54
+ import collections as _col
55
+ # ── Redis-backed rate store (Upstash) — DoS-FIX: persiste cross-restart ────
56
+ # Fallback automatico a in-memory se UPSTASH_REDIS_URL non configurato.
57
+ _upstash_url = os.getenv('UPSTASH_REDIS_URL', '')
58
+ _upstash_token = os.getenv('UPSTASH_REDIS_TOKEN', '')
59
+ _use_redis = bool(_upstash_url and _upstash_token)
60
+
61
+ def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]:
62
+ """Rate check via Upstash Redis REST API (zero dipendenze extra)."""
63
+ import urllib.request as _ur, json as _js, time as _rt
64
+ now_s = int(_rt.time())
65
+ window_key = f'{key}:{now_s // window_s}'
66
+ try:
67
+ req = _ur.Request(
68
+ f'{_upstash_url}/pipeline',
69
+ data=_js.dumps([
70
+ ['INCR', window_key],
71
+ ['EXPIRE', window_key, window_s * 2],
72
+ ]).encode(),
73
+ headers={'Authorization': f'Bearer {_upstash_token}', 'Content-Type': 'application/json'},
74
+ method='POST',
75
+ )
76
+ with _ur.urlopen(req, timeout=1) as r:
77
+ results = _js.loads(r.read())
78
+ count = results[0]['result'] if isinstance(results[0], dict) else results[0]
79
+ if count > limit:
80
+ return False, window_s
81
+ return True, 0
82
+ except Exception as _exc: # SEC2-5: Redis irraggiungibile → fallback in-memory, non fail-open puro
83
+ logger.warning('[auth_guard] Redis rate check fallito (%s), fallback in-memory', _exc)
84
+ return _inmem_rate_check(key, limit, window_s)
85
+
86
+
87
+ import time as _rl_time
88
+ import hashlib as _rl_hash
89
+
90
+ _RATE_LIMITS: dict[int, int] = {
91
+ 0: 10, # USER
92
+ 1: 30, # MACHINE
93
+ 2: 60, # OPERATOR
94
+ 3: -1, # ADMIN — illimitato
95
+ }
96
+ _RATE_WINDOW_S = 60 # finestra sliding 60s
97
+ _rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
98
+
99
+ # Lo store è usato anche quando Redis non è disponibile. Un client una tantum
100
+ # lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
101
+ # sweep ammortizzato: il lavoro resta O(1) per la quasi totalità delle richieste
102
+ # e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
103
+ _RATE_STORE_SWEEP_EVERY = 128
104
+ _rate_store_checks = 0
105
+
106
+
107
+ def _prune_expired_rate_keys(now: float, window_s: float) -> None:
108
+ """Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
109
+ global _rate_store_checks
110
+ _rate_store_checks += 1
111
+ if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
112
+ return
113
+
114
+ window_start = now - window_s
115
+ stale_keys = [
116
+ stored_key
117
+ for stored_key, timestamps in _rate_store.items()
118
+ if not timestamps or timestamps[-1] < window_start
119
+ ]
120
+ for stored_key in stale_keys:
121
+ _rate_store.pop(stored_key, None)
122
+
123
+
124
+ def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
125
+ """Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
126
+
127
+ USER usa sempre l'IP come discriminante. MACHINE usa l'IP quando il proxy
128
+ fidato lo inoltra: Cloudflare usa un unico token interno per tutti i browser,
129
+ quindi il solo token renderebbe globale il limite di 30 richieste/minuto.
130
+ In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
131
+ e ADMIN mantengono il bucket per-token.
132
+ """
133
+ if role == 0:
134
+ # USER: bucket per IP, mai globale condiviso.
135
+ raw = f"0:{client_ip or 'unknown'}"
136
+ elif role == 1 and client_ip:
137
+ # MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
138
+ raw = f"1:{client_ip}"
139
+ else:
140
+ # Chiamate server-to-server e ruoli elevati: bucket per token.
141
+ raw = f"{role}:{token_header or 'anonymous'}"
142
+ return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
143
+
144
+
145
+
146
+ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]:
147
+ """Rate check in-memory sliding window.
148
+
149
+ SEC2-5: usata sia quando Redis non è configurato, sia come fallback quando
150
+ Redis è temporaneamente irraggiungibile (prima era fail-open puro).
151
+ Thread-safe in asyncio (GIL, nessun await interno).
152
+ """
153
+ now = _rl_time.monotonic()
154
+ window_start = now - window_s
155
+ _prune_expired_rate_keys(now, window_s)
156
+
157
+ if key not in _rate_store:
158
+ _rate_store[key] = _col.deque()
159
+ dq = _rate_store[key]
160
+
161
+ while dq and dq[0] < window_start:
162
+ dq.popleft()
163
+
164
+ if len(dq) >= limit:
165
+ retry_after = int(window_s - (now - dq[0])) + 1
166
+ return False, max(retry_after, 1)
167
+
168
+ dq.append(now)
169
+ return True, 0
170
+
171
+ def _check_rate_limit(
172
+ role: int,
173
+ token_header: str | None,
174
+ client_ip: str | None = None,
175
+ ) -> tuple[bool, int]:
176
+ """Controlla il rate limit per ruolo.
177
+
178
+ Ritorna (ok, retry_after_seconds).
179
+ ok=True → richiesta consentita.
180
+ ok=False → limite superato, retry_after = secondi alla prossima finestra.
181
+ ADMIN (role=3) è sempre ok.
182
+ """
183
+ limit = _RATE_LIMITS.get(role, 10)
184
+ if limit < 0:
185
+ return True, 0 # ADMIN — illimitato
186
+
187
+ key = _rate_key(role, token_header, client_ip)
188
+ # DoS-FIX: usa Redis se disponibile (persiste cross-restart HF Space)
189
+ # SEC2-5: se Redis fallisce, _redis_rate_check fa fallback su _inmem_rate_check
190
+ if _use_redis:
191
+ return _redis_rate_check(key, limit, int(_RATE_WINDOW_S))
192
+ return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
193
+
194
+
195
+ async def require_supabase_user(request: Request) -> dict[str, Any]:
196
+ """Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
197
+
198
+ La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
199
+ Authorization del chiamante e non viene scritto nei log.
200
+ """
201
+ import httpx
202
+
203
+ authorization = request.headers.get("Authorization", "")
204
+ if not authorization.lower().startswith("bearer "):
205
+ raise HTTPException(status_code=401, detail="Bearer token richiesto")
206
+ jwt = authorization[7:].strip()
207
+ if not jwt:
208
+ raise HTTPException(status_code=401, detail="Bearer token non valido")
209
+
210
+ supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
211
+ api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
212
+ if not supabase_url or not api_key:
213
+ raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
214
+
215
+ try:
216
+ async with httpx.AsyncClient(timeout=5) as client:
217
+ response = await client.get(
218
+ f"{supabase_url}/auth/v1/user",
219
+ headers={
220
+ "apikey": api_key,
221
+ "Authorization": f"Bearer {jwt}",
222
+ "Accept": "application/json",
223
+ },
224
+ )
225
+ except httpx.HTTPError as exc:
226
+ logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
227
+ raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
228
+
229
+ if response.status_code != 200:
230
+ raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
231
+ try:
232
+ user = response.json()
233
+ except ValueError as exc:
234
+ raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
235
+ if not isinstance(user, dict) or not user.get("id"):
236
+ raise HTTPException(status_code=401, detail="Utente Supabase non valido")
237
+ return user
238
+
239
+
240
+ async def require_admin_user(request: Request) -> dict[str, Any]:
241
+ """Richiede un JWT Supabase con app_metadata.role=admin.
242
+
243
+ app_metadata è server-controlled; user_metadata non viene mai considerato
244
+ per autorizzare l’area amministrativa.
245
+ """
246
+ user = await require_supabase_user(request)
247
+ app_metadata = user.get("app_metadata") or {}
248
+ roles = app_metadata.get("roles") or []
249
+ is_admin = app_metadata.get("role") == "admin" or "admin" in roles
250
+ if not is_admin:
251
+ raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
252
+ return user
253
+
254
+
255
+ class AuthRole(IntEnum):
256
+ """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
257
+ USER = 0
258
+ MACHINE = 1
259
+ OPERATOR = 2
260
+ ADMIN = 3
261
+
262
+
263
+ def _get_token(env_var: str) -> str:
264
+ return os.getenv(env_var, "").strip()
265
+
266
+
267
+ async def _resolve_role(
268
+ x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
269
+ x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
270
+ x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
271
+ x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
272
+ ) -> AuthRole:
273
+ """Risolve il ruolo del chiamante in base agli header presenti."""
274
+ import secrets as _sec_comp
275
+ # ADMIN (massima priorità)
276
+ admin_tok = _get_token("ADMIN_TOKEN")
277
+ if admin_tok and x_admin_token and _sec_comp.compare_digest(x_admin_token, admin_tok):
278
+ logger.debug("auth: ADMIN role granted")
279
+ return AuthRole.ADMIN
280
+
281
+ # OPERATOR
282
+ op_tok = _get_token("OPERATOR_TOKEN")
283
+ if op_tok and x_operator_token and _sec_comp.compare_digest(x_operator_token, op_tok):
284
+ logger.debug("auth: OPERATOR role granted")
285
+ return AuthRole.OPERATOR
286
+
287
+ # MACHINE: supporta entrambi gli header per compatibilità tra runner e backend.
288
+ # Il valore resta confrontato esclusivamente con il secret server-side.
289
+ int_tok = _get_token("INTERNAL_TOKEN") or _get_token("MACHINE_TOKEN")
290
+ machine_header = x_internal_token or x_machine_token
291
+ if int_tok and machine_header and _sec_comp.compare_digest(machine_header, int_tok):
292
+ logger.debug("auth: MACHINE role granted")
293
+ return AuthRole.MACHINE
294
+
295
+ # Nessun token valido → ruolo USER (minimo)
296
+ return AuthRole.USER
297
+
298
+
299
+ async def require_private_state_machine(
300
+ request: 'Request',
301
+ x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
302
+ ) -> AuthRole:
303
+ """Autorizza esclusivamente il proxy Pages dello stato privato.
304
+
305
+ Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
306
+ dipendono le integrazioni legacy del master B. Il token non conferisce un
307
+ ruolo più ampio del canale MACHINE e resta soggetto allo stesso rate limit.
308
+ """
309
+ import secrets as _sec_comp
310
+ private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
311
+ if not private_token:
312
+ raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
313
+ if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
314
+ raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
315
+
316
+ client_ip = (
317
+ request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
318
+ or request.headers.get('X-Real-IP', '')
319
+ or (request.client.host if request.client else None)
320
+ ) or None
321
+ allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
322
+ if not allowed:
323
+ raise HTTPException(
324
+ status_code=429,
325
+ detail="Rate limit stato privato superato",
326
+ headers={'Retry-After': str(retry_after)},
327
+ )
328
+ return AuthRole.MACHINE
329
+
330
+
331
+ def require_role(min_role: AuthRole):
332
+ """
333
+ FastAPI Depends factory per autorizzazione granulare.
334
+
335
+ Esempio:
336
+ @router.post("/trigger")
337
+ async def trigger(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
338
+ ...
339
+
340
+ Se il ruolo risolto < min_role → 403 Forbidden.
341
+ Se il token richiesto non è configurato (env var assente) → 503 Service Unavailable.
342
+ """
343
+ async def _check(
344
+ request: 'Request',
345
+ resolved: AuthRole = Depends(_resolve_role),
346
+ ) -> AuthRole:
347
+ # RATE-LIMIT-FIX + GAP-AUTH-FIX: estrai IP cliente per bucket USER per-IP
348
+ _token_hdr = (
349
+ request.headers.get('X-Admin-Token') or
350
+ request.headers.get('X-Operator-Token') or
351
+ request.headers.get('X-Internal-Token') or
352
+ request.headers.get('X-Machine-Token')
353
+ )
354
+ # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
355
+ _client_ip: str | None = (
356
+ request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
357
+ or request.headers.get('X-Real-IP', '')
358
+ or (request.client.host if request.client else None)
359
+ ) or None
360
+ _ok, _retry = _check_rate_limit(int(resolved), _token_hdr, _client_ip)
361
+ if not _ok:
362
+ raise HTTPException(
363
+ status_code=429,
364
+ detail={
365
+ 'error': 'Rate limit superato',
366
+ 'role': resolved.name,
367
+ 'limit': f'{_RATE_LIMITS.get(int(resolved), 10)}/min',
368
+ 'retry_after_seconds': _retry,
369
+ },
370
+ headers={'Retry-After': str(_retry)},
371
+ )
372
+ if resolved < min_role:
373
+ # Distingue "token non configurato" (503) da "token errato" (403)
374
+ if min_role == AuthRole.OPERATOR and not _get_token("OPERATOR_TOKEN"):
375
+ raise HTTPException(503, {
376
+ "error": "Endpoint non disponibile",
377
+ "reason": "OPERATOR_TOKEN non configurato su Railway",
378
+ "required": "AuthRole.OPERATOR",
379
+ })
380
+ if min_role == AuthRole.ADMIN and not _get_token("ADMIN_TOKEN"):
381
+ raise HTTPException(503, {
382
+ "error": "Endpoint non disponibile",
383
+ "reason": "ADMIN_TOKEN non configurato su Railway",
384
+ "required": "AuthRole.ADMIN",
385
+ })
386
+ raise HTTPException(403, {
387
+ "error": "Permessi insufficienti",
388
+ "required_role": min_role.name,
389
+ "your_role": resolved.name,
390
+ "hint": f"Fornire header X-{min_role.name.capitalize()}-Token con il token corretto",
391
+ })
392
+ return resolved
393
+ return _check
394
+
395
+
396
+ # ── Convenienza: dependency per endpoint pubblici (nessun controllo) ─────────
397
+
398
+ async def any_role(resolved: AuthRole = Depends(_resolve_role)) -> AuthRole:
399
+ """Dependency che accetta qualsiasi ruolo (incluso USER senza token).
400
+ Usare per endpoint pubblici che vogliono loggare il ruolo del chiamante."""
401
+ return resolved
api/auth_managed.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/auth_managed.py — OAuth "One-Click" connectors (P38).
2
+
3
+ Rotte:
4
+ GET /api/auth/connect/{provider} → redirect URL OAuth del provider
5
+ GET /api/auth/callback/{provider} → scambia code → token, salva su Supabase cifrato
6
+ GET /api/auth/providers → stato connessione tutti i provider per l'utente
7
+ DELETE /api/auth/disconnect/{provider} → revoca e cancella token da Supabase
8
+
9
+ Provider supportati: github, google, instagram.
10
+ I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
11
+
12
+ Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) — richiede cryptography>=42.
13
+ """
14
+ import os, time, secrets, json, logging, asyncio, hashlib
15
+ from typing import Optional
16
+ from fastapi import APIRouter, Depends, Request, HTTPException
17
+ from .auth_guard import require_role, AuthRole
18
+ from fastapi.responses import RedirectResponse, JSONResponse
19
+ import httpx
20
+
21
+ _logger = logging.getLogger('api.auth_managed')
22
+ router = APIRouter()
23
+
24
+ _DIAGNOSTIC_SECRET_NAMES = (
25
+ 'ADMIN_DIAGNOSTICS_TOKEN', 'INTERNAL_TOKEN', 'PUBLIC_API_TOKEN',
26
+ 'PRIVATE_STATE_INTERNAL_TOKEN', 'SUPABASE_SERVICE_ROLE_KEY',
27
+ 'SUPABASE_SERVICE_ROLE_KEY_B', 'GROQ_API_KEY', 'HF_TOKEN',
28
+ 'GEMINI_API_KEY', 'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
29
+ 'OPENROUTER_API_KEY', 'OPENROUTER_API_KEY_B', 'OPENROUTER_API_KEY_C',
30
+ 'OPENROUTER_PROFILES_JSON', 'NVIDIA_API_KEY', 'NVIDIA_API_KEY_B',
31
+ )
32
+
33
+
34
+ def _secret_fingerprint(name: str) -> dict[str, object]:
35
+ value = os.getenv(name, '').strip()
36
+ return {
37
+ 'configured': bool(value),
38
+ 'length': len(value),
39
+ 'sha256': hashlib.sha256(value.encode('utf-8')).hexdigest()[:16] if value else None,
40
+ }
41
+
42
+
43
+ @router.get('/api/admin/secret-fingerprint')
44
+ async def secret_fingerprint(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
45
+ return {'secrets': {name: _secret_fingerprint(name) for name in _DIAGNOSTIC_SECRET_NAMES}}
46
+
47
+ # ── Fernet encryption setup ──────────────────────────────────────────────────
48
+ # Salt statico pubblico: accettabile per chiave macchina (non password utente).
49
+ # Il VAULT_KEY è il segreto; il salt previene rainbow-table cross-application.
50
+ # ⚠️ ATTENZIONE MIGRAZIONE: cambiare questo salt o la derivazione invalida
51
+ # tutti i token OAuth cifrati esistenti → gli utenti dovranno riconnettere
52
+ # i provider. Questo è intenzionale quando si corregge una derivazione debole.
53
+ _FERNET_SALT = b'agente-ai-vault-v1-pbkdf2'
54
+
55
+ def _get_fernet():
56
+ """Lazy-init Fernet cipher da VAULT_KEY via PBKDF2HMAC-SHA256 (260k iter).
57
+
58
+ Fix P19-SEC2-4: SHA-256 raw (veloce, GPU-bruteforce in ore su chiavi brevi)
59
+ sostituito con PBKDF2HMAC 260_000 iter — conforme NIST SP 800-132 (2023).
60
+ ⚠️ Cambio di derivazione: i token cifrati con SHA-256 non sono più decifrabili.
61
+ _decrypt() ritorna '' su InvalidToken — gli utenti devono riconnettersi.
62
+ """
63
+ from cryptography.fernet import Fernet
64
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
65
+ from cryptography.hazmat.primitives import hashes as _hashes
66
+ import base64
67
+ vault_key = os.getenv('VAULT_KEY', '')
68
+ if not vault_key:
69
+ raise RuntimeError('VAULT_KEY non configurata — impossibile cifrare/decifrare i token OAuth')
70
+ kdf = PBKDF2HMAC(
71
+ algorithm=_hashes.SHA256(),
72
+ length=32,
73
+ salt=_FERNET_SALT,
74
+ iterations=260_000, # NIST SP 800-132 (2023): ≥ 210_000 per PBKDF2-SHA256
75
+ )
76
+ fernet_key = base64.urlsafe_b64encode(kdf.derive(vault_key.encode('utf-8')))
77
+ return Fernet(fernet_key)
78
+
79
+ def _encrypt(text: str) -> str:
80
+ if not text:
81
+ return text
82
+ f = _get_fernet() # P19-SEC2-4: propaga eccezione — niente più plaintext fallback
83
+ return f.encrypt(text.encode()).decode()
84
+
85
+ def _decrypt(token: str) -> str:
86
+ if not token:
87
+ return token
88
+ f = _get_fernet()
89
+ try:
90
+ return f.decrypt(token.encode()).decode()
91
+ except Exception:
92
+ _logger.error('[auth_managed] decrypt fallito — token corrotto o VAULT_KEY cambiata')
93
+ return '' # corrotto o key cambiata
94
+
95
+ # ── OAuth state store (CSRF) — P19-SEC2-5 ────────────────────────────────────
96
+ # Era in-memory dict: rotto con >1 worker/replica (state creato su worker A,
97
+ # consumato su worker B → 404/invalid_state) e perso a ogni restart.
98
+ # Ora persistito su Supabase tabella `oauth_states` (vedi sec01_rls_agent_tasks.sql).
99
+ _STATE_TTL = 600 # 10 minuti
100
+
101
+ # Fallback in-memory SOLO per sviluppo locale senza Supabase configurato.
102
+ _oauth_states_fallback: dict[str, dict] = {}
103
+
104
+ async def _make_state(provider: str, user_id: str) -> str:
105
+ from api.state import _sb
106
+ state = secrets.token_urlsafe(32)
107
+ now = time.time()
108
+ if _sb:
109
+ try:
110
+ await asyncio.to_thread(
111
+ lambda: _sb.table('oauth_states').insert({
112
+ 'state': state,
113
+ 'provider': provider,
114
+ 'user_id': user_id,
115
+ # created_at: lasciato al DEFAULT NOW() della tabella (TIMESTAMPTZ)
116
+ }).execute()
117
+ )
118
+ return state
119
+ except Exception as e:
120
+ _logger.warning('[auth_managed] oauth_states insert fallito, fallback memoria: %s', e)
121
+ # SEC2-7: in produzione (Railway/HF Spaces) il fallback in-memory è pericoloso
122
+ # su multi-replica: state creato su replica A, consumato su replica B → CSRF bypass.
123
+ # In dev locale (_sb assente o Supabase non configurato) il fallback rimane attivo.
124
+ if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
125
+ raise HTTPException(503, detail={
126
+ 'error': 'oauth_state_store_unavailable',
127
+ 'detail': 'Il database OAuth (Supabase) non è raggiungibile. Riprova tra qualche secondo.',
128
+ })
129
+ _purge_states_fallback()
130
+ _oauth_states_fallback[state] = {'provider': provider, 'user_id': user_id, 'created_at': now}
131
+ return state
132
+
133
+ async def _consume_state(state: str) -> Optional[dict]:
134
+ from api.state import _sb
135
+ if _sb:
136
+ try:
137
+ res = await asyncio.to_thread(
138
+ lambda: _sb.table('oauth_states').select('*').eq('state', state).limit(1).execute()
139
+ )
140
+ if res.data:
141
+ row = res.data[0]
142
+ await asyncio.to_thread(
143
+ lambda: _sb.table('oauth_states').delete().eq('state', state).execute()
144
+ )
145
+ # created_at è TIMESTAMPTZ (stringa ISO8601) — parse per calcolare l'età
146
+ try:
147
+ from datetime import datetime, timezone
148
+ created_raw = row.get('created_at', '')
149
+ created_dt = datetime.fromisoformat(created_raw.replace('Z', '+00:00'))
150
+ age_s = (datetime.now(timezone.utc) - created_dt).total_seconds()
151
+ except Exception:
152
+ age_s = 0 # se il parsing fallisce, non blocchiamo il flow OAuth per questo
153
+ if age_s > _STATE_TTL:
154
+ return None
155
+ return {'provider': row['provider'], 'user_id': row['user_id']}
156
+ return None
157
+ except Exception as e:
158
+ _logger.warning('[auth_managed] oauth_states select fallito, fallback memoria: %s', e)
159
+ # SEC2-7: in produzione, se Supabase è irraggiungibile rifiutiamo il state
160
+ # (sicuro: l'utente deve ripetere il flow OAuth). Meglio un 400 che un bypass CSRF.
161
+ if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
162
+ return None
163
+ _purge_states_fallback()
164
+ entry = _oauth_states_fallback.pop(state, None)
165
+ if not entry:
166
+ return None
167
+ if time.time() - entry['created_at'] > _STATE_TTL:
168
+ return None
169
+ return entry
170
+
171
+ def _purge_states_fallback():
172
+ now = time.time()
173
+ expired = [k for k, v in _oauth_states_fallback.items() if now - v['created_at'] > _STATE_TTL]
174
+ for k in expired:
175
+ _oauth_states_fallback.pop(k, None)
176
+
177
+ # ── Provider configs ──────────────────────────────────────────────────────────
178
+ _BACKEND_URL = os.getenv('BACKEND_URL', '').rstrip('/')
179
+
180
+ def _get_callback_url(provider: str) -> str:
181
+ return f"{_BACKEND_URL}/api/auth/callback/{provider}"
182
+
183
+ def _get_frontend_url() -> str:
184
+ """URL frontend da redirigere dopo il callback OAuth."""
185
+ return os.getenv('FRONTEND_URL', 'https://agente-ai.pages.dev')
186
+
187
+ _PROVIDER_CONFIGS = {
188
+ 'github': {
189
+ 'authorize_url': 'https://github.com/login/oauth/authorize',
190
+ 'token_url': 'https://github.com/login/oauth/access_token',
191
+ 'userinfo_url': 'https://api.github.com/user',
192
+ 'scope': 'read:user,repo',
193
+ 'client_id_env': 'GITHUB_OAUTH_CLIENT_ID',
194
+ 'client_secret_env': 'GITHUB_OAUTH_CLIENT_SECRET',
195
+ },
196
+ 'google': {
197
+ 'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
198
+ 'token_url': 'https://oauth2.googleapis.com/token',
199
+ 'userinfo_url': 'https://www.googleapis.com/oauth2/v2/userinfo',
200
+ # P19-SEC2-6: rimosso scope 'calendar' (accesso full R/W al calendario) —
201
+ # non richiesto da nessuna feature attuale, violava il principio del minimo privilegio.
202
+ 'scope': 'openid email profile',
203
+ 'client_id_env': 'GOOGLE_OAUTH_CLIENT_ID',
204
+ 'client_secret_env': 'GOOGLE_OAUTH_CLIENT_SECRET',
205
+ },
206
+ 'instagram': {
207
+ 'authorize_url': 'https://api.instagram.com/oauth/authorize',
208
+ 'token_url': 'https://api.instagram.com/oauth/access_token',
209
+ 'userinfo_url': 'https://graph.instagram.com/me?fields=id,username',
210
+ 'scope': 'user_profile,user_media',
211
+ 'client_id_env': 'INSTAGRAM_CLIENT_ID',
212
+ 'client_secret_env': 'INSTAGRAM_CLIENT_SECRET',
213
+ },
214
+ }
215
+
216
+ # ── Supabase helpers ──────────────────────────────────────────────────────────
217
+
218
+ async def _sb_upsert_token(user_id: str, provider: str, access_token: str,
219
+ refresh_token: str, expires_at: int, scope: str, meta: dict) -> None:
220
+ from api.state import _sb
221
+ if not _sb:
222
+ return
223
+ now = int(time.time() * 1000)
224
+ payload = {
225
+ 'user_id': user_id,
226
+ 'provider': provider,
227
+ 'access_token': _encrypt(access_token),
228
+ 'refresh_token': _encrypt(refresh_token) if refresh_token else '',
229
+ 'expires_at': expires_at,
230
+ 'scope': scope,
231
+ 'raw_meta': json.dumps(meta)[:4000],
232
+ 'created_at': now,
233
+ 'updated_at': now,
234
+ }
235
+ try:
236
+ await asyncio.to_thread(
237
+ lambda: _sb.table('managed_tokens')
238
+ .upsert(payload, on_conflict='user_id,provider')
239
+ .execute()
240
+ )
241
+ except Exception as e:
242
+ _logger.warning('[auth_managed] upsert token %s/%s: %s', user_id, provider, e)
243
+
244
+
245
+ async def _sb_get_token(user_id: str, provider: str) -> Optional[dict]:
246
+ from api.state import _sb
247
+ if not _sb:
248
+ return None
249
+ try:
250
+ res = await asyncio.to_thread(
251
+ lambda: _sb.table('managed_tokens')
252
+ .select('provider,scope,expires_at,updated_at,raw_meta,access_token,refresh_token')
253
+ .eq('user_id', user_id)
254
+ .eq('provider', provider)
255
+ .limit(1)
256
+ .execute()
257
+ )
258
+ return res.data[0] if res.data else None
259
+ except Exception as e:
260
+ _logger.debug('[auth_managed] get_token %s/%s: %s', user_id, provider, e)
261
+ return None
262
+
263
+
264
+ async def _sb_list_tokens(user_id: str) -> list[dict]:
265
+ from api.state import _sb
266
+ if not _sb:
267
+ return []
268
+ try:
269
+ res = await asyncio.to_thread(
270
+ lambda: _sb.table('managed_tokens')
271
+ .select('provider,scope,expires_at,updated_at,raw_meta')
272
+ .eq('user_id', user_id)
273
+ .execute()
274
+ )
275
+ return res.data or []
276
+ except Exception as e:
277
+ _logger.debug('[auth_managed] list_tokens %s: %s', user_id, e)
278
+ return []
279
+
280
+
281
+ async def _sb_delete_token(user_id: str, provider: str) -> None:
282
+ from api.state import _sb
283
+ if not _sb:
284
+ return
285
+ try:
286
+ await asyncio.to_thread(
287
+ lambda: _sb.table('managed_tokens')
288
+ .delete()
289
+ .eq('user_id', user_id)
290
+ .eq('provider', provider)
291
+ .execute()
292
+ )
293
+ except Exception as e:
294
+ _logger.warning('[auth_managed] delete_token %s/%s: %s', user_id, provider, e)
295
+
296
+
297
+ # Public helper: altri moduli chiamano questa per ottenere un token decifrato
298
+ async def get_managed_token(user_id: str, provider: str) -> Optional[str]:
299
+ """Restituisce il token d'accesso decifrato per (user_id, provider). None se non connesso.
300
+
301
+ P19-SEC2-8: prima ritornava sempre il token salvato, anche se scaduto da
302
+ tempo (provider come Google li invalidano dopo ~1h) → chiamate a valle
303
+ fallivano silenziosamente con 401. Ora, se scaduto e c'è un refresh_token,
304
+ tenta il refresh presso il provider prima di restituire.
305
+ """
306
+ row = await _sb_get_token(user_id, provider)
307
+ if not row:
308
+ return None
309
+
310
+ exp = row.get('expires_at', 0)
311
+ now_ms = int(time.time() * 1000)
312
+ if exp and exp > now_ms + 60_000: # ancora valido per >60s
313
+ return _decrypt(row['access_token'])
314
+
315
+ encrypted_refresh = row.get('refresh_token', '')
316
+ if not encrypted_refresh:
317
+ # Niente refresh_token: ritorna quello che c'è (potrebbe essere già scaduto)
318
+ return _decrypt(row['access_token'])
319
+
320
+ cfg = _PROVIDER_CONFIGS.get(provider)
321
+ if not cfg:
322
+ return _decrypt(row['access_token'])
323
+
324
+ refresh_token = _decrypt(encrypted_refresh)
325
+ if not refresh_token:
326
+ return _decrypt(row['access_token'])
327
+
328
+ client_id = os.getenv(cfg['client_id_env'], '')
329
+ client_secret = os.getenv(cfg['client_secret_env'], '')
330
+ try:
331
+ async with httpx.AsyncClient(timeout=15) as http:
332
+ resp = await http.post(
333
+ cfg['token_url'],
334
+ data={
335
+ 'grant_type': 'refresh_token',
336
+ 'refresh_token': refresh_token,
337
+ 'client_id': client_id,
338
+ 'client_secret': client_secret,
339
+ },
340
+ headers={'Accept': 'application/json'},
341
+ )
342
+ if resp.status_code != 200:
343
+ _logger.warning('[auth_managed] refresh token fallito %s/%s: %s', user_id, provider, resp.text[:200])
344
+ return _decrypt(row['access_token'])
345
+
346
+ tok_json = resp.json()
347
+ new_access = tok_json.get('access_token', '')
348
+ new_refresh = tok_json.get('refresh_token', refresh_token) # alcuni provider non lo riemettono
349
+ expires_in = tok_json.get('expires_in', 0)
350
+ new_expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
351
+ if not new_access:
352
+ return _decrypt(row['access_token'])
353
+
354
+ await _sb_upsert_token(user_id, provider, new_access, new_refresh,
355
+ new_expires_at, row.get('scope', cfg['scope']),
356
+ json.loads(row.get('raw_meta') or '{}') if isinstance(row.get('raw_meta'), str) else {})
357
+ _logger.info('[auth_managed] token refreshed %s/%s (expires_at=%d)', user_id, provider, new_expires_at)
358
+ return new_access
359
+ except Exception as e:
360
+ _logger.error('[auth_managed] refresh exception %s/%s: %s', user_id, provider, e)
361
+ return _decrypt(row['access_token'])
362
+
363
+
364
+ # ── Routes ────────────────────────────────────────────────────────────────────
365
+
366
+ def _user_id(request: Request) -> str:
367
+ # P19-SEC2-7: NON fidarsi di X-User-ID lato client — permetteva a chiunque di
368
+ # impersonare/leggere i token OAuth di un altro utente semplicemente inviando
369
+ # un header diverso. L'app è single-tenant (un solo utente reale), quindi si
370
+ # usa sempre 'default'. Se in futuro serve multi-tenant, l'identità va
371
+ # derivata da una sessione autenticata server-side (JWT/cookie firmato),
372
+ # mai da un header controllato dal client.
373
+ return 'default'
374
+
375
+
376
+ @router.get('/api/auth/connect/{provider}')
377
+ async def connect_provider(provider: str, request: Request):
378
+ """Genera l'URL OAuth e redirige il browser dell'utente."""
379
+ cfg = _PROVIDER_CONFIGS.get(provider)
380
+ if not cfg:
381
+ raise HTTPException(400, detail={'error': 'unknown_provider', 'provider': provider})
382
+
383
+ client_id = os.getenv(cfg['client_id_env'], '')
384
+ if not client_id:
385
+ raise HTTPException(503, detail={
386
+ 'error': 'provider_not_configured',
387
+ 'provider': provider,
388
+ 'hint': f"Set {cfg['client_id_env']} in HuggingFace Spaces secrets.",
389
+ })
390
+
391
+ user_id = _user_id(request)
392
+ state = await _make_state(provider, user_id)
393
+ callback = _get_callback_url(provider)
394
+
395
+ params = {
396
+ 'client_id': client_id,
397
+ 'redirect_uri': callback,
398
+ 'scope': cfg['scope'],
399
+ 'state': state,
400
+ 'response_type': 'code',
401
+ }
402
+ # Google richiede access_type=offline per il refresh_token
403
+ if provider == 'google':
404
+ params['access_type'] = 'offline'
405
+ params['prompt'] = 'consent'
406
+
407
+ from urllib.parse import urlencode
408
+ auth_url = cfg['authorize_url'] + '?' + urlencode(params)
409
+ _logger.info('[auth_managed] connect %s → redirect %s', provider, auth_url[:80])
410
+ return RedirectResponse(url=auth_url, status_code=302)
411
+
412
+
413
+ @router.get('/api/auth/callback/{provider}')
414
+ async def oauth_callback(provider: str, request: Request):
415
+ """Riceve il code dal provider, scambia con token, salva su Supabase."""
416
+ cfg = _PROVIDER_CONFIGS.get(provider)
417
+ if not cfg:
418
+ raise HTTPException(400, detail={'error': 'unknown_provider'})
419
+
420
+ code = request.query_params.get('code', '')
421
+ state = request.query_params.get('state', '')
422
+ error = request.query_params.get('error', '')
423
+
424
+ frontend = _get_frontend_url()
425
+
426
+ if error:
427
+ _logger.warning('[auth_managed] callback %s error=%s', provider, error)
428
+ return RedirectResponse(url=f"{frontend}?oauth_error={error}&provider={provider}")
429
+
430
+ state_data = await _consume_state(state)
431
+ if not state_data:
432
+ _logger.warning('[auth_managed] invalid/expired state %s', state[:20])
433
+ return RedirectResponse(url=f"{frontend}?oauth_error=invalid_state&provider={provider}")
434
+
435
+ user_id = state_data['user_id']
436
+
437
+ client_id = os.getenv(cfg['client_id_env'], '')
438
+ client_secret = os.getenv(cfg['client_secret_env'], '')
439
+ callback_url = _get_callback_url(provider)
440
+
441
+ # Scambia code → token
442
+ try:
443
+ async with httpx.AsyncClient(timeout=15) as http:
444
+ token_resp = await http.post(
445
+ cfg['token_url'],
446
+ data={
447
+ 'code': code,
448
+ 'client_id': client_id,
449
+ 'client_secret': client_secret,
450
+ 'redirect_uri': callback_url,
451
+ 'grant_type': 'authorization_code',
452
+ },
453
+ headers={'Accept': 'application/json'},
454
+ )
455
+ if token_resp.status_code != 200:
456
+ _logger.error('[auth_managed] token exchange %s: %s', provider, token_resp.text[:200])
457
+ return RedirectResponse(url=f"{frontend}?oauth_error=token_exchange&provider={provider}")
458
+
459
+ tok_json = token_resp.json()
460
+ access_token = tok_json.get('access_token', '')
461
+ refresh_token = tok_json.get('refresh_token', '')
462
+ scope = tok_json.get('scope', cfg['scope'])
463
+ expires_in = tok_json.get('expires_in', 0)
464
+ expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
465
+
466
+ if not access_token:
467
+ _logger.error('[auth_managed] no access_token from %s: %s', provider, tok_json)
468
+ return RedirectResponse(url=f"{frontend}?oauth_error=no_token&provider={provider}")
469
+
470
+ # Recupera metadata utente (opzionale, soft fail)
471
+ meta: dict = {}
472
+ try:
473
+ async with httpx.AsyncClient(timeout=8) as http:
474
+ me_resp = await http.get(
475
+ cfg['userinfo_url'],
476
+ headers={'Authorization': f'Bearer {access_token}', 'Accept': 'application/json'},
477
+ )
478
+ if me_resp.status_code == 200:
479
+ meta = me_resp.json()
480
+ except Exception:
481
+ pass
482
+
483
+ await _sb_upsert_token(user_id, provider, access_token, refresh_token,
484
+ expires_at, str(scope), meta)
485
+ _logger.info('[auth_managed] token saved %s/%s (expires_at=%d)', user_id, provider, expires_at)
486
+
487
+ except Exception as e:
488
+ _logger.error('[auth_managed] callback exception %s: %s', provider, e)
489
+ return RedirectResponse(url=f"{frontend}?oauth_error=server_error&provider={provider}")
490
+
491
+ # Redirect frontend con successo
492
+ return RedirectResponse(url=f"{frontend}?oauth_success=1&provider={provider}", status_code=302)
493
+
494
+
495
+ @router.get('/api/auth/providers')
496
+ async def list_providers(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: info-disclosure
497
+ """Restituisce stato connessione di tutti i provider per l'utente corrente."""
498
+ user_id = _user_id(request)
499
+ rows = await _sb_list_tokens(user_id)
500
+ connected = {r['provider']: r for r in rows}
501
+
502
+ now_ms = int(time.time() * 1000)
503
+ result = []
504
+ for pname, cfg in _PROVIDER_CONFIGS.items():
505
+ row = connected.get(pname)
506
+ is_configured = bool(os.getenv(cfg['client_id_env'], ''))
507
+ if row:
508
+ exp = row.get('expires_at', 0)
509
+ meta_raw = row.get('raw_meta', '{}')
510
+ try:
511
+ meta = json.loads(meta_raw) if isinstance(meta_raw, str) else meta_raw
512
+ except Exception:
513
+ meta = {}
514
+ result.append({
515
+ 'provider': pname,
516
+ 'connected': True,
517
+ 'configured': is_configured,
518
+ 'expired': bool(exp and exp < now_ms),
519
+ 'scope': row.get('scope', ''),
520
+ 'updated_at': row.get('updated_at', 0),
521
+ 'username': meta.get('login') or meta.get('email') or meta.get('username', ''),
522
+ })
523
+ else:
524
+ result.append({
525
+ 'provider': pname,
526
+ 'connected': False,
527
+ 'configured': is_configured,
528
+ 'expired': False,
529
+ 'scope': '',
530
+ 'updated_at': 0,
531
+ 'username': '',
532
+ })
533
+
534
+ return JSONResponse({'providers': result, 'user_id': user_id})
535
+
536
+
537
+ @router.delete('/api/auth/disconnect/{provider}')
538
+ async def disconnect_provider(provider: str, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
539
+ """Rimuove il token salvato per il provider specificato."""
540
+ if provider not in _PROVIDER_CONFIGS:
541
+ raise HTTPException(400, detail={'error': 'unknown_provider'})
542
+ user_id = _user_id(request)
543
+ await _sb_delete_token(user_id, provider)
544
+ _logger.info('[auth_managed] disconnected %s/%s', user_id, provider)
545
+ return JSONResponse({'ok': True, 'provider': provider})
api/background_tasks.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supervision utilities for long-lived background asyncio tasks."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from collections.abc import Awaitable
7
+ from typing import Any
8
+
9
+ _logger = logging.getLogger("agente_ai.background_tasks")
10
+ _tasks: dict[str, asyncio.Task[Any]] = {}
11
+
12
+
13
+ def spawn_background_task(coro: Awaitable[Any], *, name: str) -> asyncio.Task[Any]:
14
+ """Start one named background task and retain it for lifecycle shutdown.
15
+
16
+ A live task with the same name is reused. The passed coroutine is closed in
17
+ that case so duplicate startup calls do not leak an un-awaited coroutine.
18
+ """
19
+ current = _tasks.get(name)
20
+ if current is not None and not current.done():
21
+ close = getattr(coro, "close", None)
22
+ if close is not None:
23
+ close()
24
+ return current
25
+
26
+ task = asyncio.create_task(coro, name=name)
27
+ _tasks[name] = task
28
+
29
+ def _report(task_result: asyncio.Task[Any]) -> None:
30
+ if task_result.cancelled():
31
+ return
32
+ try:
33
+ error = task_result.exception()
34
+ except asyncio.CancelledError:
35
+ return
36
+ if error is not None:
37
+ _logger.error("background task %s failed: %s", name, error, exc_info=error)
38
+
39
+ task.add_done_callback(_report)
40
+ return task
41
+
42
+
43
+ async def shutdown_background_tasks() -> None:
44
+ """Cancel and await all supervised background tasks."""
45
+ tasks = [task for task in _tasks.values() if not task.done()]
46
+ for task in tasks:
47
+ task.cancel()
48
+ if tasks:
49
+ await asyncio.gather(*tasks, return_exceptions=True)
50
+ _tasks.clear()
51
+
52
+
53
+ __all__ = ["spawn_background_task", "shutdown_background_tasks"]
api/benchmark.py ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/benchmark.py — Self-test endpoint server-side (S-BENCH).
2
+
3
+ Endpoint: GET /api/debug/benchmark?token=<daily_hmac>
4
+
5
+ Auth: HMAC-SHA256(seed=_BENCH_SEED, msg=YYYY-MM-DD) — calcolabile da Replit
6
+ senza dover conoscere INTERNAL_TOKEN. Token cambia ogni giorno (replay protection).
7
+ Zero config aggiuntivo su HF Spaces.
8
+
9
+ Il benchmark esegue ~20 test interni e restituisce JSON con:
10
+ { ok, score, pass, fail, warn, duration_ms, tests: [...], gaps: [...] }
11
+
12
+ Uso da Replit:
13
+ python3 -c "
14
+ import hmac, hashlib, datetime
15
+ seed = 'agente-ai-bench-2026'
16
+ day = datetime.date.today().isoformat()
17
+ tok = hmac.new(seed.encode(), day.encode(), hashlib.sha256).hexdigest()
18
+ print(tok)
19
+ "
20
+ curl '<hf-space-a-url>/api/debug/benchmark?token=<tok>'
21
+ """
22
+ import os, sys, asyncio, time, hmac, hashlib, datetime, importlib, tempfile, subprocess, re, uuid
23
+ from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Query, Request
24
+ from .auth_guard import require_role, AuthRole
25
+ from fastapi.responses import JSONResponse
26
+
27
+ router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
28
+
29
+ # ── Seed per HMAC daily token — NON è un secret, è solo anti-scraping ─────────
30
+ _BENCH_SEED = "agente-ai-bench-2026"
31
+
32
+
33
+ def _daily_token() -> str:
34
+ day = datetime.date.today().isoformat()
35
+ return hmac.new(_BENCH_SEED.encode(), day.encode(), hashlib.sha256).hexdigest()
36
+
37
+
38
+ # ── Result helpers ──────────────────────────────────────────────────────────────
39
+ def _ok(id: str, desc: str, note: str = "") -> dict:
40
+ return {"id": id, "desc": desc, "ok": True, "warn": False, "note": note}
41
+
42
+ def _ko(id: str, desc: str, note: str = "") -> dict:
43
+ return {"id": id, "desc": desc, "ok": False, "warn": False, "note": note}
44
+
45
+ def _wn(id: str, desc: str, note: str = "") -> dict:
46
+ return {"id": id, "desc": desc, "ok": False, "warn": True, "note": note}
47
+
48
+
49
+ async def _run_tests() -> list[dict]:
50
+ results: list[dict] = []
51
+ t_global = time.monotonic()
52
+
53
+ # ── T01: Sprint / version ─────────────────────────────────────────────────
54
+ try:
55
+ from api.providers import api_version
56
+ ver = await api_version() if asyncio.iscoroutinefunction(api_version) else api_version()
57
+ sprint = ver.get("sprint", "?") if isinstance(ver, dict) else getattr(ver, "sprint", "?")
58
+ body = ver if isinstance(ver, dict) else ver.__dict__
59
+ results.append(_ok("T01", f"Sprint: {sprint} — {body.get('version','?')}", f"build={body.get('build_date','?')}"))
60
+ except Exception as e:
61
+ results.append(_ko("T01", "Sprint/version import fallito", str(e)))
62
+
63
+ # ── T02: INTERNAL_TOKEN configurato ──────────────────────────────────────
64
+ itok = os.getenv("INTERNAL_TOKEN", "")
65
+ if itok and len(itok) >= 16:
66
+ results.append(_ok("T02", "INTERNAL_TOKEN configurato in env", f"len={len(itok)}"))
67
+ elif itok:
68
+ results.append(_wn("T02", "INTERNAL_TOKEN troppo corto (< 16 chars)", f"len={len(itok)}"))
69
+ else:
70
+ results.append(_wn("T02", "INTERNAL_TOKEN non configurato — token effimero generato a ogni boot"))
71
+
72
+ # ── T03: UnifiedAgentLoop import ─────────────────────────────────────────
73
+ t = time.monotonic()
74
+ try:
75
+ from agents.unified_loop import UnifiedAgentLoop
76
+ ms = int((time.monotonic() - t) * 1000)
77
+ results.append(_ok("T03", f"UnifiedAgentLoop import OK ({ms}ms)"))
78
+ except Exception as e:
79
+ ms = int((time.monotonic() - t) * 1000)
80
+ results.append(_ko("T03", "UnifiedAgentLoop import FALLITO", str(e)[:120]))
81
+
82
+ # ── T04: RoleRouter + Role.FAST ──────────────────────────────────────────
83
+ t = time.monotonic()
84
+ try:
85
+ from models.role_router import RoleRouter, Role
86
+ fast_role = Role.FAST
87
+ client = RoleRouter.get_client(Role.FAST)
88
+ ms = int((time.monotonic() - t) * 1000)
89
+ provider = getattr(client, "provider_name", type(client).__name__)
90
+ results.append(_ok("T04", f"Role.FAST client OK ({ms}ms) — provider={provider}"))
91
+ except Exception as e:
92
+ ms = int((time.monotonic() - t) * 1000)
93
+ results.append(_ko("T04", f"Role.FAST client FALLITO ({ms}ms)", str(e)[:120]))
94
+
95
+ # ── T05: Python exec via asyncio subprocess ───────────────────────────────
96
+ t = time.monotonic()
97
+ try:
98
+ proc = await asyncio.wait_for(
99
+ asyncio.create_subprocess_exec(
100
+ sys.executable, "-c",
101
+ "import sys; print(f'py{sys.version_info.major}.{sys.version_info.minor} ok')",
102
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
103
+ ),
104
+ timeout=10.0,
105
+ )
106
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10.0)
107
+ ms = int((time.monotonic() - t) * 1000)
108
+ out = stdout.decode().strip()
109
+ if "ok" in out:
110
+ results.append(_ok("T05", f"Python exec subprocess OK ({ms}ms)", out))
111
+ else:
112
+ results.append(_ko("T05", f"Python exec output inatteso ({ms}ms)", out[:80]))
113
+ except Exception as e:
114
+ ms = int((time.monotonic() - t) * 1000)
115
+ results.append(_ko("T05", f"Python exec FALLITO ({ms}ms)", str(e)[:120]))
116
+
117
+ # ── T06: Shell echo + date ────────────────────────────────────────────────
118
+ t = time.monotonic()
119
+ try:
120
+ with tempfile.TemporaryDirectory() as tmpdir:
121
+ proc = await asyncio.wait_for(
122
+ asyncio.create_subprocess_shell(
123
+ "echo bench_ok && date +%s",
124
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
125
+ cwd=tmpdir,
126
+ ),
127
+ timeout=8.0,
128
+ )
129
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=8.0)
130
+ ms = int((time.monotonic() - t) * 1000)
131
+ out = stdout.decode().strip()
132
+ if "bench_ok" in out:
133
+ results.append(_ok("T06", f"Shell execute OK ({ms}ms)", out[:60]))
134
+ else:
135
+ results.append(_ko("T06", f"Shell output inatteso ({ms}ms)", out[:60]))
136
+ except Exception as e:
137
+ ms = int((time.monotonic() - t) * 1000)
138
+ results.append(_ko("T06", f"Shell execute FALLITO ({ms}ms)", str(e)[:120]))
139
+
140
+ # ── T07: Shell denylist — BLOCKED_CMDS + _EXEC_BLOCKED_RE ──────────────────
141
+ # exec.py ha due meccanismi: BLOCKED_CMDS (pattern esatti per /api/execute-shell)
142
+ # e _EXEC_BLOCKED_RE (regex per /api/exec sandbox Python).
143
+ # Test: verifica che almeno uno dei due meccanismi esista e funzioni.
144
+ try:
145
+ from api.exec import BLOCKED_CMDS
146
+ # BLOCKED_CMDS deve contenere pattern per i comandi più pericolosi
147
+ # Realtà: {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
148
+ must_have = ["rm -rf /", "mkfs", "dd if=/dev/zero"]
149
+ present = [p for p in must_have if any(p in b or b in p for b in BLOCKED_CMDS)]
150
+ if len(present) >= 2:
151
+ results.append(_ok("T07", f"Shell denylist OK — BLOCKED_CMDS: {len(BLOCKED_CMDS)} pattern",
152
+ f"include: {list(BLOCKED_CMDS)[:3]}"))
153
+ else:
154
+ results.append(_wn("T07", f"Shell denylist parziale — {len(present)}/{len(must_have)} pattern critici",
155
+ f"BLOCKED_CMDS={list(BLOCKED_CMDS)}"))
156
+ except ImportError:
157
+ try:
158
+ from api.exec import _EXEC_BLOCKED_RE
159
+ results.append(_ok("T07", "Shell denylist OK — _EXEC_BLOCKED_RE presente"))
160
+ except ImportError:
161
+ results.append(_wn("T07", "denylist non importabile da api.exec (modulo assente)"))
162
+
163
+ # ── T08: asyncio.Semaphore S734 ──────────────────────────────────────────
164
+ try:
165
+ from agents.unified_loop_tools import DirectToolsMixin
166
+ src_path = importlib.util.find_spec("agents.unified_loop_tools")
167
+ if src_path:
168
+ import inspect
169
+ src = inspect.getsource(DirectToolsMixin)
170
+ if "asyncio.Semaphore(4)" in src or "Semaphore" in src:
171
+ results.append(_ok("T08", "asyncio.Semaphore S734 presente in DirectToolsMixin"))
172
+ else:
173
+ results.append(_wn("T08", "asyncio.Semaphore non trovato in DirectToolsMixin"))
174
+ else:
175
+ results.append(_wn("T08", "unified_loop_tools non trovato"))
176
+ except Exception as e:
177
+ results.append(_wn("T08", "S734 check non eseguibile", str(e)[:80]))
178
+
179
+ # ── T09: Provider env keys — tutti i provider supportati ─────────────────
180
+ # Aggiornato 2026-06-14: aggiunto SAMBANOVA_API_KEY (DeepSeek-V3.1 100% bench)
181
+ providers_conf = {
182
+ "GROQ_API_KEY": "Groq",
183
+ "OPENROUTER_API_KEY": "OpenRouter",
184
+ "GEMINI_API_KEY": "Gemini",
185
+ "HF_TOKEN": "HuggingFace",
186
+ "CEREBRAS_API_KEY": "Cerebras",
187
+ "SAMBANOVA_API_KEY": "SambaNova",
188
+ }
189
+ configured = [name for key, name in providers_conf.items() if os.getenv(key)]
190
+ missing = [name for key, name in providers_conf.items() if not os.getenv(key)]
191
+ if len(configured) >= 2:
192
+ results.append(_ok("T09", f"Provider keys: {len(configured)}/{len(providers_conf)} configurati", ", ".join(configured)))
193
+ elif len(configured) == 1:
194
+ results.append(_wn("T09", f"Provider keys: solo 1/{len(providers_conf)} ({configured[0]}) — fallback chain ridotta", f"mancanti: {', '.join(missing)}"))
195
+ else:
196
+ results.append(_ko("T09", "Nessuna provider API key configurata!", f"mancanti: {', '.join(missing)}"))
197
+
198
+ # ── T10: SQLite DB write/read ─────────────────────────────────────────────
199
+ t = time.monotonic()
200
+ try:
201
+ import sqlite3
202
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=True) as f:
203
+ db_path = f.name
204
+ conn = sqlite3.connect(db_path)
205
+ conn.execute("CREATE TABLE bench (k TEXT, v TEXT)")
206
+ conn.execute("INSERT INTO bench VALUES ('test', 'bench_ok')")
207
+ conn.commit()
208
+ row = conn.execute("SELECT v FROM bench WHERE k='test'").fetchone()
209
+ conn.close()
210
+ os.unlink(db_path)
211
+ ms = int((time.monotonic() - t) * 1000)
212
+ if row and row[0] == "bench_ok":
213
+ results.append(_ok("T10", f"SQLite write/read OK ({ms}ms)"))
214
+ else:
215
+ results.append(_ko("T10", f"SQLite round-trip fallito ({ms}ms)", str(row)))
216
+ except Exception as e:
217
+ ms = int((time.monotonic() - t) * 1000)
218
+ results.append(_ko("T10", f"SQLite FALLITO ({ms}ms)", str(e)[:120]))
219
+
220
+ # ── T11: /tmp write + read ────────────────────────────────────────────────
221
+ t = time.monotonic()
222
+ try:
223
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".bench", delete=False) as f:
224
+ f.write("bench_filesystem_ok")
225
+ fname = f.name
226
+ with open(fname) as _bf: content = _bf.read()
227
+ os.unlink(fname)
228
+ ms = int((time.monotonic() - t) * 1000)
229
+ if content == "bench_filesystem_ok":
230
+ results.append(_ok("T11", f"/tmp filesystem write/read OK ({ms}ms)"))
231
+ else:
232
+ results.append(_ko("T11", f"/tmp read mismatch ({ms}ms)", content[:40]))
233
+ except Exception as e:
234
+ ms = int((time.monotonic() - t) * 1000)
235
+ results.append(_ko("T11", f"/tmp filesystem FALLITO ({ms}ms)", str(e)[:120]))
236
+
237
+ # ── T12: asyncio concurrency — 5 task paralleli ───────────────────────────
238
+ t = time.monotonic()
239
+ try:
240
+ async def _noop(i: int) -> int:
241
+ await asyncio.sleep(0.01)
242
+ return i * 2
243
+ outcomes = await asyncio.gather(*[_noop(i) for i in range(5)])
244
+ ms = int((time.monotonic() - t) * 1000)
245
+ if outcomes == [0, 2, 4, 6, 8]:
246
+ results.append(_ok("T12", f"asyncio concurrency 5x tasks OK ({ms}ms)"))
247
+ else:
248
+ results.append(_ko("T12", f"asyncio concurrency output inatteso ({ms}ms)", str(outcomes)))
249
+ except Exception as e:
250
+ ms = int((time.monotonic() - t) * 1000)
251
+ results.append(_ko("T12", f"asyncio concurrency FALLITA ({ms}ms)", str(e)[:120]))
252
+
253
+ # ── T13: Memory manager import ────────────────────────────────────────────
254
+ t = time.monotonic()
255
+ try:
256
+ from memory.manager import MemoryManager
257
+ ms = int((time.monotonic() - t) * 1000)
258
+ results.append(_ok("T13", f"MemoryManager import OK ({ms}ms)"))
259
+ except Exception as e:
260
+ ms = int((time.monotonic() - t) * 1000)
261
+ results.append(_wn("T13", f"MemoryManager import WARN ({ms}ms)", str(e)[:80]))
262
+
263
+ # ── T14: Role.FAST _run_fast_path — verifica wiring nel codice sorgente ──
264
+ try:
265
+ import inspect
266
+ from agents.unified_loop import UnifiedAgentLoop
267
+ src = inspect.getsource(UnifiedAgentLoop)
268
+ has_fast_llm = "_fast_llm" in src
269
+ has_get_fast = "_get_fast_llm" in src
270
+ has_fast_client = "_fast_client" in src
271
+ if has_fast_llm and has_get_fast and has_fast_client:
272
+ results.append(_ok("T14", "Role.FAST wiring completo in UnifiedAgentLoop",
273
+ "_fast_llm + _get_fast_llm() + _fast_client.chat()"))
274
+ else:
275
+ missing = [k for k, v in [("_fast_llm", has_fast_llm), ("_get_fast_llm", has_get_fast), ("_fast_client", has_fast_client)] if not v]
276
+ results.append(_ko("T14", "Role.FAST wiring incompleto", f"mancanti: {missing}"))
277
+ except Exception as e:
278
+ results.append(_ko("T14", "Role.FAST wiring check FALLITO", str(e)[:120]))
279
+
280
+ # ── T15: Python deps critici importabili ──────────────────────────────────
281
+ critical_deps = ["fastapi", "pydantic", "httpx", "aiohttp", "groq", "google.generativeai"]
282
+ dep_ok, dep_ko = [], []
283
+ for dep in critical_deps:
284
+ try:
285
+ importlib.import_module(dep)
286
+ dep_ok.append(dep)
287
+ except ImportError:
288
+ dep_ko.append(dep)
289
+ if not dep_ko:
290
+ results.append(_ok("T15", f"Deps critici: tutti {len(dep_ok)} importabili", ", ".join(dep_ok)))
291
+ elif len(dep_ko) <= 2:
292
+ results.append(_wn("T15", f"Deps critici: {len(dep_ko)} mancanti", f"ko={dep_ko}"))
293
+ else:
294
+ results.append(_ko("T15", f"Deps critici: {len(dep_ko)}/{len(critical_deps)} mancanti", f"ko={dep_ko}"))
295
+
296
+ # ── T16: Groq FAST path — latenza client init ────────────────────────────
297
+ t = time.monotonic()
298
+ groq_key = os.getenv("GROQ_API_KEY", "")
299
+ if groq_key:
300
+ try:
301
+ from models.role_router import RoleRouter, Role
302
+ client = RoleRouter.get_client(Role.FAST)
303
+ ms = int((time.monotonic() - t) * 1000)
304
+ results.append(_ok("T16", f"Groq FAST client init ({ms}ms)",
305
+ getattr(client, "provider_name", type(client).__name__)))
306
+ except Exception as e:
307
+ ms = int((time.monotonic() - t) * 1000)
308
+ results.append(_ko("T16", f"Groq FAST client FALLITO ({ms}ms)", str(e)[:120]))
309
+ else:
310
+ results.append(_wn("T16", "GROQ_API_KEY non configurata — Role.FAST userà self.llm come fallback"))
311
+
312
+ # ── T17: Latenza totale benchmark ─────────────────────────────────────────
313
+ total_ms = int((time.monotonic() - t_global) * 1000)
314
+ results.append(_ok("T17", f"Benchmark completato in {total_ms}ms",
315
+ f"{'OK' if total_ms < 5000 else 'SLOW'} (target <5s)"))
316
+
317
+ return results
318
+
319
+
320
+ @router.get("/api/debug/benchmark")
321
+ async def run_benchmark(
322
+ token: str = Query(..., description="Daily HMAC token — vedi docstring modulo"),
323
+ pretty: bool = Query(False, description="Output human-readable invece di JSON compatto"),
324
+ ):
325
+ """S-BENCH: Self-test server-side completo — zero dipendenza da Replit.
326
+
327
+ Auth: HMAC-SHA256 daily token (seed fisso in codice, non è un secret).
328
+ Calcola il token del giorno con:
329
+ python3 -c "import hmac,hashlib,datetime; print(hmac.new(b'agente-ai-bench-2026', datetime.date.today().isoformat().encode(), hashlib.sha256).hexdigest())"
330
+ """
331
+ expected = _daily_token()
332
+ if not hmac.compare_digest(token, expected):
333
+ raise HTTPException(
334
+ status_code=401,
335
+ detail={
336
+ "error": "Token non valido o scaduto (cambia ogni giorno).",
337
+ "hint": "python3 -c \"import hmac,hashlib,datetime; "
338
+ "print(hmac.new(b'agente-ai-bench-2026', datetime.date.today().isoformat().encode(), hashlib.sha256).hexdigest())\"",
339
+ },
340
+ )
341
+
342
+ t0 = time.monotonic()
343
+ results = await _run_tests()
344
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
345
+
346
+ pass_n = sum(1 for r in results if r["ok"])
347
+ fail_n = sum(1 for r in results if not r["ok"] and not r["warn"])
348
+ warn_n = sum(1 for r in results if r["warn"])
349
+ total_n = len(results)
350
+ score = round(pass_n / max(1, pass_n + fail_n) * 100)
351
+
352
+ gaps = [r for r in results if not r["ok"] and not r["warn"]]
353
+
354
+ payload = {
355
+ "ok": fail_n == 0,
356
+ "score": score,
357
+ "pass": pass_n,
358
+ "fail": fail_n,
359
+ "warn": warn_n,
360
+ "total": total_n,
361
+ "duration_ms": elapsed_ms,
362
+ "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
363
+ "tests": results,
364
+ "gaps": [{"id": r["id"], "desc": r["desc"], "note": r.get("note", "")} for r in gaps],
365
+ }
366
+ return JSONResponse(content=payload, status_code=200 if fail_n == 0 else 207)
367
+
368
+
369
+ # ═══════════════════════════════════════════════════════════════════════════════
370
+ # QUALITY BENCHMARK — misura miglioramenti LLM per sprint (S-BENCH-Q)
371
+ # POST /api/benchmark/quality/run → avvia background task, ritorna task_id
372
+ # GET /api/benchmark/quality/status/{id} → polling risultati
373
+ #
374
+ # Per ogni categoria agente (DA / ORCH / MC / REC):
375
+ # 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
376
+ # 2. Chiama il LLM (ARCHITECT = openai/gpt-oss-120b) a temperatura 0.3
377
+ # 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
378
+ # 4. Produce score 0-100 per categoria + media totale
379
+ #
380
+ # Auth: stesso token HMAC daily del /api/debug/benchmark.
381
+ # ═══════════════════════════════════════════════════════════════════════════════
382
+
383
+ _QUALITY_RUNS: dict[str, dict] = {} # task_id → {status, results, …}
384
+
385
+ # ── Definizione task benchmark qualità ─────────────────────────────────────────
386
+ _QUALITY_TASKS = [
387
+ {
388
+ "id": "DA",
389
+ "category": "data_analysis",
390
+ "label": "Analisi Dati",
391
+ "goal_for_rules": "analisi dati vendite statistiche media picco anomalia trend",
392
+ "user_prompt": (
393
+ "Analizza questi dati di vendite mensili:\n"
394
+ "Gen=100, Feb=120, Mar=80, Apr=150, Mag=90, Giu=200.\n\n"
395
+ "Fornisci un'analisi strutturata con Media, Picco, Anomalia e Trend."
396
+ ),
397
+ "criteria": [
398
+ {"id": "media", "label": "**Media: N**", "weight": 25},
399
+ {"id": "picco", "label": "**Picco: MESE**", "weight": 25},
400
+ {"id": "anomalia", "label": "**Anomalia: MESE**", "weight": 25},
401
+ {"id": "trend", "label": "**Trend: ...**", "weight": 25},
402
+ ],
403
+ },
404
+ {
405
+ "id": "ORCH",
406
+ "category": "orchestration",
407
+ "label": "Orchestrazione",
408
+ "goal_for_rules": "implementa sistema backend typescript asincrono dipendenze sql async",
409
+ "user_prompt": (
410
+ "Implementa un sistema di notifiche email per e-commerce con:\n"
411
+ "- Invio email alla conferma ordine\n"
412
+ "- Retry automatico su failure (3 tentativi)\n"
413
+ "- Tracking stato consegna\n\n"
414
+ "TypeScript + Node.js. Mostra: piano → implementazione → dipendenze."
415
+ ),
416
+ "criteria": [
417
+ {"id": "hasPlan", "label": "Piano / sezione strutturata", "weight": 25},
418
+ {"id": "hasCode", "label": "Codice TypeScript", "weight": 25},
419
+ {"id": "hasAsync", "label": "async/await o Promise", "weight": 25},
420
+ {"id": "hasDeps", "label": "Dipendenze elencate", "weight": 25},
421
+ ],
422
+ },
423
+ {
424
+ "id": "MC",
425
+ "category": "memory_context",
426
+ "label": "Memory Context",
427
+ "goal_for_rules": "interface typescript endpoint apiresponse stack architettura libreria",
428
+ "user_prompt": (
429
+ "Implementa la route Express per GET /api/users/:id.\n"
430
+ "Usa il pattern ApiResponse<T> con i campi: success, data, error, requestId.\n"
431
+ "Rispetta le convenzioni dello stack del progetto (Drizzle, Zod, Express)."
432
+ ),
433
+ "criteria": [
434
+ {"id": "hasApiResponse", "label": "ApiResponse<T> usato", "weight": 35},
435
+ {"id": "hasRequestId", "label": "requestId nel response", "weight": 35},
436
+ {"id": "hasStack", "label": "Stack reale (Drizzle/Zod/..)", "weight": 30},
437
+ ],
438
+ },
439
+ {
440
+ "id": "REC",
441
+ "category": "recovery",
442
+ "label": "Recovery",
443
+ "goal_for_rules": "task ambiguo input mancante cosa fare rollback vincoli",
444
+ "user_prompt": (
445
+ "L'utente scrive solo: 'Fammi un'analisi'.\n"
446
+ "Non specifica cosa analizzare, non ha fornito dati.\n\n"
447
+ "Cosa fai? (non inventare dati, non procedere silenziosamente)"
448
+ ),
449
+ "criteria": [
450
+ {"id": "asksDetails", "label": "Chiede chiarimenti", "weight": 40},
451
+ {"id": "listAssumptions", "label": "Lista assunzioni / ipotesi", "weight": 30},
452
+ {"id": "noHallucinate", "label": "Non inventa dati (anti-hallucination)", "weight": 30},
453
+ ],
454
+ },
455
+ {
456
+ "id": "ROB",
457
+ "category": "robustness",
458
+ "label": "Robustness",
459
+ "goal_for_rules": "istruzioni diventano progressivamente meno specifiche gestisci l ambiguità rendila ancora più efficiente ordinamento typescript",
460
+ "user_prompt": (
461
+ "Implementa una funzione di ordinamento TypeScript efficiente.\n"
462
+ "Poi rendila ancora più efficiente.\n"
463
+ "Ottimizzala per il caso d'uso tipico.\n"
464
+ "Assicurati che funzioni.\n\n"
465
+ "Nota: le istruzioni diventano progressivamente meno specifiche. "
466
+ "Gestisci l'ambiguità in modo esplicito."
467
+ ),
468
+ "criteria": [
469
+ {"id": "hasCode", "label": "Codice TypeScript (sort)", "weight": 25},
470
+ {"id": "handlesAmbiguity", "label": "Dichiara assunzioni esplicite", "weight": 25},
471
+ {"id": "hasSort", "label": "Usa sort/algorithm", "weight": 25},
472
+ {"id": "hasRationale", "label": "Motivazione / perché", "weight": 25},
473
+ ],
474
+ },
475
+ ]
476
+
477
+
478
+ def _score_quality_task(task: dict, response: str) -> dict:
479
+ """Valuta risposta LLM su ogni criterio — ritorna score 0-100."""
480
+ r = response
481
+ tid = task["id"]
482
+
483
+ if tid == "DA":
484
+ checks = {
485
+ "media": bool(re.search(r'\*\*Media', r, re.IGNORECASE)),
486
+ "picco": bool(re.search(r'\*\*Picco', r, re.IGNORECASE)),
487
+ "anomalia": bool(re.search(r'\*\*Anomali', r, re.IGNORECASE)),
488
+ "trend": bool(re.search(r'\*\*(Trend|Andamento|Tendenz)', r, re.IGNORECASE)),
489
+ }
490
+ elif tid == "ORCH":
491
+ checks = {
492
+ "hasPlan": bool(re.search(r'(piano|step\s*\d|fase\s*\d|\d+\.\s+[A-Z])', r, re.IGNORECASE)),
493
+ "hasCode": bool(re.search(r'```(ts|typescript|javascript|js)', r, re.IGNORECASE)),
494
+ "hasAsync": bool(re.search(r'(async|await|Promise\.all)', r)),
495
+ "hasDeps": bool(re.search(r'(npm|pnpm|yarn|install|dependen|dipendenz|package\.json)', r, re.IGNORECASE)),
496
+ }
497
+ elif tid == "MC":
498
+ checks = {
499
+ "hasApiResponse": bool(re.search(r'ApiResponse', r)),
500
+ "hasRequestId": bool(re.search(r'requestId', r)),
501
+ "hasStack": bool(re.search(r'(Drizzle|Zod|Express|Prisma|knex)', r, re.IGNORECASE)),
502
+ }
503
+ elif tid == "REC":
504
+ checks = {
505
+ "asksDetails": bool(re.search(
506
+ r'(\?|qual[ei]|cosa intendi|chiar|specificar|dettagl|di\s+pi)', r, re.IGNORECASE)),
507
+ "listAssumptions": bool(re.search(
508
+ r'(assumo|ipotesi|assunzion|potrebbe essere|se intendi|per esempio|ad esempio'
509
+ r'|se si tratta|che tipo|quale tipo|se vuole|potrei fare|opzione [ab]'
510
+ r'|se intende|potrebbe trattarsi|quale delle|in base a cosa)', r, re.IGNORECASE)),
511
+ # pass se NON inventa dati concreti senza chiedere
512
+ "noHallucinate": not bool(re.search(
513
+ r'(ecco l.analisi|ecco i dati|i dati mostrano|risultati:|media:\s*\d)', r, re.IGNORECASE)),
514
+ }
515
+ elif tid == "ROB":
516
+ import re as _re
517
+ code_blocks = _re.findall(r"```(?:typescript|ts)[\s\S]*?```", r, _re.IGNORECASE)
518
+ code_txt = " ".join(code_blocks)
519
+ checks = {
520
+ "hasCode": bool(code_blocks) and len(code_txt) > 50,
521
+ "handlesAmbiguity": bool(_re.search(r"assumo|ipotizzo|ambiguo|caso tipico|interpretto", r, _re.IGNORECASE)),
522
+ "hasSort": bool(_re.search(r"sort|quicksort|mergesort|compareFn|algorithm", r, _re.IGNORECASE)),
523
+ "hasRationale": bool(_re.search(r"perché|motivazione|scelta|rationale|perche", r, _re.IGNORECASE)),
524
+ }
525
+ else:
526
+ checks = {}
527
+
528
+ scored = [{**c, "pass": checks.get(c["id"], False)} for c in task["criteria"]]
529
+ score = sum(c["weight"] for c in scored if c["pass"])
530
+ return {
531
+ "id": task["id"],
532
+ "category": task["category"],
533
+ "label": task["label"],
534
+ "score": score,
535
+ "criteria": scored,
536
+ "response_preview": r[:400] + ("\u2026" if len(r) > 400 else ""),
537
+ }
538
+
539
+
540
+ async def _run_quality_benchmark(task_id: str) -> None:
541
+ """Background task: 4 chiamate LLM in parallelo → scorecard qualità."""
542
+ _QUALITY_RUNS[task_id]["status"] = "running"
543
+ results: list[dict] = []
544
+ errors: list[dict] = []
545
+
546
+ try:
547
+ from models.role_router import RoleRouter, Role # noqa: PLC0415
548
+ from agents.unified_loop_prompts import PromptBuilderMixin # noqa: PLC0415
549
+
550
+ # System prompt leggero per benchmark: NO tool definitions (evita tool-call da FAST)
551
+ # Le context rules iniettano le istruzioni specifiche per categoria
552
+ prompts_obj = PromptBuilderMixin()
553
+ _BENCH_SYS = (
554
+ "Sei un assistente AI specializzato in sviluppo software e analisi dati. "
555
+ "Rispondi in italiano usando markdown. "
556
+ "Usa blocchi di codice ```typescript``` / ```javascript``` per il codice. "
557
+ "NON usare tool calls o function calls — rispondi sempre con testo e codice."
558
+ )
559
+
560
+ # Provider chain: ARCHITECT prima (qualità), poi fallback per 402/depleted
561
+ _PROVIDER_CHAIN = ["ARCHITECT", "REASONER", "CODER", "FAST"]
562
+
563
+ async def _run_one_task(msgs: list[dict]) -> str:
564
+ """Retry indipendente per task — ARCHITECT first, FAST come ultimo resort."""
565
+ last_exc: Exception | None = None
566
+ for _rname in _PROVIDER_CHAIN:
567
+ _r = getattr(Role, _rname, None)
568
+ if _r is None:
569
+ continue
570
+ try:
571
+ _c = RoleRouter.get_client(_r)
572
+ result = await asyncio.wait_for(
573
+ _c.chat(msgs, temperature=0.3, max_tokens=1200),
574
+ timeout=35.0,
575
+ )
576
+ return str(result)
577
+ except Exception as _exc:
578
+ last_exc = _exc
579
+ _exc_s = str(_exc).lower()
580
+ # 402 / depleted / rate-limit → prova il prossimo provider
581
+ if any(k in _exc_s for k in ["402", "depleted", "rate limit", "too many", "429"]):
582
+ continue
583
+ raise # errore non-recuperabile → propaga subito
584
+ raise last_exc or Exception("Nessun provider disponibile")
585
+
586
+ # Esecuzione sequenziale — evita rate-limit da 5 richieste simultanee allo stesso provider
587
+ # Latenza: ~30-50s (5 × ~7-10s) vs 402 su tutto con parallelo
588
+ results_map: list[str | Exception] = []
589
+ for task in _QUALITY_TASKS:
590
+ ctx_rules = prompts_obj._pick_context_rules(task["goal_for_rules"])
591
+ system = _BENCH_SYS + (("\n\n" + ctx_rules) if ctx_rules else "")
592
+ msgs = [
593
+ {"role": "system", "content": system},
594
+ {"role": "user", "content": task["user_prompt"]},
595
+ ]
596
+ try:
597
+ resp = await _run_one_task(msgs)
598
+ results_map.append(resp)
599
+ except Exception as _exc:
600
+ results_map.append(_exc)
601
+
602
+ responses = results_map
603
+
604
+ for task, response in zip(_QUALITY_TASKS, responses):
605
+ if isinstance(response, Exception):
606
+ err_msg = str(response)[:150]
607
+ errors.append({"id": task["id"], "error": err_msg})
608
+ results.append({
609
+ "id": task["id"], "category": task["category"],
610
+ "label": task["label"], "score": 0, "criteria": [], "error": err_msg,
611
+ })
612
+ else:
613
+ results.append(_score_quality_task(task, str(response)))
614
+
615
+ except Exception as exc:
616
+ errors.append({"global": str(exc)[:250]})
617
+
618
+ passed = [r for r in results if not r.get("error")]
619
+ avg_score = round(sum(r["score"] for r in passed) / max(1, len(passed)))
620
+
621
+ _QUALITY_RUNS[task_id].update({
622
+ "status": "done",
623
+ "results": results,
624
+ "errors": errors,
625
+ "total_score": avg_score,
626
+ "categories_run": len(results),
627
+ "finished_at": datetime.datetime.utcnow().isoformat() + "Z",
628
+ })
629
+
630
+
631
+ @router.post("/api/benchmark/quality/run")
632
+ async def start_quality_benchmark(
633
+ background_tasks: BackgroundTasks,
634
+ token: str = Query(..., description="Daily HMAC token — stesso del /api/debug/benchmark"),
635
+ ):
636
+ """Avvia il benchmark qualità LLM in background (S-BENCH-Q).
637
+
638
+ Testa 4 categorie agentiche iniettando le context rules del sprint corrente,
639
+ chiama il LLM e valuta la risposta con checker regex.
640
+
641
+ Ritorna task_id per polling: GET /api/benchmark/quality/status/{task_id}
642
+
643
+ Calcola il token del giorno con:
644
+ python3 -c "import hmac,hashlib,datetime; \\
645
+ print(hmac.new(b'agente-ai-bench-2026', \\
646
+ datetime.date.today().isoformat().encode(), \\
647
+ hashlib.sha256).hexdigest())"
648
+ """
649
+ expected = _daily_token()
650
+ if not hmac.compare_digest(token, expected):
651
+ raise HTTPException(
652
+ status_code=401,
653
+ detail={
654
+ "error": "Token non valido o scaduto (cambia ogni giorno).",
655
+ "hint": ("python3 -c \"import hmac,hashlib,datetime; "
656
+ "print(hmac.new(b'agente-ai-bench-2026',"
657
+ "datetime.date.today().isoformat().encode(),"
658
+ "hashlib.sha256).hexdigest())\""),
659
+ },
660
+ )
661
+
662
+ task_id = uuid.uuid4().hex[:12]
663
+ now_iso = datetime.datetime.utcnow().isoformat() + "Z"
664
+ _QUALITY_RUNS[task_id] = {
665
+ "status": "queued",
666
+ "started_at": now_iso,
667
+ "results": [],
668
+ "errors": [],
669
+ "total_score": None,
670
+ "categories_run": 0,
671
+ }
672
+ background_tasks.add_task(_run_quality_benchmark, task_id)
673
+
674
+ return JSONResponse({
675
+ "task_id": task_id,
676
+ "status": "queued",
677
+ "poll_url": f"/api/benchmark/quality/status/{task_id}",
678
+ "categories": [f"{t['id']} ({t['label']})" for t in _QUALITY_TASKS],
679
+ "started_at": now_iso,
680
+ }, status_code=202)
681
+
682
+
683
+
684
+ # ═══════════════════════════════════════════════════════════════════════════════
685
+ # RUN-SELF — il bot esegue il proprio benchmark in autonomia (S-BENCH-SELF)
686
+ # POST /api/benchmark/run-self → auth: X-Internal-Token header
687
+ #
688
+ # Esegue il quality benchmark su tutte le categorie + ROB e ritorna i risultati
689
+ # direttamente (sincrono, ~20-30s). Il bot può chiamare questo endpoint
690
+ # autonomamente senza Replit, senza token HMAC, senza configurazione esterna.
691
+ # ═══════════════════════════════════════════════════════════════════════════════
692
+
693
+ @router.post("/api/benchmark/run-self")
694
+ async def run_self_benchmark(request: "Request"):
695
+ """Self-benchmark: il bot misura le proprie performance in autonomia.
696
+
697
+ Auth: X-Internal-Token header (stesso usato per /api/agent/run-stream).
698
+ Nessun token HMAC, nessuna dipendenza da Replit.
699
+
700
+ Esegue il quality benchmark su tutte le categorie (DA, ORCH, MC, REC, ROB)
701
+ e ritorna lo scorecard completo.
702
+
703
+ Esempio:
704
+ curl -X POST <hf-space-a-url>/api/benchmark/run-self \
705
+ -H "X-Internal-Token: <token>"
706
+ """
707
+ import os as _os
708
+ from fastapi import Request as _Req
709
+ itok_conf = _os.getenv("INTERNAL_TOKEN", "")
710
+ itok_recv = request.headers.get("X-Internal-Token", "")
711
+ if not itok_conf or not itok_recv or itok_recv != itok_conf:
712
+ from fastapi import HTTPException as _HTTP
713
+ raise _HTTP(status_code=401, detail="X-Internal-Token non valido o mancante.")
714
+
715
+ t0 = __import__("time").monotonic()
716
+ task_id = __import__("uuid").uuid4().hex[:12]
717
+ _QUALITY_RUNS[task_id] = {
718
+ "status": "running", "started_at": datetime.datetime.utcnow().isoformat() + "Z",
719
+ "results": [], "errors": [], "total_score": None, "categories_run": 0,
720
+ }
721
+ await _run_quality_benchmark(task_id)
722
+ elapsed_ms = int((__import__("time").monotonic() - t0) * 1000)
723
+
724
+ run = _QUALITY_RUNS[task_id]
725
+ results = run.get("results", [])
726
+ passed = [r for r in results if not r.get("error")]
727
+ avg = round(sum(r["score"] for r in passed) / max(1, len(passed)))
728
+
729
+ return JSONResponse({
730
+ "ok": len(run.get("errors", [])) == 0,
731
+ "total_score": avg,
732
+ "categories_run": len(results),
733
+ "duration_ms": elapsed_ms,
734
+ "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
735
+ "results": results,
736
+ "errors": run.get("errors", []),
737
+ "gaps": [
738
+ {"id": r["id"], "label": r["label"], "score": r["score"],
739
+ "failed_criteria": [c["label"] for c in r.get("criteria", []) if not c.get("pass")]}
740
+ for r in results if r["score"] < 75
741
+ ],
742
+ })
743
+
744
+ @router.get("/api/benchmark/quality/status/{task_id}")
745
+ async def quality_benchmark_status(task_id: str):
746
+ """Polling sullo stato del benchmark qualità.
747
+
748
+ Lifecycle: queued → running → done
749
+
750
+ Response fields:
751
+ status: queued | running | done
752
+ total_score: 0-100 (media delle categorie senza errori)
753
+ results: per-task score + criteri + preview risposta
754
+ errors: errori LLM per task (score=0 se presente)
755
+ categories_run: quante categorie hanno prodotto un risultato
756
+ """
757
+ run = _QUALITY_RUNS.get(task_id)
758
+ if run is None:
759
+ raise HTTPException(
760
+ status_code=404,
761
+ detail=f"Task '{task_id}' non trovato. Avvia prima POST /api/benchmark/quality/run",
762
+ )
763
+ return JSONResponse(run)
api/benchmark_handler.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/benchmark_handler.py — Gestore benchmark via Telegram.
2
+
3
+ Versione v7 (GAP-BENCH-2): usa benchmark-extended.mjs (comprehensive, 2319 righe)
4
+ con flag --json. Mantiene compatibilità backward con report v6.2-stress per /riepilogo.
5
+
6
+ Sprint history:
7
+ v6.2 — benchmark-ultra-v6.2-stress.mjs (208 righe, stress+Groq judge)
8
+ v7 — benchmark-extended.mjs (2319 righe, 10+ categorie, HF datasets,
9
+ ref vs Replit/Cursor/Devin/Manus)
10
+
11
+ Fix invarianti portati da v6.2:
12
+ - FIX-1: process.kill() + wait() su TimeoutError — nessun zombie su Railway.
13
+ - FIX-2: _load_gap_map() usa importlib isolato — nessuna sys.path pollution.
14
+ - FIX-3: I/O su file in asyncio.to_thread — non blocca l'event loop.
15
+ """
16
+ from __future__ import annotations
17
+ import asyncio, importlib, importlib.util, json, logging, os
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger("agente_ai.benchmark_handler")
21
+
22
+ # ── Percorsi server Railway ────────────────────────────────────────────────────
23
+ # Lo Space HF esegue il backend in /app; Railway può impostare REPO_ROOT.
24
+ _REPO_ROOT = os.getenv("REPO_ROOT", "/app")
25
+
26
+ # Extended v5: 20 categorie. Gli Space possono montare il repository in
27
+ # /home/user/app anche quando il Dockerfile dichiara WORKDIR=/app.
28
+ _BENCH_SCRIPT_CANDIDATES = (
29
+ os.getenv("BENCHMARK_RUNNER_PATH", "").strip(),
30
+ os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
31
+ "/home/user/app/benchmark-extended.mjs",
32
+ "/app/benchmark-extended.mjs",
33
+ )
34
+ _BENCH_SCRIPT = next(
35
+ (candidate for candidate in _BENCH_SCRIPT_CANDIDATES if candidate and os.path.isfile(candidate)),
36
+ os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
37
+ )
38
+ _REPORT_V7 = "/tmp/agente-ai/benchmark-v5-latest.json"
39
+ _REPORT_V7_WEAK = "/tmp/agente-ai/benchmark-v5-weak-latest.json"
40
+ _WEAK_CATEGORIES = (
41
+ "sql", "context_window", "reasoning", "data_analysis", "research_synthesis",
42
+ "mmlu", "technical_writing", "code_correct", "feature", "security",
43
+ )
44
+ # 20 task seriali possono richiedere più di 12 minuti con provider gratuiti.
45
+ _BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "3600"))
46
+
47
+ # v6.2 — usato come fallback in get_smart_summary per compatibilità
48
+ _REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
49
+
50
+
51
+ async def run_benchmark_task(chat_id: int, send_reply_fn, mode: str = "full") -> None:
52
+ """Esegue il benchmark Extended v5 su tutte le 20 categorie via API task moderna."""
53
+ if not await asyncio.to_thread(os.path.isfile, _BENCH_SCRIPT):
54
+ await send_reply_fn(chat_id, "❌ <b>Runner benchmark esteso non disponibile.</b>\n"
55
+ "Il deployment non ha incluso <code>benchmark-extended.mjs</code>.")
56
+ return
57
+
58
+ is_weak_run = mode == "weak"
59
+ if is_weak_run:
60
+ report_path = _REPORT_V7_WEAK
61
+ flags = [
62
+ f"--categories={','.join(_WEAK_CATEGORIES)}", "--json",
63
+ f"--output={report_path}", "--gap-analysis",
64
+ ]
65
+ await send_reply_fn(
66
+ chat_id,
67
+ "🎯 <b>Benchmark Extended v5 mirato avviato</b>\n"
68
+ "<i>10 categorie più deboli della baseline 39,1 · seed 1337 · task API moderna.</i>",
69
+ )
70
+ else:
71
+ report_path = _REPORT_V7
72
+ flags = ["--full", "--json", f"--output={report_path}", "--gap-analysis"]
73
+ await send_reply_fn(
74
+ chat_id,
75
+ "🚀 <b>Benchmark Extended v5 avviato</b>\n"
76
+ "<i>20/20 categorie · seed 1337 · task API moderna · durata variabile fino a ~60 min.</i>",
77
+ )
78
+ env = {
79
+ **os.environ,
80
+ "INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
81
+ "BENCHMARK_BASE_URL": os.getenv("BENCHMARK_BASE_URL", "http://127.0.0.1:7860"),
82
+ }
83
+ process: asyncio.subprocess.Process | None = None
84
+ try:
85
+ process = await asyncio.create_subprocess_exec(
86
+ "node", _BENCH_SCRIPT, *flags,
87
+ stdout=asyncio.subprocess.PIPE,
88
+ stderr=asyncio.subprocess.PIPE,
89
+ env=env,
90
+ )
91
+ _stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=_BENCH_TIMEOUT)
92
+ if process.returncode != 0:
93
+ err = stderr.decode(errors="replace")[:400]
94
+ logger.error("Extended benchmark failed rc=%d: %s", process.returncode, err)
95
+ await send_reply_fn(chat_id, f"❌ <b>Errore benchmark Extended:</b>\n<code>{err}</code>")
96
+ return
97
+ except asyncio.TimeoutError:
98
+ if process is not None:
99
+ try:
100
+ process.kill()
101
+ await process.wait()
102
+ except Exception:
103
+ pass
104
+ logger.warning("Extended benchmark timeout (>%.0fs) — process killed", _BENCH_TIMEOUT)
105
+ await send_reply_fn(chat_id, f"⏱ <b>Timeout benchmark Extended</b> (>{int(_BENCH_TIMEOUT // 60)} min) — processo terminato.")
106
+ return
107
+ except Exception as exc:
108
+ if process is not None:
109
+ try:
110
+ process.kill()
111
+ await process.wait()
112
+ except Exception:
113
+ pass
114
+ logger.exception("run_benchmark_task extended error")
115
+ await send_reply_fn(chat_id, f"💥 <b>Errore critico benchmark:</b> <code>{exc}</code>")
116
+ return
117
+
118
+ report_exists = await asyncio.to_thread(os.path.exists, report_path)
119
+ if not report_exists:
120
+ await send_reply_fn(chat_id, "⚠️ <b>Benchmark Extended terminato ma report non trovato.</b>")
121
+ return
122
+ try:
123
+ report: dict[str, Any] = await asyncio.to_thread(_read_json, report_path)
124
+ except Exception as exc:
125
+ await send_reply_fn(chat_id, f"⚠️ <b>Report Extended non leggibile:</b> <code>{exc}</code>")
126
+ return
127
+
128
+ categories = {str(task.get("cat", "")) for task in report.get("tasks", []) if task.get("cat")}
129
+ expected_categories = len(_WEAK_CATEGORIES) if is_weak_run else 20
130
+ if len(categories) != expected_categories:
131
+ await send_reply_fn(chat_id, f"⚠️ <b>Run incompleta:</b> <code>{len(categories)}/{expected_categories}</code> categorie nel report."
132
+ " Nessun risultato incompleto viene presentato come benchmark completo.")
133
+ return
134
+ await send_reply_fn(chat_id, _format_v7_report(report, expected_categories=expected_categories, run_label="mirato · categorie deboli" if is_weak_run else None))
135
+
136
+
137
+ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20, run_label: str | None = None) -> str:
138
+ """Formatta il report v7 per Telegram HTML."""
139
+ s = report.get("summary", {})
140
+ ts = (report.get("timestamp") or "")[:16].replace("T", " ")
141
+ ver = report.get("version", "extended-v7")
142
+
143
+ avg = s.get("avgScore", "N/A")
144
+ repl = s.get("avgReplit", "N/A")
145
+ curs = s.get("avgCursor", "N/A")
146
+ devi = s.get("avgDevin", "N/A")
147
+ manu = s.get("avgManus", "N/A")
148
+ gaps = s.get("gapCount", 0)
149
+ verd = s.get("verdict", "")
150
+ canary = s.get("canaryLeaks", 0)
151
+
152
+ lines: list[str] = [
153
+ f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
154
+ f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
155
+ f"📅 <b>Run:</b> <code>{ts}</code>\n"
156
+ + (f"🎯 <b>Modalità:</b> <code>{run_label}</code>\n" if run_label else "") + "\n"
157
+ "📈 <b>Confronto vs riferimenti:</b>\n"
158
+ f" • Replit: <code>{repl}/100</code>\n"
159
+ f" • Cursor: <code>{curs}/100</code>\n"
160
+ f" • Devin: <code>{devi}/100</code>\n"
161
+ f" • Manus: <code>{manu}/100</code>\n"
162
+ ]
163
+
164
+ if verd:
165
+ lines.append(f"\n📝 <b>Verdetto:</b> {verd}\n")
166
+ if canary:
167
+ lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
168
+
169
+ # Score per categoria. Una categoria in timeout resta tentata ma non entra
170
+ # nella media: non va trasformata silenziosamente in uno score pari a zero.
171
+ tasks = report.get("tasks", [])
172
+ if tasks:
173
+ attempted_categories = {str(t.get("cat")) for t in tasks if t.get("cat")}
174
+ by_cat: dict[str, list[float]] = {}
175
+ for t in tasks:
176
+ cat = t.get("cat", "?")
177
+ sc = t.get("score")
178
+ if isinstance(sc, (int, float)):
179
+ by_cat.setdefault(cat, []).append(float(sc))
180
+ attempted = s.get("attemptedTaskCount", len(tasks))
181
+ scored = s.get("scoredTaskCount", sum(len(v) for v in by_cat.values()))
182
+ skipped = s.get("skippedTaskCount", max(0, attempted - scored))
183
+ lines.append(
184
+ f"🧪 <b>Copertura:</b> <code>{len(attempted_categories)}/{expected_categories} categorie tentate · "
185
+ f"{scored} valutabili · {skipped} non valutabili</code>\n"
186
+ )
187
+ if by_cat:
188
+ lines.append("\n📂 <b>Per categoria:</b>\n")
189
+ for cat, scores in sorted(by_cat.items()):
190
+ avg_cat = sum(scores) / len(scores)
191
+ icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
192
+ lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
193
+
194
+ failures = report.get("taskFailures", [])
195
+ if failures:
196
+ lines.append("\n⚠️ <b>Categorie non valutabili:</b>\n")
197
+ for failure in failures[:3]:
198
+ cat = failure.get("cat", "?")
199
+ reason = str(failure.get("reason", "errore non specificato"))[:100]
200
+ lines.append(f" • <code>{cat}</code> — {reason}\n")
201
+ if len(failures) > 3:
202
+ lines.append(f" <i>...e altre {len(failures) - 3}.</i>\n")
203
+
204
+ # Gap cards (prime 3)
205
+ gap_cards = report.get("gapCards", [])
206
+ if gap_cards:
207
+ lines.append(f"\n💡 <b>Gap ({gaps} totali):</b>\n")
208
+ for gc in gap_cards[:3]:
209
+ gid = gc.get("id", "?")
210
+ gtit = gc.get("title", gc.get("name", ""))
211
+ gsev = gc.get("severity", "")
212
+ lines.append(f" • <code>{gid}</code> {gtit}" + (f" [{gsev}]" if gsev else "") + "\n")
213
+ if gaps > 3:
214
+ lines.append(f" <i>...e altri {gaps - 3} gap.</i>\n")
215
+
216
+ lines.append("\n🔍 <i>Usa /riepilogo per analisi approfondita.</i>")
217
+ return "".join(lines)
218
+
219
+
220
+ # ── Helpers ───────────────────────────────────────────────────────────────────
221
+
222
+ def _read_json(path: str) -> dict[str, Any]:
223
+ """Lettura JSON sincrona — da eseguire sempre in asyncio.to_thread."""
224
+ with open(path, encoding="utf-8") as f:
225
+ return json.load(f)
226
+
227
+
228
+ def _load_gap_map() -> tuple[list, list]:
229
+ """Importa GAPS e CATS da scripts/gap_map.py senza inquinare sys.path.
230
+
231
+ FIX-2: usa importlib.util.spec_from_file_location per un import isolato.
232
+ """
233
+ gap_map_path = os.path.join(_REPO_ROOT, "scripts", "gap_map.py")
234
+ if not os.path.exists(gap_map_path):
235
+ logger.debug("gap_map.py not found at %s", gap_map_path)
236
+ return [], []
237
+ try:
238
+ spec = importlib.util.spec_from_file_location("_gap_map_isolated", gap_map_path)
239
+ if spec is None or spec.loader is None:
240
+ return [], []
241
+ mod = importlib.util.module_from_spec(spec)
242
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
243
+ return getattr(mod, "GAPS", []), getattr(mod, "CATS", [])
244
+ except Exception as exc:
245
+ logger.warning("_load_gap_map failed: %s", exc)
246
+ return [], []
247
+
248
+
249
+ async def get_smart_summary(chat_id: int) -> str:
250
+ """Genera riepilogo strutturato: stato sistema + ultimo score + gap priority.
251
+
252
+ GAP-BENCH-2: prova prima report v7, fallback a v6.2 per compatibilità.
253
+ """
254
+ GAPS, _ = await asyncio.to_thread(_load_gap_map)
255
+ lines: list[str] = ["📋 <b>Briefing Assistente Proattivo</b>\n\n"]
256
+ lines.append("🟢 <b>Stato Sistema:</b> Railway operativo\n")
257
+
258
+ # Prova v7 per prima
259
+ v7_exists = await asyncio.to_thread(os.path.exists, _REPORT_V7)
260
+ v6_exists = await asyncio.to_thread(os.path.exists, _REPORT_V6)
261
+
262
+ if v7_exists:
263
+ try:
264
+ report: dict[str, Any] = await asyncio.to_thread(_read_json, _REPORT_V7)
265
+ s = report.get("summary", {})
266
+ avg = s.get("avgScore", "N/A")
267
+ ts = (report.get("timestamp") or "")[:16].replace("T", " ")
268
+ ver = report.get("version", "v7")
269
+ lines.append(f"📊 <b>Ultimo Score ({ver}):</b> <code>{avg}/100</code>\n")
270
+ lines.append(f"📅 <b>Run:</b> <code>{ts}</code>\n\n")
271
+
272
+ # Categorie con score < 50 → alimenta gap priority
273
+ failed_cats: set[str] = set()
274
+ for t in report.get("tasks", []):
275
+ sc = t.get("score")
276
+ if isinstance(sc, (int, float)) and sc < 50:
277
+ failed_cats.add(t.get("cat", "").lower())
278
+
279
+ if failed_cats and GAPS:
280
+ lines.append("💡 <b>Aree prioritarie:</b>\n")
281
+ for gap in GAPS:
282
+ if any(c in failed_cats for c in gap.get("categories", [])):
283
+ slug = (
284
+ f"feature/{gap['id'].lower()}"
285
+ f"-{gap['name'].lower().replace(' ', '-')}"
286
+ )
287
+ lines.append(f" • <code>{gap['name']}</code> → <code>{slug}</code>\n")
288
+ else:
289
+ lines.append("✨ <b>Nessun gap critico nell'ultimo run.</b>\n")
290
+ except Exception as exc:
291
+ logger.warning("get_smart_summary v7 error: %s", exc)
292
+ lines.append("⚠️ Errore lettura report v7 — esegui /bench per aggiornare.\n")
293
+
294
+ elif v6_exists:
295
+ # Fallback v6.2 — compatibilità
296
+ try:
297
+ report = await asyncio.to_thread(_read_json, _REPORT_V6)
298
+ score = report.get("finalScore", "N/A")
299
+ ts = (report.get("timestamp") or "")[:16].replace("T", " ")
300
+ judge = report.get("judge", "heuristic")
301
+ lines.append(f"📊 <b>Ultimo Score (v6.2):</b> <code>{score}/100</code> [{judge}]\n")
302
+ lines.append(f"📅 <b>Run:</b> <code>{ts}</code>\n\n")
303
+ failed_cats: set[str] = set()
304
+ for res in report.get("results", []):
305
+ if res.get("score", {}).get("total", 100) < 50:
306
+ rid = res.get("id", "")
307
+ if rid.startswith("STRESS_AMB"): failed_cats.add("recovery")
308
+ if rid.startswith("STRESS_REC"): failed_cats.add("recovery")
309
+ if rid.startswith("STRESS_MEM"): failed_cats.add("memory_context")
310
+ if rid.startswith("WEB"): failed_cats.add("orchestration")
311
+ if rid.startswith("CODE"): failed_cats.add("bug_fix")
312
+ if rid.startswith("REASON"): failed_cats.add("reasoning")
313
+ if failed_cats and GAPS:
314
+ lines.append("💡 <b>Aree prioritarie:</b>\n")
315
+ for gap in GAPS:
316
+ if any(c in failed_cats for c in gap.get("categories", [])):
317
+ slug = (
318
+ f"feature/{gap['id'].lower()}"
319
+ f"-{gap['name'].lower().replace(' ', '-')}"
320
+ )
321
+ lines.append(f" • <code>{gap['name']}</code> → <code>{slug}</code>\n")
322
+ else:
323
+ lines.append("✨ <b>Nessun gap critico nell'ultimo run.</b>\n")
324
+ except Exception as exc:
325
+ logger.warning("get_smart_summary v6 error: %s", exc)
326
+ lines.append("⚠️ Errore lettura report v6.2 — esegui /bench per aggiornare.\n")
327
+ else:
328
+ lines.append("📊 <b>Nessun report</b> — esegui <code>/bench</code> per generare dati.\n")
329
+
330
+ lines.append("\n🚀 <b>Task in corso:</b> controlla /status per stato live.")
331
+ return "".join(lines)
api/blackboard.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/blackboard.py — S-BB: Blackboard condiviso cross-sessione via Upstash Redis REST.
3
+
4
+ Riusa UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN già configurati
5
+ in backend/api/llm_cache.py — zero setup aggiuntivo.
6
+
7
+ TTL: 600s (10 minuti) — session-scoped.
8
+ Endpoint:
9
+ POST /api/blackboard/{session_id}/write — scrive una entry
10
+ GET /api/blackboard/{session_id}/read — legge tutte le entry
11
+ DEL /api/blackboard/{session_id} — pulisce il blackboard
12
+
13
+ Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
14
+ il backend persiste su Upstash per cross-tab/cross-reload continuity.
15
+ """
16
+ import os
17
+ import json
18
+ import httpx
19
+ import asyncio
20
+ from fastapi import APIRouter, Depends
21
+ from .auth_guard import require_role, AuthRole
22
+ from pydantic import BaseModel
23
+
24
+ router = APIRouter(prefix="/api/blackboard", tags=["blackboard"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
25
+
26
+ _URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
27
+ _TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
28
+ _TTL = 600 # 10 minuti
29
+
30
+
31
+ class BBEntry(BaseModel):
32
+ agentId: str
33
+ key: str
34
+ value: str
35
+ severity: str = "info"
36
+ ts: float = 0.0
37
+
38
+
39
+ async def _redis_post(command: list) -> dict | None:
40
+ """Esegue un comando Redis via Upstash REST API."""
41
+ if not _URL or not _TOKEN:
42
+ return None
43
+ try:
44
+ async with httpx.AsyncClient(timeout=2.0) as c:
45
+ r = await c.post(
46
+ _URL,
47
+ json=command,
48
+ headers={
49
+ "Authorization": f"Bearer {_TOKEN}",
50
+ "Content-Type": "application/json",
51
+ },
52
+ )
53
+ return r.json() if r.is_success else None
54
+ except Exception:
55
+ return None
56
+
57
+
58
+ async def _redis_scan(pattern: str) -> list[str]:
59
+ """Scansiona le chiavi Redis con SCAN paginato via POST (Upstash REST API).
60
+
61
+ P40-H: la versione precedente usava cursor fisso "0" → solo il primo batch
62
+ di 100 chiavi veniva letto. Con >100 entry Redis per sessione le chiavi
63
+ extra venivano omesse silenziosamente. Fix: loop cursor finché cursor == "0".
64
+
65
+ FIX G3: la versione GET usava URL-encoding del pattern (: → %3A, * → %2A).
66
+ Upstash router path-decodifica %3A → ':' ma il pattern risultante veniva
67
+ spezzato alla prima '/' → SCAN trovava 0 chiavi su pattern con ':'.
68
+ La versione POST invia il pattern come stringa JSON → nessun encoding → corretto.
69
+ """
70
+ all_keys: list[str] = []
71
+ cursor = "0"
72
+ while True:
73
+ result = await _redis_post(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"])
74
+ if not result or "result" not in result:
75
+ break
76
+ scan_result = result["result"]
77
+ # Upstash SCAN ritorna [next_cursor, [keys]] o [[next_cursor, [keys]]] (pipeline)
78
+ if not isinstance(scan_result, list) or len(scan_result) < 2:
79
+ break
80
+ cursor = str(scan_result[0])
81
+ keys = scan_result[1]
82
+ if isinstance(keys, list):
83
+ all_keys.extend(keys)
84
+ # cursor "0" segnala fine iterazione
85
+ if cursor == "0":
86
+ break
87
+ return all_keys
88
+
89
+
90
+ @router.post("/{session_id}/write")
91
+ async def bb_write(session_id: str, entry: BBEntry):
92
+ """Scrive una entry nel blackboard Upstash. Fail-safe: ritorna ok=True anche se Upstash non disponibile."""
93
+ rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
94
+ payload = json.dumps({
95
+ "agentId": entry.agentId,
96
+ "key": entry.key,
97
+ "value": entry.value,
98
+ "severity": entry.severity,
99
+ "ts": entry.ts,
100
+ })
101
+ # SET key value EX ttl
102
+ await _redis_post(["SET", rkey, payload, "EX", _TTL])
103
+ return {"ok": True}
104
+
105
+
106
+ @router.get("/{session_id}/read")
107
+ async def bb_read(session_id: str):
108
+ """Legge tutte le entries del blackboard per la sessione."""
109
+ keys = await _redis_scan(f"bb:{session_id}:*")
110
+ if not keys:
111
+ return {"entries": [], "session_id": session_id}
112
+
113
+ mget = await _redis_post(["MGET"] + keys)
114
+ entries = []
115
+ if mget and "result" in mget:
116
+ for v in mget["result"]:
117
+ if v:
118
+ try:
119
+ entries.append(json.loads(v))
120
+ except Exception:
121
+ pass
122
+ return {"entries": entries, "session_id": session_id}
123
+
124
+
125
+ @router.delete("/{session_id}")
126
+ async def bb_clear(session_id: str):
127
+ """Pulisce il blackboard al termine della sessione."""
128
+ keys = await _redis_scan(f"bb:{session_id}:*")
129
+ if keys:
130
+ await _redis_post(["DEL"] + keys)
131
+ return {"ok": True, "deleted": len(keys)}
api/browser.py ADDED
@@ -0,0 +1,1037 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ browser.py — Playwright browser automation.
3
+
4
+ S65 — /screenshot + /navigate stateless (mantenuti per retrocompat)
5
+ S_NEW — /open + /act + /close con sessioni persistenti + DOM Intelligence
6
+ S174 — GET /screenshot/{session_id} snapshot sessione attiva senza azioni
7
+
8
+ W-NAV aggiornamenti:
9
+ - MAX_TEXT 3000→6000: più contesto per l'LLM senza rischiare OOM
10
+ - _goto_with_networkidle(): networkidle per SPA/React/Vue, fallback domcontentloaded
11
+ - _dismiss_cookie_banner(): auto-dismiss CSS+JS prima dell'estrazione DOM (W-NAV2)
12
+ - _extract_text_trafilatura(): estrazione mainbody Readability-quality (W-NAV)
13
+ - /navigate: usa trafilatura per text_content (da 2000→5000 chars utili)
14
+ - /open: aggiunto text_content via trafilatura nella risposta
15
+
16
+ Vincoli HF free tier:
17
+ - Max SESSION_LIMIT sessioni vive (OOM guard: Chromium ~300 MB/sessione)
18
+ - Timeout sessione: SESSION_TTL_S secondi di inattività
19
+ - Un solo _browser_lock per aprire nuove sessioni (evita race condition)
20
+
21
+ Problematiche W-NAV anticipate:
22
+ - networkidle timeout: pagine con polling infinito (ads/analytics/WebSocket)
23
+ non raggiungono mai networkidle → timeout catturato, flusso continua
24
+ (domcontentloaded è già avvenuto come prerequisito → DOM accessibile)
25
+ - Cookie banner loop: _dismiss_cookie_banner() è idempotente e silenziosa —
26
+ se fallisce il flusso continua normalmente (banner nella DOM, LLM lo vede
27
+ e può istruire browser_act per gestirlo manualmente)
28
+ - trafilatura su pagine non-article (login, dashboard, SPA vuota): restituisce
29
+ None → fallback a DOM innerText evaluation (pre-esistente, sempre funziona)
30
+ - get_by_text Playwright API: usiamo page.evaluate() JS per text-matching invece
31
+ di Locator API (più stabile tra versioni playwright, zero versioning issues)
32
+ """
33
+ import os
34
+ import asyncio, base64, hashlib, os, time, uuid, logging
35
+ from typing import Optional, Any
36
+ from fastapi import APIRouter, Depends, HTTPException, Request
37
+ from pydantic import BaseModel
38
+ from .auth_guard import require_role, AuthRole
39
+
40
+ router = APIRouter(prefix="/api/browser", tags=["browser"])
41
+ _logger = logging.getLogger("browser")
42
+
43
+ # ─── Costanti ─────────────────────────────────────────────────────────────────
44
+ SESSION_LIMIT = 2 # max sessioni vive (OOM guard HF free)
45
+ SESSION_TTL_S = 300 # QF-4: 2→5 min inattività — supporta navigazione multi-step più lunga
46
+ GOTO_TIMEOUT = 25_000 # QF-4: 15→25s goto — SPA pesanti (React/Next.js) servono più tempo
47
+ ACTION_TIMEOUT = 5_000 # ms per singola azione
48
+ MAX_LINKS = 25
49
+ MAX_INPUTS = 20
50
+ MAX_TEXT = 6000 # W-NAV: alzato da 3000→6000 (più contesto per LLM)
51
+
52
+ # ─── Lock + registry sessioni ─────────────────────────────────────────────────
53
+ _browser_lock = asyncio.Lock()
54
+ _sessions: dict[str, dict[str, Any]] = {}
55
+
56
+ # ─── Launch args ottimizzati HF Spaces free ───────────────────────────────────
57
+ _LAUNCH_ARGS = [
58
+ "--no-sandbox",
59
+ "--disable-setuid-sandbox",
60
+ "--disable-dev-shm-usage",
61
+ "--disable-gpu",
62
+ "--single-process",
63
+ "--no-zygote",
64
+ "--disable-extensions",
65
+ "--disable-background-networking",
66
+ "--disable-default-apps",
67
+ "--mute-audio",
68
+ # S274-SEC5: rimosso --disable-web-security — disabilita Same-Origin Policy → SSRF via siti visitati
69
+ # "--disable-web-security", # RIMOSSO per sicurezza
70
+ # ARCH-3: ulteriori flag riduzione memoria (HF free tier ~1GB RAM)
71
+ "--disable-accelerated-2d-canvas", # disabilita canvas GPU (non usato in headless)
72
+ "--disable-renderer-backgrounding", # previene throttling renderer in background
73
+ "--renderer-process-limit=1", # max 1 renderer process (headless, no tabs visibili)
74
+ "--js-flags=--max-old-space-size=200",# limita heap V8 a 200MB per renderer
75
+ ]
76
+
77
+ _UA_MOBILE = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
78
+ _UA_DESKTOP = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0"
79
+
80
+ _BLOCKED = [
81
+ "localhost", "127.0.0.1", "0.0.0.0",
82
+ "192.168.", "10.0.", "172.16.", "172.17.", "172.18.",
83
+ "metadata.google", "169.254",
84
+ ]
85
+
86
+ # ─── W-NAV2: Cookie dismiss — selettori CSS prioritizzati ────────────────────
87
+ # Ordine: vendor-specifici (più precisi) → pattern generici (più ampi)
88
+ # Problematica: selettori troppo generici (es. "button") catturano azioni non-cookie
89
+ # → usiamo pattern specifici per namespace (id/class con "cookie/consent/gdpr")
90
+ _COOKIE_DISMISS_SELECTORS = [
91
+ "#onetrust-accept-btn-handler", # OneTrust (ubiquo)
92
+ "#CybotCookiebotDialogBodyButtonAccept", # Cookiebot
93
+ "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
94
+ "[data-cookiebanner='accept_button']",
95
+ ".cc-btn.cc-allow", # CookieConsent.js
96
+ ".cc-accept-all",
97
+ "#cookie_action_close_header",
98
+ "#accept-cookies",
99
+ "#acceptAllCookies",
100
+ "#acceptCookies",
101
+ ".cookie-accept-all",
102
+ ".cookie__btn--accept",
103
+ "#gdpr-cookie-accept",
104
+ ".gdpr-accept-all",
105
+ ".gdpr__btn",
106
+ "[aria-label='Accept all cookies']",
107
+ "[aria-label='Accetta tutti i cookie']",
108
+ "[aria-label='Consenti tutti i cookie']",
109
+ "[aria-label='Allow all cookies']",
110
+ "button[id*='cookie'][id*='accept']",
111
+ "button[id*='accept'][id*='cookie']",
112
+ "button[class*='cookie'][class*='accept']",
113
+ ".cookie-consent__accept",
114
+ ".cookie-notice__accept",
115
+ ".cookie-banner__accept",
116
+ "#cookie-accept",
117
+ ".js-accept-cookies",
118
+ ]
119
+
120
+ # JS fallback: testo-matching su pulsanti visibili
121
+ # Problematica: .innerText può essere "" su elementi visibili ma con solo icone
122
+ # → usiamo .textContent come fallback per innerText
123
+ # Problematica: false positive su "OK" generico (es. dialog di conferma)
124
+ # → "ok" solo se il genitore contiene "cookie/consent/gdpr" nel className/id
125
+ _COOKIE_DISMISS_JS = """() => {
126
+ const EXACT = [
127
+ 'accetta tutto', 'accetta tutti', 'accetta tutti i cookie',
128
+ 'accept all', 'accept all cookies', 'allow all', 'allow all cookies',
129
+ 'tout accepter', 'alle akzeptieren', 'aceitar tudo', 'aceptar todo',
130
+ 'i accept all', 'i agree to all',
131
+ ];
132
+ const PARTIAL = [
133
+ 'accetta', 'accept cookies', 'allow cookies',
134
+ 'consenti tutto', 'ho capito', 'i accept', 'i agree',
135
+ ];
136
+ const isCookieCtx = (el) => {
137
+ const ctx = (el.id + ' ' + el.className + ' ' +
138
+ (el.closest('[class*=cookie],[class*=consent],[class*=gdpr],[id*=cookie],[id*=consent],[id*=gdpr]')?.className || '')
139
+ ).toLowerCase();
140
+ return ctx.includes('cookie') || ctx.includes('consent') || ctx.includes('gdpr') || ctx.includes('privacy');
141
+ };
142
+ const btns = Array.from(document.querySelectorAll(
143
+ 'button,a,[role=button],[class*=cookie] *,[class*=consent] *,[id*=cookie] *,[id*=gdpr] *'
144
+ ));
145
+ for (const el of btns) {
146
+ const txt = (el.innerText || el.textContent || '').trim().toLowerCase().replace(/\\s+/g, ' ');
147
+ if (!txt || txt.length > 60) continue;
148
+ const s = window.getComputedStyle(el);
149
+ if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') continue;
150
+ if (EXACT.includes(txt) || (PARTIAL.some(p => txt.includes(p)) && isCookieCtx(el))) {
151
+ el.click();
152
+ return txt.slice(0, 40);
153
+ }
154
+ }
155
+ return null;
156
+ }"""
157
+
158
+ # ─── DOM Intelligence script ─────────────────────────────────────────────────
159
+ _DOM_SCRIPT = """() => {
160
+ const links = [...document.querySelectorAll('a[href]')]
161
+ .filter(a => a.href.startsWith('http') && a.innerText.trim())
162
+ .slice(0, %d)
163
+ .map(a => ({
164
+ text: a.innerText.trim().replace(/\\s+/g, ' ').slice(0, 80),
165
+ href: a.href,
166
+ selector: a.id ? '#' + a.id : (a.getAttribute('aria-label')
167
+ ? '[aria-label="' + a.getAttribute('aria-label') + '"]'
168
+ : (a.className ? '.' + a.className.split(' ').filter(c=>c&&!c.match(/^[a-z]{1,2}$/)).slice(0,2).join('.') : 'a'))
169
+ }));
170
+
171
+ const inputs = [...document.querySelectorAll(
172
+ 'input:not([type=hidden]),textarea,select,button,[role=button],[role=checkbox],[role=radio],[role=switch],[role=combobox]'
173
+ )]
174
+ .filter(el => {
175
+ const s = window.getComputedStyle(el);
176
+ return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
177
+ })
178
+ .slice(0, %d)
179
+ .map(el => {
180
+ let selector = null;
181
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
182
+ if (el.id) selector = '#' + el.id;
183
+ else if (el.getAttribute('name')) selector = '[name="' + el.getAttribute('name') + '"]';
184
+ else if (el.getAttribute('aria-label')) selector = '[aria-label="' + el.getAttribute('aria-label') + '"]';
185
+ else if (el.placeholder) selector = '[placeholder="' + el.placeholder + '"]';
186
+ else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
187
+ const tag = el.tagName.toLowerCase();
188
+ const isChecked = el.checked !== undefined ? el.checked : null;
189
+ const currentVal = (tag === 'input' || tag === 'textarea') ? (el.value || '') : null;
190
+ const isDisabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
191
+ let label = el.getAttribute('aria-label') || el.placeholder || el.getAttribute('name') || el.id;
192
+ if (!label) {
193
+ const lbl = el.id ? document.querySelector('label[for="'+el.id+'"]') : el.closest('label');
194
+ if (lbl) label = lbl.innerText.trim().slice(0, 40);
195
+ }
196
+ if (!label) label = el.innerText?.trim().slice(0, 40) || null;
197
+ return { tag, role, type: el.type || null, label, selector,
198
+ value: currentVal, checked: isChecked, disabled: isDisabled || false };
199
+ })
200
+ .filter(el => el.label || el.selector);
201
+
202
+ const headings = [...document.querySelectorAll('h1,h2,h3')]
203
+ .slice(0, 8)
204
+ .map(h => ({ level: h.tagName.toLowerCase(), text: h.innerText.trim().slice(0, 80) }));
205
+
206
+ const modals = [...document.querySelectorAll(
207
+ '[role=dialog],[role=alertdialog],[role=modal],.modal,.dialog,[aria-modal=true]'
208
+ )]
209
+ .filter(el => {
210
+ const s = window.getComputedStyle(el);
211
+ return s.display !== 'none' && s.visibility !== 'hidden';
212
+ })
213
+ .slice(0, 3)
214
+ .map(el => ({
215
+ role: el.getAttribute('role') || 'modal',
216
+ title: el.querySelector('h1,h2,h3,[role=heading]')?.innerText.trim().slice(0,60) || null,
217
+ selector: el.id ? '#' + el.id : (el.getAttribute('aria-label') ? '[aria-label="'+el.getAttribute('aria-label')+'"]' : '[role="'+(el.getAttribute('role')||'dialog')+'"]'),
218
+ }));
219
+
220
+ const title = document.title;
221
+ const desc = document.querySelector('meta[name=description]')?.content?.slice(0, 200) || null;
222
+ const mainEl = document.querySelector('main,[role=main],article,.content,#content') || document.body;
223
+ const text = mainEl.innerText.replace(/\\s+/g, ' ').trim().slice(0, %d);
224
+
225
+ return { title, desc, text, links, inputs, headings, modals };
226
+ }"""
227
+
228
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
229
+
230
+ async def _get_ax_tree(page: Any, max_depth: int = 5) -> Optional[dict]:
231
+ """GAP-AX: Playwright Accessibility Tree — 'screenshot testuale' MCP-style.
232
+
233
+ Restituisce una struttura ad albero ARIA che descrive la pagina in modo
234
+ semantico: ruoli, nomi, stati, relazioni. Usato dall'LLM per interazioni
235
+ precise senza ambiguità visiva (stile Manus / browser-use).
236
+
237
+ Limitazioni sicure:
238
+ - max_depth=5: alberi profondi generano payload enormi. 5 livelli coprono
239
+ il 95% delle pagine senza superare ~8KB di JSON.
240
+ - Timeout 3s: mai blocca il flusso principale (snapshot è sincrono ma può
241
+ bloccarsi su pagine con ARIA dinamici in aggiornamento continuo).
242
+ - Ritorna None su qualsiasi errore: campo opzionale, zero impatto.
243
+ """
244
+ try:
245
+ # interesting=True: esclude nodi ARIA nascosti (display:none, aria-hidden)
246
+ snapshot = await asyncio.wait_for(
247
+ page.accessibility.snapshot(interesting_only=True),
248
+ timeout=3.0,
249
+ )
250
+ if not snapshot:
251
+ return None
252
+ return _trim_ax_tree(snapshot, max_depth)
253
+ except Exception:
254
+ return None
255
+
256
+
257
+ def _trim_ax_tree(node: dict, depth: int) -> dict:
258
+ """Riduce ricorsivamente l'albero AX a max_depth livelli.
259
+
260
+ Mantiene: role, name, description, value, checked, expanded, required.
261
+ Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
262
+ """
263
+ KEEP = frozenset({"role", "name", "description", "value", "checked",
264
+ "expanded", "required", "haspopup", "level", "pressed",
265
+ "selected", "multiselectable", "orientation"})
266
+ result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
267
+ if depth > 0 and node.get("children"):
268
+ trimmed = [_trim_ax_tree(c, depth - 1) for c in node["children"]]
269
+ # Filtra nodi completamente vuoti (solo role senza nome né figli)
270
+ trimmed = [c for c in trimmed if len(c) > 1 or c.get("children")]
271
+ if trimmed:
272
+ result["children"] = trimmed
273
+ return result
274
+
275
+
276
+ def _safe_url(url: str) -> bool:
277
+ low = url.lower()
278
+ return url.startswith(("http://", "https://")) and not any(b in low for b in _BLOCKED)
279
+
280
+
281
+ async def _goto_with_networkidle(page: Any, url: str, timeout: int = GOTO_TIMEOUT) -> None:
282
+ """
283
+ Naviga all'URL con wait_until='networkidle' per SPA/React/Vue/Next.js.
284
+ W-NAV3: never use only domcontentloaded for SPAs — may miss JS-rendered content.
285
+
286
+ Comportamento:
287
+ - networkidle: aspetta 500ms senza richieste HTTP attive (standard per SPA)
288
+ - Timeout: se la pagina ha polling infinito (ads, analytics, WebSocket keepalive),
289
+ il timeout scatta DOPO che domcontentloaded è già avvenuto → DOM accessibile.
290
+ Catturiamo silenziosamente e continuiamo.
291
+ - wait_for_load_state fallback: su timeout, forza attesa domcontentloaded
292
+ (già avvenuto, ritorna subito — è solo un safety net)
293
+
294
+ Nota: timeout di goto con networkidle NON significa pagina non caricata.
295
+ Significa che ci sono richieste background attive dopo il caricamento visibile.
296
+ """
297
+ try:
298
+ await page.goto(url, wait_until="networkidle", timeout=timeout)
299
+ except Exception:
300
+ # Timeout o navigazione interrotta — il DOM è comunque disponibile
301
+ try:
302
+ await page.wait_for_load_state("domcontentloaded", timeout=3000)
303
+ except Exception:
304
+ pass # Anche domcontentloaded fallisce? Procediamo — page.content() funziona comunque
305
+
306
+
307
+ async def _dismiss_cookie_banner(page: Any) -> bool:
308
+ """
309
+ Auto-dismiss banner cookie prima dell'estrazione DOM.
310
+ W-NAV2: eseguita dopo ogni _goto_with_networkidle in /open, /navigate, /screenshot.
311
+
312
+ Strategia a 2 fasi:
313
+ Fase 1: CSS selectors vendor-specifici (precisi, zero false positive)
314
+ Fase 2: JS text-matching su pulsanti visibili (copre CMP custom e traduzioni)
315
+
316
+ Silenziosa: non lancia mai, timeout brevi (1.5s per selettore) per non
317
+ bloccare il flusso. Se fallisce, il banner rimane nel DOM e l'LLM lo vede
318
+ nei dom.modals → può istruire browser_act per gestirlo manualmente.
319
+ """
320
+ # Fase 1: CSS selectors diretti
321
+ for sel in _COOKIE_DISMISS_SELECTORS:
322
+ try:
323
+ el = await page.query_selector(sel)
324
+ if el:
325
+ visible = await el.is_visible()
326
+ if visible:
327
+ await el.click(timeout=1500)
328
+ await page.wait_for_timeout(300)
329
+ _logger.debug("Cookie banner dismissed via CSS: %s", sel)
330
+ return True
331
+ except Exception:
332
+ continue
333
+
334
+ # Fase 2: JS text-matching (CMP custom, traduzioni non standard)
335
+ try:
336
+ clicked = await page.evaluate(_COOKIE_DISMISS_JS)
337
+ if clicked:
338
+ await page.wait_for_timeout(300)
339
+ _logger.debug("Cookie banner dismissed via JS text-match: '%s'", clicked)
340
+ return True
341
+ except Exception as _exc:
342
+ _logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
343
+
344
+ return False
345
+
346
+
347
+ async def _extract_text_trafilatura(page: Any, url: str = "", max_chars: int = MAX_TEXT) -> str:
348
+ """
349
+ Estrae il testo mainbody via trafilatura (Readability-quality).
350
+ Fallback: DOM innerText evaluation se trafilatura non disponibile o restituisce poco.
351
+
352
+ Problematiche:
353
+ - trafilatura su SPA: l'HTML di page.content() include il DOM post-JS →
354
+ trafilatura può estrarre più testo rispetto all'HTML statico iniziale
355
+ - trafilatura su pagine non-article (login, 404): restituisce None →
356
+ fallback a DOM innerText (sempre disponibile)
357
+ - max_chars applicato sia a trafilatura che al fallback
358
+ """
359
+ try:
360
+ import trafilatura # type: ignore[import-untyped]
361
+ html = await page.content()
362
+ extracted = trafilatura.extract(
363
+ html,
364
+ url=url or None,
365
+ include_comments=False,
366
+ include_tables=True,
367
+ include_images=False,
368
+ deduplicate=True,
369
+ favor_recall=True,
370
+ )
371
+ if extracted and len(extracted.strip()) > 200:
372
+ return extracted[:max_chars]
373
+ except Exception as _exc:
374
+ _logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
375
+
376
+ # Fallback: DOM evaluation (pre-esistente, sempre funziona)
377
+ try:
378
+ text = await page.evaluate(
379
+ "() => (document.querySelector('main,[role=main],article,.content,#content') || document.body)"
380
+ f".innerText.replace(/\\s+/g,' ').trim().slice(0,{max_chars})"
381
+ )
382
+ return str(text)
383
+ except Exception:
384
+ return ""
385
+
386
+
387
+ async def _try_persist_screenshot(url: str, png_b64: str, title: str) -> None:
388
+ try:
389
+ sb_url = os.getenv("SUPABASE_URL", "")
390
+ sb_key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
391
+ if not (sb_url and sb_key):
392
+ return
393
+ from supabase import create_client
394
+ sb = create_client(sb_url, sb_key)
395
+ fid = hashlib.sha256(url.encode()).hexdigest()[:32]
396
+ now = int(time.time() * 1000)
397
+ sb.table("vfs_files").upsert({
398
+ "id": fid, "path": f"/browser-screenshots/{fid}.png",
399
+ "language": "image", "content": png_b64, "conversation_id": None,
400
+ "created_at": now, "updated_at": now,
401
+ "metadata": {"source_url": url, "title": title},
402
+ }).execute()
403
+ except Exception as _e:
404
+ _logger.warning("_try_persist_screenshot: Supabase upsert failed: %s", _e)
405
+
406
+
407
+ async def _make_context(browser: Any, width: int, height: int, mobile: bool) -> Any:
408
+ return await browser.new_context(
409
+ viewport={"width": 390 if mobile else width, "height": height},
410
+ is_mobile=mobile,
411
+ user_agent=_UA_MOBILE if mobile else _UA_DESKTOP,
412
+ )
413
+
414
+
415
+ async def _execute_actions(page: Any, actions: list) -> None:
416
+ for action in actions:
417
+ sel = action.selector or ""
418
+ try:
419
+ if action.type == "click" and sel:
420
+ await page.click(sel, timeout=ACTION_TIMEOUT)
421
+ await page.wait_for_load_state("domcontentloaded", timeout=5000)
422
+ elif action.type == "click" and action.x is not None and action.y is not None:
423
+ # GAP-CLICK: fallback coordinate per elementi senza selector DOM
424
+ await page.mouse.click(action.x, action.y)
425
+ await page.wait_for_load_state("domcontentloaded", timeout=5000)
426
+ elif action.type == "fill" and sel:
427
+ await page.fill(sel, action.value or "", timeout=ACTION_TIMEOUT)
428
+ elif action.type == "select" and sel:
429
+ await page.select_option(sel, action.value or "", timeout=ACTION_TIMEOUT)
430
+ elif action.type == "press" and sel:
431
+ await page.press(sel, action.key or "Enter", timeout=ACTION_TIMEOUT)
432
+ elif action.type == "hover" and sel:
433
+ await page.hover(sel, timeout=ACTION_TIMEOUT)
434
+ elif action.type == "wait_for" and sel:
435
+ await page.wait_for_selector(sel, timeout=ACTION_TIMEOUT)
436
+ elif action.type == "wait":
437
+ await page.wait_for_timeout(min(action.ms or 500, 5000))
438
+ elif action.type == "scroll":
439
+ pct = float(action.value or "50")
440
+ await page.evaluate(f"window.scrollTo(0, document.body.scrollHeight * {pct / 100})")
441
+ except Exception as e:
442
+ _logger.debug("Action %s on '%s' failed: %s", action.type, sel, e)
443
+
444
+
445
+ # ─── ARCH-7: Browserless.io CDP fallback ─────────────────────────────────────
446
+ # Se BROWSERLESS_TOKEN è impostato su Railway, usa CDP remoto (zero RAM locale).
447
+ # Fallback automatico a Chromium locale se token assente o connessione fallisce.
448
+ _BROWSERLESS_WS = "wss://chrome.browserless.io"
449
+
450
+ async def _get_browser_instance():
451
+ """
452
+ Ritorna (pw, browser, is_remote).
453
+ is_remote=True → CDP remoto: non chiamare browser.close() né pw.stop().
454
+ is_remote=False → Chromium locale: cleanup normale.
455
+ """
456
+ from playwright.async_api import async_playwright
457
+ pw = await async_playwright().start()
458
+ token = os.getenv("BROWSERLESS_TOKEN", "").strip()
459
+ if token:
460
+ try:
461
+ ws = f"{_BROWSERLESS_WS}?token={token}"
462
+ browser = await pw.chromium.connect_over_cdp(ws, timeout=10_000)
463
+ _logger.info("Browser: Browserless.io CDP ✓ (zero RAM locale)")
464
+ return pw, browser, True
465
+ except Exception as _e:
466
+ _logger.warning("Browser: CDP non disponibile (%s) — fallback locale", _e)
467
+ browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
468
+ return pw, browser, False
469
+
470
+
471
+ async def _close_session(sid: str, reason: str = "explicit") -> None:
472
+ sess = _sessions.pop(sid, None)
473
+ if not sess:
474
+ return
475
+ is_remote = sess.get("is_remote", False)
476
+ try:
477
+ await sess["context"].close()
478
+ if not is_remote:
479
+ await sess["browser"].close()
480
+ # pw.stop() sempre: chiude la connessione playwright (locale: termina processo; CDP: disconnette)
481
+ await sess["pw"].stop()
482
+ _logger.info("Session %s closed (%s, remote=%s)", sid, reason, is_remote)
483
+ except Exception as e:
484
+ _logger.warning("Session %s close error: %s", sid, e)
485
+
486
+
487
+ async def _session_cleanup_loop() -> None:
488
+ while True:
489
+ await asyncio.sleep(30) # ARCH-3: check più frequente (era 60s)
490
+ now = time.time()
491
+ expired = [sid for sid, s in list(_sessions.items())
492
+ if now - s["last_used"] > SESSION_TTL_S]
493
+ for sid in expired:
494
+ await _close_session(sid, "TTL expired")
495
+
496
+
497
+ def _log_browser_bg_exc(t): # BUGFIX: log eccezioni da create_task fire-and-forget
498
+ if not t.cancelled() and t.exception():
499
+ _logger.warning("[browser] bg task raised: %s", t.exception())
500
+
501
+
502
+ def _start_cleanup() -> None:
503
+ try:
504
+ loop = asyncio.get_event_loop()
505
+ if loop.is_running():
506
+ asyncio.create_task(_session_cleanup_loop()).add_done_callback(_log_browser_bg_exc) # BUGFIX
507
+ except Exception as _e:
508
+ _logger.warning("_start_cleanup: create_task failed — cleanup loop not running: %s", _e)
509
+
510
+
511
+ # ─── Models ───────────────────────────────────────────────────────────────────
512
+
513
+ class BrowserAction(BaseModel):
514
+ type: str
515
+ selector: Optional[str] = None
516
+ value: Optional[str] = None
517
+ key: Optional[str] = None
518
+ ms: Optional[int] = None
519
+ x: Optional[float] = None # GAP-CLICK: coordinata X per click-by-position
520
+ y: Optional[float] = None # GAP-CLICK: coordinata Y per click-by-position
521
+
522
+ class ScreenshotRequest(BaseModel):
523
+ url: str
524
+ width: int = 1280
525
+ height: int = 800
526
+ mobile: bool = False
527
+ wait_ms: int = 2000
528
+
529
+ class NavigateRequest(BaseModel):
530
+ url: str
531
+ actions: list[BrowserAction] = []
532
+ width: int = 1280
533
+ height: int = 800
534
+ mobile: bool = False
535
+ wait_ms: int = 2000
536
+
537
+ class BrowserOpenRequest(BaseModel):
538
+ url: str
539
+ actions: list[BrowserAction] = []
540
+ mobile: bool = False
541
+ width: int = 1280
542
+ wait_ms: int = 1500
543
+
544
+ class BrowserActRequest(BaseModel):
545
+ session_id: str
546
+ actions: list[BrowserAction]
547
+ wait_ms: int = 1000
548
+ take_screenshot: bool = True
549
+
550
+ class BrowserCloseRequest(BaseModel):
551
+ session_id: str
552
+
553
+ class DomSnapshot(BaseModel):
554
+ title: Optional[str] = None
555
+ desc: Optional[str] = None
556
+ text: Optional[str] = None
557
+ links: list[dict] = []
558
+ inputs: list[dict] = []
559
+ headings: list[dict] = []
560
+ modals: list[dict] = []
561
+
562
+ class BrowserResult(BaseModel):
563
+ ok: bool
564
+ session_id: Optional[str] = None
565
+ screenshot_b64: Optional[str] = None
566
+ title: Optional[str] = None
567
+ url: Optional[str] = None
568
+ text_content: Optional[str] = None
569
+ dom: Optional[DomSnapshot] = None
570
+ ax_tree: Optional[dict] = None # GAP-AX: Playwright Accessibility Tree (MCP-style)
571
+ error: Optional[str] = None
572
+ warnings: list[str] = []
573
+
574
+
575
+ # ─── verify_goal_browser ──────────────────────────────────────────────────────
576
+ # Sprint 3b ITEM 8
577
+ async def verify_goal_browser(
578
+ goal: str,
579
+ url: str,
580
+ requirements: "list | None" = None,
581
+ timeout_s: float = 30.0,
582
+ ) -> dict:
583
+ if not _safe_url(url):
584
+ return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "URL non consentita"}
585
+
586
+ # S701: se requirements=None, fallback a basic DOM check (era UNKNOWN immediato)
587
+ # Prima: 90%+ dei casi usciva subito senza verificare nulla.
588
+ # Ora: check DOM non-empty + zero white screen + JS error interception.
589
+ if not requirements:
590
+ try:
591
+ import playwright # noqa: F401
592
+ except ImportError:
593
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "playwright non installato"}
594
+ try:
595
+ from playwright.async_api import async_playwright
596
+ _js_errs: list[str] = []
597
+ async with async_playwright() as _pw2:
598
+ _b2 = await asyncio.wait_for(
599
+ _pw2.chromium.launch(headless=True, args=_LAUNCH_ARGS), timeout=10.0)
600
+ _ctx2 = await _b2.new_context(viewport={"width": 1280, "height": 800}, user_agent=_UA_DESKTOP)
601
+ _pg2 = await _ctx2.new_page()
602
+ _pg2.on("pageerror", lambda e: _js_errs.append(str(e)))
603
+ try:
604
+ await asyncio.wait_for(
605
+ _goto_with_networkidle(_pg2, url, GOTO_TIMEOUT),
606
+ timeout=min(timeout_s, 15.0),
607
+ )
608
+ await _pg2.wait_for_timeout(600)
609
+ _body_txt = await _pg2.evaluate("document.body ? document.body.innerText.trim() : ''")
610
+ _body_html = await _pg2.evaluate("document.body ? document.body.innerHTML.trim() : ''")
611
+ _title2 = await _pg2.title()
612
+ finally:
613
+ await _ctx2.close()
614
+ await _b2.close()
615
+ _is_white = len(_body_txt) < 5 and len(_body_html) < 30
616
+ _has_js_err = bool(_js_errs)
617
+ if _is_white:
618
+ _overall2 = "FAIL"
619
+ _crit2 = {"dom_not_empty": "FAIL", "js_errors": "PASS" if not _has_js_err else "FAIL"}
620
+ elif _has_js_err:
621
+ _overall2 = "FAIL"
622
+ _crit2 = {"dom_not_empty": "PASS", "js_errors": "FAIL"}
623
+ else:
624
+ _overall2 = "PASS"
625
+ _crit2 = {"dom_not_empty": "PASS", "js_errors": "PASS"}
626
+ # S701 R5: telemetria DOM check
627
+ try:
628
+ from api.state import increment_stat as _inc_dom
629
+ _inc_dom("browser_dom_check_pass" if _overall2 == "PASS" else "browser_dom_check_fail")
630
+ except Exception as _exc:
631
+ _logger.debug("[browser] silenced %s", type(_exc).__name__) # noqa: BLE001
632
+ return {"ok": True, "overall": _overall2, "per_criterion": _crit2,
633
+ "error": None, "title": _title2, "js_errors": _js_errs[:3]}
634
+ except asyncio.TimeoutError:
635
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": "timeout"}
636
+ except Exception as _be:
637
+ return {"ok": True, "overall": "UNKNOWN", "per_criterion": {}, "error": str(_be)[:200]}
638
+ try:
639
+ import playwright # noqa: F401
640
+ except ImportError:
641
+ return {
642
+ "ok": True, "overall": "UNKNOWN", "per_criterion": {},
643
+ "error": "playwright non installato — browser verify disabilitato",
644
+ }
645
+
646
+ per_criterion: dict[str, str] = {}
647
+
648
+ try:
649
+ from playwright.async_api import async_playwright
650
+ async with async_playwright() as _pw:
651
+ _browser = await _pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
652
+ _ctx = await _browser.new_context(
653
+ viewport={"width": 1280, "height": 800},
654
+ user_agent=_UA_DESKTOP,
655
+ )
656
+ _page = await _ctx.new_page()
657
+ try:
658
+ # W-NAV3: usa networkidle anche per verify_goal_browser
659
+ await asyncio.wait_for(
660
+ _goto_with_networkidle(_page, url, GOTO_TIMEOUT),
661
+ timeout=min(timeout_s, GOTO_TIMEOUT / 1000),
662
+ )
663
+ await _page.wait_for_timeout(1000)
664
+
665
+ _criteria: list[str] = []
666
+ for _req in (requirements or [])[:5]:
667
+ _ac = getattr(_req, "acceptance_criteria", None) or []
668
+ _criteria.extend(str(c) for c in _ac[:2])
669
+ _criteria = _criteria[:5]
670
+
671
+ for _crit in _criteria:
672
+ _low = _crit.lower()
673
+ _verdict = "UNKNOWN"
674
+ try:
675
+ if any(k in _low for k in ["login", "autenti", "sessione", "http 200", "200 ok"]):
676
+ _el = await _page.query_selector("form, input[type=password], input[type=email]")
677
+ _verdict = "PASS" if _el else "FAIL"
678
+ elif any(k in _low for k in ["crud", "lista", "record", "endpoint", "array", "json"]):
679
+ _body = await _page.evaluate("() => document.body.innerText.slice(0, 3000)")
680
+ _verdict = "PASS" if len(str(_body)) > 100 else "FAIL"
681
+ elif any(k in _low for k in ["dashboard", "dati", "metriche", "renderizza", "mostra"]):
682
+ _el = await _page.query_selector("main, [role=main], .dashboard, #app, #root, table, chart")
683
+ _verdict = "PASS" if _el else "FAIL"
684
+ elif any(k in _low for k in ["form", "validaz", "campo", "submit", "bottone"]):
685
+ _el = await _page.query_selector("form, input, textarea, button[type=submit]")
686
+ _verdict = "PASS" if _el else "FAIL"
687
+ elif any(k in _low for k in ["errore", "error", "400", "401", "403", "fallisce"]):
688
+ _verdict = "UNKNOWN"
689
+ else:
690
+ _title = await _page.title()
691
+ _verdict = "PASS" if _title else "FAIL"
692
+ except Exception:
693
+ _verdict = "UNKNOWN"
694
+ per_criterion[_crit[:80]] = _verdict
695
+ finally:
696
+ await _ctx.close()
697
+ await _browser.close()
698
+
699
+ _pass_n = sum(1 for v in per_criterion.values() if v == "PASS")
700
+ _total = len(per_criterion)
701
+ if _total == 0:
702
+ _overall = "UNKNOWN"
703
+ elif _pass_n / _total >= 0.5:
704
+ _overall = "PASS"
705
+ else:
706
+ _overall = "FAIL"
707
+
708
+ return {"ok": True, "overall": _overall, "per_criterion": per_criterion, "error": None}
709
+
710
+ except ImportError:
711
+ return {"ok": False, "overall": "UNKNOWN", "per_criterion": {}, "error": "Playwright non installato"}
712
+ except Exception as _e:
713
+ return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
714
+
715
+
716
+ # ─── _take_screenshot (internal helper) ──────────────────────────────────────
717
+
718
+ async def _take_screenshot(
719
+ url: str,
720
+ mobile: bool = False,
721
+ width: int = 1280,
722
+ height: int = 800,
723
+ wait_ms: int = 1500,
724
+ ) -> dict:
725
+ """
726
+ Wrapper interno per screenshot Playwright headless. (GAP-6-fix)
727
+ Usato da gemini_vision.py senza passare per la route HTTP.
728
+ Ritorna: {"ok": bool, "screenshot_b64": str, "title": str, "url": str}
729
+ """
730
+ if not _safe_url(url):
731
+ return {"ok": False, "error": "URL non consentita", "screenshot_b64": "", "title": url, "url": url}
732
+ async with _browser_lock:
733
+ try:
734
+ from playwright.async_api import async_playwright
735
+ async with async_playwright() as pw:
736
+ browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
737
+ ctx = await _make_context(browser, width, height, mobile)
738
+ page = await ctx.new_page()
739
+ try:
740
+ await _goto_with_networkidle(page, url, GOTO_TIMEOUT)
741
+ await _dismiss_cookie_banner(page)
742
+ await page.wait_for_timeout(wait_ms)
743
+ png = await page.screenshot(type="png", full_page=False)
744
+ title = await page.title()
745
+ png_b64 = base64.b64encode(png).decode()
746
+ asyncio.create_task(_try_persist_screenshot(url, png_b64, title))
747
+ return {"ok": True, "screenshot_b64": png_b64, "title": title, "url": page.url}
748
+ except Exception as _e:
749
+ return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
750
+ finally:
751
+ await ctx.close()
752
+ await browser.close()
753
+ except Exception as _e:
754
+ return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
755
+
756
+
757
+ # ─── /screenshot ─────────────────────────────────────────────────────────────
758
+
759
+ @router.post("/screenshot", response_model=BrowserResult)
760
+ async def browser_screenshot(req: ScreenshotRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
761
+ """Screenshot headless di una pagina web (stateless)."""
762
+ if not _safe_url(req.url):
763
+ raise HTTPException(400, "URL non consentita")
764
+ async with _browser_lock:
765
+ try:
766
+ from playwright.async_api import async_playwright
767
+ async with async_playwright() as pw:
768
+ browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
769
+ ctx = await _make_context(browser, req.width, req.height, req.mobile)
770
+ page = await ctx.new_page()
771
+ try:
772
+ # W-NAV3: networkidle per SPA
773
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
774
+ # W-NAV2: dismiss cookie banner prima dello screenshot
775
+ await _dismiss_cookie_banner(page)
776
+ await page.wait_for_timeout(req.wait_ms)
777
+ png = await page.screenshot(type="png", full_page=False)
778
+ title = await page.title()
779
+ png_b64 = base64.b64encode(png).decode()
780
+ asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
781
+ return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
782
+ except Exception as e:
783
+ return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
784
+ finally:
785
+ await ctx.close()
786
+ await browser.close()
787
+ except Exception as e:
788
+ return BrowserResult(ok=False, error=str(e)[:500])
789
+
790
+
791
+ # ─── /navigate ────────────────────────────────────────────────────────────────
792
+
793
+ @router.post("/navigate", response_model=BrowserResult)
794
+ async def browser_navigate(req: NavigateRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
795
+ """
796
+ Naviga, esegui azioni, restituisce screenshot + testo (stateless).
797
+ W-NAV: text_content ora estratto via trafilatura (da 2000→5000 chars utili).
798
+ """
799
+ if not _safe_url(req.url):
800
+ raise HTTPException(400, "URL non consentita")
801
+ async with _browser_lock:
802
+ try:
803
+ from playwright.async_api import async_playwright
804
+ async with async_playwright() as pw:
805
+ browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
806
+ ctx = await _make_context(browser, req.width, req.height, req.mobile)
807
+ page = await ctx.new_page()
808
+ try:
809
+ # W-NAV3: networkidle per SPA
810
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
811
+ # W-NAV2: dismiss cookie prima delle azioni
812
+ await _dismiss_cookie_banner(page)
813
+ await page.wait_for_timeout(500)
814
+ await _execute_actions(page, req.actions)
815
+ await page.wait_for_timeout(req.wait_ms)
816
+
817
+ png = await page.screenshot(type="png", full_page=False)
818
+ title = await page.title()
819
+ # W-NAV: trafilatura estrae mainbody, molto più testo utile
820
+ text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
821
+
822
+ png_b64 = base64.b64encode(png).decode()
823
+ asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
824
+ ax_tree = await _get_ax_tree(page) # GAP-AX
825
+ return BrowserResult(
826
+ ok=True, screenshot_b64=png_b64, title=title,
827
+ url=page.url, text_content=text[:5000] if text else None,
828
+ ax_tree=ax_tree,
829
+ )
830
+ except Exception as e:
831
+ return BrowserResult(ok=False, error=str(e)[:500])
832
+ finally:
833
+ await ctx.close()
834
+ await browser.close()
835
+ except Exception as e:
836
+ return BrowserResult(ok=False, error=str(e)[:500])
837
+
838
+
839
+ # ─── /open ────────────────────────────────────────────────────────────────────
840
+
841
+ @router.post("/open", response_model=BrowserResult)
842
+ async def browser_open(
843
+ req: BrowserOpenRequest, request: Request,
844
+ role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
845
+ ):
846
+ """
847
+ Apre una sessione Playwright persistente, naviga all'URL, restituisce
848
+ session_id + screenshot + mappa DOM + text_content (trafilatura).
849
+ """
850
+ if not _safe_url(req.url):
851
+ raise HTTPException(400, "URL non consentita")
852
+
853
+ if len(_sessions) >= SESSION_LIMIT:
854
+ oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_used"])
855
+ await _close_session(oldest, "OOM guard")
856
+
857
+ async with _browser_lock:
858
+ try:
859
+ # ARCH-7: CDP remoto (Browserless) se BROWSERLESS_TOKEN, else locale
860
+ pw, browser, _is_remote = await _get_browser_instance()
861
+ ctx = await _make_context(browser, req.width, 800, req.mobile)
862
+ page = await ctx.new_page()
863
+
864
+ # W-NAV3: networkidle per SPA
865
+ await _goto_with_networkidle(page, req.url, GOTO_TIMEOUT)
866
+ # W-NAV2: dismiss cookie prima dell'estrazione DOM
867
+ await _dismiss_cookie_banner(page)
868
+ await page.wait_for_timeout(500)
869
+ await _execute_actions(page, req.actions)
870
+ await page.wait_for_timeout(req.wait_ms)
871
+
872
+ png = await page.screenshot(type="png", full_page=False)
873
+ title = await page.title()
874
+ png_b64 = base64.b64encode(png).decode()
875
+ dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
876
+ # W-NAV: text_content via trafilatura (aggiunto — prima non era nella risposta /open)
877
+ text = await _extract_text_trafilatura(page, req.url, max_chars=MAX_TEXT)
878
+
879
+ sid = uuid.uuid4().hex[:16]
880
+ _sessions[sid] = {
881
+ "pw": pw, "browser": browser, "context": ctx, "page": page,
882
+ "created_at": time.time(), "last_used": time.time(), "url": page.url,
883
+ "click_history": [],
884
+ "visited_urls": [page.url],
885
+ "action_log": [],
886
+ "is_remote": _is_remote, # ARCH-7: True=CDP, False=locale
887
+ }
888
+ asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
889
+
890
+ dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
891
+ ax_tree = await _get_ax_tree(page) # GAP-AX: Accessibility Tree MCP-style
892
+ return BrowserResult(
893
+ ok=True, session_id=sid,
894
+ screenshot_b64=png_b64, title=title, url=page.url,
895
+ dom=dom,
896
+ text_content=text[:MAX_TEXT] if text else None,
897
+ ax_tree=ax_tree,
898
+ )
899
+ except Exception as e:
900
+ return BrowserResult(ok=False, error=str(e)[:500]) # S603: 400→500
901
+
902
+
903
+ # ─── /act ─────────────────────────────────────────────────────────────────────
904
+
905
+ @router.post("/act", response_model=BrowserResult)
906
+ async def browser_act(
907
+ req: BrowserActRequest, request: Request,
908
+ role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
909
+ ):
910
+ """
911
+ Esegue azioni su una sessione aperta.
912
+ Restituisce screenshot + mappa DOM + warnings anti-loop.
913
+ """
914
+ sess = _sessions.get(req.session_id)
915
+ if not sess:
916
+ raise HTTPException(404, f"Sessione {req.session_id} non trovata o scaduta")
917
+
918
+ sess["last_used"] = time.time()
919
+ page = sess["page"]
920
+ warnings: list[str] = []
921
+
922
+ CLICK_HISTORY_MAX = 20
923
+ LOOP_THRESHOLD = 3
924
+ for action in req.actions:
925
+ if action.type == "click" and action.selector:
926
+ history: list[str] = sess.get("click_history", [])
927
+ repeat_count = history.count(action.selector)
928
+ if repeat_count >= LOOP_THRESHOLD:
929
+ warnings.append(
930
+ f"⚠️ ANTI-LOOP: selettore '{action.selector}' già cliccato "
931
+ f"{repeat_count}x — cambia strategia (prova altro selettore, scroll, modal, ecc.)"
932
+ )
933
+ history.append(action.selector)
934
+ sess["click_history"] = history[-CLICK_HISTORY_MAX:]
935
+
936
+ try:
937
+ url_before = page.url
938
+ await _execute_actions(page, req.actions)
939
+ await page.wait_for_timeout(req.wait_ms)
940
+
941
+ url_after = page.url
942
+ sess["url"] = url_after
943
+
944
+ visited: list[str] = sess.get("visited_urls", [])
945
+ if url_after not in visited:
946
+ visited.append(url_after)
947
+ sess["visited_urls"] = visited[-30:]
948
+
949
+ action_log: list[dict] = sess.get("action_log", [])
950
+ for a in req.actions:
951
+ action_log.append({
952
+ "type": a.type, "selector": a.selector,
953
+ "value": a.value, "url_after": url_after,
954
+ })
955
+ sess["action_log"] = action_log[-50:]
956
+
957
+ title = await page.title()
958
+ dom_raw = await page.evaluate(_DOM_SCRIPT % (MAX_LINKS, MAX_INPUTS, MAX_TEXT))
959
+ dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
960
+
961
+ if dom and dom.modals:
962
+ modal_titles = [m.get("title") or m.get("role", "modal") for m in dom.modals]
963
+ warnings.append(
964
+ f"🔔 MODAL RILEVATO: {', '.join(str(t) for t in modal_titles)} "
965
+ "— potrebbe bloccare le azioni. Chiudilo prima di continuare."
966
+ )
967
+
968
+ ax_tree = await _get_ax_tree(page) if req.take_screenshot else None # GAP-AX
969
+ result = BrowserResult(
970
+ ok=True, session_id=req.session_id,
971
+ url=page.url, title=title, dom=dom, warnings=warnings,
972
+ ax_tree=ax_tree,
973
+ )
974
+
975
+ if req.take_screenshot:
976
+ png = await page.screenshot(type="png", full_page=False)
977
+ png_b64 = base64.b64encode(png).decode()
978
+ result.screenshot_b64 = png_b64
979
+ asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
980
+
981
+ return result
982
+ except Exception as e:
983
+ return BrowserResult(ok=False, session_id=req.session_id, error=str(e)[:500], warnings=warnings) # S603
984
+
985
+
986
+ # ─── /close ───────────────────────────────────────────────────────────────────
987
+
988
+ @router.post("/close", response_model=BrowserResult)
989
+ async def browser_close(req: BrowserCloseRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
990
+ """Chiude esplicitamente una sessione persistente e libera risorse."""
991
+ if req.session_id not in _sessions:
992
+ return BrowserResult(ok=True, session_id=req.session_id)
993
+ await _close_session(req.session_id, "explicit")
994
+ return BrowserResult(ok=True, session_id=req.session_id)
995
+
996
+
997
+ # ─── GET /screenshot/{session_id} ─────────────────────────────────────────────
998
+
999
+ @router.get("/screenshot/{session_id}", response_model=BrowserResult)
1000
+ async def browser_session_screenshot(session_id: str, full_page: bool = False, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
1001
+ """Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
1002
+ sess = _sessions.get(session_id)
1003
+ if not sess:
1004
+ raise HTTPException(404, f"Sessione {session_id} non trovata o scaduta")
1005
+
1006
+ sess["last_used"] = time.time()
1007
+ page = sess["page"]
1008
+
1009
+ try:
1010
+ png = await page.screenshot(type="png", full_page=full_page)
1011
+ title = await page.title()
1012
+ png_b64 = base64.b64encode(png).decode()
1013
+ asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title)).add_done_callback(_log_browser_bg_exc) # BUGFIX
1014
+ return BrowserResult(
1015
+ ok=True, session_id=session_id,
1016
+ screenshot_b64=png_b64, title=title, url=page.url,
1017
+ )
1018
+ except Exception as e:
1019
+ return BrowserResult(ok=False, session_id=session_id, error=str(e)[:500]) # S603
1020
+
1021
+
1022
+ # ─── /sessions ────────────────────────────────────────────────────────────────
1023
+
1024
+ @router.get("/sessions")
1025
+ async def list_sessions(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
1026
+ now = time.time()
1027
+ return {
1028
+ sid: {
1029
+ "url": s["url"],
1030
+ "idle_s": int(now - s["last_used"]),
1031
+ "age_s": int(now - s["created_at"]),
1032
+ }
1033
+ for sid, s in _sessions.items()
1034
+ }
1035
+
1036
+
1037
+ _start_cleanup()
api/coding.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """api/coding.py — Code analysis endpoint (S437→P37-PA)
2
+
3
+ S437: CodeAgent Ollama rimosso. Sostituito con python_analyze reale.
4
+ Analisi statica Python via ast (zero deps) + complessità ciclomatica + metriche.
5
+ Opzionale: esecuzione in sandbox isolata via exec_sandbox.
6
+
7
+ Endpoints:
8
+ POST /code/analyze — analisi statica Python (+ opzionale esecuzione sandbox)
9
+ POST /code/session — stub 501 (rimosso Ollama S437 — usa /api/agent/run)
10
+
11
+ Sprint P37-PA — 2026-06-21
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import logging
17
+ import textwrap
18
+ import time
19
+ from typing import Optional, List
20
+
21
+ from fastapi import APIRouter, Depends
22
+ from .auth_guard import require_role, AuthRole
23
+ from fastapi.responses import JSONResponse
24
+ from pydantic import BaseModel, field_validator
25
+
26
+ _logger = logging.getLogger("api.coding")
27
+
28
+ router = APIRouter(prefix="/code", tags=["coding"])
29
+
30
+ # ── Constants ─────────────────────────────────────────────────────────────────
31
+ _MAX_CODE_LEN = 50_000 # byte max per /code/analyze
32
+ _MAX_RUN_LINES = 200 # righe max per esecuzione sandbox
33
+
34
+
35
+ # ── Request / Response schemas ────────────────────────────────────────────────
36
+ class AnalyzeReq(BaseModel):
37
+ filepath: str = "code.py"
38
+ content: str
39
+ goal: str = ""
40
+ run_code: bool = False # se True → esegue in sandbox dopo analisi
41
+
42
+ @field_validator("content")
43
+ @classmethod
44
+ def _check_len(cls, v: str) -> str:
45
+ if len(v) > _MAX_CODE_LEN:
46
+ raise ValueError(f"content troppo grande ({len(v)} byte, max {_MAX_CODE_LEN})")
47
+ return v
48
+
49
+
50
+ class SessionReq(BaseModel):
51
+ goal: str
52
+ files: List[dict]
53
+ model: Optional[str] = None
54
+
55
+
56
+ # ── AST helpers ───────────────────────────────────────────────────────────────
57
+
58
+ def _lint_python(code: str) -> dict:
59
+ """Analisi sintattica via ast.parse — zero dipendenze aggiuntive."""
60
+ try:
61
+ ast.parse(code, mode="exec")
62
+ return {"ok": True, "errors": []}
63
+ except SyntaxError as e:
64
+ return {"ok": False, "errors": [f"SyntaxError riga {e.lineno}: {e.msg} ('{e.text or ''}')".strip()]}
65
+ except Exception as e:
66
+ return {"ok": False, "errors": [str(e)[:300]]}
67
+
68
+
69
+ def _complexity(tree: ast.AST) -> dict:
70
+ """
71
+ Complessità ciclomatica approssimata:
72
+ CC = 1 + somma rami decisionali (if/elif, while, for, except, with, assert, and/or booleani).
73
+ Per funzione e per modulo.
74
+ """
75
+ def _cc_node(node: ast.AST) -> int:
76
+ """Conta branch points in un sotto-albero."""
77
+ count = 0
78
+ for n in ast.walk(node):
79
+ if isinstance(n, (ast.If, ast.While, ast.For, ast.ExceptHandler,
80
+ ast.With, ast.AsyncFor, ast.AsyncWith)):
81
+ count += 1
82
+ elif isinstance(n, ast.BoolOp) and isinstance(n.op, (ast.And, ast.Or)):
83
+ count += len(n.values) - 1
84
+ elif isinstance(n, ast.Assert):
85
+ count += 1
86
+ return count
87
+
88
+ funcs: list[dict] = []
89
+ for node in ast.walk(tree):
90
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
91
+ cc = 1 + _cc_node(node)
92
+ funcs.append({
93
+ "name": node.name,
94
+ "line": node.lineno,
95
+ "cc": cc,
96
+ "risk": "HIGH" if cc > 10 else "MEDIUM" if cc > 5 else "LOW",
97
+ })
98
+
99
+ total_cc = 1 + _cc_node(tree)
100
+ return {
101
+ "module_cc": total_cc,
102
+ "functions": sorted(funcs, key=lambda f: -f["cc"])[:20], # top 20 per cc
103
+ "avg_func_cc": round(sum(f["cc"] for f in funcs) / len(funcs), 1) if funcs else 0,
104
+ }
105
+
106
+
107
+ def _imports(tree: ast.AST) -> list[str]:
108
+ """Lista moduli importati."""
109
+ mods: list[str] = []
110
+ for node in ast.walk(tree):
111
+ if isinstance(node, ast.Import):
112
+ mods.extend(a.name for a in node.names)
113
+ elif isinstance(node, ast.ImportFrom) and node.module:
114
+ mods.append(node.module)
115
+ return sorted(set(mods))
116
+
117
+
118
+ def _suggestions(lint: dict, cc: dict, code_lines: int) -> list[str]:
119
+ """Genera suggerimenti leggibili in base all'analisi."""
120
+ hints: list[str] = []
121
+ if not lint["ok"]:
122
+ hints.append("🔴 Correggere errori di sintassi prima di procedere.")
123
+ for f in cc["functions"]:
124
+ if f["risk"] == "HIGH":
125
+ hints.append(f"⚠️ Funzione '{f['name']}' (riga {f['line']}): CC={f['cc']} — considera di spezzarla.")
126
+ elif f["risk"] == "MEDIUM":
127
+ hints.append(f"📌 Funzione '{f['name']}' (riga {f['line']}): CC={f['cc']} — complessità moderata.")
128
+ if code_lines > 300:
129
+ hints.append(f"📏 File lungo ({code_lines} righe) — considera di suddividere in moduli.")
130
+ if cc["module_cc"] > 20:
131
+ hints.append(f"🔺 Complessità ciclomatica modulo alta (CC={cc['module_cc']}) — molti rami.")
132
+ return hints[:8] # max 8 suggerimenti
133
+
134
+
135
+ # ── Routes ────────────────────────────────────────────────────────────────────
136
+
137
+ @router.post("/analyze")
138
+ async def analyze(req: AnalyzeReq, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: run_code=True esegue Python
139
+ """
140
+ Analisi statica Python: sintassi, complessità ciclomatica, imports, suggerimenti.
141
+ Se run_code=True: esegue in sandbox isolata via exec_sandbox (max _MAX_RUN_LINES righe).
142
+
143
+ Response JSON:
144
+ syntax_ok, syntax_errors, complexity, imports, suggestions, metrics,
145
+ execution (solo se run_code=True e sintassi ok)
146
+ """
147
+ t0 = time.monotonic()
148
+ code = req.content.strip()
149
+ code_lines = code.count("\n") + 1
150
+
151
+ # 1. Analisi sintattica
152
+ lint = _lint_python(code)
153
+
154
+ # 2. AST analysis (solo se sintassi ok)
155
+ cc_result: dict = {"module_cc": 0, "functions": [], "avg_func_cc": 0}
156
+ imp_list: list = []
157
+ if lint["ok"]:
158
+ try:
159
+ tree = ast.parse(code, mode="exec")
160
+ cc_result = _complexity(tree)
161
+ imp_list = _imports(tree)
162
+ except Exception as e:
163
+ _logger.warning("AST analysis error: %s", e)
164
+
165
+ suggestions = _suggestions(lint, cc_result, code_lines)
166
+
167
+ result: dict = {
168
+ "syntax_ok": lint["ok"],
169
+ "syntax_errors": lint["errors"],
170
+ "complexity": cc_result,
171
+ "imports": imp_list,
172
+ "suggestions": suggestions,
173
+ "metrics": {
174
+ "lines": code_lines,
175
+ "chars": len(code),
176
+ "functions": len(cc_result["functions"]),
177
+ },
178
+ "elapsed_ms": round((time.monotonic() - t0) * 1000, 1),
179
+ }
180
+
181
+ # 3. Esecuzione sandbox opzionale
182
+ if req.run_code and lint["ok"]:
183
+ if code_lines > _MAX_RUN_LINES:
184
+ result["execution"] = {
185
+ "ok": False,
186
+ "error": f"Codice troppo lungo per esecuzione sandbox ({code_lines} righe, max {_MAX_RUN_LINES}).",
187
+ }
188
+ else:
189
+ try:
190
+ from api.exec_sandbox import run_in_sandbox_async
191
+ exec_result = await run_in_sandbox_async(
192
+ code=code,
193
+ language="python",
194
+ timeout_s=10,
195
+ )
196
+ result["execution"] = exec_result
197
+ except ImportError:
198
+ result["execution"] = {"ok": False, "error": "exec_sandbox non disponibile in questo ambiente."}
199
+ except Exception as e:
200
+ result["execution"] = {"ok": False, "error": str(e)[:300]}
201
+
202
+ _logger.info("analyze %s: syntax=%s cc=%s t=%.0fms",
203
+ req.filepath, lint["ok"], cc_result["module_cc"], result["elapsed_ms"])
204
+ return JSONResponse(content=result)
205
+
206
+
207
+ _STUB_MSG = (
208
+ "Endpoint sessione rimosso (S437) — usa /api/agent/run con AIClient multi-provider."
209
+ )
210
+
211
+
212
+ class _SR(BaseModel):
213
+ goal: str
214
+ files: list
215
+ model: str | None = None
216
+
217
+
218
+ @router.post("/session")
219
+ async def session(_req: _SR, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: stub 501 ma consistent
220
+ return JSONResponse(status_code=501, content={"error": _STUB_MSG})
api/conversations.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/conversations.py — Conversations + Messages CRUD (S354)."""
2
+ import json, logging
3
+ from .state import safe_json_dumps
4
+ from typing import Optional, Any
5
+ from fastapi import APIRouter, Depends, Body, HTTPException
6
+ from .auth_guard import require_role, AuthRole
7
+ from pydantic import BaseModel
8
+ from .state import sb
9
+
10
+ router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
11
+ _logger = logging.getLogger("conversations")
12
+
13
+
14
+ class ConversationIn(BaseModel):
15
+ id: str
16
+ title: str = 'Nuova conversazione'
17
+ created_at: int
18
+ updated_at: int
19
+
20
+
21
+ class MessageIn(BaseModel):
22
+ id: str
23
+ conversation_id: str
24
+ role: str
25
+ content: str
26
+ created_at: int
27
+ error: Optional[bool] = False
28
+ steps: Optional[Any] = None
29
+ agent_status: Optional[str] = None
30
+
31
+
32
+ # ── Conversations ──────────────────────────────────────────────────────────────
33
+
34
+ @router.get('/api/conversations')
35
+ async def list_conversations():
36
+ try:
37
+ data = sb().table('conversations').select('*').order('updated_at', desc=True).limit(200).execute() # BUGFIX: LIMIT 200 — senza limit OOM garantito su account con molte conversazioni
38
+ return {'conversations': data.data}
39
+ except Exception as exc:
40
+ _logger.warning("list_conversations: %s", exc)
41
+ # S750-GAP-I: Supabase non configurato o irraggiungibile → lista vuota invece di 500
42
+ return {'conversations': [], '_error': str(exc)[:120]}
43
+
44
+
45
+ @router.post('/api/conversations')
46
+ async def upsert_conversation(conv: ConversationIn):
47
+ try:
48
+ data = sb().table('conversations').upsert(conv.model_dump()).execute()
49
+ return {'conversation': data.data[0] if data.data else conv.model_dump()}
50
+ except Exception as exc:
51
+ _logger.warning("upsert_conversation %s: %s", conv.id, exc)
52
+ return {'conversation': conv.model_dump(), '_error': str(exc)[:120]}
53
+
54
+
55
+ @router.put('/api/conversations/{conv_id}')
56
+ async def update_conversation(conv_id: str, body: dict = Body(...)):
57
+ body['id'] = conv_id
58
+ try:
59
+ data = sb().table('conversations').upsert(body).execute()
60
+ return {'conversation': data.data[0] if data.data else body}
61
+ except Exception as exc:
62
+ _logger.warning("update_conversation %s: %s", conv_id, exc)
63
+ return {'conversation': body, '_error': str(exc)[:120]}
64
+
65
+
66
+ @router.delete('/api/conversations/{conv_id}')
67
+ async def delete_conversation(conv_id: str):
68
+ try:
69
+ sb().table('messages').delete().eq('conversation_id', conv_id).execute()
70
+ sb().table('conversations').delete().eq('id', conv_id).execute()
71
+ except Exception as exc:
72
+ _logger.warning("delete_conversation %s: %s", conv_id, exc)
73
+ return {'deleted': conv_id}
74
+
75
+
76
+ # ── Messages ───────────────────────────────────────────────────────────────────
77
+
78
+ @router.get('/api/conversations/{conv_id}/messages')
79
+ async def list_messages(conv_id: str):
80
+ try:
81
+ data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').limit(500).execute() # BUGFIX: LIMIT 500 — senza limit OOM garantito su conversazioni lunghe
82
+ return {'messages': data.data}
83
+ except Exception as exc:
84
+ _logger.warning("list_messages %s: %s", conv_id, exc)
85
+ return {'messages': [], '_error': str(exc)[:120]}
86
+
87
+
88
+ @router.post('/api/conversations/{conv_id}/messages')
89
+ async def upsert_messages(conv_id: str, body: dict = Body(...)):
90
+ msgs = body.get('messages', [])
91
+ if not msgs:
92
+ return {'upserted': 0}
93
+ for m in msgs:
94
+ m['conversation_id'] = conv_id
95
+ if 'steps' in m and m['steps'] is not None:
96
+ m['steps'] = safe_json_dumps(m['steps']) if not isinstance(m['steps'], str) else m['steps']
97
+ try:
98
+ data = sb().table('messages').upsert(msgs).execute()
99
+ return {'upserted': len(data.data)}
100
+ except Exception as exc:
101
+ _logger.warning("upsert_messages %s: %s", conv_id, exc)
102
+ return {'upserted': 0, '_error': str(exc)[:120]}
103
+
104
+
105
+ @router.delete('/api/conversations/{conv_id}/messages')
106
+ async def clear_messages(conv_id: str):
107
+ try:
108
+ sb().table('messages').delete().eq('conversation_id', conv_id).execute()
109
+ except Exception as exc:
110
+ _logger.warning("clear_messages %s: %s", conv_id, exc)
111
+ return {'cleared': conv_id}
api/daemon_status.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/daemon_status.py — Live status of the Telegram session daemon.
2
+
3
+ Espone lo stato del session-daemon (scripts/session-daemon.mjs) leggendo
4
+ le sessioni attive da Supabase agent_tasks (status = "__session__").
5
+
6
+ Endpoint:
7
+ GET /api/daemon/status — sessioni attive, ultimo heartbeat, uptime, task corrente
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ from typing import Any
16
+
17
+ from fastapi import APIRouter, Depends
18
+ from .auth_guard import require_role, AuthRole
19
+
20
+ _logger = logging.getLogger("api.daemon_status")
21
+
22
+ router = APIRouter(prefix="/api/daemon", tags=["daemon-status"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
23
+
24
+ # ACTIVE_TTL: allineato a session-daemon.mjs (5 * 60_000 ms)
25
+ _ACTIVE_TTL_MS = 5 * 60 * 1000 # 5 minuti
26
+
27
+
28
+ def _parse_context(raw: Any) -> dict:
29
+ """Decodifica il campo context (può essere str JSON o dict)."""
30
+ if isinstance(raw, dict):
31
+ return raw
32
+ if isinstance(raw, str):
33
+ try:
34
+ return json.loads(raw)
35
+ except Exception:
36
+ return {}
37
+ return {}
38
+
39
+
40
+ def _fmt_uptime(started_at_ms: int | None) -> str:
41
+ if not started_at_ms:
42
+ return "unknown"
43
+ elapsed_s = int((time.time() * 1000 - started_at_ms) / 1000)
44
+ h, rem = divmod(elapsed_s, 3600)
45
+ m, s = divmod(rem, 60)
46
+ if h:
47
+ return f"{h}h {m}m"
48
+ if m:
49
+ return f"{m}m {s}s"
50
+ return f"{s}s"
51
+
52
+
53
+ @router.get("/status")
54
+ async def daemon_status() -> dict:
55
+ """
56
+ Ritorna lo stato live del session-daemon leggendo Supabase agent_tasks.
57
+
58
+ Una sessione è ATTIVA se updated_at < 5 minuti fa (ACTIVE_TTL del daemon).
59
+ Il daemon fa heartbeat ogni 30s — se non si vede da >5min è stale/crashato.
60
+
61
+ Response:
62
+ ok — False se Supabase non raggiungibile
63
+ supabase_ok — True se query OK
64
+ active_sessions — conteggio sessioni con heartbeat < 5min
65
+ sessions[] — lista sessioni con: session_name, active, last_heartbeat,
66
+ uptime, current_task, pid, head_sha, claimed_files
67
+ checked_at — timestamp UTC della verifica
68
+ """
69
+ from .state import _sb
70
+
71
+ checked_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
72
+ now_ms = int(time.time() * 1000)
73
+
74
+ if not _sb:
75
+ return {
76
+ "ok": False,
77
+ "supabase_ok": False,
78
+ "error": "Supabase non configurato (SUPABASE_URL / SUPABASE_KEY mancanti)",
79
+ "sessions": [],
80
+ "active_sessions": 0,
81
+ "checked_at": checked_at,
82
+ }
83
+
84
+ try:
85
+ result = await asyncio.to_thread(
86
+ lambda: _sb.table("agent_tasks")
87
+ .select("task_id, goal, context, updated_at, created_at")
88
+ .eq("status", "__session__")
89
+ .order("updated_at", desc=True)
90
+ .limit(10)
91
+ .execute()
92
+ )
93
+ rows = result.data or []
94
+ except Exception as exc:
95
+ _logger.warning("daemon_status: Supabase query failed: %s", exc)
96
+ return {
97
+ "ok": False,
98
+ "supabase_ok": False,
99
+ "error": str(exc),
100
+ "sessions": [],
101
+ "active_sessions": 0,
102
+ "checked_at": checked_at,
103
+ }
104
+
105
+ sessions = []
106
+ for row in rows:
107
+ ctx = _parse_context(row.get("context"))
108
+ updated_ms: int = row.get("updated_at") or 0
109
+ age_ms = now_ms - updated_ms
110
+ # CLOCK-SKEW-FIX: Railway clock può essere leggermente avanti di HF Space.
111
+ # age_ms negativo = heartbeat *appena* avvenuto → trattare come attivo.
112
+ # Tolleriamo fino a 60s di skew (ben oltre il tipico 1-2s osservato).
113
+ is_active = -60_000 < age_ms < _ACTIVE_TTL_MS
114
+
115
+ ago_s = int(age_ms / 1000)
116
+ if ago_s < 0:
117
+ last_seen = "appena ora"
118
+ elif ago_s < 60:
119
+ last_seen = f"{ago_s}s fa"
120
+ elif ago_s < 3600:
121
+ last_seen = f"{ago_s // 60}m {ago_s % 60}s fa"
122
+ else:
123
+ last_seen = f"{ago_s // 3600}h {(ago_s % 3600) // 60}m fa"
124
+
125
+ head = (ctx.get("headSha") or "")
126
+ sessions.append({
127
+ "session_id": row.get("task_id", ""),
128
+ "session_name": row.get("goal") or ctx.get("sessionName", ""),
129
+ "active": is_active,
130
+ "last_heartbeat": last_seen,
131
+ "uptime": _fmt_uptime(ctx.get("startedAt")),
132
+ "current_task": ctx.get("sprint") or "idle",
133
+ "pid": ctx.get("pid"),
134
+ "head_sha": head[:10] if head else None,
135
+ "claimed_files": ctx.get("claimedFiles", []),
136
+ "updated_at_ms": updated_ms,
137
+ })
138
+
139
+ active_count = sum(1 for s in sessions if s["active"])
140
+
141
+ return {
142
+ "ok": True,
143
+ "supabase_ok": True,
144
+ "active_sessions": active_count,
145
+ "total_sessions": len(sessions),
146
+ "sessions": sessions,
147
+ "active_ttl_s": _ACTIVE_TTL_MS // 1000,
148
+ "checked_at": checked_at,
149
+ }