sync: 171 files from Baida98/AI [deploy-all]

#1
by Baida07 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .env.example +10 -10
  2. .gitattributes +35 -0
  3. Dockerfile +1 -1
  4. REBUILD_TRIGGER +1 -0
  5. agents/audit_semantic_l2.py +303 -0
  6. agents/context_manager.py +0 -23
  7. agents/engineering_state.py +0 -255
  8. agents/executor.py +59 -49
  9. agents/fallback_healer.py +59 -0
  10. agents/fallback_utils.py +26 -0
  11. agents/file_conversion.py +0 -163
  12. agents/goal_verifier.py +5 -22
  13. agents/grid_rag.py +124 -0
  14. agents/html_fast_path.py +0 -60
  15. agents/planner.py +1 -12
  16. agents/strategic_healer.py +0 -11
  17. agents/unified_loop.py +68 -460
  18. agents/unified_loop_delegate.py +192 -0
  19. agents/unified_loop_fallback.py +0 -0
  20. agents/unified_loop_helpers.py +1 -13
  21. agents/unified_loop_llm.py +17 -88
  22. agents/unified_loop_prompts.py +91 -394
  23. agents/unified_loop_routing.py +82 -0
  24. agents/unified_loop_tools.py +544 -367
  25. agents/unified_loop_types.py +0 -52
  26. agents/unified_loop_vfs.py +156 -0
  27. agents/watchdog.py +67 -0
  28. agents/workflow_engine.py +0 -112
  29. api/TELEGRAM_MODULES.md +172 -0
  30. api/_agent_helpers.py +127 -0
  31. api/admin_state.py +0 -75
  32. api/advanced_complex_benchmark.py +108 -0
  33. api/agent.py +47 -475
  34. api/agent_checkpoint.py +0 -131
  35. api/agent_checkpoint_routes.py +279 -0
  36. api/agent_fsm.py +375 -0
  37. api/agent_loop_routes.py +410 -0
  38. api/agent_memory.py +38 -46
  39. api/agent_task_routes.py +800 -0
  40. api/agent_telemetry.py +0 -51
  41. api/auth_guard.py +13 -145
  42. api/auth_managed.py +1 -24
  43. api/background_tasks.py +0 -53
  44. api/benchmark.py +1 -1
  45. api/benchmark_handler.py +43 -92
  46. api/benchmarks_hub.py +100 -0
  47. api/bootstrap_tools.py +44 -0
  48. api/brain_planner.py +253 -0
  49. api/browser.py +7 -48
  50. api/cache_endpoints.py +164 -0
.env.example CHANGED
@@ -12,17 +12,20 @@ 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=
@@ -51,11 +54,9 @@ RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_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=
@@ -68,5 +69,4 @@ UPSTASH_REDIS_REST_TOKEN=
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
-
 
12
  NOTIFY_TOKEN= # Notifiche Interne
13
 
14
  # ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
15
+ BACKEND_URL=https://arjanit98-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
  HF_TOKEN=
22
+ # HF Spaces URLs (configurare per ogni Space del fleet)
23
+ HF_SPACE_URL= # Brain / Backend principale
24
+ HF_SPACE_B_URL= # Daemon / Telegram worker
25
+ HF_SPACE_C_URL= # Worker A (Collab/GPU)
26
+ HF_SPACE_D_URL= # Worker B
27
+ HF_SPACE_E_URL= # Worker C
28
+ ORACLE_CLOUD_VM_URL= # Oracle Cloud A1 compute VM
29
 
30
  # ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
31
  RAILWAY_TOKEN_B=
 
54
  # Configurare nei Secrets del provider hosting (HF/Railway)
55
  GROQ_API_KEY=
56
  OPENROUTER_API_KEY=
 
 
 
57
  GEMINI_API_KEY=
58
  NVIDIA_API_KEY=
59
+ OPENAI_API_KEY=
60
 
61
  # ── 8. Sandboxes & Tools ─────────────────────────────────────
62
  E2B_API_KEY=
 
69
  # ── 9. Feature Flags ─────────────────────────────────────────
70
  VITE_ENABLE_BROWSER_SANDBOX=false
71
  UNIFIED_LOOP_MAX_STEPS=8
72
+ LLM_MODEL=deepseek/deepseek-r1:free
 
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -38,4 +38,4 @@ 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}"]
 
38
 
39
  EXPOSE 7860
40
 
41
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"]
REBUILD_TRIGGER ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild
agents/audit_semantic_l2.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ audit_semantic_l2.py β€” S303: Audit Semantico L2 (Critico Senior) su Nodo D.
3
+
4
+ L1 (goal_verifier.py) valida se la risposta *aderisce* al goal.
5
+ L2 (questo file) verifica la *coerenza logica interna* dell'output:
6
+ - Nessuna contraddizione auto-referenziale
7
+ - Claim verificabili non inventati (anti-hallucination guard)
8
+ - Completezza rispetto ai sotto-obiettivi esplicitati nel goal
9
+ - Stato outcome: PASS / FAIL / UNKNOWN β€” mai forzare PASS
10
+
11
+ Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py.
12
+ Se L1 = FAIL β†’ L2 non viene invocato (risparmio token).
13
+ Se L1 = PASS o UNKNOWN β†’ L2 aggiunge una seconda garanzia semantica.
14
+
15
+ Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from enum import Enum
25
+ from typing import Any, Optional
26
+
27
+ _logger = logging.getLogger("agents.audit_l2")
28
+
29
+
30
+ class AuditStatus(str, Enum):
31
+ PASS = "PASS"
32
+ FAIL = "FAIL"
33
+ UNKNOWN = "UNKNOWN"
34
+
35
+
36
+ @dataclass
37
+ class AuditL2Result:
38
+ status: AuditStatus
39
+ confidence: float = 0.0 # 0.0 – 1.0
40
+ issues: list[str] = field(default_factory=list)
41
+ repair_hint: str = ""
42
+ engine: str = "heuristic" # "heuristic" | "llm"
43
+
44
+
45
+ # ── Pattern anti-hallucination ────────────────────────────────────────────────
46
+ # Claim di azioni che l'agente NON puΓ² eseguire da solo senza tool confirmation.
47
+ # Copre IT / EN / ES / FR β€” le 4 lingue attive nel cluster.
48
+ _HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
49
+ # Deploy / publish
50
+ (re.compile(
51
+ r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|"
52
+ r"ho caricato|ho aggiornato il server|ho rilasciato|"
53
+ r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|"
54
+ r"he desplegado|he publicado|he enviado|he subido|he lanzado|"
55
+ r"j'ai dΓ©ployΓ©|j'ai publiΓ©|j'ai envoyΓ©|j'ai poussΓ©|j'ai mis en ligne)\b",
56
+ re.I),
57
+ "claim di deploy/push/send non verificato da tool"),
58
+
59
+ # Stato esterno live
60
+ (re.compile(
61
+ r"\b(il sito Γ¨ live|the site is live|ora funziona|it now works|"
62
+ r"Γ¨ online|is online|Γ¨ andato live|went live|"
63
+ r"the app is running|l'app Γ¨ in esecuzione|"
64
+ r"el sitio estΓ‘ en vivo|el sistema funciona ahora|"
65
+ r"le site est en ligne|l'application fonctionne maintenant)\b",
66
+ re.I),
67
+ "claim di stato esterno non verificabile"),
68
+
69
+ # Assunzioni sull'utente
70
+ (re.compile(
71
+ r"\b(l'utente ha|the user has|hai giΓ |you already|"
72
+ r"your database (is|has)|il tuo database (Γ¨|ha)|"
73
+ r"el usuario ya|vous avez dΓ©jΓ )\b",
74
+ re.I),
75
+ "assunzione su stato dell'utente non verificabile"),
76
+
77
+ # Test / CI passati senza prova
78
+ (re.compile(
79
+ r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|"
80
+ r"la CI Γ¨ verde|CI is green|build successful|build riuscita|"
81
+ r"todos los tests pasan|tous les tests passent)\b",
82
+ re.I),
83
+ "claim di test/CI passati senza esecuzione verificata"),
84
+ ]
85
+
86
+ # ── Pattern contraddizione interna ────────────────────────────────────────────
87
+ _CONTRADICTION_PAIRS: list[tuple[str, str]] = [
88
+ ("errore", "nessun errore"),
89
+ ("error", "no error"),
90
+ ("fallito", "completato con successo"),
91
+ ("failed", "completed successfully"),
92
+ ("non trovato", "trovato correttamente"),
93
+ ("not found", "found correctly"),
94
+ ("timeout", "risposta ricevuta"),
95
+ ("timeout", "response received"),
96
+ ("impossibile", "funziona"),
97
+ ("impossible", "works"),
98
+ ("non funziona", "funziona correttamente"),
99
+ ("doesn't work", "works correctly"),
100
+ ("eccezione", "nessuna eccezione"),
101
+ ("exception", "no exception"),
102
+ ("crash", "stabile"),
103
+ ("crash", "stable"),
104
+ ]
105
+
106
+
107
+ def _check_hallucinations(text: str) -> list[str]:
108
+ issues = []
109
+ for pattern, label in _HALLUCINATION_PATTERNS:
110
+ if pattern.search(text):
111
+ issues.append(f"Possibile hallucination: {label}")
112
+ return issues
113
+
114
+
115
+ def _check_contradictions(text: str) -> list[str]:
116
+ issues = []
117
+ text_lower = text.lower()
118
+ for a, b in _CONTRADICTION_PAIRS:
119
+ if a in text_lower and b in text_lower:
120
+ issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti")
121
+ return issues
122
+
123
+
124
+ def _check_completeness(goal: str, answer: str) -> list[str]:
125
+ """
126
+ Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate
127
+ o bullet points) siano almeno menzionati nella risposta.
128
+ """
129
+ issues = []
130
+ sub_goals = re.findall(
131
+ r"(?:^|\n)\s*(?:\d+\.|[-*β€’])\s+(.+?)(?:\n|$)", goal
132
+ )
133
+ if not sub_goals:
134
+ return []
135
+ answer_lower = answer.lower()
136
+ missing = []
137
+ for sg in sub_goals[:8]: # max 8 sotto-obiettivi
138
+ words = [w for w in sg.lower().split() if len(w) > 4][:4]
139
+ if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2):
140
+ missing.append(sg.strip()[:60])
141
+ if missing:
142
+ issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}")
143
+ return issues
144
+
145
+
146
+ def _heuristic_audit(goal: str, answer: str) -> AuditL2Result:
147
+ """Audit euristico: pattern matching su testo, senza LLM."""
148
+ issues: list[str] = []
149
+ issues.extend(_check_hallucinations(answer))
150
+ issues.extend(_check_contradictions(answer))
151
+ issues.extend(_check_completeness(goal, answer))
152
+
153
+ if not issues:
154
+ return AuditL2Result(
155
+ status=AuditStatus.PASS,
156
+ confidence=0.75,
157
+ engine="heuristic",
158
+ )
159
+ # Gravi (hallucination o contraddizione) β†’ FAIL; solo completeness β†’ UNKNOWN
160
+ has_severe = any(
161
+ "hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower()
162
+ for i in issues
163
+ )
164
+ return AuditL2Result(
165
+ status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN,
166
+ confidence=0.82 if has_severe else 0.55,
167
+ issues=issues,
168
+ repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.",
169
+ engine="heuristic",
170
+ )
171
+
172
+
173
+ _AUDIT_SYSTEM = (
174
+ "Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. "
175
+ "Rispondi SOLO con JSON valido, senza markdown. Formato:\n"
176
+ '{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,'
177
+ '"issues":["..."],"repair_hint":"..."}\n\n'
178
+ "Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). "
179
+ "UNKNOWN per incertezze moderate. PASS se la risposta Γ¨ coerente. "
180
+ "Mai forzare PASS se ci sono dubbi fondati."
181
+ )
182
+
183
+
184
+ def _build_audit_prompt(goal: str, answer: str) -> str:
185
+ # Tronca intelligentemente: preserva inizio e fine dell'answer
186
+ max_ans = 1400
187
+ if len(answer) > max_ans:
188
+ half = max_ans // 2
189
+ answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:]
190
+ else:
191
+ answer_trunc = answer
192
+ return (
193
+ f"GOAL ORIGINALE:\n{goal[:500]}\n\n"
194
+ f"RISPOSTA AGENTE:\n{answer_trunc}\n\n"
195
+ "VERIFICA (rispondi solo con JSON):\n"
196
+ "1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n"
197
+ "2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n"
198
+ "3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n"
199
+ )
200
+
201
+
202
+ class SemanticAuditorL2:
203
+ """
204
+ S303 β€” Audit Semantico L2.
205
+ Istanziato come singleton.
206
+ Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 β‰  FAIL).
207
+ """
208
+
209
+ def __init__(self, ai_client: Any = None, timeout_s: float = 12.0):
210
+ self.ai_client = ai_client
211
+ self.timeout_s = timeout_s
212
+
213
+ async def audit(self, goal: str, answer: str) -> AuditL2Result:
214
+ """
215
+ Punto di ingresso principale.
216
+ 1. Prova audit LLM se ai_client disponibile.
217
+ 2. Fallback a audit euristico in caso di errore o timeout.
218
+ """
219
+ if not goal or not answer:
220
+ return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0,
221
+ issues=["goal o answer vuoti"])
222
+
223
+ # Euristico sempre eseguito β€” base line gratuita
224
+ heuristic_result = _heuristic_audit(goal, answer)
225
+
226
+ # Se euristico ha giΓ  trovato problemi gravi, non invocare LLM per efficienza
227
+ if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2:
228
+ _logger.debug("[AuditL2] heuristic FAIL con %d issues β€” skip LLM", len(heuristic_result.issues))
229
+ return heuristic_result
230
+
231
+ if self.ai_client is not None:
232
+ try:
233
+ result = await asyncio.wait_for(
234
+ self._llm_audit(goal, answer),
235
+ timeout=self.timeout_s
236
+ )
237
+ if result:
238
+ # Merge: se LLM dice PASS ma euristico ha trovato issue β†’ UNKNOWN
239
+ if result.status == AuditStatus.PASS and heuristic_result.issues:
240
+ result.status = AuditStatus.UNKNOWN
241
+ result.issues = heuristic_result.issues
242
+ result.confidence = min(result.confidence, 0.65)
243
+ return result
244
+ except asyncio.TimeoutError:
245
+ _logger.warning("[AuditL2] timeout LLM (%.1fs) β€” fallback euristico", self.timeout_s)
246
+ except Exception as e:
247
+ _logger.warning("[AuditL2] errore LLM (%s) β€” fallback euristico", type(e).__name__)
248
+
249
+ return heuristic_result
250
+
251
+ async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]:
252
+ """Chiamata LLM reale per l'audit semantico."""
253
+ prompt = _build_audit_prompt(goal, answer)
254
+ # Preferisce modello veloce/economico (8B) β€” audit non richiede ragionamento profondo
255
+ _model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant"
256
+ response = await self.ai_client.chat.completions.create(
257
+ model=_model,
258
+ messages=[
259
+ {"role": "system", "content": _AUDIT_SYSTEM},
260
+ {"role": "user", "content": prompt},
261
+ ],
262
+ max_tokens=256,
263
+ temperature=0.0, # deterministico
264
+ )
265
+ raw = response.choices[0].message.content or ""
266
+ match = re.search(r"\{[\s\S]*?\}", raw)
267
+ if not match:
268
+ _logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw)
269
+ return None
270
+ try:
271
+ parsed = json.loads(match.group(0))
272
+ except json.JSONDecodeError as _je:
273
+ _logger.warning("[AuditL2] JSON decode error: %s", _je)
274
+ return None
275
+ status_raw = parsed.get("status", "UNKNOWN").upper()
276
+ try:
277
+ status = AuditStatus(status_raw)
278
+ except ValueError:
279
+ status = AuditStatus.UNKNOWN
280
+ return AuditL2Result(
281
+ status=status,
282
+ confidence=float(parsed.get("confidence", 0.70)),
283
+ issues=parsed.get("issues", []),
284
+ repair_hint=parsed.get("repair_hint", ""),
285
+ engine="llm",
286
+ )
287
+
288
+
289
+ # ── Singleton ─────────────────────────────────────────────────────────────────
290
+ _auditor: Optional[SemanticAuditorL2] = None
291
+
292
+ def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2:
293
+ """
294
+ Ritorna o crea il singleton SemanticAuditorL2.
295
+ Se chiamato con ai_client=<client> e il singleton esiste giΓ  senza client,
296
+ aggiorna il client sul singleton esistente (upgrade lazy).
297
+ """
298
+ global _auditor
299
+ if _auditor is None:
300
+ _auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s)
301
+ elif ai_client is not None and _auditor.ai_client is None:
302
+ _auditor.ai_client = ai_client # upgrade: inserisce client dopo init
303
+ return _auditor
agents/context_manager.py CHANGED
@@ -425,26 +425,3 @@ async def get_context_for_goal(
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
 
425
  return '\n\n'.join(parts) if parts else ''
426
  except Exception:
427
  return ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agents/engineering_state.py DELETED
@@ -1,255 +0,0 @@
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/executor.py CHANGED
@@ -12,6 +12,7 @@ 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
@@ -93,10 +94,12 @@ class Executor:
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
 
@@ -105,6 +108,53 @@ class Executor:
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:
@@ -213,26 +263,11 @@ class Executor:
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}
@@ -292,39 +327,15 @@ class Executor:
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
@@ -357,4 +368,3 @@ class Executor:
357
  await asyncio.sleep(0.5)
358
 
359
  return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
360
-
 
12
  import collections
13
  import logging
14
  import time as _time_mod
15
+ from typing import Any
16
 
17
  from models.ai_client import AIClient
18
  from memory.manager import MemoryManager
 
94
  llm_client: AIClient | None = None,
95
  memory: MemoryManager | None = None,
96
  max_retries: int = 2,
97
+ kernel: Any | None = None, # ARCH-K2.2: Brain→Kernel abstraction
98
  ):
99
  self.llm = llm_client or AIClient()
100
  self.memory = memory
101
  self.max_retries = max_retries
102
+ self._kernel = kernel # ARCH-K2.2: usato da submit_background_task()
103
  # GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
104
  self._circuit_recovery_counts: dict[str, int] = {}
105
 
 
108
  def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
109
  return cls(memory=memory, max_retries=max_retries)
110
 
111
+ # ── ARCH-K2.2: submit background task via Kernel ──────────────────────────
112
+
113
+ async def submit_background_task(
114
+ self,
115
+ payload: dict,
116
+ priority: str = "BACKGROUND",
117
+ session_id: str | None = None,
118
+ ) -> str | None:
119
+ """
120
+ Invia un task in background tramite kernel.submit_task() (ARCH-K2.2).
121
+
122
+ Il Brain/Executor non conosce l'implementazione della coda sottostante
123
+ (S9: ogni servizio ignora l'impl interna degli altri).
124
+
125
+ Fallback: asyncio.create_task() locale se il Kernel non Γ¨ disponibile.
126
+ Sempre non-bloccante β€” non aspetta il completamento del task.
127
+
128
+ Ritorna il task_id se il Kernel Γ¨ disponibile, None altrimenti.
129
+ """
130
+ # Lazy-load kernel singleton se non iniettato
131
+ k = self._kernel
132
+ if k is None:
133
+ try:
134
+ from api.kernel import kernel as _k
135
+ k = _k
136
+ except Exception:
137
+ pass
138
+
139
+ if k is not None:
140
+ try:
141
+ result = await k.submit_task(
142
+ payload=payload,
143
+ priority=priority,
144
+ session_id=session_id,
145
+ )
146
+ _logger.info(
147
+ "[executor] submit_background_task via Kernel id=%s priority=%s",
148
+ result.task_id, priority,
149
+ )
150
+ return result.task_id
151
+ except Exception as exc:
152
+ _logger.warning("[executor] kernel submit_background_task err: %s", exc)
153
+
154
+ # Fallback: esecuzione diretta asincrona locale (non attraverso la Queue)
155
+ _logger.debug("[executor] submit_background_task fallback: asyncio.create_task")
156
+ return None
157
+
158
  # ── Circuit breaker helper ────────────────────────────────────────────────
159
 
160
  def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
 
263
 
264
  # ── run_tool ─────────────────────────────────────────────────────────────
265
 
266
+ async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
 
 
 
 
267
  tool = TOOL_REGISTRY.get(tool_name)
268
  if not tool:
269
  return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
270
 
 
 
 
 
 
 
 
 
 
 
 
271
  missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
272
  if missing:
273
  return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
 
327
  _t0 = _time_mod.monotonic()
328
  result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
329
  _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
 
 
 
 
 
330
  if self.memory:
331
+ # S577β†’S600: inputs 100β†’500 β€” parity con altri handler
332
+ await self.memory.save_episode(
333
+ "tool",
334
+ f"{tool_name}: {str(inputs)[:500]}",
335
+ str(result)[:500],
336
+ True,
337
+ )
338
+ return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  except asyncio.TimeoutError:
341
  # FIX-GAP2: registra il timeout come durata massima per shrink futuro
 
368
  await asyncio.sleep(0.5)
369
 
370
  return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
 
agents/fallback_healer.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fallback_healer.py β€” Logica di Self-Healing strategico per il loop di fallback.
2
+ Estratto da unified_loop_fallback.py (split 2026-06-30).
3
+ """
4
+ import logging
5
+ import re
6
+
7
+ _logger = logging.getLogger("api.agent.healer")
8
+
9
+ class StrategicHealer:
10
+ @staticmethod
11
+ def analyze_errors(exec_errors: list, exec_warn: list) -> None:
12
+ """
13
+ Analizza gli errori ripetuti e inietta messaggi di 'CAMBIO STRATEGIA' (GAP-SELFHEAL v2).
14
+ """
15
+ if not exec_errors:
16
+ return
17
+
18
+ # Fingerprinting degli errori (Dual-mode: raw + error-class)
19
+ _selfheal_raw = {}
20
+ _selfheal_cls = {}
21
+
22
+ for _err in exec_errors:
23
+ if not isinstance(_err, str): continue
24
+ # Mode 1: raw fingerprinting
25
+ _fp = _err[:120]
26
+ _selfheal_raw[_fp] = _selfheal_raw.get(_fp, 0) + 1
27
+ # Mode 2: error-class extraction
28
+ _m = re.search(r"([A-Z][a-z]+Error):", _err)
29
+ if _m:
30
+ _c = _m.group(1).lower()
31
+ _selfheal_cls[_c] = _selfheal_cls.get(_c, 0) + 1
32
+
33
+ _selfheal_raw_max = max(_selfheal_raw.values()) if _selfheal_raw else 0
34
+ _selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0
35
+ _selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max)
36
+
37
+ if _selfheal_max >= 2:
38
+ _ERRCLASS_HINTS = {
39
+ "typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite.",
40
+ "keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza.",
41
+ "attributeerror": "Controlla che l'oggetto non sia None.",
42
+ "nameerror": "Controlla typo nel nome variabile/funzione.",
43
+ "syntaxerror": "Controlla la sintassi o le quote del comando.",
44
+ "memoryerror": "Processa in chunk, riduci dimensione dati.",
45
+ }
46
+
47
+ _dom_cls = max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else ""
48
+ _specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.")
49
+
50
+ _selfheal_msg = (
51
+ f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}Γ—{_selfheal_max}]: "
52
+ f"Hint specifico: {_specific} "
53
+ "NON ripetere lo stesso metodo β€” cambia libreria o pattern."
54
+ )
55
+
56
+ # Evita doppia iniezione
57
+ if not any(isinstance(w, str) and "CAMBIO STRATEGIA" in w for w in exec_warn):
58
+ exec_warn.insert(0, _selfheal_msg)
59
+ _logger.info("GAP-SELFHEAL: Strategia di healing iniettata per %s", _dom_cls or "errore raw")
agents/fallback_utils.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fallback_utils.py β€” Funzioni di utilitΓ  per il loop di fallback.
2
+ Estratto da unified_loop_fallback.py (split 2026-06-30).
3
+ """
4
+ import re
5
+
6
+ def _is_refusal(text: str) -> bool:
7
+ """Verifica se la risposta del modello Γ¨ un rifiuto (S129)."""
8
+ if not text: return False
9
+ refusals = ["mi dispiace", "non posso", "i apologize", "i cannot", "unauthorized", "access denied"]
10
+ t = text.lower()
11
+ return any(r in t for r in refusals)
12
+
13
+ def _s759_bjac(a: str, b: str) -> float:
14
+ """Calcola la somiglianza di Jaccard tra due stringhe (S759)."""
15
+ if not a or not b: return 0.0
16
+ set_a = set(a.lower().split())
17
+ set_b = set(b.lower().split())
18
+ intersection = len(set_a.intersection(set_b))
19
+ union = len(set_a.union(set_b))
20
+ return intersection / union if union > 0 else 0.0
21
+
22
+ def _avg10(lst: list) -> float:
23
+ """Calcola la media degli ultimi 10 elementi di una lista."""
24
+ if not lst: return 0.0
25
+ sub = lst[-10:]
26
+ return sum(sub) / len(sub)
agents/file_conversion.py DELETED
@@ -1,163 +0,0 @@
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_verifier.py CHANGED
@@ -40,7 +40,7 @@ class GoalVerificationStatus(str, Enum):
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
@@ -178,18 +178,7 @@ class GoalVerifier:
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
 
@@ -204,13 +193,7 @@ class GoalVerifier:
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:
@@ -220,9 +203,9 @@ class GoalVerifier:
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:
 
40
  FAIL = "FAIL"
41
  UNKNOWN = "UNKNOWN"
42
 
43
+ RETRY_THRESHOLD = 0.35
44
  MAX_GOAL_CHARS = 400
45
  MAX_ANS_CHARS = 1500
46
  MAX_HINT_CHARS = 150
 
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|e2e|unit.*test|integration.*test)\b",
 
 
 
 
 
 
 
 
 
 
 
182
  re.IGNORECASE,
183
  )
184
 
 
193
 
194
  @classmethod
195
  def is_code_goal(cls, goal: str) -> bool:
196
+ return bool(cls._CODE_RE.search(goal[:500]))
 
 
 
 
 
 
197
 
198
  @classmethod
199
  def adaptive_threshold(cls, goal: str) -> float:
 
203
  if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
204
  return 0.25
205
  if _COMPLEX_CODE_RE.search(g[:500]):
206
+ return 0.55
207
  if cls._CODE_RE.search(g[:500]):
208
+ return 0.42
209
  return RETRY_THRESHOLD
210
 
211
  def __init__(self, llm: Any) -> None:
agents/grid_rag.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/agents/grid_rag.py β€” Grid-Enhanced RAG (S766-GRID-4)
3
+
4
+ Sistema RAG (Retrieval-Augmented Generation) avanzato che indicizza:
5
+ - Memoria distribuita (Supabase A, B, C, D)
6
+ - Log di sistema e di Railway
7
+ - Documentazione interna (.agents/memory/)
8
+
9
+ Architettura:
10
+ - GridIndexer: Indicizza i dati provenienti da diverse fonti
11
+ - ContextRetriever: Recupera il contesto piΓΉ rilevante per il goal corrente
12
+ - KnowledgeGraph: Mappa le relazioni tra i diversi profili e i loro stati
13
+ """
14
+
15
+ import os
16
+ import asyncio
17
+ import logging
18
+ from typing import List, Dict, Any, Optional
19
+ from datetime import datetime
20
+ import json
21
+
22
+ _logger = logging.getLogger("grid_rag")
23
+
24
+ # ── Configurazione ─────────────────────────────────────────────────────────
25
+ RAG_INDEX_SIZE = 100 # Numero di elementi da mantenere nel buffer RAG
26
+ RAG_SIMILARITY_THRESHOLD = 0.75
27
+
28
+
29
+ class GridIndexer:
30
+ """Indicizzatore per la Grid."""
31
+
32
+ def __init__(self):
33
+ self.index = []
34
+ self._lock = asyncio.Lock()
35
+
36
+ async def add_to_index(self, source: str, content: str, metadata: Dict):
37
+ """Aggiunge un elemento all'indice RAG."""
38
+ async with self._lock:
39
+ entry = {
40
+ "source": source,
41
+ "content": content,
42
+ "metadata": metadata,
43
+ "timestamp": datetime.now().isoformat(),
44
+ }
45
+ self.index.append(entry)
46
+ # Mantieni dimensione fissa
47
+ if len(self.index) > RAG_INDEX_SIZE:
48
+ self.index.pop(0)
49
+
50
+ async def index_railway_logs(self, profile: str, logs: str):
51
+ """Indicizza i log di Railway per identificare crash passati."""
52
+ lines = logs.split("\n")
53
+ for line in lines[-50:]: # Ultime 50 righe
54
+ if "error" in line.lower() or "crash" in line.lower() or "failed" in line.lower():
55
+ await self.add_to_index(
56
+ source=f"railway_logs_{profile}",
57
+ content=line,
58
+ metadata={"type": "log_error", "profile": profile}
59
+ )
60
+
61
+
62
+ class ContextRetriever:
63
+ """Recuperatore di contesto per l'agente."""
64
+
65
+ def __init__(self, indexer: GridIndexer):
66
+ self.indexer = indexer
67
+
68
+ async def retrieve_relevant_context(self, query: str) -> List[Dict]:
69
+ """
70
+ Recupera il contesto rilevante basato sulla query.
71
+ Attualmente usa keyword matching semplice (potenziabile con embeddings).
72
+ """
73
+ relevant = []
74
+ keywords = query.lower().split()
75
+
76
+ async with self.indexer._lock:
77
+ for entry in self.indexer.index:
78
+ content = entry["content"].lower()
79
+ score = sum(1 for kw in keywords if kw in content)
80
+
81
+ if score > 0:
82
+ entry_with_score = entry.copy()
83
+ entry_with_score["score"] = score
84
+ relevant.append(entry_with_score)
85
+
86
+ # Ordina per score decrescente
87
+ relevant.sort(key=lambda x: x["score"], reverse=True)
88
+ return relevant[:10] # Ritorna i top 10
89
+
90
+
91
+ class GridRAG:
92
+ """Interfaccia principale per il RAG della Grid."""
93
+
94
+ def __init__(self):
95
+ self.indexer = GridIndexer()
96
+ self.retriever = ContextRetriever(self.indexer)
97
+
98
+ async def prepare_agent_context(self, goal: str) -> str:
99
+ """
100
+ Prepara il contesto per l'agente unificando i dati RAG.
101
+ """
102
+ context_items = await self.retriever.retrieve_relevant_context(goal)
103
+
104
+ if not context_items:
105
+ return ""
106
+
107
+ context_str = "\n--- GRID RAG CONTEXT ---\n"
108
+ for item in context_items:
109
+ context_str += f"[{item['source']}] {item['content']}\n"
110
+ context_str += "------------------------\n"
111
+
112
+ return context_str
113
+
114
+
115
+ # ── Singleton globale ──────────────────────────────────────────────────────
116
+ _grid_rag_instance: Optional[GridRAG] = None
117
+
118
+
119
+ def get_grid_rag() -> GridRAG:
120
+ """Restituisce l'istanza globale del GridRAG."""
121
+ global _grid_rag_instance
122
+ if _grid_rag_instance is None:
123
+ _grid_rag_instance = GridRAG()
124
+ return _grid_rag_instance
agents/html_fast_path.py DELETED
@@ -1,60 +0,0 @@
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 CHANGED
@@ -118,14 +118,6 @@ REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numeric
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
@@ -194,7 +186,6 @@ def _parse_plan(raw: str) -> dict | None:
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:
@@ -211,9 +202,7 @@ class Planner:
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
 
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
  REGOLE GRAFO DI DIPENDENZE:
122
  - requires:[] β†’ subtask eseguibile immediatamente in parallelo con altri requires:[]
123
  - requires:[N] β†’ subtask che dipende dall'output di subtask id N
 
186
 
187
  class Planner:
188
  def __init__(self, llm_client: AIClient | None = None):
 
189
  if llm_client is not None:
190
  self.llm = llm_client
191
  else:
 
202
 
203
  def _get_fast_llm(self) -> AIClient:
204
  """Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
205
+ Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente."""
 
 
206
  try:
207
  from models.role_router import RoleRouter, Role
208
  return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
agents/strategic_healer.py CHANGED
@@ -67,17 +67,6 @@ class StrategyDecision:
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
 
 
67
  # ── Healer principale ──────────────────────────────────────────────────────────
68
 
69
  class StrategicHealer:
 
 
 
 
 
 
 
 
 
 
 
70
  """
71
  Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
72
 
agents/unified_loop.py CHANGED
@@ -55,57 +55,10 @@ from agents.unified_loop_types import (
55
  _ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
56
  _is_goal_ambiguous,
57
  _is_borderline_ambiguous,
58
- AgentState,
59
  UnifiedLoopState,
60
  _maybe_await,
61
  )
62
 
63
- # I4.5: active state is scoped to the current asyncio task, not the loop instance.
64
- # This lets the public guard close unexpected exceptions without sharing state across runs.
65
- _ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
66
- # P0: EngineeringState is a shadow/canary projection of the legacy lifecycle.
67
- # Context-local storage keeps parallel runs isolated even when one loop instance is reused.
68
- from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode
69
-
70
- _ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar(
71
- "active_engineering_state", default=None
72
- )
73
- _ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar(
74
- "active_engineering_mode", default=None
75
- )
76
-
77
-
78
- def _schedule_engineering_persist(engineering_state: EngineeringState) -> None:
79
- """Persist a snapshot without blocking the loop or making observability fatal."""
80
- snapshot = engineering_state.snapshot()
81
-
82
- async def _persist() -> None:
83
- try:
84
- from api.persistence import sb_save_engineering_state
85
- await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot)
86
- except Exception as exc: # shadow state must never break the user task
87
- _logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__)
88
-
89
- try:
90
- task = asyncio.create_task(_persist())
91
- task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
92
- except RuntimeError:
93
- # No running event loop during defensive/test-only calls.
94
- return
95
-
96
-
97
- async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None:
98
- """Flush the terminal snapshot before returning a run result."""
99
- if engineering_state is None:
100
- return
101
- snapshot = engineering_state.snapshot()
102
- try:
103
- from api.persistence import sb_save_engineering_state
104
- await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True)
105
- except Exception as exc: # persistence must not turn a completed task into a crash
106
- engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}")
107
- _logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__)
108
-
109
  # S404: Error Classifier Ҁ” import lazy per evitare circular import issues
110
  def _get_classifier():
111
  from agents.error_classifier import classify_error, format_for_context
@@ -176,84 +129,38 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
176
  self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
177
  self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback β†’ iniettato in exec_warn prima di StrategicHealer
178
  # Ҕ€Ò”€ GAP-3: Rollback atomico scritture Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
179
- async def _transition_state(
180
- self,
181
- state: UnifiedLoopState,
182
- next_state: AgentState,
183
- on_step: StepCallback | None = None,
184
- ) -> None:
185
- """Validate and publish one per-run state transition."""
186
- previous = state.state_machine.current
187
- state.state_machine.transition(next_state)
188
-
189
- # P0 adapter: mirror every legacy transition into the versioned state.
190
- engineering_state = _ACTIVE_ENGINEERING_STATE.get()
191
- if engineering_state is not None:
192
- try:
193
- engineering_state.transition(next_state.value)
194
- _schedule_engineering_persist(engineering_state)
195
- except Exception as exc:
196
- engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}")
197
- if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE:
198
- raise
199
- _logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__)
200
-
201
- if previous == next_state or on_step is None:
202
- return
203
- try:
204
- event = {
205
- "action": "state_transition",
206
- "status": "done",
207
- "from_state": previous.value,
208
- "to_state": next_state.value,
209
- }
210
- if engineering_state is not None:
211
- event["engineering_state"] = engineering_state.projection()
212
- await _maybe_await(on_step(event))
213
- except Exception as _state_callback_error:
214
- _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
215
-
216
  async def _rollback_writes(self, on_step=None) -> None:
217
- """Restore all writes from this run or report an incomplete rollback.
218
-
219
- A None snapshot means the file did not exist and must be removed.
220
- Failed restores remain tracked so a supervisor can retry or block the run.
 
221
  """
222
  if not self._write_snapshots or not self.executor:
223
  return
224
  if on_step:
225
  await _maybe_await(on_step({
226
  "action": "text_chunk",
227
- "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
228
  "status": "streaming",
229
  }))
230
-
231
- remaining: dict[str, str | None] = {}
232
- rolled = 0
233
- for path, original in list(self._write_snapshots.items()):
234
- tool_name = "delete_file" if original is None else "write_file"
235
- inputs = {"path": path} if original is None else {"path": path, "content": original}
236
  try:
237
- result = await asyncio.wait_for(
238
- self.executor.run_tool(tool_name, inputs),
239
  timeout=10.0,
240
  )
241
- payload = result.get("output") if isinstance(result, dict) else None
242
- nested_failed = isinstance(payload, dict) and payload.get("ok") is False
243
- if not isinstance(result, dict) or not result.get("success") or nested_failed:
244
- error = (payload or {}).get("error") if isinstance(payload, dict) else None
245
- raise RuntimeError(error or result.get("error", "rollback tool failed"))
246
- rolled += 1
247
- except Exception as exc:
248
- remaining[path] = original
249
- _logger.error("GAP-3 rollback fallito per %s: %s", path, str(exc)[:240])
250
-
251
- total = len(self._write_snapshots)
252
- self._write_snapshots = remaining
253
- _logger.info("GAP-3 rollback: %d/%d file ripristinati", rolled, total)
254
- if remaining:
255
- raise RuntimeError(f"VFS rollback incompleto: {len(remaining)}/{total} file non ripristinati")
256
 
 
257
  async def _vfs_git_backup(self) -> None:
258
  """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
259
 
@@ -559,29 +466,9 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
559
  # F17+B7: planner per task di progettazione/implementazione Ҁ” soglia ridotta a 10 chars
560
  # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
561
  # _NEEDS_PLAN_RE filtra già query semplici Ҁ” len guard serve solo per 1-8 char input.
562
- try:
563
- from agents.html_fast_path import classify_html_fast_path
564
- _html_fast_decision = classify_html_fast_path(state.goal)
565
- except Exception as _html_cls_exc:
566
- _logger.debug("[html-fast-path] classifier unavailable: %s", type(_html_cls_exc).__name__)
567
- _html_fast_decision = None
568
- _html_fast_plan = None
569
- if _html_fast_decision is not None and _html_fast_decision.eligible and not tool_results:
570
- _html_fast_plan = {
571
- "summary": "Piano locale mini-app HTML a file singolo",
572
- "goal": state.goal,
573
- "subtasks": [
574
- {"id": 1, "description": f"Scrivi {_html_fast_decision.path}: {state.goal}", "tool": "write_file", "requires": []},
575
- {"id": 2, "description": f"Rileggi {_html_fast_decision.path} e verifica la scrittura", "tool": "read_file", "requires": [1]},
576
- ],
577
- "complexity": "low",
578
- "source": "local_html_fast_path",
579
- }
580
- _logger.info("[html-fast-path] planner bypass: %s", _html_fast_decision.path)
581
  _should_plan = (
582
  self.planner
583
  and not tool_results
584
- and _html_fast_plan is None
585
  and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
586
  and len(state.goal) > 10
587
  )
@@ -598,7 +485,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
598
  }
599
  _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
600
  _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
601
- if _should_plan or _html_fast_plan is not None:
602
  if on_step:
603
  await _maybe_await(on_step({
604
  "loop": 0, "action": "plan", "status": "started",
@@ -607,10 +494,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
607
  }))
608
  # S640: timeout planner + S-FMT-ORCH fast-fix bypass
609
  # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
610
- if _html_fast_plan is not None:
611
- plan = _html_fast_plan
612
- _logger.info("[html-fast-path] ARCHITECT bypassato")
613
- elif _fast_fix_plan is not None:
614
  plan = _fast_fix_plan
615
  _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
616
  else:
@@ -635,12 +519,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
635
  "explanation": "Il pianificatore ha impiegato troppo Ҁ” procedo senza piano",
636
  "visibility": "progress",
637
  }))
638
- # P1-RECOVERY: check if plan already exists in steps
639
- existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None)
640
- if existing_plan_step:
641
- plan = existing_plan_step.get("result")
642
- _logger.info("[P1-RECOVERY] Plan restored from steps")
643
- elif plan is not None:
644
  state.steps.append({"action": "plan", "result": plan})
645
  try:
646
  from api.state import record_timing as _rtc_pl
@@ -1047,15 +926,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
1047
  tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
1048
  reg_name, inp_builder = tool_key_pair
1049
  if reg_name and inp_builder is not None:
1050
- # P1-RECOVERY: skip subtasks already completed in state.steps
1051
- _st_id = subtask.get("id")
1052
- _done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None)
1053
- if _done_step:
1054
- _logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id)
1055
- # Ripristiniamo l'output nel buffer per i dipendenti
1056
- _existing_out = _done_step.get("output", "")
1057
- _subtask_outputs[str(_st_id)] = _existing_out
1058
- continue
1059
  _pending_exec.append((subtask, reg_name, inp_builder))
1060
  elif _s_tool:
1061
  # COG-4: tool non in _TOOL_MAP β€” tenta generazione dinamica
@@ -1328,28 +1198,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
1328
  {"path": _wf_path, "content": _wf_generated} if rn == "write_file"
1329
  else {"path": _wf_path, "patch": _wf_generated}
1330
  )
1331
- # GAP-3: snapshot pre-write; read errors are not "file absent".
1332
  if rn == "write_file" and _wf_path not in self._write_snapshots:
1333
- _snap_r = await asyncio.wait_for(
1334
- self.executor.run_tool("read_file", {"path": _wf_path}),
1335
- timeout=4.0,
1336
- )
1337
- _snap_payload = _snap_r.get("output") if isinstance(_snap_r, dict) else None
1338
- _snap_failed = isinstance(_snap_payload, dict) and _snap_payload.get("ok") is False
1339
- if isinstance(_snap_payload, dict):
1340
- _snap_content = _snap_payload.get("content")
1341
- _snap_error = str(_snap_payload.get("error", ""))
1342
- else:
1343
- _snap_content = _snap_payload
1344
- _snap_error = str(_snap_r.get("error", "")) if isinstance(_snap_r, dict) else ""
1345
- if isinstance(_snap_content, str) and not _snap_failed:
1346
- self._write_snapshots[_wf_path] = _snap_content
1347
- elif "File non trovato" in _snap_error or "File not found" in _snap_error:
1348
- self._write_snapshots[_wf_path] = None
1349
- else:
1350
- raise RuntimeError(
1351
- f"Snapshot VFS non disponibile per {_wf_path}: {_snap_error[:240]}"
1352
  )
 
 
1353
  # GAP-VFS: lock per-path β€” serializza scritture parallele sullo stesso file
1354
  _vfs_lock = self._get_vfs_lock(_wf_path)
1355
  async with _vfs_lock:
@@ -1801,16 +1661,16 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
1801
  _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
1802
  self._tdd_fail_inject = None
1803
  # GAP-4: StrategicHealer β€” analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
1804
- if _tool_exec_errors and getattr(self, '_strategic_healer', None):
1805
  try:
1806
  _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
1807
- _sh_decision = await self._strategic_healer.analyze_and_decide(_tool_exec_errors, _sh_ctx_str)
1808
  if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
1809
  exec_warn.insert(0, _sh_decision.strategy_prompt)
1810
  _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
1811
  if _sh_decision and getattr(_sh_decision, 'should_stop', False):
1812
  _logger.info("GAP-4: StrategicHealer β†’ should_stop, interruzione fallback")
1813
- return {"success": False, "output": "", "error": "StrategicHealer ha interrotto il fallback dopo errori di esecuzione"}
1814
  except Exception as _sh_loop_err:
1815
  _logger.debug("GAP-4: StrategicHealer loop silenced β€” %s", _sh_loop_err)
1816
  # GAP-SELFHEAL v2: dual-mode fingerprinting β€” raw + error-class extraction.
@@ -2273,58 +2133,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
2273
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2274
  except Exception as _exc:
2275
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
2276
- # BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
2277
- # modifica answer, retry, provider routing o scoring.
2278
- try:
2279
- from benchmarks.shadow_telemetry import validate_and_record_shadow
2280
- validate_and_record_shadow(
2281
- goal=state.goal,
2282
- answer=answer,
2283
- metadata={
2284
- "provider": getattr(_active_llm, "provider", None),
2285
- "model": getattr(_active_llm, "model", None),
2286
- "profile": getattr(_active_llm, "profile", None),
2287
- "attempt": _llm_try,
2288
- "latency_ms": round(_llm_elapsed, 2),
2289
- "source": "unified_loop",
2290
- },
2291
- )
2292
- except Exception as _exc:
2293
- _logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
2294
-
2295
- # BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
2296
- # non estraibile/non conforme. Non aggiunge tentativi oltre il budget
2297
- # esistente e non scatta su goal non-coding.
2298
- if not _is_last:
2299
- try:
2300
- from benchmarks.validators import validate_coding_retry
2301
- _code_validation = validate_coding_retry(
2302
- state.goal,
2303
- answer,
2304
- is_last_attempt=_is_last,
2305
- )
2306
- if _code_validation is not None:
2307
- state.steps.append({
2308
- "action": f"typescript_contract_retry_{_llm_try}",
2309
- "failure_code": _code_validation.failure_code,
2310
- })
2311
- _code_repair = (
2312
- "CONTRATTO TYPESCRIPT FALLITO: "
2313
- f"{_code_validation.failure_code}.\n"
2314
- "Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
2315
- "```typescript ... ``` non vuoto, completo e compilabile. "
2316
- "Mantieni la firma e tutti i simboli richiesti dal task. "
2317
- "Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
2318
- )
2319
- messages = [
2320
- messages[0],
2321
- {"role": "system", "content": _code_repair},
2322
- *messages[1:],
2323
- ]
2324
- _error_severity = "syntax"
2325
- continue
2326
- except Exception as _exc:
2327
- _logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
2328
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2329
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2330
  if _fr == 'length' and on_step:
@@ -3080,7 +2888,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3080
  except Exception:
3081
  pass
3082
  # S455-P10: task supervisionato β€” done_callback logga eccezioni silenziate
3083
- _rv_t = asyncio.create_task(_reverify_task())
3084
  _rv_t.add_done_callback(
3085
  lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
3086
  )
@@ -3552,63 +3360,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3552
 
3553
  async def run(self, goal: str, context: str = "", max_steps: int = 8,
3554
  on_step: StepCallback | None = None,
3555
- session_id: str = "", allow_tools: bool = True,
3556
- allow_local_csv_conversion: bool = False) -> dict[str, Any]:
3557
- """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3558
- previous_state = _ACTIVE_LOOP_STATE.get()
3559
- previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
3560
- previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
3561
- try:
3562
- return await self._run_impl(
3563
- goal, context, max_steps, on_step, session_id, allow_tools,
3564
- allow_local_csv_conversion,
3565
- )
3566
- except Exception as _run_error:
3567
- state = _ACTIVE_LOOP_STATE.get()
3568
- error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
3569
- if state is None:
3570
- return {
3571
- "success": False,
3572
- "goal": goal,
3573
- "error": error_text,
3574
- "agent_state": AgentState.FAILED.value,
3575
- "state_history": [AgentState.IDLE.value, AgentState.FAILED.value],
3576
- }
3577
-
3578
- state.errors.append(error_text)
3579
- if getattr(self, "_write_snapshots", None):
3580
- try:
3581
- await self._rollback_writes(on_step)
3582
- except Exception as rollback_error:
3583
- state.errors.append(str(rollback_error)[:500])
3584
- _logger.error("[unified_loop] rollback inatteso incompleto: %s", rollback_error)
3585
- previous = state.state_machine.current
3586
- if previous != AgentState.FAILED:
3587
- try:
3588
- await self._transition_state(state, AgentState.FAILED, on_step)
3589
- except Exception as _state_transition_error:
3590
- _logger.debug(
3591
- "[unified_loop] failure transition silenced: %s",
3592
- _state_transition_error,
3593
- )
3594
- await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3595
- return {
3596
- "success": False,
3597
- "goal": state.goal,
3598
- "steps": state.steps,
3599
- "errors": state.errors,
3600
- "error": error_text,
3601
- **state.state_machine.snapshot(),
3602
- }
3603
- finally:
3604
- _ACTIVE_LOOP_STATE.set(previous_state)
3605
- _ACTIVE_ENGINEERING_STATE.set(previous_engineering_state)
3606
- _ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode)
3607
-
3608
- async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3609
- on_step: StepCallback | None = None,
3610
- session_id: str = "", allow_tools: bool = True,
3611
- allow_local_csv_conversion: bool = False) -> dict[str, Any]:
3612
  # S390-B-L: strip role prefixes che causano prompt injection
3613
  # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
3614
  # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso Ҁ” input come
@@ -3643,17 +3395,17 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3643
  except Exception:
3644
  _sid_token = None # fallback silente Ҁ” registry usa default "agent_default"
3645
 
3646
- # S750-GAP-B: pre-warm sandbox solo per task che possono usare tool.
3647
- # Con allow_tools=False non avviamo alcuna sessione esterna prima della risposta.
3648
- if allow_tools:
3649
- try:
3650
- from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
3651
- if _eurl:
3652
- asyncio.ensure_future(
3653
- _ce({"session_id": self._run_task_id}, endpoint="/api/session")
3654
- )
3655
- except Exception as _exc:
3656
- _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3657
 
3658
  # S568-B: reset _session_files ogni run Ҁ” previene memory leak su sessioni lunghe.
3659
  # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
@@ -3668,127 +3420,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3668
 
3669
  state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
3670
 
3671
- # P1: EngineeringState is the recovery authority unless explicitly disabled.
3672
- engineering_config = EngineeringStateConfig.from_env()
3673
- _effective_mode = engineering_config.mode
3674
- _ACTIVE_ENGINEERING_MODE.set(_effective_mode)
3675
-
3676
- engineering_state: EngineeringState | None = None
3677
- recovery_status = "disabled"
3678
- if _effective_mode != EngineeringStateMode.OFF:
3679
- engineering_state = EngineeringState.start(
3680
- goal,
3681
- run_id=self._run_task_id,
3682
- session_id=session_id,
3683
- checkpoint_id=session_id or self._run_task_id,
3684
- )
3685
- _ACTIVE_ENGINEERING_STATE.set(engineering_state)
3686
- recovery_status = "started"
3687
-
3688
- # RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition.
3689
- if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id:
3690
- try:
3691
- from api.persistence import sb_get_checkpoint
3692
- legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id)
3693
- candidate = (legacy_checkpoint or {}).get("engineering_state")
3694
- if candidate:
3695
- restored = EngineeringState.from_snapshot(candidate)
3696
- if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest:
3697
- engineering_state.diagnostic("restore conflict: identity mismatch")
3698
- recovery_status = "conflict"
3699
- elif _effective_mode == EngineeringStateMode.AUTHORITATIVE:
3700
- engineering_state = restored
3701
- engineering_state.prepare_for_resume()
3702
- _ACTIVE_ENGINEERING_STATE.set(engineering_state)
3703
- if legacy_checkpoint:
3704
- checkpoint_steps = legacy_checkpoint.get("steps")
3705
- checkpoint_errors = legacy_checkpoint.get("errors")
3706
- state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else []
3707
- state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else []
3708
- recovery_status = "restored"
3709
- _logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision)
3710
- else:
3711
- engineering_state.diagnostic("restore validated read-only")
3712
- recovery_status = "validated"
3713
- else:
3714
- recovery_status = "checkpoint_missing"
3715
- except Exception as restore_error:
3716
- engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}")
3717
- recovery_status = "rejected"
3718
- _logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__)
3719
-
3720
- _ACTIVE_LOOP_STATE.set(state)
3721
- await self._transition_state(state, AgentState.CLASSIFYING, on_step)
3722
-
3723
- def _with_state(result: dict[str, Any]) -> dict[str, Any]:
3724
- result.update(state.state_machine.snapshot())
3725
- if engineering_state is not None:
3726
- result["engineering_state"] = engineering_state.projection()
3727
- return result
3728
-
3729
- if engineering_state is not None and on_step is not None:
3730
- try:
3731
- await _maybe_await(on_step({
3732
- "action": "engineering_state",
3733
- "status": recovery_status,
3734
- "mode": _effective_mode.value,
3735
- "engineering_state": engineering_state.projection(),
3736
- }))
3737
- except Exception as recovery_event_error:
3738
- _logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__)
3739
-
3740
- async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3741
- next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
3742
- if next_state == AgentState.FAILED and getattr(self, "_write_snapshots", None):
3743
- try:
3744
- await self._rollback_writes(on_step)
3745
- except Exception as rollback_error:
3746
- result.setdefault("errors", []).append(str(rollback_error)[:500])
3747
- result["rollback_incomplete"] = True
3748
- try:
3749
- await self._transition_state(state, next_state, on_step)
3750
- finally:
3751
- # P1 contract: persist the terminal state before returning to the caller.
3752
- await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3753
- return _with_state(result)
3754
-
3755
- # Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner,
3756
- # sandbox, speculazione o tool card Γ¨ raggiungibile. L'unica eccezione Γ¨ la
3757
- # conversione CSV→JSON già riconosciuta e validata dal parser puro al confine HTTP.
3758
- if not allow_tools:
3759
- if allow_local_csv_conversion:
3760
- await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
3761
- direct_results, _tools_count, _exec_success, _exec_errors = await self._run_direct_tools(
3762
- goal, on_step=on_step, local_csv_only=True,
3763
- )
3764
- if direct_results.startswith("[DIRECT_TERMINAL]\n"):
3765
- _r = await _finish({
3766
- "success": _exec_success > 0,
3767
- "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
3768
- "steps": state.steps,
3769
- })
3770
- else:
3771
- _r = await _finish({
3772
- "success": False,
3773
- "output": direct_results,
3774
- "steps": state.steps,
3775
- "errors": ["conversione CSV locale non completata"],
3776
- })
3777
- _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3778
- _r["effective_max_steps"] = state.max_steps
3779
- if _sid_token is not None:
3780
- try: _sid_var.reset(_sid_token)
3781
- except Exception: pass
3782
- return _r
3783
- await self._transition_state(state, AgentState.THINKING, on_step)
3784
- _r = await _finish(await self._run_fallback(state, on_step))
3785
- _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3786
- _r["effective_max_steps"] = state.max_steps
3787
- if _sid_token is not None:
3788
- try: _sid_var.reset(_sid_token)
3789
- except Exception: pass
3790
- return _r
3791
-
3792
  # GAP-4: StrategicHealer β€” init + load past failures (LLM-based self-healing cognitivo)
3793
  try:
3794
  from agents.strategic_healer import StrategicHealer as _SHClass
@@ -3870,7 +3501,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3870
  "title": "Specifica cosa vuoi fare",
3871
  "explanation": _amb_answer,
3872
  }))
3873
- _r_amb = await _finish({"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps})
3874
  if _sid_token is not None:
3875
  try: _sid_var.reset(_sid_token)
3876
  except Exception: pass
@@ -3987,7 +3618,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3987
  "title": "Puoi essere piΓΉ specifico?",
3988
  "explanation": _bl_answer,
3989
  }))
3990
- _r_bl = await _finish({"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps})
3991
  if _sid_token is not None:
3992
  try: _sid_var.reset(_sid_token)
3993
  except Exception: pass
@@ -4004,8 +3635,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4004
  _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
4005
  except Exception as _exc:
4006
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
4007
- await self._transition_state(state, AgentState.THINKING, on_step)
4008
- _r = await _finish(await self._run_fast_path(state, on_step))
4009
  _r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
4010
  _r["effective_max_steps"] = state.max_steps # GAP-2-FIX
4011
  # S749-D: reset ContextVar
@@ -4024,8 +3654,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4024
  except Exception as _exc:
4025
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
4026
  # Puro ragionamento Ҁ” LLM diretto, nessun overhead tool
4027
- await self._transition_state(state, AgentState.THINKING, on_step)
4028
- _r = await _finish(await self._run_fallback(state, on_step))
4029
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
4030
  try:
4031
  from api.state import record_timing as _rtc_ttr
@@ -4055,8 +3684,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4055
  _rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
4056
  except Exception:
4057
  pass
4058
- await self._transition_state(state, AgentState.THINKING, on_step)
4059
- _r = await _finish(await self._run_fallback(state, on_step))
4060
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
4061
  _r["effective_max_steps"] = state.max_steps
4062
  if _sid_token is not None:
@@ -4123,15 +3751,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4123
  if _sid_token is not None:
4124
  try: _sid_var.reset(_sid_token)
4125
  except Exception: pass
4126
- await self._transition_state(state, AgentState.THINKING, on_step)
4127
- return await _finish({
4128
  "success": True,
4129
  "answer": _p36_answer,
4130
  "timing_ms": _p36_ms,
4131
  "effective_max_steps": state.max_steps,
4132
  "steps": [{"action": "p36_python_analyze", "status": "done",
4133
  "output": _p36_answer[:300]}],
4134
- })
4135
  except Exception as _p36_exc:
4136
  _logger.debug("P36 fast-path silenced: %s", _p36_exc)
4137
  # fail-open: cade nel percorso normale
@@ -4143,7 +3770,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4143
  except Exception as _exc:
4144
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
4145
  _t_tool = _time.monotonic()
4146
- await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
4147
  direct_results, _tools_count, _exec_success, _exec_errors = \
4148
  await self._run_direct_tools(goal, on_step=on_step)
4149
  _tool_ms = int((_time.monotonic() - _t_tool) * 1000)
@@ -4152,20 +3778,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4152
  "loop": 0, "action": "direct_tools", "status": "done",
4153
  "tools_fired": _tools_count,
4154
  }))
4155
- if direct_results.startswith("[DIRECT_TERMINAL]\n"):
4156
- _r = await _finish({
4157
- "success": _exec_success > 0,
4158
- "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
4159
- "steps": state.steps,
4160
- })
4161
- else:
4162
- await self._transition_state(state, AgentState.THINKING, on_step)
4163
- _r = await _finish(await self._run_fallback(
4164
- state, on_step,
4165
- preloaded_tool_results=direct_results or None,
4166
- preloaded_tool_exec_successes=_exec_success,
4167
- preloaded_tool_exec_errors=_exec_errors,
4168
- ))
4169
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
4170
  try:
4171
  from api.state import record_timing as _rtc_ttr
@@ -4192,7 +3810,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4192
  # S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
4193
  # S402: unpack 4-tuple Ҁ” aggiunto _exec_success/_exec_errors per Tool Integrity Guard
4194
  _t_tool = _time.monotonic()
4195
- await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
4196
  direct_results, _tools_count, _exec_success, _exec_errors = \
4197
  await self._run_direct_tools(goal, on_step=on_step)
4198
  _tool_ms = int((_time.monotonic() - _t_tool) * 1000)
@@ -4204,20 +3821,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4204
  "loop": 0, "action": "direct_tools", "status": "done",
4205
  "tools_fired": _tools_count,
4206
  }))
4207
- if direct_results.startswith("[DIRECT_TERMINAL]\n"):
4208
- _r = await _finish({
4209
- "success": _exec_success > 0,
4210
- "output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
4211
- "steps": state.steps,
4212
- })
4213
- else:
4214
- await self._transition_state(state, AgentState.THINKING, on_step)
4215
- _r = await _finish(await self._run_fallback(
4216
- state, on_step,
4217
- preloaded_tool_results=direct_results,
4218
- preloaded_tool_exec_successes=_exec_success,
4219
- preloaded_tool_exec_errors=_exec_errors,
4220
- ))
4221
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
4222
  try:
4223
  from api.state import record_timing as _rtc_ttr
@@ -4241,8 +3850,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
4241
  # Rimosso: -25s worst case, path sempre: direct_tools ҆’ _run_fallback.
4242
 
4243
  # Fallback: LLM senza tool results (tool non triggered o tutti skip)
4244
- await self._transition_state(state, AgentState.THINKING, on_step)
4245
- _r = await _finish(await self._run_fallback(state, on_step))
4246
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
4247
  try:
4248
  from api.state import record_timing as _rtc_ttr
 
55
  _ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
56
  _is_goal_ambiguous,
57
  _is_borderline_ambiguous,
 
58
  UnifiedLoopState,
59
  _maybe_await,
60
  )
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # S404: Error Classifier Ҁ” import lazy per evitare circular import issues
63
  def _get_classifier():
64
  from agents.error_classifier import classify_error, format_for_context
 
129
  self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
130
  self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback β†’ iniettato in exec_warn prima di StrategicHealer
131
  # Ҕ€Ò”€ GAP-3: Rollback atomico scritture Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  async def _rollback_writes(self, on_step=None) -> None:
133
+ """
134
+ GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà.
135
+ Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
136
+ Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
137
+ File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
138
  """
139
  if not self._write_snapshots or not self.executor:
140
  return
141
  if on_step:
142
  await _maybe_await(on_step({
143
  "action": "text_chunk",
144
+ "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
145
  "status": "streaming",
146
  }))
147
+ _rolled = 0
148
+ for path, original in self._write_snapshots.items():
149
+ if original is None:
150
+ continue # file non esisteva prima Ҁ” saltiamo (non eliminiamo)
 
 
151
  try:
152
+ await asyncio.wait_for(
153
+ self.executor.run_tool("write_file", {"path": path, "content": original}),
154
  timeout=10.0,
155
  )
156
+ _rolled += 1
157
+ except Exception:
158
+ pass # non-fatal Ҁ” best effort rollback
159
+ _total = len(self._write_snapshots) # salva prima del clear
160
+ self._write_snapshots = {}
161
+ _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
 
 
 
 
 
 
 
 
 
162
 
163
+ # ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
164
  async def _vfs_git_backup(self) -> None:
165
  """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
166
 
 
466
  # F17+B7: planner per task di progettazione/implementazione Ҁ” soglia ridotta a 10 chars
467
  # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
468
  # _NEEDS_PLAN_RE filtra già query semplici Ҁ” len guard serve solo per 1-8 char input.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  _should_plan = (
470
  self.planner
471
  and not tool_results
 
472
  and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
473
  and len(state.goal) > 10
474
  )
 
485
  }
486
  _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
487
  _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
488
+ if _should_plan:
489
  if on_step:
490
  await _maybe_await(on_step({
491
  "loop": 0, "action": "plan", "status": "started",
 
494
  }))
495
  # S640: timeout planner + S-FMT-ORCH fast-fix bypass
496
  # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
497
+ if _fast_fix_plan is not None:
 
 
 
498
  plan = _fast_fix_plan
499
  _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
500
  else:
 
519
  "explanation": "Il pianificatore ha impiegato troppo Ҁ” procedo senza piano",
520
  "visibility": "progress",
521
  }))
522
+ if plan is not None:
 
 
 
 
 
523
  state.steps.append({"action": "plan", "result": plan})
524
  try:
525
  from api.state import record_timing as _rtc_pl
 
926
  tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
927
  reg_name, inp_builder = tool_key_pair
928
  if reg_name and inp_builder is not None:
 
 
 
 
 
 
 
 
 
929
  _pending_exec.append((subtask, reg_name, inp_builder))
930
  elif _s_tool:
931
  # COG-4: tool non in _TOOL_MAP β€” tenta generazione dinamica
 
1198
  {"path": _wf_path, "content": _wf_generated} if rn == "write_file"
1199
  else {"path": _wf_path, "patch": _wf_generated}
1200
  )
1201
+ # GAP-3: snapshot pre-write Ҁ” cattura originale per rollback atomico
1202
  if rn == "write_file" and _wf_path not in self._write_snapshots:
1203
+ try:
1204
+ _snap_r = await asyncio.wait_for(
1205
+ self.executor.run_tool("read_file", {"path": _wf_path}),
1206
+ timeout=4.0,
1207
+ )
1208
+ self._write_snapshots[_wf_path] = (
1209
+ _snap_r.get("output") if _snap_r.get("success") else None
 
 
 
 
 
 
 
 
 
 
 
 
1210
  )
1211
+ except Exception:
1212
+ self._write_snapshots[_wf_path] = None # file non esisteva
1213
  # GAP-VFS: lock per-path β€” serializza scritture parallele sullo stesso file
1214
  _vfs_lock = self._get_vfs_lock(_wf_path)
1215
  async with _vfs_lock:
 
1661
  _logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
1662
  self._tdd_fail_inject = None
1663
  # GAP-4: StrategicHealer β€” analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
1664
+ if exec_errors and getattr(self, '_strategic_healer', None):
1665
  try:
1666
  _sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
1667
+ _sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str)
1668
  if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
1669
  exec_warn.insert(0, _sh_decision.strategy_prompt)
1670
  _logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
1671
  if _sh_decision and getattr(_sh_decision, 'should_stop', False):
1672
  _logger.info("GAP-4: StrategicHealer β†’ should_stop, interruzione fallback")
1673
+ return # _run_fallback: should_stop β†’ esci dal fallback (non c'Γ¨ loop da rompere)
1674
  except Exception as _sh_loop_err:
1675
  _logger.debug("GAP-4: StrategicHealer loop silenced β€” %s", _sh_loop_err)
1676
  # GAP-SELFHEAL v2: dual-mode fingerprinting β€” raw + error-class extraction.
 
2133
  _rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
2134
  except Exception as _exc:
2135
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2136
  # P16-B4: segnala truncation SSE se finish_reason == "length"
2137
  _fr = getattr(_active_llm, '_last_finish_reason', 'stop')
2138
  if _fr == 'length' and on_step:
 
2888
  except Exception:
2889
  pass
2890
  # S455-P10: task supervisionato β€” done_callback logga eccezioni silenziate
2891
+ asyncio.create_task(_reverify_task())
2892
  _rv_t.add_done_callback(
2893
  lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
2894
  )
 
3360
 
3361
  async def run(self, goal: str, context: str = "", max_steps: int = 8,
3362
  on_step: StepCallback | None = None,
3363
+ session_id: str = "") -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3364
  # S390-B-L: strip role prefixes che causano prompt injection
3365
  # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
3366
  # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso Ҁ” input come
 
3395
  except Exception:
3396
  _sid_token = None # fallback silente Ҁ” registry usa default "agent_default"
3397
 
3398
+ # S750-GAP-B: pre-warm sandbox backend-exec Ҁ” POST /api/session in background.
3399
+ # asyncio.create_task lancia la richiesta senza bloccare il routing:
3400
+ # mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta.
3401
+ try:
3402
+ from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
3403
+ if _eurl:
3404
+ asyncio.ensure_future(
3405
+ _ce({"session_id": self._run_task_id}, endpoint="/api/session")
3406
+ )
3407
+ except Exception as _exc:
3408
+ _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3409
 
3410
  # S568-B: reset _session_files ogni run Ҁ” previene memory leak su sessioni lunghe.
3411
  # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
 
3420
 
3421
  state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
3422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3423
  # GAP-4: StrategicHealer β€” init + load past failures (LLM-based self-healing cognitivo)
3424
  try:
3425
  from agents.strategic_healer import StrategicHealer as _SHClass
 
3501
  "title": "Specifica cosa vuoi fare",
3502
  "explanation": _amb_answer,
3503
  }))
3504
+ _r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
3505
  if _sid_token is not None:
3506
  try: _sid_var.reset(_sid_token)
3507
  except Exception: pass
 
3618
  "title": "Puoi essere piΓΉ specifico?",
3619
  "explanation": _bl_answer,
3620
  }))
3621
+ _r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
3622
  if _sid_token is not None:
3623
  try: _sid_var.reset(_sid_token)
3624
  except Exception: pass
 
3635
  _rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
3636
  except Exception as _exc:
3637
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3638
+ _r = await self._run_fast_path(state, on_step)
 
3639
  _r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
3640
  _r["effective_max_steps"] = state.max_steps # GAP-2-FIX
3641
  # S749-D: reset ContextVar
 
3654
  except Exception as _exc:
3655
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3656
  # Puro ragionamento Ҁ” LLM diretto, nessun overhead tool
3657
+ _r = await self._run_fallback(state, on_step)
 
3658
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3659
  try:
3660
  from api.state import record_timing as _rtc_ttr
 
3684
  _rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
3685
  except Exception:
3686
  pass
3687
+ _r = await self._run_fallback(state, on_step)
 
3688
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3689
  _r["effective_max_steps"] = state.max_steps
3690
  if _sid_token is not None:
 
3751
  if _sid_token is not None:
3752
  try: _sid_var.reset(_sid_token)
3753
  except Exception: pass
3754
+ return {
 
3755
  "success": True,
3756
  "answer": _p36_answer,
3757
  "timing_ms": _p36_ms,
3758
  "effective_max_steps": state.max_steps,
3759
  "steps": [{"action": "p36_python_analyze", "status": "done",
3760
  "output": _p36_answer[:300]}],
3761
+ }
3762
  except Exception as _p36_exc:
3763
  _logger.debug("P36 fast-path silenced: %s", _p36_exc)
3764
  # fail-open: cade nel percorso normale
 
3770
  except Exception as _exc:
3771
  _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3772
  _t_tool = _time.monotonic()
 
3773
  direct_results, _tools_count, _exec_success, _exec_errors = \
3774
  await self._run_direct_tools(goal, on_step=on_step)
3775
  _tool_ms = int((_time.monotonic() - _t_tool) * 1000)
 
3778
  "loop": 0, "action": "direct_tools", "status": "done",
3779
  "tools_fired": _tools_count,
3780
  }))
3781
+ _r = await self._run_fallback(
3782
+ state, on_step,
3783
+ preloaded_tool_results=direct_results or None,
3784
+ preloaded_tool_exec_successes=_exec_success,
3785
+ preloaded_tool_exec_errors=_exec_errors,
3786
+ )
 
 
 
 
 
 
 
 
3787
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3788
  try:
3789
  from api.state import record_timing as _rtc_ttr
 
3810
  # S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
3811
  # S402: unpack 4-tuple Ҁ” aggiunto _exec_success/_exec_errors per Tool Integrity Guard
3812
  _t_tool = _time.monotonic()
 
3813
  direct_results, _tools_count, _exec_success, _exec_errors = \
3814
  await self._run_direct_tools(goal, on_step=on_step)
3815
  _tool_ms = int((_time.monotonic() - _t_tool) * 1000)
 
3821
  "loop": 0, "action": "direct_tools", "status": "done",
3822
  "tools_fired": _tools_count,
3823
  }))
3824
+ _r = await self._run_fallback(
3825
+ state, on_step,
3826
+ preloaded_tool_results=direct_results,
3827
+ preloaded_tool_exec_successes=_exec_success,
3828
+ preloaded_tool_exec_errors=_exec_errors,
3829
+ )
 
 
 
 
 
 
 
 
3830
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3831
  try:
3832
  from api.state import record_timing as _rtc_ttr
 
3850
  # Rimosso: -25s worst case, path sempre: direct_tools ҆’ _run_fallback.
3851
 
3852
  # Fallback: LLM senza tool results (tool non triggered o tutti skip)
3853
+ _r = await self._run_fallback(state, on_step)
 
3854
  _r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
3855
  try:
3856
  from api.state import record_timing as _rtc_ttr
agents/unified_loop_delegate.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_delegate.py β€” DelegateMixin: debug riflessivo, replan, delega in-loop.
2
+
3
+ Estratto da unified_loop.py per ridurre il file principale.
4
+
5
+ Contiene:
6
+ _reflective_debug(goal, errors): BGAP-GUARD diagnosi breve da errori tool
7
+ _budget_replan_check(state, step): BGAP-1 replan probabilistico su budget critico
8
+ _DELEGATE_RESEARCH_RE: regex riconoscimento sub-goal tipo ricerca
9
+ _run_in_loop_delegate(sub_goal): GAP-1 micro-agente specializzato in-loop
10
+
11
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
12
+ MRO garantisce che DelegateMixin._budget_replan_check sovrascriva HelpersMixin
13
+ (DelegateMixin precede HelpersMixin nella lista basi di UnifiedAgentLoop).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ import re
20
+ from typing import Any
21
+
22
+ from agents.unified_loop_types import StepCallback, UnifiedLoopState, _maybe_await
23
+
24
+ _logger = logging.getLogger("agente_ai")
25
+
26
+
27
+ class DelegateMixin:
28
+ async def _reflective_debug(
29
+ self, goal: str = "", errors: Any = None, **kwargs: Any
30
+ ) -> str:
31
+ """Reflective debug: analizza errori e propone diagnosi in max 2 frasi.
32
+ Chiamato dopo tool failures per arricchire state.context con ipotesi fix.
33
+ Fail-open: non blocca mai il loop in caso di errore LLM."""
34
+ try:
35
+ _ctx = f"Goal: {str(goal)[:200]}\nErrori: {'; '.join(str(e)[:300] for e in (errors if isinstance(errors, list) else [errors])[:3])}" # S573: 150β†’300
36
+ _fast = self._get_fast_llm()
37
+ _diag = await asyncio.wait_for(
38
+ _fast.chat([{"role": "user", "content": f"Diagnosi breve (max 2 frasi):\n{_ctx}"}], max_tokens=300), # S586: 120->180->300
39
+ timeout=5.0,
40
+ )
41
+ return (str(_diag) if _diag else "").strip()[:300]
42
+ except Exception:
43
+ pass # fail-open
44
+ return ""
45
+
46
+ # ── BGAP-1: Probabilistic Re-planning Trigger ────────────────────────────
47
+ async def _budget_replan_check(
48
+ self, state: Any, step_count: int, on_step: Any = None
49
+ ) -> str:
50
+ """BGAP-1: probabilistic re-planning trigger.
51
+ Guards: skip se _n_err < 2 OR _budget_ratio < 0.6.
52
+ Usa _get_fast_llm() con max_tokens=120. Fail-open."""
53
+ _n_err = len(state.errors) if getattr(state, 'errors', None) else 0
54
+ if _n_err < 2:
55
+ return ''
56
+ _budget_ratio = step_count / max(state.max_steps, 1)
57
+ if _budget_ratio < 0.6:
58
+ return ''
59
+ # dedup guard [GAP-1-REPLAN]: skip se giΓ  replanned in questo loop
60
+ if '[GAP-1-REPLAN]' in (state.context or ''):
61
+ return ''
62
+ try:
63
+ _fast_llm = self._get_fast_llm()
64
+ _prompt = (
65
+ f'Task ha avuto {_n_err} errori e usato {_budget_ratio:.0%} del budget. '
66
+ f'Suggerisci UN approccio alternativo in max 2 frasi. Goal: {state.goal[:500]}' # S597: 200->300->500
67
+ )
68
+ _hint = await asyncio.wait_for(
69
+ _fast_llm.chat([{'role': 'user', 'content': _prompt}], max_tokens=120),
70
+ timeout=5.0,
71
+ )
72
+ return (str(_hint) if _hint else '').strip()[:200]
73
+ except Exception:
74
+ pass # fail-open totale
75
+ return ''
76
+
77
+ # ── GAP-1: Delega Dinamica In-Loop ─────────────────────────────────────
78
+ _DELEGATE_RESEARCH_RE = re.compile(
79
+ r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|'
80
+ r'news|notizie|fetch|scrape|pagina|sito|http)\b',
81
+ re.IGNORECASE,
82
+ )
83
+
84
+ async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict:
85
+ """GAP-1: Delega Dinamica In-Loop.
86
+ Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale.
87
+ Architettura:
88
+ - Stesso executor del parent β†’ accesso ai tool reali (write_file, run_python, ...)
89
+ - LLM selezionato per ruolo β†’ RESEARCHER, CODER o REASONER in base al goal
90
+ - _is_delegate_child = True β†’ blocca ricorsione (max 1 livello di delega)
91
+ - max_steps = 4 β†’ micro-agente leggero, non un loop completo
92
+ - output troncato a 4000 chars β†’ evita context-window explosion nel parent
93
+ """
94
+ # P18: defensive anti-recursion guard at entry point
95
+ if getattr(self, '_is_delegate_child', False):
96
+ _logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry")
97
+ return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False}
98
+ try:
99
+ from models.role_router import RoleRouter as _RR_d, Role as _Role_d
100
+ # Seleziona LLM specializzato in base al tipo di sotto-obiettivo
101
+ if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]):
102
+ _sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash
103
+ elif self._CODE_RE.search(sub_goal[:300]):
104
+ _sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout
105
+ else:
106
+ _sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B
107
+ except Exception:
108
+ _sub_llm = self.llm # fallback: usa LLM del parent
109
+
110
+ # Crea loop figlio: stessi executor/planner/memory, LLM specializzato
111
+ _sub_loop = UnifiedAgentLoop(
112
+ llm_client=_sub_llm,
113
+ planner=self.planner,
114
+ executor=self.executor,
115
+ critic=None, # no critic β€” micro-agente leggero
116
+ memory=self.memory,
117
+ verifier=None, # no verifier β€” massima velocitΓ 
118
+ )
119
+ # Anti-ricorsione: il figlio non puΓ² delegare ulteriormente
120
+ _sub_loop._is_delegate_child = True
121
+ # Propaga session_id per isolare sandbox backend-exec
122
+ _sub_loop._run_task_id = self._run_task_id + "_d"
123
+ # GAP-6: condividi dict mutabile _session_files con il parent loop
124
+ # Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent
125
+ # Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate
126
+ _sub_loop._session_files = self._session_files
127
+
128
+ # P17-F1: buffer output parziale via on_step β€” sopravvive al timeout
129
+ _partial_steps: list[dict] = []
130
+ async def _capture_partial(step: dict) -> None:
131
+ if step.get("output") or step.get("explanation"):
132
+ _partial_steps.append(step)
133
+
134
+ try:
135
+ _res = await asyncio.wait_for(
136
+ _sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial),
137
+ timeout=timeout,
138
+ )
139
+ _out = (_res.get("output") or "")[:4000]
140
+ _logger.info(
141
+ "GAP-1 delegate OK [%s] steps=%d: %s",
142
+ _res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60],
143
+ )
144
+ return {
145
+ "success": _res.get("success", False),
146
+ "output": _out,
147
+ "engine": _res.get("engine", "delegate"),
148
+ "steps": len(_res.get("steps", [])),
149
+ }
150
+ except asyncio.TimeoutError:
151
+ # P17-F1: esponi stato parziale invece di stringa vuota
152
+ # _session_files giΓ  condiviso con parent β†’ parent vede file scritti
153
+ _partial_files = list(getattr(_sub_loop, "_session_files", {}).keys())
154
+ _partial_out = " ".join(
155
+ (s.get("output") or s.get("explanation") or "")[:300]
156
+ for s in _partial_steps[-3:]
157
+ ).strip()[:1500]
158
+ _logger.warning(
159
+ "GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s",
160
+ timeout, len(_partial_steps), len(_partial_files), sub_goal[:60],
161
+ )
162
+ # S-PARTIAL: emetti evento SSE partial_output al frontend PRIMA di restituire
163
+ # cosΓ¬ l'utente vede il chip "⚠ output parziale β€” riprendo" in tempo reale
164
+ if on_step:
165
+ await _maybe_await(on_step({
166
+ "event": "partial_output",
167
+ "action": "partial_output",
168
+ "visibility": "progress",
169
+ "partial": True,
170
+ "steps_done": len(_partial_steps),
171
+ "partial_files": _partial_files,
172
+ "partial_output": _partial_out,
173
+ "output": _partial_out,
174
+ "explanation": f"Output parziale dopo {timeout:.0f}s β€” l'agente sta recuperando",
175
+ "status": "warning",
176
+ }))
177
+ return {
178
+ "success": False,
179
+ "output": _partial_out,
180
+ "error": f"delegate timeout ({timeout:.0f}s) β€” risultato parziale",
181
+ "partial": True,
182
+ "partial_files": _partial_files,
183
+ "steps_done": len(_partial_steps),
184
+ }
185
+ except Exception as _de:
186
+ _logger.warning("GAP-1 delegate error: %s", _de)
187
+ return {"success": False, "output": "", "error": str(_de)[:200]}
188
+
189
+ # Ҕ€Ò”€ S362: Role routing helpers Ҕ€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
190
+
191
+ # S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi.
192
+ # Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM.
agents/unified_loop_fallback.py ADDED
The diff for this file is too large to render. See raw diff
 
agents/unified_loop_helpers.py CHANGED
@@ -27,19 +27,7 @@ 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:
 
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 StepCallback, UnifiedLoopState
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  class HelpersMixin:
agents/unified_loop_llm.py CHANGED
@@ -34,28 +34,13 @@ class LLMSelectionMixin:
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
@@ -65,7 +50,7 @@ class LLMSelectionMixin:
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:
@@ -119,28 +104,12 @@ class LLMSelectionMixin:
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
@@ -300,18 +269,11 @@ class LLMSelectionMixin:
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"
@@ -502,23 +464,6 @@ class LLMSelectionMixin:
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.
@@ -559,7 +504,7 @@ class LLMSelectionMixin:
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))"
@@ -572,22 +517,6 @@ class LLMSelectionMixin:
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|"
 
34
 
35
  def _get_llm_for_goal(self, goal: str) -> Any:
36
  """S362: return CODER-role LLM for code-heavy goals, default otherwise.
 
37
  S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
38
+ anche se _CODE_RE non matcha Ҁ” garantisce qualità su app multi-file."""
39
+ _is_code = bool(self._CODE_RE.search(goal[:500]))
40
+ _tok = self._max_tokens_for_goal(goal)
41
+ _needs_coder = _is_code or _tok >= 6144 # app complesse ҆’ sempre 70B
42
+ if not _needs_coder:
 
 
 
 
 
 
43
  return self.llm
 
 
 
 
 
 
 
 
44
  if self._coder_llm is None:
45
  try:
46
  from models.role_router import RoleRouter, Role
 
50
  return self._coder_llm
51
 
52
  def _get_fast_llm(self) -> Any:
53
+ """S-FAST: return Role.FAST client (Groq llama-3.1-8b-instant) per query semplici.
54
  Caricato lazy e cachato in self._fast_llm β€” zero overhead dopo il primo accesso.
55
  Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
56
  if self._fast_llm is None:
 
104
  return self._verifier_llm
105
 
106
  def _is_pure_explanation(self, goal: str) -> bool:
107
+ """B5: True se goal Γ¨ domanda concettuale pura β€” nessun tool necessario.
108
+ 4 guard fail-open: len<300 | pattern interrogativo | no action verb | no file ref."""
 
 
 
 
109
  if len(goal) > 300: return False
110
+ if not self._PURE_EXPLANATION_RE.search(goal[:200]): return False
111
+ if self._EXPL_ACTION_RE.search(goal[:200]): return False
112
+ if self._EXPL_FILE_REF_RE.search(goal[:200]): return False
 
 
 
 
 
 
 
 
 
 
 
 
113
  return True
114
 
115
  # S371: _SKIP_SMOL_RE Ҁ” skippa smolagents per query semplici (notizie, cerca) ҆’ direct tools
 
269
  _FORMAT_DIRECTIVE_CODE = (
270
  "FORMATO RISPOSTA OBBLIGATORIO Ҁ” CODICE:\n"
271
  "Ҁ’ Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
272
+ "Ҁ’ Struttura: breve spiegazione ҆’ blocco codice completo ҆’ come usarlo\n"
273
+ "Ҁ’ Ogni blocco deve essere autonomo ed eseguibile senza modifiche\n"
274
+ "Ҁ’ Aggiungi commenti inline per la logica non ovvia\n"
275
+ "Ҁ’ Se multi-file: mostra ogni file in un blocco separato con il nome come titolo\n"
276
+ "Ҁ’ Formato titolo file OBBLIGATORIO: ### src/nomefile.tsx (H3 - risparmia spazio verticale su mobile)"
 
 
 
 
 
 
 
277
  )
278
  _FORMAT_DIRECTIVE_MARKDOWN = (
279
  "FORMATO RISPOSTA OBBLIGATORIO Ҁ” STRUTTURATO:\n"
 
464
  r'risposta\s+breve|brief\s+answer|short\s+answer)\b',
465
  re.IGNORECASE,
466
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  # S-FMT-ORCH: fast-fix detector per bypass ARCHITECT su singola operazione (<180 chars)
468
  # B1: espansa con 10 operazioni atomiche β€” guardata da len(goal)<180 nel chiamante.
469
  # Conseguenze: skip ARCHITECT (-15s) per operazioni single-step unambiguamente chiare.
 
504
  r"^\s*(?:"
505
  r"(?:cos'?[e\xe8]\s+)"
506
  r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
507
+ r"|(?:spiegami\b)"
508
  r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
509
  r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
510
  r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
 
517
  r")",
518
  re.IGNORECASE | re.DOTALL,
519
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  _EXPL_ACTION_RE = re.compile(
521
  r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
522
  r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
agents/unified_loop_prompts.py CHANGED
@@ -40,16 +40,8 @@ class PromptBuilderMixin:
40
  "4. Non dire 'puoi fare X' β€” mostra X fatto, con codice completo se richiesto\n"
41
  "5. Se incontri un errore, analizza e riprova con approccio diverso\n"
42
  "6. Sii specifico e concreto β€” niente placeholder o risposte vaghe\n"
43
- "7. Per codice: SEMPRE blocchi markdown con linguaggio esplicito (```typescript, ```python, ```bash ecc). Codice tipizzato, compilabile, senza placeholder\n"
44
- "8. Per matematica: mostra calcoli passo passo con numeri esatti. "
45
- "OBBLIGO per problemi GSM8K/math: termina SEMPRE la risposta con una riga separata "
46
- "\'#### <numero>\' (es. #### 225). Niente testo dopo quel numero.\n"
47
- "8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
48
- "\'Risposta: X\' dove X Γ¨ la lettera scelta, poi spiega il ragionamento.\n"
49
- "8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
50
- "```typescript```...```typescript. Mai inline, mai in blocchi generici. "
51
- "Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
52
- "8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.\n"
53
  "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
54
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
55
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
@@ -117,13 +109,10 @@ class PromptBuilderMixin:
117
  " **Passo 4:** Estrai sub β€” mai decode() senza verify()\n"
118
  "β€’ Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
119
  "\n"
120
- "=== ONESTΓ€ TECNICA E VERIFICA REALE ===\n"
121
- "Il tuo obiettivo Γ¨ essere AFFIDABILE e CREDIBILE.\n"
122
- "Se incontri un limite tecnico reale (es. file non trovato, errore API persistente,\n"
123
- "mancanza di permessi), segnalalo onestamente. NON inventare mai di aver eseguito\n"
124
- "un'azione se non hai ricevuto conferma dal sistema.\n"
125
- "Se l'approccio A fallisce, prova B o C, ma se tutti falliscono, spiega il motivo\n"
126
- "tecnico reale invece di simulare un successo inesistente.\n"
127
  "Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
128
  "pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
129
  "Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
@@ -306,7 +295,7 @@ class PromptBuilderMixin:
306
 
307
  # ── S200: Context-aware rule injection ──────────────────────────────────────
308
  # Seleziona solo le regole rilevanti per il task corrente.
309
- # Con openai/gpt-oss-20b (8K context), mettere tutto nel system prompt
310
  # causa troncamento silenzioso β€” le regole non vengono mai lette.
311
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
312
  # (posizione con massima attenzione del modello = "recency bias").
@@ -430,22 +419,6 @@ class PromptBuilderMixin:
430
  " }\n"
431
  "EventRegistry: on+off+listEvents SOLO (NO emit). EventHistory: emit+getHistory+historySize+clearHistory SOLO (NO on)."
432
  ),
433
- (
434
- ["fixa", "correggi", "patch", "fix ", "corregg", "aggiusta", "sistema il bug",
435
- "correggi il bug", "bug fix", "bugfix", "applica il fix", "correggi solo",
436
- "modifica solo", "cambia solo", "tocca solo"],
437
- "PATCH MINIMALE OBBLIGATORIA (RB1-FIX): Stai operando in modalita' FIX/PATCH. "
438
- "REGOLA ASSOLUTA: modifica SOLO i punti specificati dall'utente. "
439
- "VIETATO riscrivere la struttura esistente. "
440
- "VIETATO aggiungere import, dipendenze o funzioni non richieste dall'utente. "
441
- "VIETATO cambiare il comportamento delle parti non menzionate. "
442
- "Approccio corretto: (1) identifica esattamente cosa e' rotto, "
443
- "(2) scrivi SOLO il diff minimo necessario, "
444
- "(3) preserva import/export, API pubbliche e side effect non coinvolti, "
445
- "(4) verifica che il resto del codice rimanga invariato. "
446
- "Usa apply_patch invece di write_file per qualsiasi modifica < 50% del file. "
447
- "NON riscrivere funzioni, classi o moduli interi β€” applica il fix minimo."
448
- ),
449
  (
450
  ["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
451
  "REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
@@ -493,151 +466,6 @@ class PromptBuilderMixin:
493
  "4. Half-stars: Math.floor(value) per intere + value % 1 >= 0.5 per mezza stella\n"
494
  "5. INCLUDI SEMPRE le parole: interface, Props, export, star nel codice completo"
495
  ),
496
- (
497
- ["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
498
- "github.com/drizzle", "drizzle-orm/pg-core"],
499
- "DRIZZLE ORM β€” schema-first guidance:\n"
500
- "Use typed table definitions, explicit relations, and migrations; avoid raw SQL when the task asks for Drizzle ORM."
501
- ),
502
- (
503
- ["sql", "postgresql", "cte ricorsiva", "gerarchia organizzativa",
504
- "recursive cte", "with recursive", "gerarchia con depth", "window functions",
505
- "rank()", "row_number()", "partition by", "gerarchia dipendenti", "over("],
506
- "SQL EXPERT β€” RECURSIVE CTE & ANALYTICS (S-BENCH-SQL):\n"
507
- "Per query su gerarchie (manager-dipendente, categorie padre-figlio) o analisi dati avanzate.\n"
508
- "PROCEDURA OBBLIGATORIA:\n"
509
- "1. Apri un blocco <thinking>.\n"
510
- "2. Identifica la tabella e le colonne chiave (id, parent_id/manager_id).\n"
511
- "3. Definisci l'ANCORA (la radice della gerarchia, es. manager_id IS NULL).\n"
512
- "4. Definisci la PARTE RICORSIVA (il JOIN tra la CTE e la tabella base).\n"
513
- "5. Calcola la profonditΓ  (depth) incrementando ad ogni iterazione.\n"
514
- "6. Per classifiche/aggregati mobili usa Window Functions: `RANK() OVER (PARTITION BY ... ORDER BY ...)`.\n"
515
- "7. Chiudi il blocco </thinking>.\n\n"
516
- "ESEMPIO FEW-SHOT (Gerarchia):\n"
517
- "```sql\n"
518
- "WITH RECURSIVE org_chart AS (\n"
519
- " SELECT id, name, manager_id, 1 as depth FROM employees WHERE manager_id IS NULL\n"
520
- " UNION ALL\n"
521
- " SELECT e.id, e.name, e.manager_id, oc.depth + 1 FROM employees e\n"
522
- " JOIN org_chart oc ON e.manager_id = oc.id\n"
523
- ") SELECT * FROM org_chart ORDER BY depth, name;\n"
524
- "```\n"
525
- "REGOLA: Usa SEMPRE `WITH RECURSIVE` per le gerarchie. MAI fare join multipli manuali."
526
- ),
527
- (
528
- ["data analysis", "time series", "anomalia", "outlier", "trend", "stagionalitΓ ",
529
- "luglio", "lug", "z-score", "13m", "anomaly", "media mobile", "peak", "drop",
530
- "calo", "picco", "mese", "month", "weekly", "daily", "revenue", "traffic"],
531
- "DATA ANALYST β€” ANOMALY DETECTION v2 (S-BENCH-DA):\n"
532
- "PROCEDURA OBBLIGATORIA (mostra tutti i calcoli):\n"
533
- "1. TABELLA: riproponi i dati in tabella markdown (mese|valore).\n"
534
- "2. STATISTICHE: Media=Ξ£valori/n, StdDev=√(Ξ£(xi-ΞΌ)Β²/n) β€” calcola esplicitamente.\n"
535
- "3. Z-SCORE: per ogni punto: Z=(x-ΞΌ)/Οƒ. Flag se |Z|>2 (moderata) o |Z|>3 (grave).\n"
536
- "4. ANOMALIA: nomina il mese/periodo con Z-score preciso e tipo (drop/spike).\n"
537
- "5. CAUSA: suggerisci 2-3 cause plausibili con ragionamento.\n"
538
- "6. CONCLUSIONE: '## Anomalia: [periodo] β€” Z-score: [X] β€” Tipo: [drop/spike]'\n\n"
539
- "ESEMPIO: luglio=200, media=400, Οƒ=80 β†’ Z=(200-400)/80=-2.5 β†’ ANOMALIA MODERATA (drop).\n"
540
- "Struttura risposta: ## Dati β†’ ## Statistiche β†’ ## Z-Score β†’ ## Anomalie β†’ ## Cause β†’ ## Conclusione"
541
- ),
542
- (
543
- ["reasoning", "gsm8k", "math", "matematica", "logica", "ragionamento", "ted the t-rex",
544
- "how many", "quanti", "quante", "calcola", "quanto", "totale", "potato salad",
545
- "kg", "pounds", "cost", "costo", "distance", "distanza", "speed", "velocitΓ ",
546
- "bought", "sold", "left", "rimane", "remaining", "ore", "minuti", "days", "weeks"],
547
- "REASONER β€” GSM8K & CHAIN-OF-THOUGHT v2 (S-BENCH-RE):\n"
548
- "STEP 1 β€” VARIABILI: elenca ogni entitΓ  del problema con il suo valore numerico.\n"
549
- "STEP 2 β€” EQUAZIONI: scrivi l'equazione matematica PRIMA di calcolarla.\n"
550
- "STEP 3 β€” CALCOLO: mostra ogni operazione intermedia con il risultato.\n"
551
- "STEP 4 β€” SELF-CHECK: rileggi il problema originale e verifica che la risposta risponda ESATTAMENTE alla domanda.\n"
552
- "STEP 5 β€” RISPOSTA FINALE: ultima riga DEVE essere 'Risposta: **X**' (bold, numero esatto).\n\n"
553
- "ESEMPIO:\n"
554
- "Problema: Ted the T-Rex vuole portare 225g di insalata. Ha giΓ  45g. Quanto manca?\n"
555
- "STEP 1: target=225g, giΓ =45g\n"
556
- "STEP 2: mancante = target - giΓ  = 225 - 45\n"
557
- "STEP 3: 225 - 45 = 180\n"
558
- "STEP 4: domanda=quanto manca β†’ risposta=180g βœ“\n"
559
- "Risposta: **180 g**\n\n"
560
- "CRITICO: MAI rispondere con NULL, stringa vuota o approssimazioni. "
561
- "MAI saltare i passaggi intermedi."
562
- ),
563
- (
564
- ["mmlu", "computer science", "informatica", "architettura", "os", "networking", "database",
565
- "quale delle seguenti", "which of the following", "pairs of", "which pair", "algorithm",
566
- "complexity", "complessitΓ ", "big-o", "sorting", "hashing", "binary", "heap", "tree",
567
- "cpu", "memory", "virtual memory", "deadlock", "semaphore", "mutex", "protocol"],
568
- "CS EXPERT β€” MMLU ELIMINATION METHOD v2 (S-BENCH-MMLU):\n"
569
- "METODO ELIMINAZIONE OBBLIGATORIO:\n"
570
- "1. Leggi tutte le opzioni (A/B/C/D) PRIMA di rispondere.\n"
571
- "2. Elimina le opzioni chiaramente false con motivazione di 1 riga.\n"
572
- "3. Per le rimanenti: applica il principio tecnico pertinente.\n"
573
- "4. Scegli con certezza: 'La risposta corretta Γ¨ **X** perchΓ©...'\n\n"
574
- "CONOSCENZE CORE:\n"
575
- "β€’ ComplessitΓ : O(1)<O(log n)<O(n)<O(n log n)<O(nΒ²)<O(2ⁿ)\n"
576
- "β€’ OS: FCFS/SJF/RR scheduling; paging/segmentation; mutex/semaphore sync\n"
577
- "β€’ Networking: TCP/IP 4 layers; DNS; TLS handshake; HTTP vs HTTPS\n"
578
- "β€’ Database: ACID; 1NF/2NF/3NF; B-tree index; JOIN types; MVCC\n"
579
- "β€’ Strutture dati: array O(1); linked list O(n); BST O(log n) avg; hash O(1) avg\n"
580
- "FORMATO: prima ragionamento eliminazione, poi riga finale 'Risposta: **X**'"
581
- ),
582
- (
583
- ["changelog", "semver", "release notes", "patch", "minor", "major", "feat",
584
- "breaking change", "CHANGELOG", "release history", "versioning", "bumped"],
585
- "WRITER PRO β€” CHANGELOG & SEMVER v2 (S-BENCH-WR):\n"
586
- "STRUTTURA OBBLIGATORIA (Keep A Changelog):\n"
587
- "## [X.Y.Z] - AAAA-MM-GG\n"
588
- "### Added\n"
589
- "- [feat] Descrizione in imperativo (es. 'Add retry logic for failed requests')\n"
590
- "### Changed\n"
591
- "- [change] Descrizione modifica con impatto\n"
592
- "### Fixed\n"
593
- "- [fix] Descrizione bug fix con riferimento issue se disponibile\n"
594
- "### Security\n"
595
- "- [sec] Fix CVE-YYYY-XXXX se applicabile\n\n"
596
- "REGOLE SEMVER:\n"
597
- "β€’ MAJOR (X.0.0): breaking changes β€” API incompatibili\n"
598
- "β€’ MINOR (0.Y.0): nuove feature backward-compatible\n"
599
- "β€’ PATCH (0.0.Z): bug fix backward-compatible\n"
600
- "Linguaggio: imperativo inglese formale ('Add', 'Fix', 'Remove', 'Update').\n"
601
- "Ogni entry: max 80 caratteri. No emoji. Ogni sezione solo se ci sono voci pertinenti."
602
- ),
603
- (
604
- ["context", "finestra", "1101ch", "recupero", "quante persone", "lungo testo",
605
- "quanti", "trova nel testo", "nel documento", "how many", "team", "anni di esperienza",
606
- "members", "employees", "experience", "years of experience"],
607
- "CONTEXT RETRIEVAL β€” LONG CONTEXT v2 (S-BENCH-CTX):\n"
608
- "PROCEDURA ANTI-HALLUCINATION:\n"
609
- "1. SCANSIONA l'intero testo β€” non fermarti alla prima occorrenza.\n"
610
- "2. ELENCA: crea una lista esplicita di tutti gli elementi trovati.\n"
611
- "3. CONTA: numero = len(lista). Mostra lista + count.\n"
612
- "4. VERIFICA: rileggi la lista, controlla che non manchino elementi.\n"
613
- "5. RISPOSTA: 'Ho trovato N elementi: [lista]. Risposta: **N**'\n\n"
614
- "CRITICO: se il testo dice '>5 anni', conta SOLO chi supera 5 (escludere esattamente 5).\n"
615
- "MAI rispondere con un numero senza aver prima elencato gli elementi contati."
616
- ),
617
- (
618
- ["compare", "confronta", "paragona", "message queue", "kafka", "rabbitmq", "redis pub",
619
- "use case", "caso d'uso", "quando usare", "quale scegliere", "pro e contro", "trade-off",
620
- "vs", "versus", "differenza tra", "difference between", "quale tecnologia",
621
- "research synthesis", "analizza e confronta", "microservizi", "architettura"],
622
- "RESEARCH SYNTHESIZER β€” COMPARE & CONTRAST (S-BENCH-RS):\n"
623
- "STRUTTURA OBBLIGATORIA per confronti tecnici:\n"
624
- "## Contesto\n"
625
- "Definisci il problema/use case in 2 righe.\n"
626
- "## Confronto\n"
627
- "| Criterio | Opzione A | Opzione B | Vincitore |\n"
628
- "| --- | --- | --- | --- |\n"
629
- "| Performance | ... | ... | ... |\n"
630
- "| ScalabilitΓ  | ... | ... | ... |\n"
631
- "| ComplessitΓ  setup | ... | ... | ... |\n"
632
- "| Use case ideale | ... | ... | ... |\n"
633
- "## Raccomandazione\n"
634
- "Per [use case X]: scegli **Opzione A** perchΓ© [motivo specifico con numeri].\n"
635
- "Per [use case Y]: scegli **Opzione B** perchΓ© [motivo specifico con numeri].\n"
636
- "## Conclusione\n"
637
- "Non esiste risposta universale: dipende da [fattori chiave specifici].\n\n"
638
- "REGOLA: ogni affermazione deve essere concreta e specifica. "
639
- "MAI risposte vaghe come 'dipende' senza spiegare DA COSA dipende."
640
- ),
641
  (
642
  ["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
643
  "github.com/drizzle", "drizzle-orm/pg-core"],
@@ -881,18 +709,17 @@ class PromptBuilderMixin:
881
  "lrucache", "eviction", "minheap", "comparatore", "stack generico", "ringbuffer",
882
  "circular buffer", "rate limiter", "token bucket", "trie", "prefix tree",
883
  "capacita fissa", "fixed capacity"],
884
- "REGOLA CLASSE TypeScript (S-BENCH-FEAT) β€” ARCHITECTURE-AWARE CODING:\n"
885
- "PROCEDURA OBBLIGATORIA:\n"
886
- "1. Apri un blocco <thinking>.\n"
887
- "2. Analizza i requisiti: identifica interfacce, classi, funzioni e le loro dipendenze.\n"
888
- "3. Pianifica la struttura del codice: definisci nomi, tipi, e relazioni tra i componenti.\n"
889
- "4. Considera i pattern architetturali (es. Dependency Injection, Strategy, Observer) se applicabili.\n"
890
- "5. Chiudi il blocco </thinking>.\n\n"
891
- "OUTPUT FINALE: UN SOLO blocco ```typescript con il codice completo.\n"
892
- "CRITICO: Il codice DEVE essere TypeScript valido e compilabile (zero errori tsc).\n"
893
- "CRITICO: Tutti i test forniti o impliciti devono passare.\n"
894
- "CRITICO: Evita TS2323 (redeclaration) β€” usa un solo stile di export per nome (es. `export class X {}`).\n"
895
- "CRITICO: Ogni metodo richiesto deve essere implementato DENTRO la classe (non fuori)."
896
  ),
897
  (
898
  ["correggi solo", "typescript strict", "strict error", "parametri senza tipo",
@@ -1054,16 +881,15 @@ class PromptBuilderMixin:
1054
  # P27-B1: FR equivalents
1055
  "tΓ’che ambiguΓ«", "que faire", "sans donnΓ©es", "manque d'informations",
1056
  ],
1057
- "RECOVERY TASK AMBIGUO E DATI INCOERENTI (REC-AMB) β€” DATA INTEGRITY CHECK OBBLIGATORIO:\n"
1058
- "PROCEDURA OBBLIGATORIA:\n"
1059
- "1. Apri un blocco <thinking>.\n"
1060
- "2. Valuta la coerenza e completezza dei dati forniti. Identifica eventuali anomalie, dati mancanti o impossibili (es. tassi di conversione > 100%).\n"
1061
- "3. Se i dati sono incoerenti o insufficienti, formula una domanda chiara all'utente per ottenere chiarimenti.\n"
1062
- "4. Se i dati sono validi, procedi con l'analisi o l'implementazione.\n"
1063
- "5. Chiudi il blocco </thinking>.\n\n"
1064
- "OUTPUT FINALE: Se i dati sono incoerenti/mancanti, chiedi chiarimenti. Altrimenti, procedi con il task.\n"
1065
- "CRITICO: NON procedere con calcoli o implementazioni su dati palesemente incoerenti (es. A/B test con numeri impossibili). Segnala l'anomalia.\n"
1066
- "ESEMPIO DI DOMANDA CHIARIFICATRICE: \"I dati forniti per l'A/B test sembrano incoerenti (es. 100% di successo per entrambi i gruppi). Potresti verificare i valori?\"\n"
1067
  "3. Ultima riga: 'Attendo chiarimenti prima di procedere.'\n"
1068
  "\n"
1069
  "VERIFICA OBBLIGATORIA β€” il testo DEVE contenere queste keyword esatte:\n"
@@ -1074,79 +900,57 @@ class PromptBuilderMixin:
1074
  ),
1075
  # ── S-BENCH-RS: research_synthesis ──────────────────────────────────
1076
  # Trigger: frasi esatte dal benchmark prompt (3 scenari: compare/tradeoff/sciq)
1077
- # V4 (Sprint S20): Rinforzato con keyword obbligatorie e sezione Raccomandazione esplicita.
 
1078
  (
1079
  ["coprire:", "message queue per use case", "event sourcing", "saga pattern",
1080
  "kafka", "rabbitmq", "nats", "redis streams", "circuit breaker",
1081
  "compare: message", "analisi tradeoff architetturale",
1082
  "immutabilitΓ ", "svantaggi (β‰₯", "vantaggi (β‰₯",
1083
  "solutions architect"],
1084
- "RISPOSTA ARCHITETTURA (RS-BENCH) β€” MARKDOWN OBBLIGATORIO (TARGET: 350+ parole):\n"
1085
- "PROCEDURA OBBLIGATORIA:\n"
1086
- "1. Apri <thinking>.\n"
1087
- "2. Elenca TUTTE le keyword richieste dal prompt (latenza, throughput, persistenza, etc.).\n"
1088
- "3. Per ogni keyword, prepara 2-3 frasi tecniche specifiche con dati (ms, MB/s, msg/s).\n"
1089
- "4. Definisci β‰₯3 vantaggi e β‰₯2 svantaggi con parole 'vantaggio'/'svantaggio' esplicite.\n"
1090
- "5. Prepara la sezione 'Raccomandazione' con conclusione e condizioni per l'alternativa.\n"
1091
- "6. Chiudi </thinking>.\n\n"
1092
- "STRUTTURA FINALE OBBLIGATORIA (usa esattamente questi header Markdown):\n"
1093
- " ## Confronto [NomeA] vs [NomeB] β€” [contesto]\n"
1094
- " ### [Keyword1]: analisi dettagliata con dati tecnici.\n"
1095
- " ### [Keyword2]: ... (ripeti per TUTTE le keyword del prompt)\n"
1096
- " ## Vantaggi di [NomeA]: [β‰₯3 bullet con **keyword** in grassetto]\n"
1097
- " ## Svantaggi di [NomeA]: [β‰₯2 bullet dettagliati]\n"
1098
- " ## Quando usarlo: [2-3 scenari industriali reali]\n"
1099
- " ## Raccomandazione\n"
1100
- " [Conclusione esplicita: quale scegliere e perchΓ©, con condizioni per l'alternativa.]\n\n"
1101
- "CRITICO: La sezione '## Raccomandazione' Γ¨ OBBLIGATORIA β€” il checker la cerca con /raccomand|conclusione/i.\n"
1102
- "CRITICO: Includi TUTTE le keyword del prompt nel testo (latenza, throughput, persistenza, etc.).\n"
1103
- "CRITICO: Usa **grassetto** per le keyword tecniche β€” il checker cerca /^#+\\s|\\*\\*/m."
1104
  ),
1105
  # ── S-BENCH-CW: context_window ──────────────────────────────────────
1106
  # Trigger: prompt benchmark CW (documento team Q2 2026) + frasi dirette del prompt
1107
- # V4 (Sprint S20): Rinforzato con parole chiave obbligatorie per cited check.
 
 
1108
  (
1109
  ["anni di anzianitΓ ", "anni in azienda", "team report",
1110
  "q2 2026", "budget allocato", "stipendio annuo",
1111
  "citando il dato dal documento",
1112
  "rispondi solo alla domanda specificata. non inventare"],
1113
  "ANALISI DOCUMENTO STRUTTURATO (CW-BENCH) β€” metodo obbligatorio:\n"
1114
- "PROCEDURA:\n"
1115
- "1. Apri <thinking>.\n"
1116
- "2. Leggi OGNI riga del documento ed estrai nome + anni in azienda + stipendio.\n"
1117
- "3. Identifica chi soddisfa il criterio (anni >= 5 β†’ senior; stipendio β†’ valore esatto).\n"
1118
- "4. Conta il totale esatto e verifica.\n"
1119
- "5. Chiudi </thinking>.\n\n"
1120
- "RISPOSTA FINALE (struttura esatta β€” NON omettere nessuna parte):\n"
1121
- "PARTE 1 β€” ELENCO COMPLETO (obbligatorio):\n"
1122
- " - [Nome] β€” [ruolo]: [N] anni in azienda β†’ [senior/junior]\n"
1123
- " (elenca OGNI membro del team dal documento)\n"
1124
- "PARTE 2 β€” RISPOSTA DIRETTA (parole obbligatorie incluse):\n"
1125
- " Per domanda su anzianitΓ : '[N] persone hanno anzianitΓ  superiore a 5 anni.'\n"
1126
- " β†’ usa SEMPRE le parole 'anzianitΓ ' e '5 anni' nella risposta\n"
1127
- " Per domanda su stipendio: 'Lo stipendio annuo di [Nome] ([ruolo]) Γ¨ €[valore].'\n"
1128
- " β†’ cita SEMPRE il nome esatto e il valore numerico dal documento\n"
1129
- " Per domanda su costo totale: 'Il costo totale annuo degli stipendi Γ¨ €[somma].'\n"
1130
- " β†’ usa SEMPRE le parole 'totale', 'somma' o 'costo' nella risposta\n"
1131
- "CRITICO: Il checker cerca /senior|anzianit|5\\s*ann/i β€” usa 'anzianitΓ ' o 'senior' SEMPRE.\n"
1132
- "CRITICO: Il numero nella risposta deve essere ESATTAMENTE quello del documento."
1133
  ),
1134
  # ── S-BENCH-CC: code_correct ─────────────────────────────────────────
1135
  # Trigger: SOLO il problema reverseWords β€” keyword unico e specifico
1136
- # V4 (Sprint S20): Rinforzato con blocco ```typescript obbligatorio e export.
1137
  (
1138
  ["reversewords", "inverti ordine parole", "rimuovi spazi extra"],
1139
- "FUNZIONE PURA TYPESCRIPT (CC-BENCH) β€” FORMATO OBBLIGATORIO:\n"
1140
- "CRITICO: Il checker usa extractCode(o, ['typescript','ts']) β€” DEVI usare il blocco ```typescript.\n"
1141
- "RISPOSTA OBBLIGATORIA (copia questo formato esatto):\n"
1142
- "```typescript\n"
1143
- "export function reverseWords(s: string): string {\n"
1144
- " return s.trim().split(/\\s+/).reverse().join(' ');\n"
1145
- "}\n"
1146
- "```\n"
1147
- "NON aggiungere testo fuori dal blocco ```typescript.\n"
1148
- "NON usare blocchi ```ts o ```js β€” SOLO ```typescript.\n"
1149
- "La funzione DEVE essere exported: export function reverseWords(...)."
1150
  ),
1151
  # ── S-BENCH-REC: recovery ────────────────────────────────────────────
1152
  # Trigger: SOLO A/B test con ratio impossibile
@@ -1190,29 +994,26 @@ class PromptBuilderMixin:
1190
  ),
1191
  # ── S-BENCH-DA: data_analysis ────────────────────────────────────────
1192
  # Trigger: SOLO la struttura esatta del prompt benchmark DA
1193
- # V4 (Sprint S20): Rinforzato con calcolo step-by-step e formato bullet obbligatorio.
1194
  (
1195
  ["vendite mensili:", "rispondi esattamente con questo formato",
1196
  "copia la struttura, sostituisci", "mese col valore massimo",
1197
  "valore anomalo fuori scala"],
1198
- "TIME SERIES ANALISI (DA-BENCH) β€” CALCOLO OBBLIGATORIO STEP-BY-STEP:\n"
1199
- "PROCEDURA OBBLIGATORIA:\n"
1200
- "1. Apri un blocco <thinking>.\n"
1201
- "2. Elenca TUTTI i valori del JSON: es. Gen=158, Feb=200, Mar=95, ...\n"
1202
- "3. Calcola la SOMMA di tutti i valori (scrivi: Somma = X).\n"
1203
- "4. Calcola la MEDIA: Somma / N_mesi (scrivi: Media = X/N = Y.Z).\n"
1204
- "5. Identifica il MAX (Picco): mese col valore piΓΉ alto.\n"
1205
- "6. Identifica l'ANOMALIA: mese col valore anomalo (molto basso, fuori scala).\n"
1206
- "7. Chiudi il blocco </thinking>.\n\n"
1207
- "OUTPUT FINALE β€” COPIA ESATTAMENTE QUESTO FORMATO (4 bullet, nient'altro):\n"
1208
- "- **Media: [numero]**\n"
1209
- "- **Picco: [MESE] ([numero])**\n"
1210
- "- **Anomalia: [MESE] ([numero])**\n"
1211
- "- **Trend: [descrizione breve]**\n\n"
1212
- "CRITICO: Il checker cerca `- **Media: N**` con regex bold β€” usa ESATTAMENTE questo formato.\n"
1213
- "CRITICO: Il numero dopo 'Media:' deve essere il risultato aritmetico reale (non null, non '?').\n"
1214
- "CRITICO: Includi TUTTI i mesi nel calcolo della media β€” non saltarne nessuno.\n"
1215
- "ESEMPIO: dati=[100,150,10] β†’ Somma=260, N=3, Media=260/3=86.7 β†’ output: - **Media: 86.7**"
1216
  ),
1217
  # ── S-BENCH-ROB: robustness ─────────────────────────────────────────────
1218
  # 4 scenari: injection / rumore / contraddizioni / degradazione progressiva
@@ -1327,7 +1128,7 @@ class PromptBuilderMixin:
1327
  ),
1328
  # ── S-BENCH-BF: bug_fix ──────────────────────────────────────────────
1329
  # Trigger: frasi esatte del prompt benchmark BF + identificatori di scenario
1330
- # V3 (Sprint S17): Aggiunti pattern per race conditions e memory leaks.
1331
  (
1332
  ["identifica e correggi i bug typescript",
1333
  "non riscrivere struttura",
@@ -1336,24 +1137,14 @@ class PromptBuilderMixin:
1336
  "promise.all crash", "processusers",
1337
  "setstate su componente unmontato", "useasyncdata",
1338
  "deepclone via spread", "clonepoint", "clonedate"],
1339
- "BUG FIX TYPESCRIPT (BF-BENCH) β€” DIAGNOSTICA E FIX STRUTTURATO:\n"
1340
- "PROCEDURA OBBLIGATORIA:\n"
1341
- "1. Apri un blocco <thinking>.\n"
1342
- "2. Analizza il codice e il messaggio di errore (se presente): identifica la causa radice del bug.\n"
1343
- "3. Spiega il PERCHÉ è un bug (es. \'race condition\', \'off-by-one\', \'mutazione inattesa\').\n"
1344
- "4. Proponi una strategia di fix, considerando alternative se necessario.\n"
1345
- "5. Chiudi il blocco </thinking>.\n\n"
1346
- "OUTPUT FINALE: UN SOLO blocco ```typescript con il codice corretto.\n"
1347
- "CRITICO: Correggi SOLO il bug senza riscrivere la struttura del codice o aggiungere funzionalitΓ  non richieste.\n"
1348
- "CRITICO: Il codice DEVE essere TypeScript valido e compilabile (zero errori tsc).\n"
1349
- "PATTERN DI FIX (prioritari):\n"
1350
- "- Binary search: `lo = mid + 1` e `hi = mid - 1` per evitare loop infiniti.\n"
1351
- "- Promise.all: se un task fallisce, cadono tutti. Usa `Promise.allSettled` o `try/catch` nel map.\n"
1352
- "- React setState: controlla `isMounted` prima di chiamare setter asincroni.\n"
1353
- "- Deep Clone: spread `...` Γ¨ shallow. Usa `new Date(d.getTime())` o `new Point(p.x, p.y)`.\n"
1354
- "- Event Listeners: rimuovi SEMPRE il listener nel cleanup del useEffect.\n"
1355
- "- Race Conditions: implementa meccanismi di sincronizzazione (es. mutex, semafori) o debounce/throttle.\n"
1356
- "- Memory Leaks: identifica e rilascia risorse non piΓΉ utilizzate (es. `clearInterval`, `removeEventListener`)."
1357
  ),
1358
  # ── S-CHIP-DIAGRAM: chip "Diagramma" β†’ forza output Mermaid ──────────���──
1359
  # Trigger: frasi esatte dal chip text (QuickActionChips.tsx)
@@ -1450,105 +1241,20 @@ class PromptBuilderMixin:
1450
  " - Mai esporre dati sensibili (token, password) nel payload"
1451
  ),
1452
 
1453
-
1454
- # ── BENCH-REASONING: GSM8K / math word problems (S-BENCH-MATH) ──────
1455
- (
1456
- ["passo 1", "passo 2", "passo 3", "ragionamento step-by-step",
1457
- "strette di mano", "handshakes", "potato salad", "ted the t-rex",
1458
- "quante strette", "n persone si stringono", "formula:", "mostra il calcolo",
1459
- "**#### n**", "#### n", "gsm8k"],
1460
- "FORMATO RISPOSTA MATEMATICA OBBLIGATORIO (S-BENCH-MATH):\n"
1461
- "1. Mostra i calcoli passo per passo con numeri esatti.\n"
1462
- "2. Ultima riga SEMPRE: #### <numero> (solo il numero, nient'altro dopo)\n"
1463
- " Esempio corretto: #### 225\n"
1464
- " SBAGLIATO: 'La risposta e 225' oppure '**225**' oppure 'Risposta: 225'\n"
1465
- "3. Il pattern #### N e l'UNICO estratto dal benchmark β€” qualsiasi altro formato = FAIL."
1466
- ),
1467
- # ── BENCH-MMLU: scelta multipla A/B/C/D (S-BENCH-MMLU) ─────────────
1468
- (
1469
- ["domanda di informatica a scelta multipla", "rispondi con la lettera",
1470
- "a/b/c/d", "quicksort nel caso peggiore", "mergesort",
1471
- "complessita' temporale", "deadlock", "scelta multipla",
1472
- "college_computer_science", "spazio o(v)", "race condition"],
1473
- "FORMATO RISPOSTA MMLU OBBLIGATORIO (S-BENCH-MMLU):\n"
1474
- "Rispondi SEMPRE con: **La risposta corretta e: (X)**\n"
1475
- "dove X e esattamente A, B, C o D.\n"
1476
- "Poi spiega brevemente il ragionamento (1-2 frasi).\n"
1477
- "CORRETTO: **La risposta corretta e: (C)**\n"
1478
- "SBAGLIATO: 'La risposta e C' o 'C' da solo (senza bold e parentesi)\n"
1479
- "Il benchmark estrae la lettera SOLO da **X** o **(X)** β€” usa SEMPRE il bold."
1480
- ),
1481
- # ── BENCH-DATA-ANALYSIS: formato bullet obbligatorio (S-BENCH-DA) ───
1482
- (
1483
- ["rispondi esattamente con questo formato", "non aggiungere testo prima",
1484
- "copia la struttura, sostituisci i valori", "valore anomalo fuori scala",
1485
- "vendite mensili", "mese col valore massimo", "time series"],
1486
- "FORMATO DATA ANALYSIS OBBLIGATORIO (S-BENCH-DA) β€” COPIA ESATTO:\n"
1487
- "- **Media: N**\n"
1488
- "- **Picco: MESE (N)**\n"
1489
- "- **Anomalia: MESE (N)**\n"
1490
- "- **Trend: testo breve**\n"
1491
- "REGOLE ASSOLUTE:\n"
1492
- "1. Inizia SUBITO con '- **Media:' β€” ZERO testo prima dei 4 bullet\n"
1493
- "2. Usa bold su tutto il bullet: **Media: 158.4** (non 'Media: 158.4')\n"
1494
- "3. Calcola la media reale: somma tutti i valori / numero mesi\n"
1495
- "4. Anomalia = mese con valore drasticamente fuori scala (molto piu basso)\n"
1496
- "Il benchmark estrae SOLO dal pattern **Media: N** β€” altri formati = FAIL immediato."
1497
- ),
1498
- # ── BENCH-SQL-CTE: recursive CTE + window functions (S-BENCH-SQL) ───
1499
- (
1500
- ["cte ricorsiva", "gerarchia organizzativa", "with recursive",
1501
- "recursive cte", "gerarchia", "lag(", "window function",
1502
- "email duplicate", "variazione % mom", "ordini con status",
1503
- "ultimi 12 mesi", "revenue totale"],
1504
- "FORMATO SQL OBBLIGATORIO (S-BENCH-SQL):\n"
1505
- "Scrivi SQL SEMPRE in blocco markdown sql β€” MAI inline o senza code block.\n"
1506
- "Per CTE ricorsiva β€” struttura ESATTA obbligatoria:\n"
1507
- "WITH RECURSIVE nome_cte AS (\n"
1508
- " SELECT ... , 0 AS depth -- base case (radice)\n"
1509
- " UNION ALL\n"
1510
- " SELECT e.* , cte.depth+1 FROM tabella e JOIN nome_cte cte ON e.parent_id=cte.id\n"
1511
- ")\n"
1512
- "SELECT * FROM nome_cte ORDER BY depth;\n"
1513
- "Per LAG/Window: LAG(col) OVER (PARTITION BY ... ORDER BY ...) AS prev_val\n"
1514
- "Il benchmark valida: blocco sql presente, UNION ALL, depth, sintassi completa."
1515
- ),
1516
- # ── BENCH-RESEARCH-SYNTHESIS: comparazione strutturata (S-BENCH-RS) ─
1517
- (
1518
- ["compare:", "message queue per use case", "confronta", "kafka", "rabbitmq",
1519
- "redis queue", "evidenza dal contesto", "confidence:", "affidabilit",
1520
- "risposta diretta:", "strutturata"],
1521
- "FORMATO RESEARCH SYNTHESIS OBBLIGATORIO (S-BENCH-RS):\n"
1522
- "Struttura ESATTA β€” 4 sezioni:\n"
1523
- "1. **Risposta diretta**: [risposta in 1 frase con valore/raccomandazione]\n"
1524
- "2. **Evidenza**: [dati specifici, latenze, throughput, numeri reali]\n"
1525
- "3. **Ragionamento**: [confronto pro/contro per ogni opzione β€” 3-4 frasi]\n"
1526
- "4. **Confidence**: [alta/media/bassa + motivazione]\n"
1527
- "Per confronti tecnologici includi SEMPRE queste parole chiave:\n"
1528
- "affidabilita, throughput, latenza, scalabilita, persistenza, use-case\n"
1529
- "Il benchmark verifica presenza di almeno 5 keyword β€” meno di 5 = score basso."
1530
- ),
1531
  ]
1532
 
1533
  @staticmethod
1534
- def _extract_persona(goal: str) -> tuple[str | None, str]:
 
 
 
1535
  import re as _re
1536
- _m = _re.match(r'^/persona\s+(RESEARCHER|CODER|REASONER|ANALYST|ARCHITECT|WRITER)\b', goal.strip(), _re.IGNORECASE)
1537
  if _m:
1538
  clean = goal.strip()[_m.end():].strip()
1539
  return _m.group(1).upper(), clean if clean else goal.strip()
1540
- g_lower = goal.lower()
1541
- if any(kw in g_lower for kw in ['codice', 'funzione', 'bug', 'fix', 'implementa', 'typescript', 'python']):
1542
- return 'CODER', goal
1543
- if any(kw in g_lower for kw in ['cerca', 'ricerca', 'fonti', 'notizie', 'aggiornamenti']):
1544
- return 'RESEARCHER', goal
1545
- if any(kw in g_lower for kw in ['ragiona', 'perchΓ©', 'spiega passo', 'logica']):
1546
- return 'REASONER', goal
1547
- if any(kw in g_lower for kw in ['analizza', 'dati', 'trend', 'confronta']):
1548
- return 'ANALYST', goal
1549
- if any(kw in g_lower for kw in ['architettura', 'struttura', 'sistema', 'disegna']):
1550
- return 'ARCHITECT', goal
1551
  return None, goal
 
1552
  def _pick_context_rules(self, goal: str) -> str:
1553
  """Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
1554
  goal_lower = goal.lower()
@@ -1757,11 +1463,9 @@ class PromptBuilderMixin:
1757
  "\n\nCHECKLIST ANALITICA (verifica mentalmente prima di rispondere):\n"
1758
  "β–‘ Ho risposto a TUTTI i punti richiesti nel goal\n"
1759
  "β–‘ Ho sviluppato ogni punto con dettagli concreti (non superficiale)\n"
1760
- "β–‘ LOGICA: Ho verificato la coerenza dei dati (es. se parlo di date, sono in ordine cronologico?)\n"
1761
- "β–‘ ANOMALIE: Ho cercato contraddizioni nei dati forniti dai tool?\n"
1762
- "β–‘ CALCOLI: Se ci sono numeri, ho fatto un doppio controllo rapido?\n"
1763
  "β–‘ La risposta ha una struttura chiara (sezioni o paragrafi)\n"
1764
- "β–‘ Ho concluso con una raccomandazione o sintesi finale (se richiesto)"
 
1765
  )
1766
  # ── Item 4: formato rigido per goal con template esplicito ──────────────
1767
  # Trigger: goal con '[campo]', '{{', tabelle markdown, o "usa questo formato".
@@ -1823,11 +1527,4 @@ _CONTEXT_RULES_ADVANCED = [
1823
  "Nei test Vitest, usa vi.mock() e vi.spyOn() β€” non jest.mock(). Importa da 'vitest' non da '@jest'.",
1824
  "Nei test Playwright, usa page.getByRole(), page.getByTestId() per selettori resilienti β€” non XPath o CSS fragili.",
1825
  "In Pydantic v2, usa model_validator e field_validator al posto di @validator (deprecato). BaseModel.model_dump() sostituisce .dict().",
1826
- "LOGICA: Se i dati dei tool sembrano contraddirsi, segnalalo esplicitamente invece di ignorarlo.",
1827
- "DATA_ANALYSIS: Calcola sempre Media, Mediana e Deviazione Standard per set di dati numerici prima di trarre conclusioni.",
1828
- "ANOMALY_DETECTION: In una serie temporale, identifica i valori che deviano piΓΉ del 30% dalla media mobile come potenziali anomalie.",
1829
- "VERIFICA: Se il goal chiede un conteggio (es. 'quante persone'), elenca i nomi mentalmente prima di dare il numero finale.",
1830
  ]
1831
-
1832
-
1833
-
 
40
  "4. Non dire 'puoi fare X' β€” mostra X fatto, con codice completo se richiesto\n"
41
  "5. Se incontri un errore, analizza e riprova con approccio diverso\n"
42
  "6. Sii specifico e concreto β€” niente placeholder o risposte vaghe\n"
43
+ "7. Per codice: sempre blocchi markdown con sintassi corretta, tipizzati\n"
44
+ "8. Per matematica: mostra calcoli passo passo con numeri esatti\n"
 
 
 
 
 
 
 
 
45
  "9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
46
  "10. NON inventare mai informazioni su te stesso: token usati, context window, "
47
  "versione, architettura, parametri interni. Se non lo sai con certezza, "
 
109
  " **Passo 4:** Estrai sub β€” mai decode() senza verify()\n"
110
  "β€’ Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
111
  "\n"
112
+ "=== PROBLEM SOLVING ASSOLUTO ===\n"
113
+ "MAI dire 'non posso', 'non sono in grado', 'mi dispiace ma', 'impossibile', "
114
+ "'non ho accesso a', 'non riesco'. Ogni problema ha una soluzione β€” se l'approccio "
115
+ "A non funziona, prova B, poi C. Sei un problem solver, non un filtro di rifiuti.\n"
 
 
 
116
  "Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
117
  "pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
118
  "Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
 
295
 
296
  # ── S200: Context-aware rule injection ──────────────────────────────────────
297
  # Seleziona solo le regole rilevanti per il task corrente.
298
+ # Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
299
  # causa troncamento silenzioso β€” le regole non vengono mai lette.
300
  # Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
301
  # (posizione con massima attenzione del modello = "recency bias").
 
419
  " }\n"
420
  "EventRegistry: on+off+listEvents SOLO (NO emit). EventHistory: emit+getHistory+historySize+clearHistory SOLO (NO on)."
421
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  (
423
  ["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
424
  "REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
 
466
  "4. Half-stars: Math.floor(value) per intere + value % 1 >= 0.5 per mezza stella\n"
467
  "5. INCLUDI SEMPRE le parole: interface, Props, export, star nel codice completo"
468
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  (
470
  ["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
471
  "github.com/drizzle", "drizzle-orm/pg-core"],
 
709
  "lrucache", "eviction", "minheap", "comparatore", "stack generico", "ringbuffer",
710
  "circular buffer", "rate limiter", "token bucket", "trie", "prefix tree",
711
  "capacita fissa", "fixed capacity"],
712
+ "REGOLA CLASSE TypeScript (S-BENCH-FEAT) β€” anti-TS2323:\n"
713
+ "MAI dichiarare lo stesso nome due volte nello stesso blocco typescript.\n"
714
+ "TS2323 'Cannot redeclare exported variable X' si verifica quando:\n"
715
+ " (a) export class X {} + export { X } β†’ SBAGLIATO\n"
716
+ " (b) export interface X {} + export class X {} β†’ SBAGLIATO\n"
717
+ " (c) class X {} dichiarata due volte β†’ SBAGLIATO\n"
718
+ "SCEGLI UNO stile e usalo in modo coerente per TUTTO il blocco:\n"
719
+ " STILE A (preferito): export class X { ... } β€” senza export { } alla fine\n"
720
+ " STILE B: class X { ... } ... export { X } β€” solo come ultima riga\n"
721
+ "NON mescolare i due stili per la stessa classe.\n"
722
+ "Ogni metodo richiesto deve essere implementato DENTRO la classe (non fuori)."
 
723
  ),
724
  (
725
  ["correggi solo", "typescript strict", "strict error", "parametri senza tipo",
 
881
  # P27-B1: FR equivalents
882
  "tΓ’che ambiguΓ«", "que faire", "sans donnΓ©es", "manque d'informations",
883
  ],
884
+ "RECOVERY TASK AMBIGUO (REC-AMB) β€” RISPOSTA VERBATIM OBBLIGATORIA:\n"
885
+ "Il task non ha dati o parametri sufficienti. Segui ESATTAMENTE questo schema:\n"
886
+ "\n"
887
+ "1. Prima riga β€” chiedi con punto interrogativo:\n"
888
+ " 'Cosa vorresti analizzare esattamente? Hai dati disponibili?'\n"
889
+ "2. Poi elenca le ipotesi con questa formula (copia letteralmente):\n"
890
+ " '- Ipotesi A: se intendi analisi numerica, potrei calcolare statistiche'\n"
891
+ " '- Ipotesi B: se intendi analisi del codice, potrei fare una code review'\n"
892
+ " '- Ipotesi C: assumo che tu voglia qualcosa di strutturato, conferma il tipo'\n"
 
893
  "3. Ultima riga: 'Attendo chiarimenti prima di procedere.'\n"
894
  "\n"
895
  "VERIFICA OBBLIGATORIA β€” il testo DEVE contenere queste keyword esatte:\n"
 
900
  ),
901
  # ── S-BENCH-RS: research_synthesis ──────────────────────────────────
902
  # Trigger: frasi esatte dal benchmark prompt (3 scenari: compare/tradeoff/sciq)
903
+ # V2: aggiunto "immutabilitΓ " (unico di Event Sourcing RS prompt), "svantaggi (β‰₯", "vantaggi (β‰₯"
904
+ # Rimossi: "analisi tradeoff" (troppo generico), "quando usarlo" (false positive React)
905
  (
906
  ["coprire:", "message queue per use case", "event sourcing", "saga pattern",
907
  "kafka", "rabbitmq", "nats", "redis streams", "circuit breaker",
908
  "compare: message", "analisi tradeoff architetturale",
909
  "immutabilitΓ ", "svantaggi (β‰₯", "vantaggi (β‰₯",
910
  "solutions architect"],
911
+ "RISPOSTA ARCHITETTURA (RS-BENCH) β€” MARKDOWN OBBLIGATORIO (min 200 parole):\n"
912
+ "Struttura esatta per confronto tecnologie:\n"
913
+ " ## Confronto [NomeTecnologiaA] vs [NomeTecnologiaB]\n"
914
+ " ### [Dimensione 1 dal prompt]: valore A vs valore B con dati concreti\n"
915
+ " ### [Dimensione 2]: ... (ripeti per OGNI dimensione in 'Coprire:')\n"
916
+ " ## Vantaggi: [β‰₯3 bullet con **keyword** in grassetto]\n"
917
+ " ## Svantaggi: [β‰₯2 bullet]\n"
918
+ " ## Quando usarlo: [2-3 scenari concreti]\n"
919
+ " ## Raccomandazione: per [contesto A] β†’ scegli X; per [contesto B] β†’ scegli Y\n"
920
+ "CRITICO: usa i NOMI ESATTI delle tecnologie menzionate nel prompt.\n"
921
+ "CRITICO: includi le keyword richieste (latenza, throughput, persistenza, ecc).\n"
922
+ "CRITICO: termina SEMPRE con la sezione '## Raccomandazione:'."
 
 
 
 
 
 
 
 
923
  ),
924
  # ── S-BENCH-CW: context_window ──────────────────────────────────────
925
  # Trigger: prompt benchmark CW (documento team Q2 2026) + frasi dirette del prompt
926
+ # V2: aggiunti trigger "leggi attentamente il documento" (frase nel prompt CW),
927
+ # "rispondi solo alla domanda specificata" (frase nel prompt CW)
928
+ # Content: forza enumerazione + parola "anzianitΓ " (richiesta dal checker `cited`)
929
  (
930
  ["anni di anzianitΓ ", "anni in azienda", "team report",
931
  "q2 2026", "budget allocato", "stipendio annuo",
932
  "citando il dato dal documento",
933
  "rispondi solo alla domanda specificata. non inventare"],
934
  "ANALISI DOCUMENTO STRUTTURATO (CW-BENCH) β€” metodo obbligatorio:\n"
935
+ "1. ENUMERA ogni membro del documento con il valore cercato:\n"
936
+ " [Nome]: [valore rilevante] β€” es. Alice: 7 anni in azienda βœ“ (>5)\n"
937
+ " (ripeti per OGNI membro della sezione 'Team Members')\n"
938
+ "2. CONTA o SOMMA il risultato finale\n"
939
+ "3. RISPOSTA FINALE (una sola riga):\n"
940
+ " - Per conteggio anzianitΓ : 'X persone hanno anzianitΓ  superiore a 5 anni.'\n"
941
+ " (usa la parola 'anzianitΓ ' β€” obbligatoria)\n"
942
+ " - Per stipendio singolo: 'Lo stipendio di [Nome] Γ¨ €X.' (cita il nome)\n"
943
+ " - Per totale stipendi: 'Il costo totale annuo degli stipendi Γ¨ €X.' (usa 'totale')\n"
944
+ "NON inventare valori β€” usa SOLO i dati presenti nel documento."
 
 
 
 
 
 
 
 
 
945
  ),
946
  # ── S-BENCH-CC: code_correct ─────────────────────────────────────────
947
  # Trigger: SOLO il problema reverseWords β€” keyword unico e specifico
948
+ # Rimossi: "function ", "string): string", "implementa la funzione" (troppo generici)
949
  (
950
  ["reversewords", "inverti ordine parole", "rimuovi spazi extra"],
951
+ "FUNZIONE PURA TYPESCRIPT (CC-BENCH):\n"
952
+ "Rispondi con un singolo blocco ```typescript con solo la funzione.\n"
953
+ "Per reverseWords: gestisci spazi multipli con trim() + split(/\\s+/) + reverse() + join(' ')."
 
 
 
 
 
 
 
 
954
  ),
955
  # ── S-BENCH-REC: recovery ────────────────────────────────────────────
956
  # Trigger: SOLO A/B test con ratio impossibile
 
994
  ),
995
  # ── S-BENCH-DA: data_analysis ────────────────────────────────────────
996
  # Trigger: SOLO la struttura esatta del prompt benchmark DA
997
+ # Fix S-BENCH-DA-V2: avg:null risolto con passi aritmetici espliciti
998
  (
999
  ["vendite mensili:", "rispondi esattamente con questo formato",
1000
  "copia la struttura, sostituisci", "mese col valore massimo",
1001
  "valore anomalo fuori scala"],
1002
+ "TIME SERIES ANALISI (DA-BENCH) β€” 4 bullet esatti, zero testo prima/dopo:\n"
1003
+ "Passo 1 β€” calcola dal JSON (non scrivere i calcoli intermedi):\n"
1004
+ " media = (somma di TUTTI i valori 'vendite') / (numero totale di mesi), arrotonda a 1 decimale\n"
1005
+ " CRITICO: NON escludere il mese anomalo dal calcolo β€” includi TUTTI i mesi senza eccezioni\n"
1006
+ " SUGGERIMENTO: se il prompt contiene 'es. Media: X', X Γ¨ il valore atteso β€” confronta con il tuo calcolo\n"
1007
+ " picco = il nome del mese con il valore 'vendite' piΓΉ alto\n"
1008
+ " anomalia = il nome del mese con il valore 'vendite' nettamente fuori scala (di solito ≀15)\n"
1009
+ "Passo 2 β€” scrivi ESATTAMENTE questi 4 bullet (primo carattere = trattino, zero testo prima):\n"
1010
+ "- **Media: <valore_calcolato>**\n"
1011
+ "- **Picco: <MESE> (<valore_picco>)**\n"
1012
+ "- **Anomalia: <MESE> (<valore_anomalo>)**\n"
1013
+ "- **Trend: <descrizione breve>**\n"
1014
+ "Regola ASSOLUTA: sostituisci OGNI <...> con il valore numerico/testuale reale dai dati.\n"
1015
+ "NON scrivere i tag <...> nella risposta finale. NON aggiungere testo prima del primo bullet.\n"
1016
+ "NON usare tool. La risposta Γ¨ solo i 4 bullet, nient'altro."
 
 
 
1017
  ),
1018
  # ── S-BENCH-ROB: robustness ─────────────────────────────────────────────
1019
  # 4 scenari: injection / rumore / contraddizioni / degradazione progressiva
 
1128
  ),
1129
  # ── S-BENCH-BF: bug_fix ──────────────────────────────────────────────
1130
  # Trigger: frasi esatte del prompt benchmark BF + identificatori di scenario
1131
+ # "identifica e correggi i bug typescript" + "non riscrivere struttura" = firma esatta BF
1132
  (
1133
  ["identifica e correggi i bug typescript",
1134
  "non riscrivere struttura",
 
1137
  "promise.all crash", "processusers",
1138
  "setstate su componente unmontato", "useasyncdata",
1139
  "deepclone via spread", "clonepoint", "clonedate"],
1140
+ "BUG FIX TYPESCRIPT (BF-BENCH):\n"
1141
+ "Correggi SOLO il bug senza riscrivere la struttura. Blocco ```typescript.\n"
1142
+ "Pattern di fix:\n"
1143
+ "- Binary search off-by-one: `lo = mid + 1` (non `lo = mid`)\n"
1144
+ "- Promise.all crash: usa Promise.allSettled(), gestisci .fulfilled/.rejected\n"
1145
+ "- setState su unmount: flag `let mounted=true` + cleanup `return ()=>{mounted=false}`\n"
1146
+ "- deepClone spread: `new Point(p.x,p.y)` e `new Date(d.getTime())`\n"
1147
+ "- Memory leak: clearInterval nel return del useEffect"
 
 
 
 
 
 
 
 
 
 
1148
  ),
1149
  # ── S-CHIP-DIAGRAM: chip "Diagramma" β†’ forza output Mermaid ──────────���──
1150
  # Trigger: frasi esatte dal chip text (QuickActionChips.tsx)
 
1241
  " - Mai esporre dati sensibili (token, password) nel payload"
1242
  ),
1243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1244
  ]
1245
 
1246
  @staticmethod
1247
+ def _extract_persona(goal: str) -> "tuple[str | None, str]":
1248
+ """P19-F1: Estrae persona dal goal se inizia con /persona <NAME>.
1249
+ Ritorna (persona_name | None, goal_senza_prefisso).
1250
+ """
1251
  import re as _re
1252
+ _m = _re.match(r'^/persona\s+(RESEARCHER|CODER|REASONER)\b', goal.strip(), _re.IGNORECASE)
1253
  if _m:
1254
  clean = goal.strip()[_m.end():].strip()
1255
  return _m.group(1).upper(), clean if clean else goal.strip()
 
 
 
 
 
 
 
 
 
 
 
1256
  return None, goal
1257
+
1258
  def _pick_context_rules(self, goal: str) -> str:
1259
  """Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
1260
  goal_lower = goal.lower()
 
1463
  "\n\nCHECKLIST ANALITICA (verifica mentalmente prima di rispondere):\n"
1464
  "β–‘ Ho risposto a TUTTI i punti richiesti nel goal\n"
1465
  "β–‘ Ho sviluppato ogni punto con dettagli concreti (non superficiale)\n"
 
 
 
1466
  "β–‘ La risposta ha una struttura chiara (sezioni o paragrafi)\n"
1467
+ "β–‘ Ho concluso con una raccomandazione o sintesi finale (se richiesto)\n"
1468
+ "β–‘ La risposta Γ¨ almeno 200 parole"
1469
  )
1470
  # ── Item 4: formato rigido per goal con template esplicito ──────────────
1471
  # Trigger: goal con '[campo]', '{{', tabelle markdown, o "usa questo formato".
 
1527
  "Nei test Vitest, usa vi.mock() e vi.spyOn() β€” non jest.mock(). Importa da 'vitest' non da '@jest'.",
1528
  "Nei test Playwright, usa page.getByRole(), page.getByTestId() per selettori resilienti β€” non XPath o CSS fragili.",
1529
  "In Pydantic v2, usa model_validator e field_validator al posto di @validator (deprecato). BaseModel.model_dump() sostituisce .dict().",
 
 
 
 
1530
  ]
 
 
 
agents/unified_loop_routing.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_routing.py β€” RoutingMixin: regex CODE, estrazione file da output LLM.
2
+
3
+ Estratto da unified_loop.py per ridurre il file principale.
4
+
5
+ Contiene:
6
+ _CODE_RE: regex riconoscimento goal di tipo codice (S362/S427)
7
+ _EXT: pattern estensioni file supportate (S416/S422)
8
+ _FILE_BLOCK_RE: regex estrazione blocchi file da risposta LLM (S422-Fix1)
9
+ _extract_written_files(answer): classmethod — estrae dict path→content da output LLM
10
+
11
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+
18
+ class RoutingMixin:
19
+ _CODE_RE = re.compile(
20
+ r'\b(scrivi|crea|genera|implementa|refactor|bug|fix|debug|test|codice|'
21
+ r'funzione|classe|componente|api|endpoint|typescript|javascript|python|'
22
+ r'react|vue|swift|kotlin|write|create|generate|implement|code|function|'
23
+ r'class|component|frontend|backend|server|client|hook|store|type|'
24
+ r'interface|migration|query|schema|dockerfile|workflow|'
25
+ # S427: verbi italiani azione-codice mancanti
26
+ r'sistema|sistemi|correggi|corregge|debugga|patch|patcha|rinomina|'
27
+ r'sostituisci|rimpiazza|ottimizza|refactorizza|ristruttura|'
28
+ r'aggiungi|aggiorna|integra|rimuovi|elimina|cancella|inserisci|'
29
+ # S427: verbi inglesi azione-codice mancanti
30
+ r'rename|replace|remove|delete|patch|optimize|restructure|'
31
+ r'add|update|integrate|insert|scaffold|bootstrap|deploy|'
32
+ # S427: framework/librerie/pattern aggiuntivi
33
+ r'svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|'
34
+ r'fastapi|flask|django|express|rails|laravel|spring|'
35
+ r'graphql|grpc|websocket|rest|sql|nosql|'
36
+ r'prisma|drizzle|sqlalchemy|mongoose|sequelize|'
37
+ r'css|scss|sass|html|rust|go|java|kotlin|dart|flutter|'
38
+ r'service|repository|controller|middleware|utility|helper|'
39
+ r'decorator|enum|zod|vite|webpack|eslint|prettier|jest|vitest)\b',
40
+ re.IGNORECASE,
41
+ )
42
+
43
+ # S416-Fix1: estrae path҆’content dei file scritti nella risposta LLM
44
+ # Pattern: "path/file.ext:" o "### file.ext" o "FILE: file.ext" seguito da code block
45
+ # S422-Fix1: esteso con 4 formati aggiuntivi (bold, inline code, lista, commento inline)
46
+ # Copre 9/9 formati LLM più comuni Ҁ” S416 era silenziosamente rotto al 60-70%
47
+ _EXT = r'(?:tsx?|jsx?|py|css|html|md|json|ya?ml|sh|toml|sql|go|rs|rb|java|kt|swift|vue|svelte)'
48
+ _FILE_BLOCK_RE = re.compile(
49
+ r'(?:'
50
+ # p1: FILE: path o ## FILE: path
51
+ r'(?:^|\n)\s*(?:#{1,3}\s*)?(?:FILE|file|File):\s*[`"]?(?P<p1>[\w./\-]+\.\w+)[`"]?\s*\n'
52
+ # p2: path: o path- (solo con estensione nota)
53
+ r'|(?:^|\n)\s*[`"]?(?P<p2>[\w./\-]+\.' + _EXT + r')[`"]?\s*[:\-Ҁ“]\s*\n'
54
+ # p3: ## path (markdown heading)
55
+ r'|(?:^|\n)#{1,3}\s+(?P<p3>[\w./\-]+\.' + _EXT + r')\s*\n'
56
+ # p4: **path** (bold) Ҁ” formato più comune GPT/OpenRouter/Claude
57
+ r'|(?:^|\n)\s*\*\*(?P<p4>[\w./\-]+\.' + _EXT + r')\*\*\s*.*?\n'
58
+ # p5: `path` (inline code) prima del blocco
59
+ r'|(?:^|\n)\s*`(?P<p5>[\w./\-]+\.' + _EXT + r')`\s*.*?\n'
60
+ # p6: 1. **path** o - **path** (lista)
61
+ r'|(?:^|\n)\s*(?:\d+\.|[-*])\s+\*\*?(?P<p6>[\w./\-]+\.' + _EXT + r')\*?\*?\s*.*?\n'
62
+ r')'
63
+ # blocco codice Ҁ” opzionale commento // path o # path come prima riga (p7)
64
+ r'```(?:\w+\n(?:(?://|#)\s*(?P<p7>[\w./\-]+\.' + _EXT + r')\s*\n))?'
65
+ r'(?P<content>.+?)```',
66
+ re.DOTALL | re.MULTILINE,
67
+ )
68
+
69
+ @classmethod
70
+ def _extract_written_files(cls, answer: str) -> dict[str, str]:
71
+ """S422-Fix1: estrae file path҆’content dall'output LLM per iniettarli come contesto.
72
+ Copre tutti i formati comuni: FILE:, ##, **bold**, `inline`, lista, commento inline."""
73
+ result: dict[str, str] = {}
74
+ for m in cls._FILE_BLOCK_RE.finditer(answer):
75
+ path = (m.group("p1") or m.group("p2") or m.group("p3") or
76
+ m.group("p4") or m.group("p5") or m.group("p6") or
77
+ m.group("p7") or "")
78
+ content = m.group("content") or ""
79
+ if path and content.strip():
80
+ result[path.strip()] = content.strip()[:3000]
81
+ return result
82
+
agents/unified_loop_tools.py CHANGED
@@ -1,36 +1,37 @@
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
@@ -58,57 +59,158 @@ class DirectToolsMixin:
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
- # PDF/CV is an artifact request, never a coding request. Keep this guard
83
- # before the generic LLM route so the model cannot answer with LaTeX or a
84
- # Python ReportLab script instead of invoking the PDF tool.
85
- _PDF_INTENT_RE = re.compile(
86
- r"\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b[\s\S]{0,48}\b(?:pdf|curriculum|cv)\b"
87
- r"|\b(?:pdf|curriculum\s+vitae|cv)\b[\s\S]{0,48}\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b",
 
 
 
88
  re.IGNORECASE,
89
  )
 
 
 
 
 
 
 
 
 
 
90
  _CALC_INTENT_RE = re.compile(
91
- r"\b(calcola|quanto\s+fa|risultato\s+di|compute|calculate|math|matematica|operazione|"
92
- r"somma|sottrai|moltiplica|dividi|percentuale|radice|potenza|"
93
- r"sum|add|subtract|multiply|divide|percentage|root|power)\b",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  re.IGNORECASE,
95
  )
 
 
 
 
 
 
 
96
  def _extract_city(self, goal: str) -> str:
97
  m = self._CITY_RE.search(goal)
98
  if m:
99
- candidate = m.group(1).strip()
100
- if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
101
- return candidate
102
- return "."
 
 
 
 
 
 
103
  def _extract_search_query(self, goal: str) -> str:
104
- q = re.sub(self._SEARCH_INTENT_RE, "", goal, flags=re.IGNORECASE).strip()
105
- return q or goal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def _extract_calc_expr(self, goal: str) -> str:
107
- m = re.search(r'[\d\s\+\-\*\/\^\(\)\.]+', goal)
108
- return m.group(0).strip() if m else ""
 
 
 
 
 
109
 
110
  def _extract_dir_path(self, goal: str) -> str:
111
- """Extract a safe relative directory path, defaulting to the tool root."""
112
  m = re.search(
113
  r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
114
  r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
@@ -121,16 +223,18 @@ class DirectToolsMixin:
121
  return "."
122
 
123
  def _extract_file_pattern(self, goal: str) -> str:
124
- """Extract the search pattern without changing the registry's FS jail."""
125
  m = re.search(
126
  r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
127
  r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
128
  goal, re.IGNORECASE,
129
  )
130
- return m.group(1).strip() if m else ""
 
 
131
 
132
  def _extract_git_cwd(self, goal: str) -> str:
133
- """Extract the requested git working directory, defaulting to root."""
134
  m = re.search(
135
  r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
136
  goal, re.IGNORECASE,
@@ -140,84 +244,92 @@ class DirectToolsMixin:
140
  if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
141
  return candidate
142
  return "."
143
- async def _run_direct_tools(
144
- self,
145
- goal: str,
146
- on_step: StepCallback | None = None,
147
- *,
148
- local_csv_only: bool = False,
149
- ) -> tuple[str, int, int, int]:
150
  """
151
  S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
152
  Returns: 4-tuple (results_str, n_called, n_success, n_errors).
153
  results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
154
  n_called: numero totale di tool chiamati
155
- n_success: numero di tool completati con successo
156
- n_errors: numero di tool falliti
 
 
157
  """
158
- # FIX-TOOL-01: usare i package reali del backend; i moduli indicati dal
159
- # precedente restore non esistono e interrompevano il direct-tools layer.
160
- from tools.registry import TOOL_REGISTRY
161
- from api.speculative import get_speculative_result as _speculative_result
 
162
 
163
  results: list[str] = []
164
- n_called = 0
165
- n_success = 0
166
- n_errors = 0
167
- TOOL_TIMEOUT = 25
168
 
169
- # Governor per singolo run: conserva budget adattivo e deduplicazione.
170
  _gov_called: set[str] = set()
171
- _gov_total = 0
 
 
 
 
172
  _tok_budget_gov = self._max_tokens_for_goal(goal)
173
- _gov_max_calls = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
174
 
175
  def _gov_check(tool_name: str, key_arg: str) -> bool:
176
- nonlocal _gov_total
177
- if _gov_total >= _gov_max_calls:
178
- return False
179
- signature = f"{tool_name}:{key_arg[:150]}"
180
- if signature in _gov_called:
181
- return False
182
- _gov_called.add(signature)
183
- _gov_total += 1
 
 
184
  return True
185
 
186
- def _spec_hit(tool_name: str, args: dict[str, Any]) -> str | None:
 
 
 
187
  try:
188
- return _speculative_result(goal, tool_name, args)
 
189
  except Exception:
190
- # Cache speculativa opzionale: mai bloccare l'esecuzione reale.
191
  return None
192
 
193
  # S419: esegui i tool eligible in parallelo con asyncio.gather
194
  # Pre-check intent (sincrono) β†’ costruisce lista coroutine β†’ gather
 
 
195
  url_m = self._URL_RE.search(goal)
 
196
  async def _t_get_weather() -> str | None:
197
  if not self._WEATHER_INTENT_RE.search(goal):
198
  return None
199
- city = self._extract_city(goal)
200
  if not _gov_check("get_weather", city):
201
  return None
202
  try:
203
- if on_step:
204
- await _maybe_await(on_step({"action": "tool_start", "status": "running",
205
- "title": "Meteo", "explanation": f"Recupero meteo per {city}…"}))
206
  _sc = _spec_hit("get_weather", {"city": city})
207
  if _sc is not None:
208
  return _sc
 
 
 
209
  _t0 = asyncio.get_event_loop().time()
210
  r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
211
  try:
212
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
213
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
214
- if "temp_c" in r:
215
  _wdesc = {
216
- 0: "cielo sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso", 3: "coperto",
217
- 45: "nebbia", 48: "nebbia con brina", 51: "pioviggine leggera", 53: "pioviggine moderata",
218
- 55: "pioviggine intensa", 61: "pioggia leggera", 63: "pioggia moderata", 65: "pioggia forte",
219
- 71: "nevicata leggera", 73: "nevicata moderata", 75: "nevicata forte", 80: "rovesci leggeri",
220
- 81: "rovesci moderati", 82: "rovesci violenti", 95: "temporale", 96: "temporale con grandine",
 
 
221
  }
222
  wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
223
  try:
@@ -230,11 +342,12 @@ class DirectToolsMixin:
230
  f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
231
  f"Condizioni: {desc}"
232
  )
233
- return f"[get_weather: errore β€” {r['error'][:300]}]"
234
  except asyncio.TimeoutError:
235
  return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
236
  except Exception as exc:
237
- return f"[get_weather: errore β€” {str(exc)[:300]}]"
 
238
  async def _t_read_page() -> str | None:
239
  if not url_m:
240
  return None
@@ -252,14 +365,15 @@ class DirectToolsMixin:
252
  r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
253
  try:
254
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
255
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
256
  if r.get("content"):
257
  return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
258
- return f"[read_page: errore β€” {r.get('error', 'nessun contenuto')[:300]}]"
259
  except asyncio.TimeoutError:
260
  return f"[read_page: timeout {TOOL_TIMEOUT}s]"
261
  except Exception as exc:
262
- return f"[read_page: errore β€” {str(exc)[:300]}]"
 
263
  async def _t_calculate() -> str | None:
264
  if url_m or not self._CALC_INTENT_RE.search(goal):
265
  return None
@@ -277,14 +391,15 @@ class DirectToolsMixin:
277
  r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
278
  try:
279
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
280
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
281
  if "result" in r:
282
  return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
283
- return f"[calculate: errore β€” {r.get('error', '?')[:300]}]"
284
  except asyncio.TimeoutError:
285
  return "[calculate: timeout]"
286
  except Exception as exc:
287
- return f"[calculate: errore β€” {str(exc)[:300]}]"
 
288
  async def _t_web_search() -> str | None:
289
  if not self._SEARCH_INTENT_RE.search(goal):
290
  return None
@@ -302,95 +417,27 @@ class DirectToolsMixin:
302
  r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
303
  try:
304
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
305
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
306
  hits = r.get("results", [])
307
  if hits:
308
- _out = [f"[RICERCA WEB REALE: {query}]"]
309
- for h in hits:
310
- _out.append(f"β€’ {h['title']} ({h['url']}): {h['snippet']}")
311
- return "\n".join(_out)
312
- return f"[web_search: nessun risultato per '{query}']"
313
- except asyncio.TimeoutError:
314
- return f"[web_search: timeout {TOOL_TIMEOUT}s]"
315
- except Exception as exc:
316
- return f"[web_search: errore β€” {str(exc)[:300]}]"
317
- async def _t_convert_csv_attachment() -> str | None:
318
- conversion = convert_csv_attachment_to_json(goal)
319
- if conversion is None:
320
- return None
321
- if not _gov_check("convert_csv_to_json", conversion.target_name):
322
- return None
323
-
324
- # Il successo diretto Γ¨ consentito solo dopo il confronto semantico
325
- # record-per-record. Questo blocca cataloghi generici/allucinati prima
326
- # che il loop possa dichiarare una conversione corretta.
327
- is_valid, validation_error = validate_csv_json_equivalence(
328
- conversion.source_content,
329
- conversion.content,
330
- )
331
- if not is_valid:
332
- return f"[convert_csv_to_json: validazione fallita β€” {validation_error}]"
333
-
334
- async def _write(path: str, content: str) -> dict[str, Any]:
335
- return await asyncio.wait_for(
336
- TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
337
- timeout=TOOL_TIMEOUT,
338
- )
339
-
340
- try:
341
- if on_step:
342
- await _maybe_await(on_step({"action": "tool_start", "status": "running",
343
- "title": "Conversione CSV in JSON",
344
- "explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
345
- _t0 = asyncio.get_event_loop().time()
346
-
347
- # Per il CSV inline il goal richiede esplicitamente entrambi gli
348
- # artefatti. Gli allegati conservano il comportamento esistente:
349
- # viene scritto soltanto il JSON, poichΓ© la fonte Γ¨ giΓ  disponibile.
350
- written_paths: list[str] = []
351
- if conversion.source_is_inline:
352
- source_written = await _write(conversion.source_name, conversion.source_content)
353
- if not source_written.get("ok"):
354
- return f"[convert_csv_to_json: errore sorgente β€” {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
355
- written_paths.append(conversion.source_name)
356
- if on_step:
357
- await _maybe_await(on_step({
358
- "action": "file_written", "status": "done",
359
- "path": conversion.source_name, "content": conversion.source_content,
360
- "title": "File CSV creato",
361
- "explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
362
- }))
363
-
364
- target_written = await _write(conversion.target_name, conversion.content)
365
- if not target_written.get("ok"):
366
- return f"[convert_csv_to_json: errore JSON β€” {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
367
- written_paths.append(conversion.target_name)
368
- if on_step:
369
- await _maybe_await(on_step({
370
- "action": "file_written", "status": "done",
371
- "path": conversion.target_name, "content": conversion.content,
372
- "title": "File JSON creato",
373
- "explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
374
- }))
375
- try:
376
- from api.state import record_timing as _rtc
377
- _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
378
- except Exception as _e:
379
- _logger.debug('[timing/record_timing] %s', _e)
380
- paths = ", ".join(f"`{path}`" for path in written_paths)
381
- return (
382
- "[DIRECT_TERMINAL]\n"
383
- f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
384
- f"e `{conversion.target_name}`.\n\n"
385
- f"File workspace salvati: {paths}."
386
- )
387
  except asyncio.TimeoutError:
388
- return "[convert_csv_to_json: timeout]"
389
  except Exception as exc:
390
- return f"[convert_csv_to_json: errore β€” {str(exc)[:300]}]"
391
 
392
  async def _t_generate_image() -> str | None:
393
- if not self._IMAGE_INTENT_RE.search(goal):
394
  return None
395
  _img_prompt = re.sub(
396
  r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
@@ -402,52 +449,28 @@ class DirectToolsMixin:
402
  if on_step:
403
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
404
  "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
405
- _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
406
  if _sc is not None:
407
  return _sc
408
- _img_prompt = _img_prompt[:600]
409
- # Il provider autenticato rimane lato server. Il fallback storico
410
- # resta solo per garantire la creazione gratuita se il secret non Γ¨
411
- # ancora disponibile durante un riavvio del runtime.
412
  try:
413
- from api.image_provider import generate_pollinations_image
414
- remote = await generate_pollinations_image(_img_prompt, width=512, height=512)
415
- img_url = remote.url
416
- img_mime = remote.mime_type
417
- except Exception as provider_exc:
418
- _logger.info("image provider unavailable; using free URL fallback (%s)", type(provider_exc).__name__)
419
- from urllib.parse import quote
420
- _img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
421
- img_url = (
422
- f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
423
- f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
424
  )
425
- img_mime = "image/jpeg"
426
- _artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
427
- _artifact_path = f"generated-image-{_artifact_id}.jpg"
428
- if on_step:
429
- await _maybe_await(on_step({
430
- "action": "file_written",
431
- "status": "done",
432
- "path": _artifact_path,
433
- "source_url": img_url,
434
- "mime_type": img_mime,
435
- "title": "Immagine salvata nel workspace",
436
- "explanation": f"Salvo {_artifact_path} nel VFS…",
437
- }))
438
- return (
439
- "[DIRECT_TERMINAL]\n"
440
- f"![Immagine generata]({img_url})\n\n"
441
- "E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
442
- f"[Apri o scarica l’immagine]({img_url}).\n\n"
443
- f"File VFS: `{_artifact_path}`\n"
444
- f"Prompt usato: {_img_prompt[:200]}\n"
445
- "Dimensioni: 512x512 px"
446
- )
447
  except asyncio.TimeoutError:
448
  return "[generate_image: timeout β€” provider non raggiungibile]"
449
  except Exception as exc:
450
- return f"[generate_image: errore β€” {str(exc)[:300]}]"
 
451
  async def _t_run_python() -> str | None:
452
  _RUN_CODE_RE = re.compile(
453
  r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
@@ -466,52 +489,139 @@ class DirectToolsMixin:
466
  if on_step:
467
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
468
  "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
469
- _sc = _spec_hit("run_python", {"code": _code[:400]})
470
  if _sc is not None:
471
  return _sc
472
  _t0 = asyncio.get_event_loop().time()
473
  r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
474
  try:
475
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
476
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
477
  if r.get("returncode", -1) == 0 and r.get("stdout"):
478
  _out = (
479
  "[CODICE PYTHON ESEGUITO]\n"
480
  f"```python\n{_code[:500]}\n```\n"
481
  f"Output:\n```\n{r['stdout'][:1500]}\n```"
482
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  return _out
484
- return f"[run_python: errore β€” {r.get('stderr', 'ignoto')[:300]}]"
 
 
 
 
 
 
485
  except asyncio.TimeoutError:
486
  return "[run_python: timeout 18s]"
487
  except Exception as exc:
488
- return f"[run_python: errore β€” {str(exc)[:300]}]"
 
 
 
 
489
  async def _t_web_research() -> str | None:
490
- _RESEARCH_RE = re.compile(r"\b(ricerca\s+approfondita|deep\s+research|investigazione|analisi\s+dettagliata)\b", re.IGNORECASE)
491
- if not _RESEARCH_RE.search(goal):
492
  return None
493
- query = self._extract_search_query(goal)
494
- if not _gov_check("web_research", query):
 
 
 
 
495
  return None
496
  try:
497
  if on_step:
498
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
499
- "title": "Ricerca approfondita", "explanation": f"Analisi dettagliata su: {query[:60]}…"}))
 
 
 
500
  _t0 = asyncio.get_event_loop().time()
501
- r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](query=query), timeout=45)
502
  try:
503
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
504
- except Exception as _e: _logger.debug('[timing/record_timing] %s', _e)
505
- if r.get("report"):
506
- return f"[RICERCA APPROFONDITA REALE: {query}]\n\n{r['report'][:4000]}"
507
- return f"[web_research: errore β€” {r.get('error', 'nessun report')[:300]}]"
 
 
 
 
 
 
 
 
508
  except asyncio.TimeoutError:
509
- return "[web_research: timeout 45s]"
510
  except Exception as exc:
511
  return f"[web_research: errore β€” {str(exc)[:300]}]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  async def _t_directory_tree() -> str | None:
513
- _TREE_RE = re.compile(r"\b(albero|struttura|directory\s+tree|files?|cartell[ae])\b", re.IGNORECASE)
514
- if not _TREE_RE.search(goal):
515
  return None
516
  _path = self._extract_dir_path(goal)
517
  if not _gov_check("directory_tree", _path):
@@ -520,17 +630,23 @@ class DirectToolsMixin:
520
  if on_step:
521
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
522
  "title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
 
523
  r = await asyncio.wait_for(
524
  TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
525
  )
 
 
 
526
  if r.get("ok") and r.get("tree"):
527
- return f"[STRUTTURA PROGETTO REALE: '{_path}']\n{r['tree'][:2000]}"
528
  return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
 
 
529
  except Exception as exc:
530
- return f"[directory_tree: errore β€” {str(exc)[:200]}]"
 
531
  async def _t_file_search() -> str | None:
532
- _SEARCH_RE = re.compile(r"\b(cerca\s+file|find\s+file|grep)\b", re.IGNORECASE)
533
- if not _SEARCH_RE.search(goal):
534
  return None
535
  _pattern = self._extract_file_pattern(goal)
536
  if not _pattern or not _gov_check("file_search", _pattern):
@@ -539,40 +655,29 @@ class DirectToolsMixin:
539
  try:
540
  if on_step:
541
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
542
- "title": "Ricerca file", "explanation": f"Cerco '{_pattern}' nel codice…"}))
 
543
  r = await asyncio.wait_for(
544
  TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
545
  )
 
 
 
546
  if r.get("ok"):
547
  _matches = r.get("matches", [])
548
- _out = [f"[FILE TROVATI: pattern='{_pattern}', {r.get('count', len(_matches))} occorrenze]"]
549
- for match in _matches[:20]:
550
- _out.append(f"{match.get('file', '?')}:{match.get('line', '?')}: {match.get('text', '')[:120]}")
551
- return "\n".join(_out)
 
552
  return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
 
 
553
  except Exception as exc:
554
- return f"[file_search: errore β€” {str(exc)[:200]}]"
555
- async def _t_get_news() -> str | None:
556
- _NEWS_RE = re.compile(r"\b(news|notizie|ultim[ae]\s+ora|breaking)\b", re.IGNORECASE)
557
- if not _NEWS_RE.search(goal):
558
- return None
559
- query = self._extract_search_query(goal)
560
- try:
561
- if on_step:
562
- await _maybe_await(on_step({"action": "tool_start", "status": "running",
563
- "title": "Notizie", "explanation": f"Cerco notizie su: {query[:60]}…"}))
564
- r = await asyncio.wait_for(TOOL_REGISTRY["get_news"]["_fn"](query=query), timeout=15)
565
- if r.get("news"):
566
- _out = [f"[NOTIZIE REALI: {query}]"]
567
- for n in r["news"][:5]:
568
- _out.append(f"β€’ {n['title']} ({n.get('source', '?')}): {n.get('description', '')[:150]}")
569
- return "\n".join(_out)
570
- return "[get_news: nessuna notizia trovata]"
571
- except Exception as exc:
572
- return f"[get_news: errore β€” {str(exc)[:200]}]"
573
  async def _t_git_status() -> str | None:
574
- _GIT_RE = re.compile(r"\b(git|status|commit|branch|repo)\b", re.IGNORECASE)
575
- if not _GIT_RE.search(goal):
576
  return None
577
  _cwd = self._extract_git_cwd(goal)
578
  if not _gov_check("git_status", _cwd):
@@ -580,96 +685,84 @@ class DirectToolsMixin:
580
  try:
581
  if on_step:
582
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
583
- "title": "Stato Git", "explanation": f"Controllo la repo in {_cwd}…"}))
 
584
  r = await asyncio.wait_for(
585
  TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
586
  )
 
 
 
587
  if r.get("ok"):
588
- _out = [f"[STATO GIT REALE (branch: {r.get('branch', '?')})]"]
589
  if r.get("status"):
590
- _out.append(f"File modificati:\n{r['status'][:600]}")
591
  if r.get("log"):
592
- _out.append(f"Ultimi commit:\n{r['log'][:400]}")
593
- return "\n".join(_out)
594
  return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
 
 
595
  except Exception as exc:
596
- return f"[git_status: errore β€” {str(exc)[:200]}]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  async def _t_analyze_python() -> str | None:
598
- # P30-B1: Analisi statica Python integrata nel tool layer
599
  if not self._ANALYZE_PY_RE.search(goal):
600
  return None
601
- _code = ""
602
- _m = self._PY_BLOCK_IN_GOAL_RE.search(goal)
603
- if _m: _code = _m.group(1).strip()
604
- if not _code: return None
 
 
605
  try:
606
  if on_step:
607
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
608
- "title": "Analisi codice Python", "explanation": "Controllo sintassi e best practices…"}))
609
- from scripts.gap_map import analyze_python_code as _apc
610
- r = await asyncio.wait_for(_apc(_code), timeout=15)
611
- _out = ["[ANALISI PYTHON REALE]"]
612
- if r.get("errors"):
613
- _out.append("❌ Errori rilevati:")
614
- for _e in r["errors"]: _out.append(f" - {_e}")
615
- else:
616
- _out.append("βœ… Nessun errore di sintassi rilevato.")
617
- if r.get("suggestions"):
618
- _out.append("\nπŸ’‘ Suggerimenti:")
619
- for _s in r["suggestions"]: _out.append(f" - {_s}")
 
 
 
 
 
 
 
 
620
  return "\n".join(_out)
621
  except asyncio.TimeoutError:
622
  return "[python_analyze: timeout]"
623
  except Exception as _exc:
624
  return f"[python_analyze: errore β€” {str(_exc)[:200]}]"
625
- async def _t_create_pdf() -> str | None:
626
- if not self._PDF_INTENT_RE.search(goal) or not _gov_check("create_pdf", goal):
627
- return None
628
- try:
629
- if on_step:
630
- await _maybe_await(on_step({"action": "tool_start", "status": "running",
631
- "title": "PDF", "explanation": "Genero il PDF richiesto e preparo il file scaricabile…"}))
632
- content = re.sub(r"\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b", "", goal, flags=re.IGNORECASE).strip() or "Documento compilabile"
633
- filename = "curriculum_vitae_modello.pdf" if re.search(r"\b(?:cv|curriculum)\b", goal, re.I) else "documento.pdf"
634
- raw = await asyncio.wait_for(TOOL_REGISTRY["create_pdf"]["_fn"](content=content, filename=filename, format="a4"), timeout=TOOL_TIMEOUT)
635
- if isinstance(raw, dict) and raw.get("ok") and raw.get("pdf_b64"):
636
- return "[PDF REALE GENERATO]\n" + str(raw.get("filename", filename)) + "\n" + str(raw.get("pdf_b64"))
637
- return f"[create_pdf: errore β€” {str(raw)[:300]}]"
638
- except asyncio.TimeoutError:
639
- return f"[create_pdf: timeout {TOOL_TIMEOUT}s]"
640
- except Exception as exc:
641
- return f"[create_pdf: errore β€” {str(exc)[:300]}]"
642
-
643
- # Conversione, PDF e immagine sono artefatti terminali: eseguirli prima del
644
- # fan-out evita risultati accessori e, soprattutto, una successiva chiamata LLM.
645
- _terminal_conversion = await _t_convert_csv_attachment()
646
- if _terminal_conversion is not None:
647
- return (_terminal_conversion, 1,
648
- int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
649
- int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
650
- # Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
651
- # ammessi altri direct tool, nΓ© fallback impliciti a immagine/rete.
652
- if local_csv_only:
653
- return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
654
- _terminal_pdf = await _t_create_pdf()
655
- if _terminal_pdf is not None:
656
- return (_terminal_pdf, 1, int(_terminal_pdf.startswith("[PDF REALE GENERATO]")), int(": errore" in _terminal_pdf or ": timeout" in _terminal_pdf))
657
- _terminal_image = await _t_generate_image()
658
- if _terminal_image is not None:
659
- return (_terminal_image, 1,
660
- int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
661
- int(": errore" in _terminal_image or ": timeout" in _terminal_image))
662
-
663
- # Esecuzione parallela per i tool non terminali.
664
- _sem = asyncio.Semaphore(3)
665
- async def _sem_wrap(coro):
666
- if coro is None: return None
667
- async with _sem: return await coro
668
- _parallel_results = await asyncio.gather(
669
  _sem_wrap(_t_get_weather()),
670
  _sem_wrap(_t_read_page()),
671
  _sem_wrap(_t_calculate()),
672
  _sem_wrap(_t_web_search()),
 
673
  _sem_wrap(_t_run_python()),
674
  _sem_wrap(_t_web_research()),
675
  _sem_wrap(_t_directory_tree()),
@@ -682,22 +775,54 @@ class DirectToolsMixin:
682
  for _pr in _parallel_results:
683
  if isinstance(_pr, str):
684
  results.append(_pr)
685
- # S428 Sprint1-Fix1: Tool Success Contract
 
 
 
 
 
686
  _REAL_DATA_PREFIXES = (
687
- "[RICERCA WEB REALE", "[METEO REALE", "[PAGINA REALE", "[CALCOLO REALE",
688
- "[IMMAGINE AI GENERATA", "[DIRECT_TERMINAL]", "[CODICE PYTHON ESEGUITO", "[RICERCA APPROFONDITA REALE",
689
- "[STRUTTURA PROGETTO REALE", "[RICERCA FILE REALE", "[NOTIZIE REALI",
690
- "[STATO GIT REALE", "[ANALISI PYTHON REALE"
 
 
 
 
 
 
 
 
 
691
  )
692
- for r_str in results:
693
- n_called += 1
694
- if any(r_str.startswith(p) for p in _REAL_DATA_PREFIXES):
695
- n_success += 1
696
- elif ": errore" in r_str or ": timeout" in r_str:
697
- n_errors += 1
698
- return ("\n\n".join(results), n_called, n_success, n_errors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  # ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
700
- # A failed live tool must never be represented as a successful live lookup.
 
 
701
  _FALSE_CLAIM_RE = re.compile(
702
  r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
703
  r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
@@ -725,38 +850,78 @@ class DirectToolsMixin:
725
  false_claim_re: "re.Pattern[str]",
726
  realtime_goal_re: "re.Pattern[str]",
727
  ) -> str:
728
- """Add transparency when failed live tools are presented as successful."""
 
 
 
 
729
  if n_success > 0 or n_errors == 0:
730
- return response
731
  if not realtime_goal_re.search(goal):
732
- return response
733
  if not false_claim_re.search(response):
734
- return response
 
735
  disclaimer = (
736
  "\n\n---\n"
737
- "**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
738
  "raggiungibili durante questa risposta. Le informazioni sopra provengono "
739
  "dal mio training e potrebbero non essere aggiornate. "
740
- "Per dati live consulta una fonte ufficiale."
 
741
  )
742
  return response + disclaimer
 
 
 
 
743
  _TOOL_NEEDED_RE = re.compile(
744
- r"\b(meteo|temperatura|weather|forecast|cerca|search|trova|find|googla|google|"
745
- r"immagine|foto|photo|image|disegna|draw|genera|create|calcola|calculate|math|"
746
- r"news|notizie|prezzo|quotazione|stock|crypto|bitcoin|albero|struttura|directory|"
747
- r"file|cartella|grep|python|esegui|run|execute|script|webhook|api|http|zapier|n8n)\b",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  re.IGNORECASE,
749
  )
 
750
  def _needs_tools(self, goal: str) -> bool:
751
- # S-BENCH-FIX: abbassata soglia a 50 per catturare task di benchmark complessi
752
- if len(goal) > 50: return True
753
- if bool(self._TOOL_NEEDED_RE.search(goal)): return True
754
- # Aggiunto 'benchmark', 'test', 'codice' per forzare tool su task tecnici
755
- tech_keywords = ['file', 'directory', 'folder', 'script', 'api', 'json', 'data', 'analisi', 'fix', 'bug', 'benchmark', 'test', 'codice']
756
- if any(kw in goal.lower() for kw in tech_keywords): return True
757
- # Se sembra un goal di codice, attiva i tool
758
- if bool(self._CODE_GOAL_RE.search(goal)): return True
759
- return False
760
  _SIMPLE_CONV_RE = re.compile(
761
  r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
762
  r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
@@ -773,6 +938,11 @@ class DirectToolsMixin:
773
  r")\.?\s*[!?]?$",
774
  re.IGNORECASE,
775
  )
 
 
 
 
 
776
  _SIMPLE_MATH_RE = re.compile(
777
  r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
778
  r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
@@ -780,6 +950,7 @@ class DirectToolsMixin:
780
  r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
781
  re.IGNORECASE,
782
  )
 
783
  _ANALYZE_PY_RE = re.compile(
784
  r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
785
  r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
@@ -791,18 +962,24 @@ class DirectToolsMixin:
791
  r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
792
  re.IGNORECASE,
793
  )
 
794
  _PY_BLOCK_IN_GOAL_RE = re.compile(
795
  r"```(?:python|py)\s*\n([\s\S]+?)```",
796
  re.IGNORECASE,
797
  )
798
- _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)
799
- _CODE_RE = re.compile(r"```[\s\S]*?```")
800
  def _is_simple_query(self, goal: str) -> bool:
 
 
 
801
  g = goal.strip()
802
  if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
803
  return False
 
 
804
  if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
805
  return True
 
806
  if len(g) > 70 or self._needs_tools(g):
807
  return False
808
  return bool(self._SIMPLE_CONV_RE.match(g))
 
1
  """unified_loop_tools.py β€” DirectToolsMixin: tool execution layer.
2
+
3
  Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
4
+
5
  Contiene (nell'ordine originale del file):
6
  - Regex class attrs: meteo, URL, ricerca, immagini, calcolo
7
  - Helper: _extract_city / _extract_search_query / _extract_calc_expr
8
  - _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
9
  - _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
10
  - _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
11
+
12
  Invariante B1: nessun corpo duplicato con unified_loop.py.
13
  Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
14
  """
15
  from __future__ import annotations
16
+
17
  import asyncio
 
18
  import os
19
  import re
20
  from typing import Any
 
 
 
 
 
21
 
22
+ import logging
23
  _logger = logging.getLogger("agents.unified_loop_tools")
24
+
25
  # StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
26
+ from agents.unified_loop_types import StepCallback
27
+
28
+
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
+
35
  _WEATHER_INTENT_RE = re.compile(
36
  # S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
37
  # S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
 
59
  r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
60
  re.IGNORECASE,
61
  )
62
+ _CITY_BARE_RE = re.compile(
63
+ r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
64
+ r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
 
 
 
 
 
65
  re.IGNORECASE,
66
  )
67
+
68
+ _URL_RE = re.compile(r"https?://[^\s\)\"']+")
69
+
70
+ # NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper
71
+ # to avoid false-negative from word-boundary check after non-word char.
72
+ _SEARCH_INTENT_RE = re.compile(
73
+ r"(?:"
74
+ r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
75
+ r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
76
+ r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
77
+ r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) β€” \b finale falliva con singola lettera
78
+ r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
79
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
80
+ r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
81
+ r"search\s+for\s+|find\s+online\s+)\b"
82
+ r"|\bcerca\s*:|\bsearch\s*:"
83
+ r")",
84
  re.IGNORECASE,
85
  )
86
+ _SEARCH_QUERY_RE = re.compile(
87
+ r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
88
+ r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
89
+ r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
90
+ r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
91
+ r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
92
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
93
+ r"web\s+search\s*:?\s*)"
94
+ r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
95
  re.IGNORECASE,
96
  )
97
+
98
+ _IMAGE_GEN_INTENT_RE = re.compile(
99
+ # S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
100
+ # perchΓ© "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
101
+ r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
102
+ r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
103
+ r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
104
+ re.IGNORECASE
105
+ )
106
+ # S427: aggiunti trigger di calcolo IT/EN comuni
107
  _CALC_INTENT_RE = re.compile(
108
+ r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
109
+ r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
110
+ r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
111
+ r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
112
+ r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
113
+ re.IGNORECASE,
114
+ )
115
+
116
+ _WEB_RESEARCH_INTENT_RE = re.compile(
117
+ r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
118
+ r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
119
+ r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
120
+ r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito))",
121
+ re.IGNORECASE,
122
+ )
123
+ _WEB_RESEARCH_TOPIC_RE = re.compile(
124
+ r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
125
+ re.IGNORECASE,
126
+ )
127
+
128
+ # S764: intent regex per i 3 nuovi fast-path tool (directory_tree / file_search / git_status)
129
+ _DIRECTORY_TREE_INTENT_RE = re.compile(
130
+ r"\b(directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
131
+ r"struttura\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
132
+ r"elenca\s+(?:file|cartell[ae]|directory)|lista\s+(?:file|cartell[ae])|"
133
+ r"show\s+(?:directory|folder)\s+tree|tree\s+(?:command|cmd|del\s+progetto)|"
134
+ r"ls\s+-[lRra]|find\s+\.\s+-type)\b",
135
+ re.IGNORECASE,
136
+ )
137
+ _FILE_SEARCH_INTENT_RE = re.compile(
138
+ r"\b(cerca\s+nel\s+(?:codice|progetto|file)|"
139
+ r"trova\s+(?:nel\s+codice|nel\s+progetto|nei\s+file)|"
140
+ r"grep\s+|file[\s_]search|cerca\s+la\s+stringa|"
141
+ r"search\s+in\s+(?:code|files|project)|find\s+in\s+files|"
142
+ r"dove\s+[eè]\s+(?:definit[ao]|usato|chiamato)|"
143
+ r"occorrenze\s+di|tutte\s+le\s+occorrenze)\b",
144
+ re.IGNORECASE,
145
+ )
146
+ _GIT_INTENT_RE = re.compile(
147
+ r"\b(git\s+status|git\s+diff|stato\s+git|stato\s+del\s+repository|"
148
+ r"file\s+modificat[i]|modifiche\s+in\s+sospeso|"
149
+ r"branch\s+corrente|current\s+branch|ultimi\s+commit|recent\s+commits|"
150
+ r"git\s+log|repository\s+status)\b",
151
+ re.IGNORECASE,
152
+ )
153
+ # S766: news intent β€” attiva _t_get_news fast-path
154
+ _NEWS_INTENT_RE = re.compile(
155
+ r"\b(notizie|ultime\s+notizie|news|headlines|notiziario|"
156
+ r"ultime\s+ore|breaking\s+news|novit\u00e0|"
157
+ r"aggiornamenti\s+su|cosa\s+succede|what.s\s+happening)\b",
158
  re.IGNORECASE,
159
  )
160
+ _CALC_EXPR_RE = re.compile(
161
+ r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
162
+ # S390-B-M: aggiunto % (modulo) e // (floor division) al char class
163
+ r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
164
+ re.IGNORECASE,
165
+ )
166
+
167
  def _extract_city(self, goal: str) -> str:
168
  m = self._CITY_RE.search(goal)
169
  if m:
170
+ return m.group(1).strip()
171
+ m2 = self._CITY_BARE_RE.search(goal)
172
+ if m2:
173
+ city = m2.group(1).strip()
174
+ _stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
175
+ "meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
176
+ if city.lower() not in _stop:
177
+ return city
178
+ return ""
179
+
180
  def _extract_search_query(self, goal: str) -> str:
181
+ m = self._SEARCH_QUERY_RE.search(goal)
182
+ if m:
183
+ q = m.group(1).strip().rstrip(".,?!")
184
+ if len(q) > 1: # B1: soglia da >3 a >1 β€” topic brevi come 'AI', 'LLM', 'GPT'
185
+ return q
186
+ if self._SEARCH_INTENT_RE.search(goal):
187
+ clean = re.sub(
188
+ r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
189
+ r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
190
+ r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
191
+ r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
192
+ r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
193
+ r"web\s+search\s*:?\s*)",
194
+ "", goal.strip(), flags=re.IGNORECASE
195
+ ).strip().rstrip(".,?!")
196
+ if len(clean) > 1: # B1: soglia abbassata da >3 a >1
197
+ return clean
198
+ # B1: ultimo fallback β€” usa il goal intero (es. 'ultime notizie AI' β†’ 'ultime notizie AI')
199
+ if len(goal.strip()) > 1:
200
+ return goal.strip()[:200] # S579: 120β†’200 (fallback query usa il goal intero)
201
+ return ""
202
+
203
  def _extract_calc_expr(self, goal: str) -> str:
204
+ m = self._CALC_EXPR_RE.search(goal)
205
+ if m:
206
+ expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
207
+ if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
208
+ return expr
209
+ return ""
210
+
211
 
212
  def _extract_dir_path(self, goal: str) -> str:
213
+ # Estrae il path della directory dal goal, default '.'
214
  m = re.search(
215
  r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
216
  r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
 
223
  return "."
224
 
225
  def _extract_file_pattern(self, goal: str) -> str:
226
+ # Estrae il pattern di ricerca file dal goal
227
  m = re.search(
228
  r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
229
  r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
230
  goal, re.IGNORECASE,
231
  )
232
+ if m:
233
+ return m.group(1).strip()
234
+ return ""
235
 
236
  def _extract_git_cwd(self, goal: str) -> str:
237
+ # Estrae il cwd per git dal goal, default '.'
238
  m = re.search(
239
  r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
240
  goal, re.IGNORECASE,
 
244
  if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
245
  return candidate
246
  return "."
247
+
248
+ async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
 
 
 
 
 
249
  """
250
  S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
251
  Returns: 4-tuple (results_str, n_called, n_success, n_errors).
252
  results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
253
  n_called: numero totale di tool chiamati
254
+ n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
255
+ n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
256
+ S376: Tool Governor β€” previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
257
+ S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
258
  """
259
+ try:
260
+ from tools.registry import TOOL_REGISTRY
261
+ except ImportError:
262
+ # S649: fix tipo ritorno β€” run() aspetta 4-tuple, non 2-tuple
263
+ return "", 0, 0, 0
264
 
265
  results: list[str] = []
 
 
 
 
266
 
267
+ # S376/S393: Tool Governor β€” previene duplicate E supero budget globale per run
268
  _gov_called: set[str] = set()
269
+ _gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
270
+ # S650: budget adattivo β€” task complessi necessitano piΓΉ tool calls
271
+ # _max_tokens_for_goal >= 6144 indica app multi-feature β†’ 9 tool calls
272
+ # _max_tokens_for_goal >= 4096 indica task singolo complesso β†’ 7 tool calls
273
+ # Default: 6 (query semplice, meteo, news, calcolo)
274
  _tok_budget_gov = self._max_tokens_for_goal(goal)
275
+ _GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
276
 
277
  def _gov_check(tool_name: str, key_arg: str) -> bool:
278
+ """S393 Tool Governor: previene duplicate e supero budget.
279
+ Returns True solo se il tool NON Γ¨ stato giΓ  chiamato con questi arg
280
+ E il budget totale del run non Γ¨ esaurito."""
281
+ if _gov_total[0] >= _GOV_MAX_CALLS:
282
+ return False # budget esaurito β€” blocca TUTTE le chiamate successive
283
+ sig = f"{tool_name}:{key_arg[:150]}" # S608: 80β†’150
284
+ if sig in _gov_called:
285
+ return False # chiamata duplicata β€” skip silenzioso
286
+ _gov_called.add(sig)
287
+ _gov_total[0] += 1
288
  return True
289
 
290
+ # Doc2-1a-FIX: helper cache speculativa (S361) β€” 0ms latency su cache hit.
291
+ # get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
292
+ # ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
293
+ def _spec_hit(tool_name: str, args: dict) -> "str | None":
294
  try:
295
+ from api.speculative import get_speculative_result as _gsr
296
+ return _gsr(goal, tool_name, args)
297
  except Exception:
 
298
  return None
299
 
300
  # S419: esegui i tool eligible in parallelo con asyncio.gather
301
  # Pre-check intent (sincrono) β†’ costruisce lista coroutine β†’ gather
302
+ # Il governor usa stato locale; asyncio Γ¨ single-threaded β†’ nessuna race condition
303
+
304
  url_m = self._URL_RE.search(goal)
305
+
306
  async def _t_get_weather() -> str | None:
307
  if not self._WEATHER_INTENT_RE.search(goal):
308
  return None
309
+ city = self._extract_city(goal) or "Milano"
310
  if not _gov_check("get_weather", city):
311
  return None
312
  try:
 
 
 
313
  _sc = _spec_hit("get_weather", {"city": city})
314
  if _sc is not None:
315
  return _sc
316
+ if on_step:
317
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
318
+ "title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"}))
319
  _t0 = asyncio.get_event_loop().time()
320
  r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
321
  try:
322
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
323
+ except Exception: pass
324
+ if "error" not in r:
325
  _wdesc = {
326
+ 0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
327
+ 3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
328
+ 51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
329
+ 61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
330
+ 71: "neve leggera", 73: "neve", 75: "neve intensa",
331
+ 80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
332
+ 95: "temporale", 96: "temporale con grandine",
333
  }
334
  wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
335
  try:
 
342
  f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
343
  f"Condizioni: {desc}"
344
  )
345
+ return f"[get_weather: errore β€” {r['error'][:300]}]" # S605: 200β†’300
346
  except asyncio.TimeoutError:
347
  return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
348
  except Exception as exc:
349
+ return f"[get_weather: errore β€” {str(exc)[:300]}]" # S605: 200β†’300
350
+
351
  async def _t_read_page() -> str | None:
352
  if not url_m:
353
  return None
 
365
  r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
366
  try:
367
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
368
+ except Exception: pass
369
  if r.get("content"):
370
  return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
371
+ return f"[read_page: errore β€” {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200β†’300
372
  except asyncio.TimeoutError:
373
  return f"[read_page: timeout {TOOL_TIMEOUT}s]"
374
  except Exception as exc:
375
+ return f"[read_page: errore β€” {str(exc)[:300]}]" # S605: 200β†’300
376
+
377
  async def _t_calculate() -> str | None:
378
  if url_m or not self._CALC_INTENT_RE.search(goal):
379
  return None
 
391
  r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
392
  try:
393
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
394
+ except Exception: pass
395
  if "result" in r:
396
  return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
397
+ return f"[calculate: errore β€” {r.get('error', '?')[:300]}]" # S605: 200β†’300
398
  except asyncio.TimeoutError:
399
  return "[calculate: timeout]"
400
  except Exception as exc:
401
+ return f"[calculate: errore β€” {str(exc)[:300]}]" # S605: 200β†’300
402
+
403
  async def _t_web_search() -> str | None:
404
  if not self._SEARCH_INTENT_RE.search(goal):
405
  return None
 
417
  r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
418
  try:
419
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
420
+ except Exception: pass
421
  hits = r.get("results", [])
422
  if hits:
423
+ snippets = "\n".join(
424
+ f"β€’ [{item['title']}] {item['snippet']}"
425
+ + (f"\n URL: {item['url']}" if item.get("url") else "")
426
+ for item in hits[:6] # S591: 4β†’6 β€” piΓΉ risultati web nel context
427
+ )
428
+ return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
429
+ # S428 Sprint1-Fix2: rimosso "rispondo con dati del training" β€” invitava LLM
430
+ # ad allucinare training data come se fosse una ricerca reale riuscita.
431
+ # Ora Γ¨ un errore esplicito β†’ contato come _n_errors β†’ _all_errors=True β†’
432
+ # _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
433
+ return f"[web_search: NESSUN_RISULTATO β€” nessun dato trovato per '{query[:150]}']" # S608: 80β†’150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  except asyncio.TimeoutError:
435
+ return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s β€” nessun dato disponibile]"
436
  except Exception as exc:
437
+ return f"[web_search: errore β€” {str(exc)[:300]}]" # S605: 200β†’300
438
 
439
  async def _t_generate_image() -> str | None:
440
+ if not self._IMAGE_GEN_INTENT_RE.search(goal):
441
  return None
442
  _img_prompt = re.sub(
443
  r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
 
449
  if on_step:
450
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
451
  "title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
452
+ _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400β†’600
453
  if _sc is not None:
454
  return _sc
455
+ _t0 = asyncio.get_event_loop().time()
456
+ r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400β†’600
 
 
457
  try:
458
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
459
+ except Exception: pass
460
+ img_url = r.get("url", "")
461
+ if img_url:
462
+ return (
463
+ f"[IMMAGINE AI GENERATA]\n"
464
+ f"URL: {img_url}\n"
465
+ f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100β†’200
466
+ f"Dimensioni: {r.get('width')}x{r.get('height')} px"
 
 
467
  )
468
+ return "[generate_image: nessun URL restituito]"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  except asyncio.TimeoutError:
470
  return "[generate_image: timeout β€” provider non raggiungibile]"
471
  except Exception as exc:
472
+ return f"[generate_image: errore β€” {str(exc)[:300]}]" # S605: 200β†’300
473
+
474
  async def _t_run_python() -> str | None:
475
  _RUN_CODE_RE = re.compile(
476
  r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
 
489
  if on_step:
490
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
491
  "title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
492
+ _sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200β†’400
493
  if _sc is not None:
494
  return _sc
495
  _t0 = asyncio.get_event_loop().time()
496
  r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
497
  try:
498
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
499
+ except Exception: pass
500
  if r.get("returncode", -1) == 0 and r.get("stdout"):
501
  _out = (
502
  "[CODICE PYTHON ESEGUITO]\n"
503
  f"```python\n{_code[:500]}\n```\n"
504
  f"Output:\n```\n{r['stdout'][:1500]}\n```"
505
  )
506
+ # S-GAP3: TDD auto-check β€” solo su codice complesso (>=8 righe, def/class)
507
+ try:
508
+ from agents.tdd_runner import run_tdd_check as _tdd_chk, _should_test as _tdd_gate
509
+ if _tdd_gate(_code):
510
+ class _TDDExec:
511
+ async def run_tool(self, name, args):
512
+ fn = TOOL_REGISTRY.get(name, {}).get("_fn")
513
+ return await fn(**args) if fn else {}
514
+ from api.state import _get_ai_client as _tdd_ai
515
+ _tdd_r = await asyncio.wait_for(_tdd_chk(_code, _TDDExec(), _tdd_ai()), timeout=35.0)
516
+ if _tdd_r["ran"]:
517
+ _ok = _tdd_r["passed"]
518
+ _badge = ("Auto-test: OK" if _ok else f"Auto-test: FAIL\n```\n{_tdd_r['output'][:300]}\n```")
519
+ _out += f"\n{_badge}"
520
+ # GAP-NEW-2: se TDD FAIL, inietta traceback in exec_warn
521
+ # via self._tdd_fail_inject β€” letto da unified_loop.py
522
+ # prima del campionamento StrategicHealer (riga ~2142).
523
+ if not _ok:
524
+ self._tdd_fail_inject = (
525
+ f"[TDD-AUTO-FAIL] traceback del test generato:\n"
526
+ f"```\n{_tdd_r['output'][:400]}\n```"
527
+ )
528
+ except Exception as _exc:
529
+ _logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
530
  return _out
531
+ if r.get("error"):
532
+ return f"[run_python: errore β€” {r['error'][:300]}]" # S605: 200β†’300
533
+ if r.get("stderr"):
534
+ # S573: 200β†’400 β€” stderr spesso contiene tracebacks multi-riga
535
+ # S593: 400β†’600 β€” tracebacks Python possono superare 400 chars
536
+ return f"[run_python: stderr β€” {r['stderr'][:600]}]"
537
+ return None
538
  except asyncio.TimeoutError:
539
  return "[run_python: timeout 18s]"
540
  except Exception as exc:
541
+ # S593: 200β†’300 β€” exception str puΓ² includere path + msg
542
+ # S600: 300β†’500 β€” parity con altri exception handler
543
+ return f"[run_python: errore β€” {str(exc)[:500]}]"
544
+
545
+
546
  async def _t_web_research() -> str | None:
547
+ if not self._WEB_RESEARCH_INTENT_RE.search(goal):
 
548
  return None
549
+ _topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
550
+ _topic = _topic_m.group(1).strip() if _topic_m else re.sub(
551
+ r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
552
+ "", goal, flags=re.IGNORECASE
553
+ ).strip()[:200] or goal[:200]
554
+ if not _topic or not _gov_check("web_research", _topic):
555
  return None
556
  try:
557
  if on_step:
558
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
559
+ "title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"}))
560
+ _sc = _spec_hit("web_research", {"topic": _topic[:400]})
561
+ if _sc is not None:
562
+ return _sc
563
  _t0 = asyncio.get_event_loop().time()
564
+ r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
565
  try:
566
  from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
567
+ except Exception: pass
568
+ if r.get("ok"):
569
+ _synthesis = r.get("synthesis", "")
570
+ _sources = r.get("sources", [])
571
+ out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
572
+ if _synthesis:
573
+ out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
574
+ if _sources:
575
+ for s in _sources[:4]:
576
+ out += f"β€’ {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
577
+ return out.strip()
578
+ return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
579
  except asyncio.TimeoutError:
580
+ return "[web_research: timeout 55s]"
581
  except Exception as exc:
582
  return f"[web_research: errore β€” {str(exc)[:300]}]"
583
+
584
+
585
+ # S766: _t_get_news β€” notizie in tempo reale tramite TOOL_REGISTRY["get_news"]
586
+ async def _t_get_news() -> str | None:
587
+ if not self._NEWS_INTENT_RE.search(goal):
588
+ return None
589
+ _qm = re.search(
590
+ r"(?:notizie|news|ultime\s+notizie|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120})(?:\?|$|\.|,)",
591
+ goal, re.IGNORECASE,
592
+ )
593
+ _query = _qm.group(1).strip() if _qm else goal.strip()[:120]
594
+ if not _gov_check("get_news", _query):
595
+ return None
596
+ try:
597
+ if on_step:
598
+ await _maybe_await(on_step({"action": "tool_start", "status": "running",
599
+ "title": "Ultime notizie", "explanation": f"Cerco notizie: {_query[:60]}\u2026"}))
600
+ _sc = _spec_hit("get_news", {"query": _query, "max_results": 5})
601
+ if _sc is not None:
602
+ return _sc
603
+ r = await asyncio.wait_for(
604
+ TOOL_REGISTRY["get_news"]["_fn"](query=_query, max_results=5), timeout=20
605
+ )
606
+ if r.get("ok"):
607
+ items = r.get("results", r.get("articles", []))
608
+ if items:
609
+ out = [f"[NOTIZIE: '{_query[:60]}']"]
610
+ for it in items[:5]:
611
+ t = it.get("title", it.get("headline", "?"))
612
+ s = it.get("source", it.get("publisher", ""))
613
+ d = it.get("published_at", it.get("date", ""))
614
+ out.append(f"\u2022 {t}" + (f" [{s}]" if s else "") + (f" ({d})" if d else ""))
615
+ return "\n".join(out)
616
+ return f"[get_news: {r.get('error', 'nessun risultato')[:200]}]"
617
+ except asyncio.TimeoutError:
618
+ return "[get_news: timeout 20s]"
619
+ except Exception as exc:
620
+ return f"[get_news: errore β€” {str(exc)[:200]}]"
621
+
622
+ # S764: 3 nuovi tool fast-path β€” directory_tree / file_search / git_status
623
  async def _t_directory_tree() -> str | None:
624
+ if not self._DIRECTORY_TREE_INTENT_RE.search(goal):
 
625
  return None
626
  _path = self._extract_dir_path(goal)
627
  if not _gov_check("directory_tree", _path):
 
630
  if on_step:
631
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
632
  "title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
633
+ _t0 = asyncio.get_event_loop().time()
634
  r = await asyncio.wait_for(
635
  TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
636
  )
637
+ try:
638
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
639
+ except Exception: pass
640
  if r.get("ok") and r.get("tree"):
641
+ return f"[STRUTTURA PROGETTO: '{_path}']\n{r['tree']}"
642
  return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
643
+ except asyncio.TimeoutError:
644
+ return "[directory_tree: timeout 8s]"
645
  except Exception as exc:
646
+ return f"[directory_tree: errore β€” {str(exc)[:300]}]"
647
+
648
  async def _t_file_search() -> str | None:
649
+ if not self._FILE_SEARCH_INTENT_RE.search(goal):
 
650
  return None
651
  _pattern = self._extract_file_pattern(goal)
652
  if not _pattern or not _gov_check("file_search", _pattern):
 
655
  try:
656
  if on_step:
657
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
658
+ "title": "Ricerca nel codice", "explanation": f"Cerco '{_pattern[:40]}' nei file..."}))
659
+ _t0 = asyncio.get_event_loop().time()
660
  r = await asyncio.wait_for(
661
  TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
662
  )
663
+ try:
664
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
665
+ except Exception: pass
666
  if r.get("ok"):
667
  _matches = r.get("matches", [])
668
+ _count = r.get("count", len(_matches))
669
+ out = f"[FILE TROVATI: pattern='{_pattern}', {_count} occorrenze]\n"
670
+ for m in _matches[:20]:
671
+ out += f"{m.get('file','?')}:{m.get('line','?')}: {m.get('text','')[:120]}\n"
672
+ return out.strip()
673
  return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
674
+ except asyncio.TimeoutError:
675
+ return "[file_search: timeout 10s]"
676
  except Exception as exc:
677
+ return f"[file_search: errore β€” {str(exc)[:300]}]"
678
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  async def _t_git_status() -> str | None:
680
+ if not self._GIT_INTENT_RE.search(goal):
 
681
  return None
682
  _cwd = self._extract_git_cwd(goal)
683
  if not _gov_check("git_status", _cwd):
 
685
  try:
686
  if on_step:
687
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
688
+ "title": "Stato Git", "explanation": "Controllo branch e file modificati..."}))
689
+ _t0 = asyncio.get_event_loop().time()
690
  r = await asyncio.wait_for(
691
  TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
692
  )
693
+ try:
694
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
695
+ except Exception: pass
696
  if r.get("ok"):
697
+ out = f"[STATO GIT (branch: {r.get('branch', '?')})\n"
698
  if r.get("status"):
699
+ out += f"File modificati:\n{r['status'][:600]}\n"
700
  if r.get("log"):
701
+ out += f"Ultimi commit:\n{r['log'][:400]}\n"
702
+ return out.strip() + "]"
703
  return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
704
+ except asyncio.TimeoutError:
705
+ return "[git_status: timeout 8s]"
706
  except Exception as exc:
707
+ return f"[git_status: errore β€” {str(exc)[:300]}]"
708
+
709
+ # S419/S734: gather parallelo con Semaphore β€” limita concorrenza su mobile
710
+ # Default 4: max 4 tool simultanei β€” previene saturazione TCP su iPhone Safari.
711
+ # Impatto su goal normali (2-3 tool): ZERO (semaforo mai raggiunto).
712
+ # GAP-P3: configurabile via env TOOL_CONCURRENCY_LIMIT per ambienti server/desktop.
713
+ _TOOL_CONCURRENCY = int(os.getenv('TOOL_CONCURRENCY_LIMIT', '4'))
714
+ _gather_sem = asyncio.Semaphore(_TOOL_CONCURRENCY)
715
+
716
+ async def _sem_wrap(coro):
717
+ async with _gather_sem:
718
+ return await coro
719
+
720
+ # S764: 7->10 tool in gather (Semaphore(4) invariato)
721
+ # P30-B1: analisi statica Python β€” zero exec_engine, <5ms
722
  async def _t_analyze_python() -> str | None:
 
723
  if not self._ANALYZE_PY_RE.search(goal):
724
  return None
725
+ _pm = self._PY_BLOCK_IN_GOAL_RE.search(goal)
726
+ if not _pm:
727
+ return None
728
+ _code = _pm.group(1)
729
+ if not _gov_check("python_analyze", _code[:80]):
730
+ return None
731
  try:
732
  if on_step:
733
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
734
+ "title": "Analisi Python", "explanation": "Analisi statica codice Python (AST)…"}))
735
+ _t0 = asyncio.get_event_loop().time()
736
+ _r = await asyncio.wait_for(
737
+ TOOL_REGISTRY["python_analyze"]["_fn"](code=_code), timeout=5
738
+ )
739
+ try:
740
+ from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
741
+ except Exception: pass
742
+ _out = [f"[ANALISI PYTHON β€” {_r.get('summary', '?')}]"]
743
+ for _e in _r.get("errors", []):
744
+ _out.append(f"ERR {_e['type']} riga {_e['line']}: {_e['message']}" + (f" β†’ {_e['text']}" if _e.get('text') else ""))
745
+ _c = _r.get("complexity", {})
746
+ if _c:
747
+ _out.append(
748
+ f"Struttura: {_c.get('total_lines',0)} righe, "
749
+ f"{_c.get('functions',0)} funzioni, "
750
+ f"{_c.get('classes',0)} classi, nesting max {_c.get('max_nesting',0)}"
751
+ )
752
+ for _s in _r.get("suggestions", []):
753
+ _out.append(f"Suggerimento: {_s}")
754
  return "\n".join(_out)
755
  except asyncio.TimeoutError:
756
  return "[python_analyze: timeout]"
757
  except Exception as _exc:
758
  return f"[python_analyze: errore β€” {str(_exc)[:200]}]"
759
+
760
+ _parallel_results = await asyncio.gather(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  _sem_wrap(_t_get_weather()),
762
  _sem_wrap(_t_read_page()),
763
  _sem_wrap(_t_calculate()),
764
  _sem_wrap(_t_web_search()),
765
+ _sem_wrap(_t_generate_image()),
766
  _sem_wrap(_t_run_python()),
767
  _sem_wrap(_t_web_research()),
768
  _sem_wrap(_t_directory_tree()),
 
775
  for _pr in _parallel_results:
776
  if isinstance(_pr, str):
777
  results.append(_pr)
778
+
779
+ # S428 Sprint1-Fix1: Tool Success Contract β€” conta successi per prefisso positivo.
780
+ # Il vecchio check ": errore β€”"/": timeout" NON catturava "NESSUN_RISULTATO" e
781
+ # "rispondo con dati del training" β†’ contati come successi β†’ _build_messages
782
+ # wrappava come "DATI REALI RECUPERATI" β†’ LLM allucinava training data come reale.
783
+ # Soluzione: whitelist di prefissi che certificano dati REALI verificati.
784
  _REAL_DATA_PREFIXES = (
785
+ "[RICERCA WEB REALE",
786
+ "[METEO",
787
+ "[CALCOLO REALE",
788
+ "[IMMAGINE AI GENERATA",
789
+ "[CODICE PYTHON ESEGUITO",
790
+ "[PAGINA REALE",
791
+ "[DATI REALI",
792
+ "[RICERCA APPROFONDITA",
793
+ "[STRUTTURA PROGETTO", # S764: directory_tree
794
+ "[FILE TROVATI", # S764: file_search
795
+ "[NOTIZIE",
796
+ "[STATO GIT", # S764: git_status
797
+ "[ANALISI PYTHON", # P30-B1: python_analyze
798
  )
799
+ _n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
800
+ _n_errors = len(results) - _n_success
801
+ # Sprint 5 ITEM 13: tool_failure_count β€” mai incrementato prima
802
+ if _n_errors > 0:
803
+ try:
804
+ from api.state import increment_stat as _inc_tf
805
+ _inc_tf("tool_failure_count")
806
+ except Exception as _exc:
807
+ _logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
808
+ # P-HARNESS: traccia fallimenti per-tool; warn se threshold raggiunto
809
+ try:
810
+ from tools.harness_gate import record_failures_from_results as _hg_rec
811
+ from tools.registry import _agent_session_id_var as _hg_sid
812
+ _hg_n = _hg_rec(_hg_sid.get(), results)
813
+ if _hg_n:
814
+ _logger.warning(
815
+ "[harness_gate] %d tool(s) hit failure threshold β€” provider switch recommended",
816
+ _hg_n,
817
+ )
818
+ except Exception as _hg_exc: # noqa: BLE001
819
+ _logger.debug("[unified_loop_tools] harness silenced: %s", _hg_exc)
820
+ return "\n\n".join(results), len(results), _n_success, _n_errors
821
+
822
  # ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
823
+ # Quando tutti i tool hanno fallito, il LLM puΓ² ancora affermare "Ho trovato / Ho recuperato"
824
+ # nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
825
+ # esplicito SOLO se rileva false claim nella risposta β€” non riscrive il testo, lo estende.
826
  _FALSE_CLAIM_RE = re.compile(
827
  r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
828
  r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
 
850
  false_claim_re: "re.Pattern[str]",
851
  realtime_goal_re: "re.Pattern[str]",
852
  ) -> str:
853
+ """S428 Sprint1-Fix3: Claim Validation.
854
+ Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
855
+ contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
856
+ Non riscrive la risposta β€” la estende con una nota visibile all'utente.
857
+ """
858
  if n_success > 0 or n_errors == 0:
859
+ return response # dati reali presenti o nessun tool eseguito β†’ ok
860
  if not realtime_goal_re.search(goal):
861
+ return response # goal non richiede dati live β†’ ok
862
  if not false_claim_re.search(response):
863
+ return response # nessuna false claim β†’ ok
864
+ # Rileva false claim + goal realtime + tutti tool falliti
865
  disclaimer = (
866
  "\n\n---\n"
867
+ "⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
868
  "raggiungibili durante questa risposta. Le informazioni sopra provengono "
869
  "dal mio training e potrebbero non essere aggiornate. "
870
+ "Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
871
+ "o il sito ufficiale della tecnologia."
872
  )
873
  return response + disclaimer
874
+
875
+ # ── _needs_tools (S193) β€” regex ampliata ─────────────────────────────────
876
+
877
+ # S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
878
  _TOOL_NEEDED_RE = re.compile(
879
+ r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
880
+ r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
881
+ r"piove|nevica|neve|temporale|nebbia|umiditΓ |vento|forecast|"
882
+ r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
883
+ r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
884
+ r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
885
+ r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
886
+ r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
887
+ r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
888
+ r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
889
+ r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
890
+ r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
891
+ r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
892
+ r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
893
+ r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
894
+ r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
895
+ r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
896
+ r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
897
+ r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
898
+ # S648: email/PDF keyword
899
+ r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
900
+ r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
901
+ r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf|"
902
+ # S764: git / npm / pip / file-search / directory-tree keywords
903
+ r"git\s+status|git\s+diff|git\s+log|git\s+clone|git\s+commit|"
904
+ r"stato\s+git|branch\s+corrente|file\s+modificati|ultimi\s+commit|"
905
+ r"npm\s+install|npm\s+run|npm\s+test|npm\s+build|pnpm\s+|yarn\s+add|"
906
+ r"pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett|"
907
+ r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
908
+ r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
909
+ r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
910
+ r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|"
911
+ # R9: webhook/call_api keywords β€” mancanti da _TOOL_NEEDED_RE
912
+ r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|"
913
+ r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b",
914
  re.IGNORECASE,
915
  )
916
+
917
  def _needs_tools(self, goal: str) -> bool:
918
+ return bool(self._TOOL_NEEDED_RE.search(goal))
919
+
920
+ # ── S402: Fast Path ───────────────────────────────────────────────────────
921
+ # Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
922
+ # Target: <3s vs 20-60s per il full pipeline.
923
+
924
+ # S427: aggiunti ack comuni IT/EN per fast path piΓΉ ampio
 
 
925
  _SIMPLE_CONV_RE = re.compile(
926
  r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
927
  r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
 
938
  r")\.?\s*[!?]?$",
939
  re.IGNORECASE,
940
  )
941
+
942
+
943
+ # S-FAST-MATH: espressioni aritmetiche semplici β†’ fast-path (Groq 8B, ~150ms)
944
+ # Override del check _needs_tools: "calcola 2+2" non richiede tool di ricerca web.
945
+ # Pattern: prefisso opzionale (calcola/quanto fa) + espressione numerica.
946
  _SIMPLE_MATH_RE = re.compile(
947
  r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
948
  r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
 
950
  r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
951
  re.IGNORECASE,
952
  )
953
+ # P30-B1: trigger analisi statica Python (IT + EN)
954
  _ANALYZE_PY_RE = re.compile(
955
  r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
956
  r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
 
962
  r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
963
  re.IGNORECASE,
964
  )
965
+ # Regex per estrarre blocco python dal goal β€” P30-B1
966
  _PY_BLOCK_IN_GOAL_RE = re.compile(
967
  r"```(?:python|py)\s*\n([\s\S]+?)```",
968
  re.IGNORECASE,
969
  )
970
+
 
971
  def _is_simple_query(self, goal: str) -> bool:
972
+ """S402: True per greeting/ack/identitΓ  semplice (<70 chars, no tool/code intent).
973
+ S-FAST-MATH: aggiunto check math semplice β†’ fast-path, bypassa _needs_tools.
974
+ Attiva il fast path che salta memoria, planner, verifier e self-healing."""
975
  g = goal.strip()
976
  if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
977
  return False
978
+ # S-FAST-MATH: "calcola 2+2", "quanto fa 15*3" β†’ fast-path (Groq 8B, 150ms)
979
+ # Controllo separato da _needs_tools: la matematica pura non richiede tool web.
980
  if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
981
  return True
982
+ # Percorso originale: greeting/ack con limite 70 chars
983
  if len(g) > 70 or self._needs_tools(g):
984
  return False
985
  return bool(self._SIMPLE_CONV_RE.match(g))
agents/unified_loop_types.py CHANGED
@@ -19,61 +19,10 @@ from __future__ import annotations
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.
@@ -209,7 +158,6 @@ class UnifiedLoopState:
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:
 
19
  import asyncio
20
  import re
21
  from dataclasses import dataclass, field
 
22
  from typing import Any, Awaitable, Callable
23
 
24
  StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  def _detect_user_lang(goal: str) -> str:
28
  """P27-B2: rilevamento lingua leggero β€” zero I/O, zero LLM, <1ms.
 
158
  errors: list[str] = field(default_factory=list)
159
  has_files: bool = False # B10: flag separato Ҁ” evita di inquinare il context string
160
  session_id: str = "" # P17-F2: blackboard session key per sync Upstash
 
161
 
162
 
163
  async def _maybe_await(val: Any) -> None:
agents/unified_loop_vfs.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """unified_loop_vfs.py β€” VFSMixin: scritture VFS, git backup, lock per path.
2
+
3
+ Estratto da unified_loop.py per ridurre il file principale.
4
+
5
+ Contiene:
6
+ _rollback_writes(on_step): GAP-3 rollback atomico scritture parziali
7
+ _vfs_git_backup(): GAP-NEW-4 push session_files su branch vfs-backup GitHub
8
+ _get_vfs_lock(path): GAP-VFS per-path asyncio.Lock (lazy init)
9
+
10
+ Invariante B1: nessun corpo duplicato con unified_loop.py.
11
+ MRO Python garantisce self._write_snapshots / self._session_files / self.executor
12
+ siano risolti su UnifiedAgentLoop.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from typing import Any
19
+
20
+ from agents.unified_loop_types import _maybe_await
21
+
22
+ _logger = logging.getLogger("agente_ai")
23
+
24
+
25
+ class VFSMixin:
26
+ async def _rollback_writes(self, on_step=None) -> None:
27
+ """
28
+ GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà.
29
+ Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
30
+ Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
31
+ File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
32
+ """
33
+ if not self._write_snapshots or not self.executor:
34
+ return
35
+ if on_step:
36
+ await _maybe_await(on_step({
37
+ "action": "text_chunk",
38
+ "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
39
+ "status": "streaming",
40
+ }))
41
+ _rolled = 0
42
+ for path, original in self._write_snapshots.items():
43
+ if original is None:
44
+ continue # file non esisteva prima Ҁ” saltiamo (non eliminiamo)
45
+ try:
46
+ await asyncio.wait_for(
47
+ self.executor.run_tool("write_file", {"path": path, "content": original}),
48
+ timeout=10.0,
49
+ )
50
+ _rolled += 1
51
+ except Exception:
52
+ pass # non-fatal Ҁ” best effort rollback
53
+ _total = len(self._write_snapshots) # salva prima del clear
54
+ self._write_snapshots = {}
55
+ _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
56
+
57
+ # ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
58
+ async def _vfs_git_backup(self) -> None:
59
+ """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
60
+
61
+ Fire-and-forget β€” non blocca mai il loop principale, non solleva eccezioni.
62
+ Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo".
63
+ Crea automaticamente il branch vfs-backup se non esiste.
64
+ Force-push consentito su vfs-backup (non Γ¨ main β€” nessun rischio di perdita).
65
+ """
66
+ import os as _os_vfs
67
+ gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip()
68
+ gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip()
69
+ if not gh_token or not gh_repo:
70
+ return
71
+ files = dict(self._session_files) # snapshot immutabile
72
+ if not files:
73
+ return
74
+ run_id = self._run_task_id[:8] or "unknown"
75
+ try:
76
+ import httpx as _hx4
77
+ headers = {
78
+ "Authorization": f"Bearer {gh_token}",
79
+ "Accept": "application/vnd.github+json",
80
+ "User-Agent": "agente-ai-vfs/1.0",
81
+ }
82
+ base = f"https://api.github.com/repos/{gh_repo}"
83
+ async with _hx4.AsyncClient(timeout=20.0) as _cli:
84
+ # 1. Leggi (o crea) branch vfs-backup
85
+ r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers)
86
+ if r_ref.status_code == 404:
87
+ r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers)
88
+ if r_main.status_code != 200:
89
+ return
90
+ r_cr = await _cli.post(f"{base}/git/refs", headers=headers,
91
+ json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]})
92
+ if r_cr.status_code not in (200, 201):
93
+ return
94
+ backup_head = r_main.json()["object"]["sha"]
95
+ elif r_ref.status_code == 200:
96
+ backup_head = r_ref.json()["object"]["sha"]
97
+ else:
98
+ return
99
+
100
+ # 2. Leggi base tree del backup HEAD
101
+ r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers)
102
+ if r_c.status_code != 200:
103
+ return
104
+ base_tree = r_c.json()["tree"]["sha"]
105
+
106
+ # 3. Crea blob per ogni file (max 20 per backup, max 50KB per file)
107
+ tree_items = []
108
+ for _path, _content in list(files.items())[:20]:
109
+ rb = await _cli.post(f"{base}/git/blobs", headers=headers,
110
+ json={"content": str(_content)[:50_000], "encoding": "utf-8"})
111
+ if rb.status_code == 201:
112
+ tree_items.append({
113
+ "path": f"vfs/{_path.lstrip('/')}",
114
+ "mode": "100644",
115
+ "type": "blob",
116
+ "sha": rb.json()["sha"],
117
+ })
118
+
119
+ if not tree_items:
120
+ return
121
+
122
+ # 4. Tree + commit + force-push su vfs-backup
123
+ rt = await _cli.post(f"{base}/git/trees", headers=headers,
124
+ json={"base_tree": base_tree, "tree": tree_items})
125
+ if rt.status_code != 201:
126
+ return
127
+ rc = await _cli.post(f"{base}/git/commits", headers=headers,
128
+ json={
129
+ "message": f"vfs-backup: {len(tree_items)} file (run {run_id})",
130
+ "tree": rt.json()["sha"],
131
+ "parents": [backup_head],
132
+ })
133
+ if rc.status_code != 201:
134
+ return
135
+ # force=True consentito: vfs-backup non Γ¨ main, nessun rischio
136
+ await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers,
137
+ json={"sha": rc.json()["sha"], "force": True})
138
+ _logger.info(
139
+ "GAP-NEW-4: vfs-backup aggiornato β€” %d file, run %s",
140
+ len(tree_items), run_id,
141
+ )
142
+ except Exception as _vfs_err:
143
+ # Silent: il backup non deve MAI bloccare o crashare il loop principale
144
+ _logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80])
145
+
146
+ # ── GAP-VFS: per-path write lock ─────────────────────────────────────────
147
+ def _get_vfs_lock(self, path: str) -> asyncio.Lock:
148
+ """GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS.
149
+ Previene race condition quando subtask paralleli (asyncio.gather)
150
+ scrivono lo stesso file contemporaneamente.
151
+ Lock creato lazy: zero overhead per run che non usano write paralleli."""
152
+ if path not in self._vfs_write_locks:
153
+ self._vfs_write_locks[path] = asyncio.Lock()
154
+ return self._vfs_write_locks[path]
155
+
156
+ # ── BGAP-GUARD: Reflective Debug (no-regression invariante) ────────────────
agents/watchdog.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ watchdog.py Ҁ” S-WATCHDOG: Bidirectional Self-Healing Heartbeat.
3
+ Monitora il loop agentico e interviene in caso di stallo o deviazione dal goal.
4
+ """
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Callable, Awaitable, Optional
9
+
10
+ _logger = logging.getLogger("agente_ai.agents.watchdog")
11
+
12
+ class BidirectionalWatchdog:
13
+ def __init__(self,
14
+ timeout_seconds: float = 45.0,
15
+ on_stale_callback: Optional[Callable[[], Awaitable[None]]] = None):
16
+ self.timeout = timeout_seconds
17
+ self.on_stale = on_stale_callback
18
+ self.last_heartbeat = time.monotonic()
19
+ self._running = False
20
+ self._monitor_task = None
21
+
22
+ def heartbeat(self):
23
+ """Segnala che l'agente Γ¨ ancora attivo e progredisce."""
24
+ self.last_heartbeat = time.monotonic()
25
+ _logger.debug("[Watchdog] Heartbeat ricevuto.")
26
+
27
+ async def start(self):
28
+ """Avvia il monitoraggio in background."""
29
+ if self._running:
30
+ return
31
+ self._running = True
32
+ self.last_heartbeat = time.monotonic()
33
+ self._monitor_task = asyncio.create_task(self._monitor_loop())
34
+ _logger.info(f"[Watchdog] Monitoraggio avviato (timeout: {self.timeout}s)")
35
+
36
+ async def stop(self):
37
+ """Ferma il monitoraggio."""
38
+ self._running = False
39
+ if self._monitor_task:
40
+ self._monitor_task.cancel()
41
+ try:
42
+ await self._monitor_task
43
+ except asyncio.CancelledError:
44
+ pass
45
+ _logger.info("[Watchdog] Monitoraggio fermato.")
46
+
47
+ async def _monitor_loop(self):
48
+ while self._running:
49
+ await asyncio.sleep(5.0)
50
+ elapsed = time.monotonic() - self.last_heartbeat
51
+ if elapsed > self.timeout:
52
+ _logger.warning(f"[Watchdog] Rilevato stallo! Nessun heartbeat da {elapsed:.1f}s.")
53
+ if self.on_stale:
54
+ try:
55
+ await self.on_stale()
56
+ # Resetta il timer dopo l'intervento per evitare interventi a raffica
57
+ self.heartbeat()
58
+ except Exception as e:
59
+ _logger.error(f"[Watchdog] Errore durante l'intervento di self-healing: {e}")
60
+
61
+ async def self_check(self, state_summary: str) -> bool:
62
+ """
63
+ L'agente chiama questo metodo per un controllo esterno di coerenza.
64
+ """
65
+ _logger.info(f"[Watchdog] Eseguo Self-Check dello stato: {state_summary[:100]}...")
66
+ # Implementazione futura: chiamata a un modello critico esterno (Critic Layer)
67
+ return True
agents/workflow_engine.py DELETED
@@ -1,112 +0,0 @@
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/TELEGRAM_MODULES.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/api β€” Moduli Telegram (M2 Split)
2
+
3
+ > **Refactor M2 β€” 2 Luglio 2026**
4
+ > Il monolite `telegram_webhook.py` (2844 righe, 5 hotfix consecutivi) Γ¨ stato
5
+ > spezzato in 6 moduli a responsabilitΓ  singola. Hotfix futuri toccano **un solo file**.
6
+
7
+ ---
8
+
9
+ ## Grafo delle dipendenze
10
+
11
+ ```
12
+ telegram_tg_client.py (stdlib + httpx only)
13
+ β”‚
14
+ β”œβ”€β”€β–Ί telegram_keyboards.py (solo costanti/dict β€” zero import interni)
15
+ β”‚ β”‚
16
+ β”‚ β”œβ”€β”€β–Ί telegram_cmd_monitoring.py
17
+ β”‚ β”‚ └──► (esposto via telegram_webhook.py)
18
+ β”‚ β”‚
19
+ β”‚ β”œβ”€β”€β–Ί telegram_cmd_ai.py
20
+ β”‚ β”‚ └──► (esposto via telegram_webhook.py)
21
+ β”‚ β”‚
22
+ β”‚ └──► telegram_callbacks.py
23
+ β”‚ β”œβ”€β”€ importa _cmd_do, _cmd_autofix ... da telegram_cmd_ai
24
+ β”‚ └── importa _cmd_help, _cmd_status ... da telegram_cmd_monitoring
25
+ β”‚
26
+ └──► telegram_webhook.py (router FastAPI puro β€” importa da tutti)
27
+ └──► montato in backend/main.py come _tg_webhook_router
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Moduli
33
+
34
+ ### `telegram_tg_client.py` β€” 276 righe
35
+ **Ruolo:** Telegram Bot API puro. Layer 0 senza dipendenze interne.
36
+
37
+ | Simbolo | Descrizione |
38
+ |---------|-------------|
39
+ | `_get_bot_token()` | Legge `TELEGRAM_BOT_TOKEN` dall'env |
40
+ | `_log_tg_exc(task)` | Log eccezioni fire-and-forget (Gap-2.6) |
41
+ | `_fmt_elapsed(sec)` | Formatta durata in human-readable |
42
+ | `_tg_reply(chat_id, text)` | Invia messaggio con parse_mode=HTML |
43
+ | `_tg_send(chat_id, text)` | Come reply, ritorna `message_id` |
44
+ | `_tg_edit(chat_id, msg_id, text)` | Modifica messaggio esistente |
45
+ | `_tg_photo(chat_id, url, caption)` | Invia foto via URL |
46
+ | `_tg_typing(chat_id)` | Invia `sendChatAction typing` |
47
+ | `_tg_react(chat_id, msg_id, emoji)` | Imposta reaction emoji |
48
+ | `_tg_answer_callback(callback_id)` | Risponde a `callback_query` (≀3s) |
49
+
50
+ **Import:** `asyncio, html, logging, os, time, httpx`
51
+
52
+ ---
53
+
54
+ ### `telegram_keyboards.py` β€” 109 righe
55
+ **Ruolo:** Costanti UI e keyboards. Zero logica, zero import interni.
56
+
57
+ | Simbolo | Tipo | Descrizione |
58
+ |---------|------|-------------|
59
+ | `_QUICK_PICK_KB` | `dict` | Inline keyboard selezione task rapida |
60
+ | `_MAIN_KB` | `dict` | Reply keyboard principale |
61
+ | `_BENCH_ACTION_KB` | `dict` | Keyboard post-benchmark |
62
+ | `_TASK_MENU_KB` | `dict` | Sub-menu task |
63
+ | `_STATUS_MENU_KB` | `dict` | Sub-menu stato sistema |
64
+ | `_PERF_MENU_KB` | `dict` | Sub-menu performance |
65
+ | `_HEALTH_MENU_KB` | `dict` | Sub-menu health |
66
+ | `_DEV_MENU_KB` | `dict` | Sub-menu developer |
67
+ | `_LAST_GOAL` | `dict[int,str]` | Memoria per bottone πŸ” Rifai |
68
+ | `_BENCH_CACHE` | `dict[int,dict]` | Ultimo run benchmark per chat |
69
+ | `_after_task_kb(chat_id)` | func | Keyboard dinamica post-task |
70
+
71
+ **Import:** solo `from __future__ import annotations`
72
+
73
+ ---
74
+
75
+ ### `telegram_cmd_monitoring.py` β€” 456 righe
76
+ **Ruolo:** Comandi di monitoraggio, stato e diagnostica.
77
+
78
+ | Comando / Funzione | Trigger Telegram |
79
+ |--------------------|-----------------|
80
+ | `_cmd_help(chat_id)` | `/start` `/help` `tgw_help` |
81
+ | `_cmd_logs(chat_id)` | `/logs` `tgw_logs` |
82
+ | `_cmd_status(chat_id)` | `/stato` `tgw_status` |
83
+ | `_cmd_commit_summary(chat_id)` | `/commit` `tgw_commits` |
84
+ | `_cmd_check(chat_id)` | `/salute` `tgw_health` |
85
+ | `_cmd_tasks(chat_id)` | `/attivitΓ ` `tgw_tasks` |
86
+ | `_cmd_git(chat_id, args)` | `/git` `tgw_git` |
87
+ | `_cmd_coord(chat_id)` | `/coord` `tgw_coord` |
88
+ | `_cmd_scan_now(chat_id)` | `/scan` `tgw_scan` |
89
+ | `_cmd_telemetry(chat_id)` | `/telemetria` `tgw_telemetry` |
90
+
91
+ **Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
92
+
93
+ ---
94
+
95
+ ### `telegram_cmd_ai.py` β€” 1152 righe
96
+ **Ruolo:** Comandi AI, LLM e operativi. Modulo piΓΉ pesante β€” contiene lo streaming loop.
97
+
98
+ | Comando / Funzione | Trigger Telegram |
99
+ |--------------------|-----------------|
100
+ | `_cmd_do(chat_id, goal)` | `/avvia` β€” lancia task AI con streaming |
101
+ | `_cmd_autofix(chat_id)` | `/fix` `tgw_autofix` |
102
+ | `_cmd_nota(chat_id, text)` | `/nota` `tgw_nota` |
103
+ | `_cmd_cerca(chat_id, query)` | `/cerca` `tgw_cerca` |
104
+ | `_cmd_meteo(chat_id, city)` | `/meteo` `tgw_meteo` |
105
+ | `_cmd_riepilogo(chat_id)` | `/riepilogo` `tgw_briefing` |
106
+ | `_cmd_score(chat_id)` | `/score` `tgw_score` |
107
+ | `_cmd_bench(chat_id)` | `/bench` `tgw_bench` |
108
+ | `_cmd_improve(chat_id, target)` | `/migliora` `tgw_improve` |
109
+
110
+ **Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
111
+
112
+ ---
113
+
114
+ ### `telegram_callbacks.py` β€” 353 righe
115
+ **Ruolo:** Smista `callback_query` e `inline_query` in arrivo da Telegram.
116
+
117
+ | Funzione | Descrizione |
118
+ |----------|-------------|
119
+ | `_handle_inline(iq, token)` | Risponde alle inline query (`@ARJagent_ap_bot testo`) |
120
+ | `_handle_callback(cb, token)` | Dispatch per tutti i `callback_data` `tgw_*` / `agent` / `qp_*` |
121
+
122
+ **Import:** `telegram_tg_client` + `telegram_keyboards` + `telegram_cmd_ai` + `telegram_cmd_monitoring`
123
+
124
+ > ⚠️ **Questo modulo crea dipendenze circolari potenziali** β€” non importare
125
+ > `telegram_callbacks` da `telegram_cmd_ai` o `telegram_cmd_monitoring`.
126
+
127
+ ---
128
+
129
+ ### `telegram_webhook.py` β€” 605 righe *(era 2844)*
130
+ **Ruolo:** Router FastAPI puro. Solo endpoint HTTP, zero logica di business.
131
+
132
+ | Endpoint | Metodo | Descrizione |
133
+ |----------|--------|-------------|
134
+ | `/api/telegram/webhook` | POST | Ricezione update getUpdates (polling daemon) |
135
+ | `/api/telegram/process` | POST | Endpoint alternativo per CF Pages proxy |
136
+ | `/api/telegram/config/invalidate` | POST | Invalida cache config bot |
137
+
138
+ **Import:** FastAPI + tutti i 5 moduli sopra
139
+ **Montato in:** `backend/main.py` β†’ `app.include_router(_tg_webhook_router)`
140
+
141
+ ---
142
+
143
+ ## Regole per gli hotfix
144
+
145
+ | Vuoi cambiare... | Tocca solo... |
146
+ |------------------|---------------|
147
+ | Aspetto di un bottone / label | `telegram_keyboards.py` |
148
+ | Risposta a un comando /help, /stato… | `telegram_cmd_monitoring.py` |
149
+ | Logica task AI / streaming / bench | `telegram_cmd_ai.py` |
150
+ | Routing dei bottoni inline (tgw_*) | `telegram_callbacks.py` |
151
+ | Helpers HTTP verso api.telegram.org | `telegram_tg_client.py` |
152
+ | Routing endpoint FastAPI | `telegram_webhook.py` |
153
+
154
+ ---
155
+
156
+ ## Daemon Node.js (scripts/lib/daemon-callbacks.mjs)
157
+
158
+ Il daemon usa **getUpdates polling** (non webhook registrato).
159
+ Riceve gli update, li smista ai command handler Python via HTTP interno.
160
+ Il flusso `callback_data tgw_*` viene elaborato da `telegram_callbacks.py::_handle_callback`.
161
+
162
+ ```
163
+ Telegram ──► getUpdates daemon ──► POST /api/telegram/process
164
+ β”‚
165
+ telegram_webhook.py (router)
166
+ β”‚
167
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
168
+ message? callback_query?
169
+ β”‚ β”‚
170
+ telegram_cmd_*.py telegram_callbacks.py
171
+ ::_handle_callback
172
+ ```
api/_agent_helpers.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/_agent_helpers.py β€” Funzioni helper condivise tra i sub-router agent.
2
+
3
+ Estratto da agent_loop_routes / agent_task_routes / agent_checkpoint_routes
4
+ per eliminare le triplicazioni create dallo split S359 (2026-06-30).
5
+
6
+ Esportazioni:
7
+ _RE_SURROGATES β€” regex surrogati UTF-16
8
+ _ss(s) β€” sanitizza string (rimuove surrogati)
9
+ _log_task_exc(task) β€” done-callback asyncio con log eccezioni
10
+ _PERSONA_KEYWORD_MAP β€” dict globale (regex per persona routing)
11
+ _PERSONA_CLIENT_CACHE β€” dict globale (cache LLM client per persona)
12
+ _build_persona_kw_map β€” costruisce _PERSONA_KEYWORD_MAP (P17-F5)
13
+ _classify_persona_server β€” classifica persona via regex scoring (zero LLM)
14
+ _get_persona_llm_client β€” ritorna LLM client persona-appropriato
15
+ """
16
+ from __future__ import annotations
17
+ import re
18
+ import logging
19
+ from fastapi import APIRouter
20
+
21
+ _logger = logging.getLogger("api.agent")
22
+
23
+ _RE_SURROGATES = re.compile(r"[\uD800-\uDFFF]", re.UNICODE)
24
+ def _ss(s: object) -> str:
25
+ if not isinstance(s, str):
26
+ return s
27
+ try:
28
+ cleaned = _RE_SURROGATES.sub("", s)
29
+ cleaned = cleaned.encode("utf-8", errors="replace").decode("utf-8", errors="replace")
30
+ except Exception:
31
+ cleaned = s
32
+ return cleaned
33
+ def _log_task_exc(task):
34
+ if not task.cancelled():
35
+ exc = task.exception()
36
+ if exc:
37
+ _logger.warning("[agent] background task raised %s: %s", type(exc).__name__, exc)
38
+ try:
39
+ from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
40
+ except Exception:
41
+ async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
42
+ async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
43
+ async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
44
+ async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
45
+
46
+ router = APIRouter()
47
+
48
+ @router.post('/run_loop', deprecated=True)
49
+ async def run_loop():
50
+ """Deprecated β€” use /agent/task instead."""
51
+ from fastapi.responses import JSONResponse
52
+ return JSONResponse(status_code=410, content={"detail": {"error": "Gone", "migration": "/api/agent/tasks"}})
53
+
54
+
55
+ # ─── P17-F5: Persona helpers ──────────────────────────────────────────────────
56
+ import re as _re_persona
57
+
58
+ _PERSONA_KEYWORD_MAP: dict = {}
59
+
60
+ def _build_persona_kw_map() -> dict:
61
+ import re
62
+ return {
63
+ 'researcher': re.compile(
64
+ r'\b(cerca|ricerca|research|trova|notizie|news|url|leggi|articolo|wikipedia|'
65
+ r'google|fonte|source|scrape|fetch|sito|pagina|web|http|verifica|fact.?check)\b',
66
+ re.IGNORECASE
67
+ ),
68
+ 'coder': re.compile(
69
+ r'\b(codice|code|funzione|function|bug|script|implementa|python|javascript|'
70
+ r'typescript|refactor|debug|test|classe|class|api|endpoint|sql|database|html|'
71
+ r'css|react|app|applicazione|programma|sviluppa)\b',
72
+ re.IGNORECASE
73
+ ),
74
+ 'reasoner': re.compile(
75
+ r'\b(analizza|pianifica|strategia|decide|ragiona|valuta|confronta|'
76
+ r'piano|roadmap|architettura|valutazione|decisione|ottimale|consiglia)\b',
77
+ re.IGNORECASE
78
+ ),
79
+ 'analyst': re.compile(
80
+ r'\b(dati|statistiche|grafico|dataset|csv|dataframe|pandas|matplotlib|'
81
+ r'metriche|kpi|trend|visualizza|dashboard|excel|tabella|percentuale|distribuzione)\b',
82
+ re.IGNORECASE
83
+ ),
84
+ }
85
+
86
+ def _classify_persona_server(goal: str) -> str:
87
+ """P17-F5: classifica la persona dal goal via regex scoring. Zero LLM β€” zero latency."""
88
+ global _PERSONA_KEYWORD_MAP
89
+ if not _PERSONA_KEYWORD_MAP:
90
+ _PERSONA_KEYWORD_MAP = _build_persona_kw_map()
91
+ if not goal or len(goal) < 4:
92
+ return ''
93
+ best, best_score = '', 0
94
+ for persona_id, pattern in _PERSONA_KEYWORD_MAP.items():
95
+ score = len(pattern.findall(goal))
96
+ if score > best_score:
97
+ best_score, best = score, persona_id
98
+ return best if best_score >= 1 else ''
99
+
100
+ _PERSONA_CLIENT_CACHE: dict = {}
101
+
102
+ def _get_persona_llm_client(persona: str, default_client: object) -> object:
103
+ """P17-F5: ritorna il client LLM persona-appropriate via role_router.
104
+ Fallback silente su default_client se la chiave API manca o role_router fallisce.
105
+ Cache in-process β€” zero overhead dopo il primo accesso.""";
106
+ if not persona:
107
+ return default_client
108
+ if persona in _PERSONA_CLIENT_CACHE:
109
+ return _PERSONA_CLIENT_CACHE[persona]
110
+ _ROLE_MAP = {'researcher': 'RESEARCHER', 'analyst': 'RESEARCHER',
111
+ 'coder': 'CODER', 'reasoner': 'REASONER', 'architect': 'ARCHITECT'}
112
+ role_name = _ROLE_MAP.get(persona.lower())
113
+ if not role_name:
114
+ return default_client
115
+ try:
116
+ from models.role_router import RoleRouter, Role as _Role
117
+ role = getattr(_Role, role_name, None)
118
+ if role is None:
119
+ return default_client
120
+ client = RoleRouter.get_client(role)
121
+ _PERSONA_CLIENT_CACHE[persona] = client
122
+ return client
123
+ except Exception:
124
+ return default_client
125
+
126
+
127
+
api/admin_state.py DELETED
@@ -1,75 +0,0 @@
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/advanced_complex_benchmark.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/advanced_complex_benchmark.py β€” Stress test per task complessi e concorrenza.
3
+ """
4
+ import asyncio
5
+ import time
6
+ import random
7
+ import statistics
8
+ from .execution_fabric import ExecutionFabric, DispatchRequest, ProviderKind, ProviderSpec, AlwaysOn, ProviderHealth
9
+ from .token_rotator import rotator
10
+
11
+ class AdvancedBenchmark:
12
+ def __init__(self):
13
+ self.fabric = ExecutionFabric()
14
+ self.metrics = {
15
+ "complex_reasoning": [],
16
+ "data_heavy_sync": [],
17
+ "multi_provider_chain": [],
18
+ "failures": 0,
19
+ "successes": 0
20
+ }
21
+
22
+ async def setup(self):
23
+ # Configurazione flotta
24
+ for char in ['a', 'b', 'c', 'd']:
25
+ self.fabric._specs[f"sb-{char}"] = ProviderSpec(
26
+ provider_id=f"sb-{char}", name=f"Supabase {char.upper()}", kind=ProviderKind.LOCAL,
27
+ capabilities=["memory"], always_on=AlwaysOn.YES
28
+ )
29
+ self.fabric._states[f"sb-{char}"] = type('State', (), {"health": ProviderHealth.OK})()
30
+
31
+ self.fabric._specs["oracle-core"] = ProviderSpec(
32
+ provider_id="oracle-core", name="Oracle Core", kind=ProviderKind.ORACLE,
33
+ capabilities=["reasoning", "sandbox"], always_on=AlwaysOn.YES, timeout=60.0
34
+ )
35
+ self.fabric._states["oracle-core"] = type('State', (), {"health": ProviderHealth.OK})()
36
+
37
+ self.fabric._specs["railway-core"] = ProviderSpec(
38
+ provider_id="railway-core", name="Railway Space E", kind=ProviderKind.RAILWAY,
39
+ capabilities=["reasoning", "sandbox"], always_on=AlwaysOn.YES
40
+ )
41
+ self.fabric._states["railway-core"] = type('State', (), {"health": ProviderHealth.OK})()
42
+
43
+ self.fabric._initialized = True
44
+
45
+ async def _simulate_call(self, spec, req):
46
+ # Simula complessitΓ  variabile
47
+ if "reasoning" in spec.capabilities:
48
+ await asyncio.sleep(random.uniform(0.5, 2.0)) # Calcolo pesante
49
+ if random.random() < 0.05: # 5% probabilitΓ  di errore casuale
50
+ raise Exception("Random Provider Glitch")
51
+ if spec.provider_id == "sb-a" and random.random() < 0.3:
52
+ return {"status_code": 402} # 30% probabilitΓ  rate limit su A
53
+ return {"status": "ok", "provider": spec.name}
54
+
55
+ async def run_complex_task(self, name, capability, count=20):
56
+ print(f"βš™οΈ Esecuzione: {name} ({count} task)...")
57
+ tasks = []
58
+ for _ in range(count):
59
+ req = DispatchRequest(capability=capability, payload={"complexity": "high"})
60
+ tasks.append(self.fabric.dispatch(req))
61
+
62
+ t0 = time.time()
63
+ results = await asyncio.gather(*tasks)
64
+ duration = (time.time() - t0) * 1000
65
+
66
+ latencies = []
67
+ for r in results:
68
+ if r.status == "executed":
69
+ self.metrics["successes"] += 1
70
+ latencies.append(r.latency_ms)
71
+ else:
72
+ self.metrics["failures"] += 1
73
+
74
+ self.metrics[name] = latencies
75
+
76
+ def print_stats(self):
77
+ print("\n" + "="*50)
78
+ print("πŸ“Š REPORT AVANZATO PRESTAZIONI SISTEMA")
79
+ print("="*50)
80
+
81
+ total = self.metrics["successes"] + self.metrics["failures"]
82
+ print(f"Success Rate Totale: {(self.metrics['successes']/total)*100:.2f}% ({self.metrics['successes']}/{total})")
83
+
84
+ for name in ["complex_reasoning", "data_heavy_sync"]:
85
+ data = self.metrics.get(name, [])
86
+ if data:
87
+ print(f"\n[{name.upper()}]")
88
+ print(f" - Media Latenza: {statistics.mean(data):.2f}ms")
89
+ print(f" - P95: {statistics.quantiles(data, n=20)[18]:.2f}ms")
90
+ print(f" - P99: {max(data):.2f}ms")
91
+ print(f" - Efficienza: {len(data)} task completati con successo")
92
+
93
+ print("\n" + "="*50)
94
+
95
+ async def run(self):
96
+ await self.setup()
97
+ self.fabric._call_provider = self._simulate_call
98
+
99
+ # Scenario 1: Ragionamento Complesso (Oracle/Railway)
100
+ await self.run_complex_task("complex_reasoning", "reasoning", count=30)
101
+
102
+ # Scenario 2: Sincronizzazione Dati (Supabase A-D)
103
+ await self.run_complex_task("data_heavy_sync", "memory", count=50)
104
+
105
+ self.print_stats()
106
+
107
+ if __name__ == "__main__":
108
+ asyncio.run(AdvancedBenchmark().run())
api/agent.py CHANGED
@@ -42,30 +42,16 @@ def _sanitize_for_json(obj: object) -> object:
42
  from fastapi import APIRouter, Depends, HTTPException, Request, Body
43
  from fastapi.responses import StreamingResponse
44
  from .auth_guard import require_role, AuthRole
45
- from pydantic import BaseModel, TypeAdapter, ValidationError, field_validator
46
  from typing import Literal
47
-
48
- # P0 JSON contract: expose structured JSON only after schema validation.
49
- _STRUCTURED_JSON_ADAPTER = TypeAdapter(dict[str, object] | list[object])
50
-
51
- def _validated_response_json(output: object) -> dict[str, object] | list[object] | None:
52
- if not isinstance(output, str) or not output.strip():
53
- return None
54
- try:
55
- parsed = json.loads(output)
56
- return _STRUCTURED_JSON_ADAPTER.validate_python(parsed)
57
- except (json.JSONDecodeError, ValidationError, TypeError, ValueError):
58
- return None
59
-
60
  from .state import (
61
- _agent_tasks, _task_ai_clients, _task_checkpoints, _loop_registry, _run_stream_tasks,
62
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
63
  _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
64
  ReasonLoopIn, AgentTaskIn,
 
65
  )
66
  from .speculative import fire_speculative_tools
67
- from .vfs_sync import build_vfs_sync_complete
68
- from .task_tool_policy import build_task_tool_policy
69
  try:
70
  from .quality_guardian import run_quality_check as _run_quality_check
71
  except Exception:
@@ -102,98 +88,6 @@ except Exception:
102
  router = APIRouter()
103
 
104
 
105
- class _LLMAdmission:
106
- """Bounded in-process admission control for LLM loops."""
107
- def __init__(self) -> None:
108
- self.max_concurrency = max(1, int(os.getenv('LLM_MAX_CONCURRENCY', '2')))
109
- self.max_queue = max(0, int(os.getenv('LLM_MAX_QUEUE', '8')))
110
- self._condition = asyncio.Condition()
111
- self._active = 0
112
- self._queued = 0
113
-
114
- async def reserve(self) -> bool:
115
- async with self._condition:
116
- if self._active + self._queued >= self.max_concurrency + self.max_queue:
117
- return False
118
- self._queued += 1
119
- return True
120
-
121
- async def acquire(self) -> None:
122
- async with self._condition:
123
- try:
124
- while self._active >= self.max_concurrency:
125
- await self._condition.wait()
126
- self._queued = max(0, self._queued - 1)
127
- self._active += 1
128
- except BaseException:
129
- self._queued = max(0, self._queued - 1)
130
- self._condition.notify(1)
131
- raise
132
-
133
- async def cancel(self) -> None:
134
- async with self._condition:
135
- self._queued = max(0, self._queued - 1)
136
- self._condition.notify(1)
137
-
138
- async def release(self) -> None:
139
- async with self._condition:
140
- self._active = max(0, self._active - 1)
141
- self._condition.notify(1)
142
-
143
- async def snapshot(self) -> dict[str, int]:
144
- async with self._condition:
145
- return {
146
- 'active': self._active,
147
- 'queued': self._queued,
148
- 'max_concurrency': self.max_concurrency,
149
- 'max_queue': self.max_queue,
150
- }
151
-
152
-
153
- _LLM_ADMISSION = _LLMAdmission()
154
-
155
-
156
- async def _record_phase(phase: str, duration_ms: float = 0.0,
157
- outcome: str = 'ok', error_class: str | None = None) -> None:
158
- try:
159
- from .agent_telemetry import record_runtime_phase
160
- await record_runtime_phase(phase, duration_ms, outcome, error_class)
161
- except Exception:
162
- pass
163
-
164
-
165
- def _queue_full_error() -> HTTPException:
166
- return HTTPException(
167
- status_code=429,
168
- detail={
169
- 'error': 'llm_queue_full',
170
- 'message': 'Coda LLM satura. Riprova tra pochi secondi.',
171
- 'retry_after_seconds': 5,
172
- },
173
- headers={'Retry-After': '5'},
174
- )
175
-
176
-
177
- @router.get('/api/agent/queue-status')
178
- async def agent_queue_status(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
179
- return await _LLM_ADMISSION.snapshot()
180
-
181
-
182
- def _attach_byok_client(task_id: str, credentials: object) -> None:
183
- """Create a task-scoped LLM client without persisting or logging credentials."""
184
- if credentials is None:
185
- return
186
- try:
187
- runtime_config = credentials.as_runtime_config()
188
- if not runtime_config:
189
- return
190
- from models.ai_client import AIClient
191
- _task_ai_clients[task_id] = AIClient(byok_credentials=runtime_config)
192
- except Exception as exc:
193
- # Never include the payload or credential values in diagnostics.
194
- _logger.warning("[agent] unable to initialise BYOK task client: %s", type(exc).__name__)
195
-
196
-
197
  # ── Deprecated run_loop ───────────────────────────────────────────���────────────
198
 
199
  @router.post('/run_loop', deprecated=True)
@@ -294,17 +188,10 @@ async def agent_run_stream(
294
  body: ReasonLoopIn, request: Request,
295
  role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
296
  ):
297
- await _record_phase('auth')
298
- if not await _LLM_ADMISSION.reserve():
299
- await _record_phase('queue', outcome='rejected', error_class='queue_full')
300
- raise _queue_full_error()
301
- await _record_phase('queue')
302
  async def generate():
303
  queue: asyncio.Queue = asyncio.Queue()
304
 
305
  async def step_cb(step: dict) -> None:
306
- if str(step.get('action', '')).startswith(('tool', 'executor:')):
307
- await _record_phase('tool', outcome='ok')
308
  await queue.put(step)
309
 
310
  async def run_loop() -> None:
@@ -344,19 +231,16 @@ async def agent_run_stream(
344
  _neg_c = getattr(body, 'negative_constraints', '') or ''
345
  if _neg_c:
346
  context_str = f"[VINCOLI OPERATIVI APPRESI β€” NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
347
- _provider_started = time.perf_counter()
348
  result = await loop.run(
349
  goal=body.goal, context=context_str,
350
  max_steps=body.max_steps, on_step=step_cb,
351
  session_id=getattr(body, "session_id", "") or "",
352
  )
353
- await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
354
  await queue.put({
355
  '__done__': True,
356
  'result': result.get('output', ''),
357
  'engine': result.get('engine', 'fallback'),
358
  'success': result.get('success', False),
359
- 'response_json': _validated_response_json(result.get('output', '')),
360
  })
361
  except Exception as exc:
362
  # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
@@ -370,13 +254,7 @@ async def agent_run_stream(
370
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
371
  await queue.put({'__error__': str(exc)})
372
 
373
- async def _admitted_run_loop():
374
- await _LLM_ADMISSION.acquire()
375
- try:
376
- await run_loop()
377
- finally:
378
- await _LLM_ADMISSION.release()
379
- task = asyncio.create_task(_admitted_run_loop())
380
  task.add_done_callback(_log_task_exc) # BUG-CB-1
381
  task_id = str(uuid.uuid4())
382
  # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
@@ -439,13 +317,7 @@ async def agent_run_stream(
439
  _err_detail = _ss(item.get('error', ''))
440
  _final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
441
  else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
442
- _done_payload = {
443
- 'type': 'task_done', 'taskId': task_id, 'result': _final_res,
444
- 'engine': item.get('engine', 'fallback'), 'success': item.get('success', False),
445
- }
446
- if item.get('response_json') is not None:
447
- _done_payload['response_json'] = _sanitize_for_json(item['response_json'])
448
- yield f"data: {json.dumps(_done_payload)}\n\n"
449
  break
450
  # S393 Priority 1: Narrative Streaming β€” arricchisce step_done con explanation
451
  _NARR_QUICK = {
@@ -568,19 +440,7 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
568
  'action': step_data.get('action', ''),
569
  'output': str(step_data.get('output', ''))[:400], # S577: 200β†’400
570
  })
571
- if not await _LLM_ADMISSION.reserve():
572
- raise _queue_full_error()
573
- await _LLM_ADMISSION.acquire()
574
- _provider_started = time.perf_counter()
575
- try:
576
- result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
577
- except Exception as exc:
578
- await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000, 'error', type(exc).__name__)
579
- raise
580
- else:
581
- await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
582
- finally:
583
- await _LLM_ADMISSION.release()
584
  if isinstance(result, dict):
585
  output_text = result.get('output', '') or ''
586
  engine_used = result.get('engine', 'unknown')
@@ -589,7 +449,6 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
589
  output_text = str(result)
590
  engine_used = 'unknown'
591
  errors_list = []
592
- response_json = _validated_response_json(output_text)
593
  return {
594
  'ok': bool(output_text and output_text.strip()),
595
  'success': bool(output_text and output_text.strip()), # alias compat frontend
@@ -599,7 +458,6 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
599
  'engine': engine_used,
600
  'errors': errors_list,
601
  'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
602
- **({'response_json': response_json} if response_json is not None else {}),
603
  }
604
  except Exception as e:
605
  _logger.error("[reason/loop] Error: %s", e)
@@ -665,12 +523,12 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
665
  'goal': goal,
666
  'mode': mode,
667
  'dispatch_id': _dispatch_id,
668
- 'metadata': {'workflow': 'agent-kernel.yml'},
669
  },
670
  priority='HIGH',
 
671
  )).add_done_callback(_log_task_exc)
672
  asyncio.create_task(_kernel.publish_event(
673
- topic='agent.kernel.dispatched',
674
  payload={'goal': goal[:200], 'mode': mode},
675
  )).add_done_callback(_log_task_exc)
676
  import httpx as _httpx
@@ -694,44 +552,8 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
694
 
695
  # ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
696
 
697
- async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
698
- """
699
- Versione interna di create_agent_task per uso da job_queue (GAP-1-fix).
700
- Non richiede FastAPI body nΓ© dipendenze auth β€” chiamabile direttamente.
701
- """
702
- _prune_agent_tasks()
703
- if task_id in _agent_tasks:
704
- return {"taskId": task_id, "status": _agent_tasks[task_id]["status"]}
705
- created_at = int(time.time() * 1000)
706
- _agent_tasks[task_id] = {
707
- "id": task_id,
708
- "status": "QUEUED",
709
- "goal": goal,
710
- "context": job.get("context", {}),
711
- "max_steps": job.get("max_steps", 20),
712
- "created_at": created_at,
713
- "session_id": job.get("session_id", ""),
714
- }
715
- asyncio.create_task(
716
- sb_upsert_task(task_id, goal, "QUEUED", job.get("max_steps", 20), job.get("context", {}), created_at)
717
- ).add_done_callback(_log_task_exc)
718
- if _KERNEL_AVAILABLE and _kernel is not None:
719
- asyncio.create_task(_kernel.submit_task(
720
- payload={
721
- "task_id": task_id,
722
- "goal": goal,
723
- "max_steps": job.get("max_steps", 20),
724
- "source": "job_queue",
725
- "metadata": {"job_queue": True},
726
- },
727
- priority="NORMAL",
728
- session_id=job.get("session_id", ""),
729
- )).add_done_callback(_log_task_exc)
730
- return {"taskId": task_id, "status": "QUEUED"}
731
-
732
-
733
  @router.post('/api/agent/tasks')
734
- async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
735
  """
736
  Crea o recupera un task agent.
737
 
@@ -739,29 +561,11 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
739
  il task viene ripristinato dallo store persistente invece di essere riavviato.
740
  Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
741
  """
742
- await _record_phase('auth')
743
  _prune_agent_tasks()
744
- raw_idempotency_key = (body.idempotency_key or request.headers.get('Idempotency-Key') or '').strip()
745
- if len(raw_idempotency_key) > 200:
746
- raise HTTPException(400, detail={'error': 'invalid_idempotency_key'})
747
- task_id = body.taskId or (
748
- str(uuid.uuid5(uuid.NAMESPACE_URL, f'baida98-ai:{body.session_id or ""}:{raw_idempotency_key}'))
749
- if raw_idempotency_key else str(uuid.uuid4())
750
- )
751
- _idempotency_claimed = False
752
- if raw_idempotency_key and task_id not in _agent_tasks:
753
- # Claim before the first await: concurrent retries in this worker see the
754
- # same task immediately and cannot create a second provider loop/write.
755
- _agent_tasks[task_id] = {
756
- 'id': task_id, 'status': 'CREATING',
757
- 'idempotency_key': raw_idempotency_key,
758
- }
759
- _idempotency_claimed = True
760
 
761
  # Already in memory β†’ return immediately (normal path, includes S358 reconnect)
762
- if task_id in _agent_tasks and not _idempotency_claimed:
763
- if task_id not in _task_ai_clients:
764
- _attach_byok_client(task_id, body.provider_credentials)
765
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
766
 
767
  # S359: try Supabase lazy restore (only hit network after backend restart)
@@ -771,12 +575,9 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
771
  # Use context from the incoming request (not persisted to save space).
772
  restored['context'] = body.context
773
  _agent_tasks[task_id] = restored
774
- _attach_byok_client(task_id, body.provider_credentials)
775
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
776
 
777
- # Brand new task. La policy Γ¨ calcolata al confine HTTP, prima di ogni
778
- # tool speculativo, pianificazione o chiamata al loop.
779
- _tool_policy = build_task_tool_policy(body.goal)
780
  created_at = int(time.time() * 1000)
781
  _agent_tasks[task_id] = {
782
  'id': task_id,
@@ -790,14 +591,10 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
790
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
791
  'persona': body.persona, # P17-F5: expertise persona hint
792
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ†’'')
793
- 'idempotency_key': raw_idempotency_key,
794
- 'forbid_tools': _tool_policy.forbid_tools,
795
- 'literal_response': _tool_policy.literal_response,
796
- 'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
797
  }
798
- # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
799
- # e non raggiungono Supabase, checkpoint o buffer SSE.
800
- _attach_byok_client(task_id, body.provider_credentials)
801
  # BG-4: restore cross-session handoff context (async, non-blocking)
802
  if body.session_id:
803
  _hctx = await sb_restore_handoff_context(body.session_id)
@@ -808,11 +605,9 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
808
  asyncio.create_task(
809
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
810
  ).add_done_callback(_log_task_exc)
811
- await _record_phase('persistence')
812
- # S361: gli strumenti speculativi sono consentiti solo quando il messaggio
813
- # utente non li vieta esplicitamente. La policy Γ¨ fail-closed per questo task.
814
- if not _tool_policy.forbid_tools:
815
- asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
816
  # ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
817
  if _KERNEL_AVAILABLE and _kernel is not None:
818
  asyncio.create_task(_kernel.submit_task(
@@ -822,13 +617,13 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
822
  'max_steps': body.max_steps,
823
  'persona': body.persona,
824
  'source': 'agent_api',
825
- 'metadata': {'agent_api': True},
826
  },
827
  priority='NORMAL',
828
  session_id=body.session_id,
 
829
  )).add_done_callback(_log_task_exc)
830
  asyncio.create_task(_kernel.publish_event(
831
- topic='task.created',
832
  payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
833
  )).add_done_callback(_log_task_exc)
834
  return {'taskId': task_id, 'status': 'QUEUED'}
@@ -908,7 +703,6 @@ async def list_agent_tasks(limit: int = 50, status: str = '', role: AuthRole = D
908
  async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
909
  if task_id in _agent_tasks:
910
  _agent_tasks[task_id]['status'] = 'CANCELLED'
911
- _task_ai_clients.pop(task_id, None)
912
  reg = _loop_registry.get(task_id)
913
  if reg and not reg.get('done'):
914
  at = reg.get('asyncio_task')
@@ -959,7 +753,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
959
  - Task era RUNNING β†’ replay buffer parziale + evento task_interrupted.
960
  - Task non trovato β†’ prova sb_restore_task prima di 404.
961
  """
962
- await _record_phase('auth')
963
  # S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
964
  if task_id not in _agent_tasks:
965
  restored = await sb_restore_task(task_id)
@@ -970,17 +763,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
970
  raise HTTPException(404, detail=f'Task {task_id} non trovato')
971
 
972
  task = _agent_tasks[task_id]
973
- # I task restaurati da persistenza potrebbero non contenere metadata runtime.
974
- # Ricostruire la policy dal goal mantiene il resume fail-closed.
975
- if (
976
- "forbid_tools" not in task
977
- or "literal_response" not in task
978
- or "allow_local_csv_conversion" not in task
979
- ):
980
- _restored_policy = build_task_tool_policy(task.get("goal", ""))
981
- task["forbid_tools"] = _restored_policy.forbid_tools
982
- task["literal_response"] = _restored_policy.literal_response
983
- task["allow_local_csv_conversion"] = _restored_policy.allow_local_csv_conversion
984
  _last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
985
  _resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
986
 
@@ -989,19 +771,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
989
  async def generate():
990
  yield "retry: 3000\n\n"
991
 
992
- # Contratto letterale: chiusura immediata prima di task_start, planner o
993
- # tool. È il backstop per client SSE che non applicano il fast path UI.
994
- _literal_response = task.get('literal_response')
995
- if isinstance(_literal_response, str) and _literal_response:
996
- _agent_tasks[task_id]['status'] = 'COMPLETED'
997
- asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
998
- literal_event = json.dumps(_sanitize_for_json({
999
- 'event': 'task_done', 'taskId': task_id, 'result': _literal_response,
1000
- }))
1001
- yield f"data: {literal_event}\n\n"
1002
- yield "data: [DONE]\n\n"
1003
- return
1004
-
1005
  reg = _loop_registry.get(task_id)
1006
 
1007
  is_done_reconnect = reg is not None and reg.get('done', False)
@@ -1043,7 +812,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1043
  sb_events = await sb_get_events(task_id)
1044
  if sb_events:
1045
  task_status = task.get('status', 'UNKNOWN')
1046
- terminal = task_status in ('COMPLETED', 'SUCCESS', 'ERROR', 'CANCELLED')
1047
  # Replay buffer from resume point
1048
  for evt_str in sb_events[_resume_from:]:
1049
  yield evt_str
@@ -1096,18 +865,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1096
  yield "data: [DONE]\n\n"
1097
  return
1098
  # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
1099
- if not await _LLM_ADMISSION.reserve():
1100
- await _record_phase('queue', outcome='rejected', error_class='queue_full')
1101
- _agent_tasks[task_id]['status'] = 'RATE_LIMITED'
1102
- _rate_limited = json.dumps(_sanitize_for_json({
1103
- 'event': 'task_error', 'taskId': task_id,
1104
- 'statusCode': 429, 'retryAfter': 5,
1105
- 'error': 'llm_queue_full',
1106
- }))
1107
- yield f'data: {_rate_limited}\n\n'
1108
- yield 'data: [DONE]\n\n'
1109
- return
1110
- await _record_phase('queue')
1111
  _prune_loop_registry()
1112
  reg_entry: dict = {
1113
  'asyncio_task': None,
@@ -1122,7 +879,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1122
  def _sse(event: str, data: dict) -> None:
1123
  """Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
1124
  _ctr[0] += 1
1125
- s = f"id: {_ctr[0]}\ndata: {json.dumps(_sanitize_for_json({'event': event, **data}))}\n\n" # BUG-SSE-SURR
1126
  # GAP-3-FIX: text_chunk bypass buffer β€” fanout diretto, no persist.
1127
  # 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
1128
  # Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
@@ -1150,26 +907,16 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1150
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1151
  if _KERNEL_AVAILABLE and _kernel is not None:
1152
  asyncio.create_task(_kernel.publish_event(
1153
- topic='task.running',
1154
  payload={'task_id': task_id, 'status': 'RUNNING'},
1155
  )).add_done_callback(_log_task_exc)
1156
  _prune_agent_tasks()
1157
 
1158
  async def run_loop() -> None:
1159
  try:
1160
- # Contratto letterale: nessun provider, planner, tool, card o side effect.
1161
- # È emesso direttamente nello stream affinché i client SSE non possano bypassarlo.
1162
- _literal_response = task.get('literal_response')
1163
- if isinstance(_literal_response, str) and _literal_response:
1164
- _agent_tasks[task_id]['status'] = 'COMPLETED'
1165
- asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
1166
- _sse('task_done', {'taskId': task_id, 'result': _literal_response})
1167
- return
1168
-
1169
  from agents.unified_loop import UnifiedAgentLoop
1170
- # Ogni task BYOK usa il suo client effimero; gli altri mantengono
1171
- # il singleton runtime. Le credenziali non entrano nel task dict.
1172
- client = _task_ai_clients.get(task_id) or _get_ai_client()
1173
  try:
1174
  from agents.critic import Critic
1175
  from agents.response_verifier import ResponseVerifier
@@ -1202,24 +949,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1202
  "Il frontend mostrerΓ  automaticamente un pulsante 'Connetti' all'utente."
1203
  )
1204
  context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
1205
- # ARTIFACT-CONTRACT: le richieste di pagina/app richiedono un
1206
- # artifact reale, non una risposta narrativa. Il frontend committa
1207
- # solo file_written + vfs_sync_complete, quindi l'agente deve
1208
- # scrivere e rileggere almeno un file prima di dichiarare successo.
1209
- _artifact_goal = bool(re.search(
1210
- r"\b(?:pagina|sito|website|webapp|app|mini[- ]app|ui|interfaccia)\b",
1211
- task.get('goal', ''), re.IGNORECASE,
1212
- ))
1213
- if _artifact_goal:
1214
- _artifact_hint = (
1215
- "[CONTRATTO ARTIFACT UI]\n"
1216
- "Per questa richiesta devi creare davvero i file nel workspace usando write_file "
1217
- "(non limitarti a proporre codice in chat). Dopo la scrittura, usa read_file o "
1218
- "un controllo equivalente per verificare il contenuto. Dichiara completamento "
1219
- "solo se la scrittura Γ¨ riuscita; in caso contrario segnala l'errore senza inventare "
1220
- "un link o un'anteprima."
1221
- )
1222
- context_str = f"{_artifact_hint}\n\n{context_str}".strip()
1223
  # GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
1224
  # Bug: _resume_context era settato su task{} ma mai letto qui β†’ context perduto su resume.
1225
  _resume_ctx = task.get('_resume_context', '')
@@ -1290,49 +1019,21 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1290
  _hctx = task.get("_handoff_context", "")
1291
  if _hctx:
1292
  context_str = f"{_hctx}\n\n{context_str}".strip()
1293
- # I router persona dipendono dalle env del backend: con BYOK il
1294
- # client per task resta autorevole in ogni fase, incluso il planner.
1295
- _is_byok_task = task_id in _task_ai_clients
1296
- _persona_client = client if _is_byok_task else _get_persona_llm_client(_persona, client)
1297
- _planner = _get_planner()
1298
- if _is_byok_task:
1299
- try:
1300
- from agents.planner import Planner
1301
- _planner = Planner(llm_client=client)
1302
- except Exception as exc:
1303
- _logger.warning("[agent] BYOK planner fallback: %s", type(exc).__name__)
1304
  loop = UnifiedAgentLoop(
1305
  llm_client=_persona_client, critic=_critic, verifier=_verifier,
1306
- memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_planner,
1307
  )
1308
  step_idx = [0]
1309
  _backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
1310
- # P34/SYNC-1: i file arrivano nel frontend in staging; raccogliamo
1311
- # soltanto quelli con contenuto così il commit atomico può avvenire
1312
- # una sola volta dopo un task riuscito.
1313
- _vfs_written_paths: set[str] = set()
1314
- # Accumula soltanto i token realmente emessi per poter riconciliare
1315
- # il testo in streaming con l'output autorevole del finalizer.
1316
- _streamed_chunks: list[str] = []
1317
 
1318
  async def step_cb(step_data: dict) -> None:
1319
  step_idx[0] += 1
1320
  _action = step_data.get('action', f'Step {step_idx[0]}')
1321
  # S420: streaming token β€” emetti direttamente senza passare dal buffer step
1322
  if _action == 'text_chunk':
1323
- _token = _ss(step_data.get('token', ''))
1324
- if _token:
1325
- _streamed_chunks.append(_token)
1326
- _sse('text_chunk', {'taskId': task_id, 'token': _token})
1327
- return
1328
- # RECOV-P1: engineering_state event β€” forward projection to frontend
1329
- if _action == 'engineering_state':
1330
- _sse('engineering_state', {
1331
- 'taskId': task_id,
1332
- 'status': step_data.get('status'),
1333
- 'mode': step_data.get('mode'),
1334
- 'engineering_state': step_data.get('engineering_state'),
1335
- })
1336
  return
1337
 
1338
  # S363-Blueprint: Narrative Streaming β€” explanation lookup for ALL step_done events
@@ -1442,26 +1143,10 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1442
  step_data.get('output', '')[:500])
1443
  _vfs_op = 'delete' if 'delete' in _action else 'write'
1444
  _vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
1445
- # SYNC-1: includi content nel SSE event per file_written (≀60KB).
1446
- # Per immagini dirette il backend non serializza byte nell'SSE:
1447
- # inoltra soltanto l'URL HTTPS generato internamente, che il client
1448
- # materializza come data URI nel proprio VFS prima del commit atomico.
1449
  if _action == 'file_written' and step_data.get('content'):
1450
  _vfs_evt['content'] = str(step_data['content'])[:60_000]
1451
- _vfs_written_paths.add(str(_vfs_file)[:500])
1452
- elif _action == 'file_written':
1453
- _source_url = str(step_data.get('source_url') or '')
1454
- _mime_type = str(step_data.get('mime_type') or '')
1455
- _allowed_image_origins = (
1456
- 'https://image.pollinations.ai/',
1457
- 'https://media.pollinations.ai/',
1458
- 'https://gen.pollinations.ai/',
1459
- )
1460
- if (_source_url.startswith(_allowed_image_origins) and
1461
- _mime_type in {'image/jpeg', 'image/png', 'image/webp'}):
1462
- _vfs_evt['sourceUrl'] = _source_url[:2_000]
1463
- _vfs_evt['mimeType'] = _mime_type
1464
- _vfs_written_paths.add(str(_vfs_file)[:500])
1465
  _sse('vfs_update', _vfs_evt)
1466
 
1467
  # S363-UI: thought event β€” emitted when planner completes
@@ -1557,137 +1242,33 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1557
  except Exception:
1558
  pass # S364: skeleton injection is optional
1559
 
1560
- _provider_started = time.perf_counter()
1561
  result = await loop.run(
1562
  goal=task['goal'],
1563
  context=context_str,
1564
  max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
1565
  on_step=step_cb,
1566
  session_id=task.get('session_id', '') or '',
1567
- allow_tools=not bool(task.get('forbid_tools', False)),
1568
- allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
1569
- )
1570
- await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
1571
- # ARTIFACT-CONTRACT-FALLBACK: alcuni provider restituiscono codice HTML
1572
- # nella risposta finale dopo aver narrato una scrittura, senza produrre
1573
- # il tool event `file_written`. Non committiamo mai una falsa positivitΓ :
1574
- # se il goal Γ¨ UI, proviamo solo a materializzare un documento HTML
1575
- # completo giΓ  presente nell'output, usando l'executor VFS autenticato.
1576
- # Se non troviamo un documento completo o la scrittura fallisce, il task
1577
- # resta senza artifact e il frontend non riceve alcun sync inventato.
1578
- if _artifact_goal and not _vfs_written_paths and getattr(loop, 'executor', None):
1579
- _artifact_output = str(result.get('output', result) if isinstance(result, dict) else result)
1580
- _html_match = re.search(r'(?is)(<!doctype\s+html\b.*?</html>)', _artifact_output)
1581
- if not _html_match:
1582
- _html_match = re.search(r'(?is)(<html(?:\s[^>]*)?>.*?</html>)', _artifact_output)
1583
- if _html_match:
1584
- _artifact_content = _html_match.group(1).strip()
1585
- _artifact_path = 'index.html'
1586
- _path_match = re.search(r'(?i)(?:/|\b)([\w.-]+\.html)\b', _artifact_output)
1587
- if _path_match:
1588
- _artifact_path = _path_match.group(1)
1589
- try:
1590
- _write_result = await asyncio.wait_for(
1591
- loop.executor.run_tool('write_file', {
1592
- 'path': _artifact_path,
1593
- 'content': _artifact_content,
1594
- }),
1595
- timeout=25.0,
1596
- )
1597
- _write_ok = not (isinstance(_write_result, dict) and _write_result.get('error'))
1598
- if _write_ok:
1599
- _vfs_written_paths.add(_artifact_path)
1600
- await step_cb({
1601
- 'action': 'file_written', 'status': 'done',
1602
- 'path': _artifact_path, 'content': _artifact_content,
1603
- 'result': f'Artifact materializzato: {_artifact_path}',
1604
- })
1605
- _read_result = await asyncio.wait_for(
1606
- loop.executor.run_tool('read_file', {'path': _artifact_path}),
1607
- timeout=15.0,
1608
- )
1609
- _read_ok = not (isinstance(_read_result, dict) and _read_result.get('error'))
1610
- await step_cb({
1611
- 'action': 'read_file',
1612
- 'status': 'done' if _read_ok else 'error',
1613
- 'path': _artifact_path,
1614
- 'result': f'Verifica artifact: {_artifact_path}' if _read_ok else str(_read_result)[:300],
1615
- })
1616
- except Exception as _artifact_exc:
1617
- _logger.warning('[agent] artifact fallback failed: %s', type(_artifact_exc).__name__)
1618
-
1619
- # Lifecycle separato: l'esecuzione primaria Γ¨ completa quando VFS e risultato
1620
- # sono confermati; la verifica qualitΓ  successiva non deve tenere il task RUNNING.
1621
- _agent_tasks[task_id]['status'] = 'COMPLETED'
1622
- _agent_tasks[task_id]['quality_status'] = (
1623
- 'QUALITY_CHECK_PENDING' if _run_quality_check else 'NOT_REQUIRED'
1624
  )
1625
- asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
 
1626
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1627
  if _KERNEL_AVAILABLE and _kernel is not None:
1628
  asyncio.create_task(_kernel.publish_event(
1629
- topic='task.completed',
1630
  payload={'task_id': task_id, 'status': 'SUCCESS'},
1631
  )).add_done_callback(_log_task_exc)
1632
  _result_text = str(result.get('output', result) if isinstance(result, dict) else result)
1633
- _vfs_commit = build_vfs_sync_complete(
1634
- task_id, _vfs_written_paths, result,
1635
- )
1636
- if _vfs_commit is not None:
1637
- # P34: completa l’atomic swap frontend solo dopo che il loop ha
1638
- # confermato il task. Su errore/cancellazione lo staging rimane
1639
- # intenzionalmente non committato.
1640
- _sse('vfs_sync_complete', _vfs_commit)
1641
- if _run_quality_check:
1642
- _sse('quality_check_pending', {
1643
- 'taskId': task_id,
1644
- 'status': 'QUALITY_CHECK_PENDING',
1645
- 'primaryStatus': 'COMPLETED',
1646
- })
1647
- _streamed_text = ''.join(_streamed_chunks)
1648
- if _streamed_text and _result_text != _streamed_text:
1649
- # Il finalizer puΓ² riparare/normalizzare la risposta dopo gli
1650
- # ultimi chunk. Notifica esplicitamente la sostituzione così il
1651
- # client non mostra un testo parziale o divergente.
1652
- _sse('text_replace', {
1653
- 'taskId': task_id,
1654
- 'text': _result_text[:8000],
1655
- 'reason': 'authoritative_finalizer_output',
1656
- })
1657
- _sse('task_done', {
1658
- 'taskId': task_id,
1659
- 'result': _result_text[:8000],
1660
- 'status': 'COMPLETED',
1661
- 'qualityStatus': _agent_tasks[task_id].get('quality_status', 'NOT_REQUIRED'),
1662
- })
1663
  asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
1664
 
1665
- # Quality check non bloccante: aggiorna solo quality_status, mai lo stato
1666
- # primario del task e mai il commit VFS giΓ  confermato.
1667
  if _run_quality_check:
1668
  _qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
1669
- if len(_qg_result) > 500 and _qg_result.count('```') >= 2:
1670
- async def _run_quality_background() -> None:
1671
- try:
1672
- await _run_quality_check(
1673
- task_id, task['goal'], _qg_result,
1674
- on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
1675
- )
1676
- _agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_DONE'
1677
- _sse('quality_check_done', {
1678
- 'taskId': task_id,
1679
- 'status': 'QUALITY_CHECK_DONE',
1680
- 'primaryStatus': 'COMPLETED',
1681
- })
1682
- except Exception as _q_exc:
1683
- _agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_ERROR'
1684
- _sse('quality_check_done', {
1685
- 'taskId': task_id,
1686
- 'status': 'QUALITY_CHECK_ERROR',
1687
- 'primaryStatus': 'COMPLETED',
1688
- 'error': type(_q_exc).__name__,
1689
- })
1690
- asyncio.create_task(_run_quality_background()).add_done_callback(_log_task_exc)
1691
 
1692
 
1693
  except asyncio.CancelledError:
@@ -1696,14 +1277,14 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1696
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1697
  if _KERNEL_AVAILABLE and _kernel is not None:
1698
  asyncio.create_task(_kernel.publish_event(
1699
- topic='task.cancelled',
1700
  payload={'task_id': task_id, 'status': 'CANCELLED'},
1701
  )).add_done_callback(_log_task_exc)
1702
  _sse('task_cancelled', {'taskId': task_id})
1703
 
1704
  except (ImportError, ModuleNotFoundError):
1705
- _agent_tasks[task_id]['status'] = 'COMPLETED'
1706
- asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
1707
  _sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
1708
  _sse('task_done', {'taskId': task_id, 'result': (
1709
  f'Goal ricevuto: {task["goal"]}\n\n'
@@ -1717,7 +1298,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1717
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1718
  if _KERNEL_AVAILABLE and _kernel is not None:
1719
  asyncio.create_task(_kernel.publish_event(
1720
- topic='task.failed',
1721
  payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
1722
  )).add_done_callback(_log_task_exc)
1723
  _logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
@@ -1725,9 +1306,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1725
  asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
1726
 
1727
  finally:
1728
- # Il task Γ¨ terminale: rimuove l’unico riferimento alle credenziali
1729
- # BYOK, lasciando replay SSE e metadati senza segreti.
1730
- _task_ai_clients.pop(task_id, None)
1731
  reg_entry['done'] = True
1732
  reg_entry['finished_at'] = time.time()
1733
  for q in list(reg_entry['subscriber_queues']):
@@ -1736,13 +1314,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1736
  except Exception as _exc:
1737
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
1738
 
1739
- async def _admitted_run_loop():
1740
- await _LLM_ADMISSION.acquire()
1741
- try:
1742
- await run_loop()
1743
- finally:
1744
- await _LLM_ADMISSION.release()
1745
- reg_entry['asyncio_task'] = asyncio.create_task(_admitted_run_loop())
1746
  reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
1747
 
1748
  try:
@@ -1805,7 +1377,7 @@ async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Dep
1805
  'extra': body.extra,
1806
  'savedAt': int(time.time() * 1000),
1807
  }
1808
- asyncio.create_task(sb_save_checkpoint(task_id, body.step, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
1809
  return {'saved': True, 'taskId': task_id, 'step': body.step}
1810
 
1811
 
 
42
  from fastapi import APIRouter, Depends, HTTPException, Request, Body
43
  from fastapi.responses import StreamingResponse
44
  from .auth_guard import require_role, AuthRole
45
+ from pydantic import BaseModel, field_validator
46
  from typing import Literal
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  from .state import (
48
+ _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
49
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
50
  _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
51
  ReasonLoopIn, AgentTaskIn,
52
+ write_ahead_task_created, # WRITE-AHEAD: persist immediato alla creazione task
53
  )
54
  from .speculative import fire_speculative_tools
 
 
55
  try:
56
  from .quality_guardian import run_quality_check as _run_quality_check
57
  except Exception:
 
88
  router = APIRouter()
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  # ── Deprecated run_loop ───────────────────────────────────────────���────────────
92
 
93
  @router.post('/run_loop', deprecated=True)
 
188
  body: ReasonLoopIn, request: Request,
189
  role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
190
  ):
 
 
 
 
 
191
  async def generate():
192
  queue: asyncio.Queue = asyncio.Queue()
193
 
194
  async def step_cb(step: dict) -> None:
 
 
195
  await queue.put(step)
196
 
197
  async def run_loop() -> None:
 
231
  _neg_c = getattr(body, 'negative_constraints', '') or ''
232
  if _neg_c:
233
  context_str = f"[VINCOLI OPERATIVI APPRESI β€” NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
 
234
  result = await loop.run(
235
  goal=body.goal, context=context_str,
236
  max_steps=body.max_steps, on_step=step_cb,
237
  session_id=getattr(body, "session_id", "") or "",
238
  )
 
239
  await queue.put({
240
  '__done__': True,
241
  'result': result.get('output', ''),
242
  'engine': result.get('engine', 'fallback'),
243
  'success': result.get('success', False),
 
244
  })
245
  except Exception as exc:
246
  # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
 
254
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
255
  await queue.put({'__error__': str(exc)})
256
 
257
+ task = asyncio.create_task(run_loop())
 
 
 
 
 
 
258
  task.add_done_callback(_log_task_exc) # BUG-CB-1
259
  task_id = str(uuid.uuid4())
260
  # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
 
317
  _err_detail = _ss(item.get('error', ''))
318
  _final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
319
  else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
320
+ yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _final_res, 'engine': item.get('engine', 'fallback'), 'success': item.get('success', False)})}\n\n"
 
 
 
 
 
 
321
  break
322
  # S393 Priority 1: Narrative Streaming β€” arricchisce step_done con explanation
323
  _NARR_QUICK = {
 
440
  'action': step_data.get('action', ''),
441
  'output': str(step_data.get('output', ''))[:400], # S577: 200β†’400
442
  })
443
+ result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
 
 
 
 
 
 
 
 
 
 
 
 
444
  if isinstance(result, dict):
445
  output_text = result.get('output', '') or ''
446
  engine_used = result.get('engine', 'unknown')
 
449
  output_text = str(result)
450
  engine_used = 'unknown'
451
  errors_list = []
 
452
  return {
453
  'ok': bool(output_text and output_text.strip()),
454
  'success': bool(output_text and output_text.strip()), # alias compat frontend
 
458
  'engine': engine_used,
459
  'errors': errors_list,
460
  'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
 
461
  }
462
  except Exception as e:
463
  _logger.error("[reason/loop] Error: %s", e)
 
523
  'goal': goal,
524
  'mode': mode,
525
  'dispatch_id': _dispatch_id,
 
526
  },
527
  priority='HIGH',
528
+ metadata={'workflow': 'agent-kernel.yml'},
529
  )).add_done_callback(_log_task_exc)
530
  asyncio.create_task(_kernel.publish_event(
531
+ event_type='agent.kernel.dispatched',
532
  payload={'goal': goal[:200], 'mode': mode},
533
  )).add_done_callback(_log_task_exc)
534
  import httpx as _httpx
 
552
 
553
  # ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  @router.post('/api/agent/tasks')
556
+ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
557
  """
558
  Crea o recupera un task agent.
559
 
 
561
  il task viene ripristinato dallo store persistente invece di essere riavviato.
562
  Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
563
  """
 
564
  _prune_agent_tasks()
565
+ task_id = body.taskId or str(uuid.uuid4())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
  # Already in memory β†’ return immediately (normal path, includes S358 reconnect)
568
+ if task_id in _agent_tasks:
 
 
569
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
570
 
571
  # S359: try Supabase lazy restore (only hit network after backend restart)
 
575
  # Use context from the incoming request (not persisted to save space).
576
  restored['context'] = body.context
577
  _agent_tasks[task_id] = restored
 
578
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
579
 
580
+ # Brand new task
 
 
581
  created_at = int(time.time() * 1000)
582
  _agent_tasks[task_id] = {
583
  'id': task_id,
 
591
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
592
  'persona': body.persona, # P17-F5: expertise persona hint
593
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ†’'')
 
 
 
 
594
  }
595
+ # WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
596
+ # periodico (15-60s). Finestra di perdita per la fase di creazione β†’ zero.
597
+ asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
598
  # BG-4: restore cross-session handoff context (async, non-blocking)
599
  if body.session_id:
600
  _hctx = await sb_restore_handoff_context(body.session_id)
 
605
  asyncio.create_task(
606
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
607
  ).add_done_callback(_log_task_exc)
608
+ # S361: Speculative Tool Firing β€” pre-fires read-only tools in parallel
609
+ # while the main model processes. Results cached for _run_direct_tools to consume.
610
+ asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
 
 
611
  # ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
612
  if _KERNEL_AVAILABLE and _kernel is not None:
613
  asyncio.create_task(_kernel.submit_task(
 
617
  'max_steps': body.max_steps,
618
  'persona': body.persona,
619
  'source': 'agent_api',
 
620
  },
621
  priority='NORMAL',
622
  session_id=body.session_id,
623
+ metadata={'agent_api': True},
624
  )).add_done_callback(_log_task_exc)
625
  asyncio.create_task(_kernel.publish_event(
626
+ event_type='task.created',
627
  payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
628
  )).add_done_callback(_log_task_exc)
629
  return {'taskId': task_id, 'status': 'QUEUED'}
 
703
  async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
704
  if task_id in _agent_tasks:
705
  _agent_tasks[task_id]['status'] = 'CANCELLED'
 
706
  reg = _loop_registry.get(task_id)
707
  if reg and not reg.get('done'):
708
  at = reg.get('asyncio_task')
 
753
  - Task era RUNNING β†’ replay buffer parziale + evento task_interrupted.
754
  - Task non trovato β†’ prova sb_restore_task prima di 404.
755
  """
 
756
  # S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
757
  if task_id not in _agent_tasks:
758
  restored = await sb_restore_task(task_id)
 
763
  raise HTTPException(404, detail=f'Task {task_id} non trovato')
764
 
765
  task = _agent_tasks[task_id]
 
 
 
 
 
 
 
 
 
 
 
766
  _last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
767
  _resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
768
 
 
771
  async def generate():
772
  yield "retry: 3000\n\n"
773
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
  reg = _loop_registry.get(task_id)
775
 
776
  is_done_reconnect = reg is not None and reg.get('done', False)
 
812
  sb_events = await sb_get_events(task_id)
813
  if sb_events:
814
  task_status = task.get('status', 'UNKNOWN')
815
+ terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
816
  # Replay buffer from resume point
817
  for evt_str in sb_events[_resume_from:]:
818
  yield evt_str
 
865
  yield "data: [DONE]\n\n"
866
  return
867
  # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
868
  _prune_loop_registry()
869
  reg_entry: dict = {
870
  'asyncio_task': None,
 
879
  def _sse(event: str, data: dict) -> None:
880
  """Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
881
  _ctr[0] += 1
882
+ s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
883
  # GAP-3-FIX: text_chunk bypass buffer β€” fanout diretto, no persist.
884
  # 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
885
  # Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
 
907
  # ARCH-K2.2: pubblica lifecycle event via Kernel
908
  if _KERNEL_AVAILABLE and _kernel is not None:
909
  asyncio.create_task(_kernel.publish_event(
910
+ event_type='task.running',
911
  payload={'task_id': task_id, 'status': 'RUNNING'},
912
  )).add_done_callback(_log_task_exc)
913
  _prune_agent_tasks()
914
 
915
  async def run_loop() -> None:
916
  try:
 
 
 
 
 
 
 
 
 
917
  from agents.unified_loop import UnifiedAgentLoop
918
+ # S388: singleton β€” evita OpenAI() per ogni task
919
+ client = _get_ai_client()
 
920
  try:
921
  from agents.critic import Critic
922
  from agents.response_verifier import ResponseVerifier
 
949
  "Il frontend mostrerΓ  automaticamente un pulsante 'Connetti' all'utente."
950
  )
951
  context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
952
  # GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
953
  # Bug: _resume_context era settato su task{} ma mai letto qui β†’ context perduto su resume.
954
  _resume_ctx = task.get('_resume_context', '')
 
1019
  _hctx = task.get("_handoff_context", "")
1020
  if _hctx:
1021
  context_str = f"{_hctx}\n\n{context_str}".strip()
1022
+ # P17-F5: route primary LLM to persona-appropriate client
1023
+ _persona_client = _get_persona_llm_client(_persona, client)
 
 
 
 
 
 
 
 
 
1024
  loop = UnifiedAgentLoop(
1025
  llm_client=_persona_client, critic=_critic, verifier=_verifier,
1026
+ memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
1027
  )
1028
  step_idx = [0]
1029
  _backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
 
 
 
 
 
 
 
1030
 
1031
  async def step_cb(step_data: dict) -> None:
1032
  step_idx[0] += 1
1033
  _action = step_data.get('action', f'Step {step_idx[0]}')
1034
  # S420: streaming token β€” emetti direttamente senza passare dal buffer step
1035
  if _action == 'text_chunk':
1036
+ _sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
 
 
 
 
 
 
 
 
 
 
 
 
1037
  return
1038
 
1039
  # S363-Blueprint: Narrative Streaming β€” explanation lookup for ALL step_done events
 
1143
  step_data.get('output', '')[:500])
1144
  _vfs_op = 'delete' if 'delete' in _action else 'write'
1145
  _vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
1146
+ # SYNC-1: includi content nel SSE event per file_written (≀60KB)
1147
+ # Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
 
 
1148
  if _action == 'file_written' and step_data.get('content'):
1149
  _vfs_evt['content'] = str(step_data['content'])[:60_000]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  _sse('vfs_update', _vfs_evt)
1151
 
1152
  # S363-UI: thought event β€” emitted when planner completes
 
1242
  except Exception:
1243
  pass # S364: skeleton injection is optional
1244
 
 
1245
  result = await loop.run(
1246
  goal=task['goal'],
1247
  context=context_str,
1248
  max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
1249
  on_step=step_cb,
1250
  session_id=task.get('session_id', '') or '',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1251
  )
1252
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
1253
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
1254
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1255
  if _KERNEL_AVAILABLE and _kernel is not None:
1256
  asyncio.create_task(_kernel.publish_event(
1257
+ event_type='task.completed',
1258
  payload={'task_id': task_id, 'status': 'SUCCESS'},
1259
  )).add_done_callback(_log_task_exc)
1260
  _result_text = str(result.get('output', result) if isinstance(result, dict) else result)
1261
+ _sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1262
  asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
1263
 
1264
+ # S363: fire-and-forget quality check when code detected in output
 
1265
  if _run_quality_check:
1266
  _qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
1267
+ if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised β€” evita QG su snippet brevi
1268
+ asyncio.create_task(_run_quality_check(
1269
+ task_id, task['goal'], _qg_result,
1270
+ on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
1271
+ )).add_done_callback(_log_task_exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1272
 
1273
 
1274
  except asyncio.CancelledError:
 
1277
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1278
  if _KERNEL_AVAILABLE and _kernel is not None:
1279
  asyncio.create_task(_kernel.publish_event(
1280
+ event_type='task.cancelled',
1281
  payload={'task_id': task_id, 'status': 'CANCELLED'},
1282
  )).add_done_callback(_log_task_exc)
1283
  _sse('task_cancelled', {'taskId': task_id})
1284
 
1285
  except (ImportError, ModuleNotFoundError):
1286
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
1287
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
1288
  _sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
1289
  _sse('task_done', {'taskId': task_id, 'result': (
1290
  f'Goal ricevuto: {task["goal"]}\n\n'
 
1298
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1299
  if _KERNEL_AVAILABLE and _kernel is not None:
1300
  asyncio.create_task(_kernel.publish_event(
1301
+ event_type='task.failed',
1302
  payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
1303
  )).add_done_callback(_log_task_exc)
1304
  _logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
 
1306
  asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
1307
 
1308
  finally:
 
 
 
1309
  reg_entry['done'] = True
1310
  reg_entry['finished_at'] = time.time()
1311
  for q in list(reg_entry['subscriber_queues']):
 
1314
  except Exception as _exc:
1315
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
1316
 
1317
+ reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
 
 
 
 
 
 
1318
  reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
1319
 
1320
  try:
 
1377
  'extra': body.extra,
1378
  'savedAt': int(time.time() * 1000),
1379
  }
1380
+ asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
1381
  return {'saved': True, 'taskId': task_id, 'step': body.step}
1382
 
1383
 
api/agent_checkpoint.py DELETED
@@ -1,131 +0,0 @@
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_checkpoint_routes.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """agent_checkpoint_routes.py β€” Checkpoint, debug/timing, skill-stats, circuit-status.
2
+
3
+ Estratto da agent.py (split 2026-06-30).
4
+ Route coperte:
5
+ POST /api/agent/tasks/{task_id}/checkpoint
6
+ GET /api/agent/tasks/{task_id}/checkpoint
7
+ DELETE /api/agent/tasks/{task_id}/checkpoint
8
+ GET /api/agent/checkpoints
9
+ GET /debug/timing
10
+ GET /api/agent/skill-stats/{session_id}
11
+ GET /api/agent/skill-stats
12
+ GET /api/agent/circuit-status/{session_id}
13
+ """
14
+ from __future__ import annotations
15
+ import os, asyncio, json, uuid, time, re
16
+ import re as _re_persona
17
+ from fastapi import APIRouter, HTTPException, Request, Body
18
+ from fastapi.responses import StreamingResponse
19
+ from pydantic import BaseModel, field_validator
20
+ from typing import Literal
21
+ from .state import (
22
+ _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
23
+ _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
24
+ _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
25
+ ReasonLoopIn, AgentTaskIn,
26
+ write_ahead_task_created,
27
+ )
28
+ from .speculative import fire_speculative_tools
29
+ try:
30
+ from .quality_guardian import run_quality_check as _run_quality_check
31
+ except Exception:
32
+ _run_quality_check = None
33
+ import logging
34
+ _logger = logging.getLogger("api.agent")
35
+ from .persistence import (
36
+ sb_upsert_task, sb_update_status, sb_append_event,
37
+ sb_restore_task, sb_get_events, sb_delete_task_events,
38
+ sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
39
+ sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
40
+ )
41
+ from ._agent_helpers import _RE_SURROGATES, _ss, _log_task_exc
42
+ try:
43
+ from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
44
+ except Exception:
45
+ async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
46
+ async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
47
+ async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
48
+ async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
49
+
50
+ router = APIRouter()
51
+
52
+
53
+
54
+ # ── Task checkpoints ───────────────────────────────────────────────────────────
55
+
56
+ class CheckpointIn(BaseModel):
57
+ taskId: str
58
+ step: int
59
+ goal: str
60
+ plan: list[str] = []
61
+ logs: list[str] = []
62
+ artifacts: list[str] = []
63
+ retryCount: int = 0
64
+ extra: dict = {}
65
+
66
+
67
+ @router.post('/api/agent/tasks/{task_id}/checkpoint')
68
+ async def save_checkpoint(task_id: str, body: CheckpointIn):
69
+ _prune_checkpoints()
70
+ _task_checkpoints[task_id] = {
71
+ 'taskId': task_id,
72
+ 'step': body.step,
73
+ 'goal': body.goal,
74
+ 'plan': body.plan,
75
+ 'logs': body.logs[-50:],
76
+ 'artifacts': body.artifacts,
77
+ 'retryCount': body.retryCount,
78
+ 'extra': body.extra,
79
+ 'savedAt': int(time.time() * 1000),
80
+ }
81
+ asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
82
+ return {'saved': True, 'taskId': task_id, 'step': body.step}
83
+
84
+
85
+ @router.get('/api/agent/tasks/{task_id}/checkpoint')
86
+ async def get_checkpoint(task_id: str):
87
+ _prune_checkpoints()
88
+ cp = _task_checkpoints.get(task_id)
89
+ if not cp:
90
+ cp = await sb_get_checkpoint(task_id)
91
+ if not cp:
92
+ raise HTTPException(404, detail={'error': 'checkpoint_not_found', 'taskId': task_id})
93
+ return cp
94
+
95
+
96
+ @router.delete('/api/agent/tasks/{task_id}/checkpoint')
97
+ async def delete_checkpoint(task_id: str):
98
+ _task_checkpoints.pop(task_id, None)
99
+ return {'deleted': task_id}
100
+
101
+
102
+ @router.get('/api/agent/checkpoints')
103
+ async def list_checkpoints():
104
+ _prune_checkpoints()
105
+ now = int(time.time() * 1000)
106
+ return {
107
+ 'count': len(_task_checkpoints),
108
+ 'checkpoints': [
109
+ {'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200β†’300
110
+ for k, v in _task_checkpoints.items()
111
+ ],
112
+ }
113
+
114
+
115
+ # ─── Sprint 5 ITEM 15: /debug/timing β€” telemetria timing + qualitΓ  agente ────
116
+ # Usato da TelemetryDashboard.tsx (frontend) per la sezione "QualitΓ  agente".
117
+ # Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualitΓ ).
118
+ # Non richiede auth β€” dati aggregati, nessun dato sensibile.
119
+ @router.get('/debug/timing')
120
+ async def get_debug_timing():
121
+ """
122
+ Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
123
+ e contatori qualitΓ  (goal_success, repair_success, tool_failure, req_engine).
124
+ Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
125
+ """
126
+ try:
127
+ from api.state import _TIMING_STORE, _REPAIR_STATS
128
+ timing_stats: dict = {}
129
+ for label, samples in _TIMING_STORE.items():
130
+ if samples:
131
+ avg_val = round(sum(samples) / len(samples), 1)
132
+ else:
133
+ avg_val = None
134
+ timing_stats[label] = {"avg": avg_val, "count": len(samples)}
135
+ return {
136
+ "timing_stats": timing_stats,
137
+ "repair_stats": dict(_REPAIR_STATS),
138
+ }
139
+ except Exception as exc:
140
+ return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
141
+
142
+
143
+ # ─── GAP-SKILL-SYNC: /api/agent/skill-stats β€” statistiche tool adattive ──────
144
+ # Espone i dati del SkillTracker (session-scoped success/fail per tool)
145
+ # al frontend per merge con skillRegistry Dexie β€” vista cross-runtime unificata.
146
+ @router.get('/api/agent/skill-stats/{session_id}')
147
+ async def get_skill_stats(session_id: str):
148
+ """Success/fail rate + Wilson score per ogni tool nella sessione.
149
+
150
+ Il frontend usa questa API per arricchire i dati Dexie di skillRegistry.ts
151
+ con le stats backend: confidence reale (server-side) vs contatori browser-only.
152
+ """
153
+ try:
154
+ from agents.skill_tracker import get_skill_tracker
155
+ return {
156
+ "session_id": session_id,
157
+ "stats": get_skill_tracker().get_stats(session_id),
158
+ }
159
+ except Exception as exc:
160
+ return {"session_id": session_id, "stats": {}, "error": str(exc)}
161
+
162
+
163
+ @router.get('/api/agent/skill-stats')
164
+ async def list_all_skill_sessions():
165
+ """Debug: panoramica di tutte le sessioni SkillTracker attive (tool count, call count)."""
166
+ try:
167
+ from agents.skill_tracker import get_skill_tracker
168
+ return get_skill_tracker().get_all_sessions()
169
+ except Exception as exc:
170
+ return {"error": str(exc)}
171
+
172
+ # ── /api/agent/circuit-status/{session_id} β€” circuit breaker live status ──────
173
+ # Espone per ogni tool tracciato in sessione: stato circuito, Wilson score,
174
+ # recovery calls effettuate β€” utile per debug e monitoring real-time.
175
+ @router.get('/api/agent/circuit-status/{session_id}')
176
+ async def get_circuit_status(session_id: str):
177
+ """
178
+ Stato real-time del circuit breaker per ogni tool di una sessione.
179
+
180
+ Per ogni tool tracciato, classifica il circuito come:
181
+ - open β†’ Wilson score < 0.15 AND total_count >= 3 AND tool ha fallback
182
+ (il tool viene bypassato β€” routing automatico ai fallback)
183
+ - closed β†’ performance sufficiente o dati insufficienti per aprire il circuit
184
+
185
+ Campi per tool:
186
+ wilson_score: lower bound dell'intervallo di confidenza al 95% (0–1)
187
+ success_count: successi registrati nella sessione
188
+ fail_count: fallimenti registrati nella sessione
189
+ total_count: chiamate totali
190
+ success_rate: raw rate (NON usato dal circuit β€” solo informativo)
191
+ avg_latency_ms: latenza media (ms)
192
+ has_fallbacks: True se TOOL_REGISTRY definisce fallback per il tool
193
+ recovery_calls: quante volte il recovery credit ha concesso un tentativo
194
+ circuit_state: "open" | "closed" | "no_data" | "insufficient_data"
195
+
196
+ Thresholds (from executor.py):
197
+ circuit_open_threshold: 0.15 (Wilson score sotto cui il circuit si apre)
198
+ min_calls_for_circuit: 3 (chiamate minime prima che il circuit possa aprirsi)
199
+ recovery_interval: 5 (ogni N call con circuit open β†’ recovery attempt)
200
+ """
201
+ try:
202
+ from agents.skill_tracker import get_skill_tracker
203
+ from tools.registry import TOOL_REGISTRY
204
+ from api.state import _get_executor
205
+ from agents.executor import (
206
+ _CIRCUIT_OPEN_THRESHOLD,
207
+ _MIN_CALLS_FOR_CIRCUIT,
208
+ _RECOVERY_INTERVAL,
209
+ )
210
+
211
+ stats = get_skill_tracker().get_stats(session_id)
212
+
213
+ # Recovery counts vivono nell'istanza Executor singleton
214
+ executor = _get_executor()
215
+ rec_counts: dict = {}
216
+ if executor is not None:
217
+ rec_counts = getattr(executor, '_circuit_recovery_counts', {})
218
+
219
+ circuits_open: list[dict] = []
220
+ circuits_closed: list[dict] = []
221
+
222
+ for tool_name, s in stats.items():
223
+ has_fallbacks = bool(TOOL_REGISTRY.get(tool_name, {}).get('fallbacks'))
224
+ recovery_calls = rec_counts.get(tool_name, 0)
225
+
226
+ # Replica logica _is_circuit_open() di executor.py
227
+ if s['total_count'] == 0:
228
+ state = 'no_data'
229
+ elif s['total_count'] < _MIN_CALLS_FOR_CIRCUIT:
230
+ state = 'insufficient_data'
231
+ elif s['wilson_score'] < _CIRCUIT_OPEN_THRESHOLD and has_fallbacks:
232
+ state = 'open'
233
+ else:
234
+ state = 'closed'
235
+
236
+ entry = {
237
+ 'tool': tool_name,
238
+ 'circuit_state': state,
239
+ 'wilson_score': s['wilson_score'],
240
+ 'success_count': s['success_count'],
241
+ 'fail_count': s['fail_count'],
242
+ 'total_count': s['total_count'],
243
+ 'success_rate': s['success_rate'],
244
+ 'avg_latency_ms': s['avg_latency_ms'],
245
+ 'has_fallbacks': has_fallbacks,
246
+ 'recovery_calls': recovery_calls,
247
+ }
248
+ if state == 'open':
249
+ circuits_open.append(entry)
250
+ else:
251
+ circuits_closed.append(entry)
252
+
253
+ # Ordina open per Wilson score asc (peggiori prima), closed per desc (migliori prima)
254
+ circuits_open.sort(key=lambda x: x['wilson_score'])
255
+ circuits_closed.sort(key=lambda x: x['wilson_score'], reverse=True)
256
+
257
+ return {
258
+ 'session_id': session_id,
259
+ 'total_tools_tracked': len(stats),
260
+ 'circuits_open_count': len(circuits_open),
261
+ 'circuits_closed_count': len(circuits_closed),
262
+ 'circuits_open': circuits_open,
263
+ 'circuits_closed': circuits_closed,
264
+ 'thresholds': {
265
+ 'circuit_open_threshold': _CIRCUIT_OPEN_THRESHOLD,
266
+ 'min_calls_for_circuit': _MIN_CALLS_FOR_CIRCUIT,
267
+ 'recovery_interval': _RECOVERY_INTERVAL,
268
+ },
269
+ }
270
+ except Exception as exc:
271
+ return {
272
+ 'session_id': session_id,
273
+ 'total_tools_tracked': 0,
274
+ 'circuits_open_count': 0,
275
+ 'circuits_open': [],
276
+ 'circuits_closed': [],
277
+ 'error': str(exc),
278
+ }
279
+
api/agent_fsm.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/agent_fsm.py β€” AgentLoop come Macchina a Stati (ARCH-I4.5)
3
+
4
+ Rifactorizza il flusso dell'AgentLoop come FSM (Finite State Machine) esplicita.
5
+ Stati: IDLE β†’ PLAN β†’ THINK β†’ TOOL β†’ WAIT β†’ OBSERVE β†’ DECIDE β†’ DONE | FAILED
6
+
7
+ Vantaggi rispetto all'implementazione implicita in agent.py:
8
+ - TestabilitΓ : ogni transizione Γ¨ una funzione pura
9
+ - OsservabilitΓ : stato corrente sempre visibile via API
10
+ - Idempotenza: ogni stato ha guard di entry/exit
11
+ - DebuggabilitΓ : history completa delle transizioni
12
+
13
+ Integrazione con agent.py:
14
+ - AgentFSM NON sostituisce agent.py (troppo rischio regressione)
15
+ - Viene usato come orchestratore esterno per nuovi task creati via Kernel
16
+ - agent.py esistente continua a funzionare invariato
17
+
18
+ ADR: S10 S16 S21 S27
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import time
25
+ import uuid
26
+ from enum import Enum
27
+ from typing import Any, Callable, Coroutine
28
+
29
+ from fastapi import APIRouter, Depends, HTTPException
30
+ from pydantic import BaseModel, Field
31
+
32
+ from .auth_guard import AuthRole, require_role
33
+
34
+ _logger = logging.getLogger("api.agent_fsm")
35
+
36
+ # ── Guards ──────────────────────────────────────────────────────────────────────
37
+ try:
38
+ from .brain_planner import planner as _planner, PlanRequest as _PlanReq
39
+ _PLANNER_AVAILABLE = True
40
+ except Exception:
41
+ _planner = None; _PlanReq = None; _PLANNER_AVAILABLE = False # type: ignore
42
+
43
+ try:
44
+ from .tool_engine import tool_executor as _tool_exec, ToolExecuteRequest as _TExecReq
45
+ _TOOL_ENGINE_AVAILABLE = True
46
+ except Exception:
47
+ _tool_exec = None; _TExecReq = None; _TOOL_ENGINE_AVAILABLE = False # type: ignore
48
+
49
+ try:
50
+ from .kernel import kernel as _kernel
51
+ _KERNEL_AVAILABLE = True
52
+ except Exception:
53
+ _kernel = None; _KERNEL_AVAILABLE = False # type: ignore
54
+
55
+ # ── States ──────────────────────────────────────────────────────────────────────
56
+
57
+ class AgentState(str, Enum):
58
+ IDLE = "idle" # in attesa di un goal
59
+ PLAN = "plan" # BrainPlanner genera WorkflowPlan
60
+ THINK = "think" # LLM reasoning: analisi contesto, scelta prossimo tool
61
+ TOOL = "tool" # esecuzione tool via ToolEngine
62
+ WAIT = "wait" # attesa risultato asincrono (polling)
63
+ OBSERVE = "observe" # elaborazione risultato tool, aggiornamento contesto
64
+ DECIDE = "decide" # decide se DONE, se re-THINK, o se FAILED
65
+ DONE = "done" # goal raggiunto
66
+ FAILED = "failed" # errore non recuperabile
67
+
68
+ # Transizioni valide: stato β†’ [stati raggiungibili]
69
+ _TRANSITIONS: dict[AgentState, list[AgentState]] = {
70
+ AgentState.IDLE: [AgentState.PLAN],
71
+ AgentState.PLAN: [AgentState.THINK, AgentState.FAILED],
72
+ AgentState.THINK: [AgentState.TOOL, AgentState.DECIDE, AgentState.FAILED],
73
+ AgentState.TOOL: [AgentState.WAIT, AgentState.OBSERVE, AgentState.FAILED],
74
+ AgentState.WAIT: [AgentState.OBSERVE, AgentState.FAILED],
75
+ AgentState.OBSERVE: [AgentState.DECIDE, AgentState.FAILED],
76
+ AgentState.DECIDE: [AgentState.THINK, AgentState.DONE, AgentState.FAILED],
77
+ AgentState.DONE: [],
78
+ AgentState.FAILED: [],
79
+ }
80
+
81
+ # ── Models ──────────────────────────────────────────────────────────────────────
82
+
83
+ class StateTransition(BaseModel):
84
+ from_state: AgentState
85
+ to_state: AgentState
86
+ reason: str = ""
87
+ ts: float = Field(default_factory=time.time)
88
+ metadata: dict[str, Any] = Field(default_factory=dict)
89
+
90
+
91
+ class AgentContext(BaseModel):
92
+ """Contesto condiviso tra tutti gli stati della FSM."""
93
+ session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
94
+ goal: str = ""
95
+ plan_id: str | None = None
96
+ messages: list[dict] = Field(default_factory=list)
97
+ tool_results: list[dict] = Field(default_factory=list)
98
+ current_step: int = 0
99
+ max_steps: int = 10
100
+ last_tool: str | None = None
101
+ last_result: Any = None
102
+ error: str | None = None
103
+ metadata: dict[str, Any] = Field(default_factory=dict)
104
+
105
+
106
+ class FSMRunRequest(BaseModel):
107
+ goal: str
108
+ session_id: str | None = None
109
+ max_steps: int = 10
110
+ hints: list[str] = Field(default_factory=list,
111
+ description="Capability/tool suggeriti")
112
+ correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
113
+
114
+
115
+ class FSMStatus(BaseModel):
116
+ fsm_id: str
117
+ state: AgentState
118
+ context: AgentContext
119
+ history: list[StateTransition]
120
+ started_at: float
121
+ updated_at: float
122
+ done: bool
123
+
124
+
125
+ # ── AgentFSM ────────────────────────────────────────────────────────────────────
126
+
127
+ class AgentFSM:
128
+ """
129
+ Istanza di FSM per un singolo run dell'agente.
130
+ Ogni run ha il proprio fsm_id, contesto e history.
131
+ """
132
+
133
+ def __init__(self, fsm_id: str, goal: str, max_steps: int = 10,
134
+ hints: list[str] | None = None) -> None:
135
+ self.fsm_id = fsm_id
136
+ self.state = AgentState.IDLE
137
+ self.context = AgentContext(goal=goal, max_steps=max_steps,
138
+ metadata={"hints": hints or []})
139
+ self.history: list[StateTransition] = []
140
+ self.started_at = time.time()
141
+ self.updated_at = time.time()
142
+
143
+ def can_transition(self, to: AgentState) -> bool:
144
+ return to in _TRANSITIONS.get(self.state, [])
145
+
146
+ def transition(self, to: AgentState, reason: str = "", metadata: dict | None = None) -> None:
147
+ if not self.can_transition(to):
148
+ raise ValueError(f"Transizione non valida: {self.state} β†’ {to}")
149
+ t = StateTransition(from_state=self.state, to_state=to,
150
+ reason=reason, metadata=metadata or {})
151
+ self.history.append(t)
152
+ _logger.debug("[fsm:%s] %s β†’ %s (%s)", self.fsm_id, self.state.value, to.value, reason)
153
+ self.state = to
154
+ self.updated_at = time.time()
155
+
156
+ @property
157
+ def done(self) -> bool:
158
+ return self.state in (AgentState.DONE, AgentState.FAILED)
159
+
160
+ def to_status(self) -> FSMStatus:
161
+ return FSMStatus(
162
+ fsm_id=self.fsm_id, state=self.state, context=self.context,
163
+ history=self.history, started_at=self.started_at,
164
+ updated_at=self.updated_at, done=self.done)
165
+
166
+
167
+ # ── AgentFSMRunner ──────────────────────────────────────────────────────────────
168
+
169
+ class AgentFSMRunner:
170
+ """
171
+ Esegue la FSM step-by-step.
172
+ Ogni stato chiama il componente appropriato (Planner, LLM, ToolEngine).
173
+ """
174
+
175
+ async def run(self, fsm: AgentFSM) -> FSMStatus:
176
+ """Esegue la FSM fino a DONE o FAILED."""
177
+ try:
178
+ # IDLE β†’ PLAN
179
+ fsm.transition(AgentState.PLAN, "starting run")
180
+ await self._state_plan(fsm)
181
+
182
+ while not fsm.done:
183
+ if fsm.context.current_step >= fsm.context.max_steps:
184
+ fsm.transition(AgentState.FAILED, f"max_steps={fsm.context.max_steps} raggiunto")
185
+ break
186
+
187
+ if fsm.state == AgentState.THINK:
188
+ await self._state_think(fsm)
189
+ elif fsm.state == AgentState.TOOL:
190
+ await self._state_tool(fsm)
191
+ elif fsm.state == AgentState.WAIT:
192
+ await self._state_wait(fsm)
193
+ elif fsm.state == AgentState.OBSERVE:
194
+ await self._state_observe(fsm)
195
+ elif fsm.state == AgentState.DECIDE:
196
+ await self._state_decide(fsm)
197
+ else:
198
+ fsm.transition(AgentState.FAILED, f"stato sconosciuto: {fsm.state}")
199
+ break
200
+ except Exception as exc:
201
+ _logger.error("[fsm:%s] unhandled error: %s", fsm.fsm_id, exc)
202
+ if not fsm.done:
203
+ fsm.context.error = str(exc)
204
+ try:
205
+ fsm.transition(AgentState.FAILED, f"unhandled: {exc}")
206
+ except Exception as _te:
207
+ _logger.debug("[fsm:%s] transition→FAILED fallito: %s", fsm.fsm_id, _te)
208
+ return fsm.to_status()
209
+
210
+ async def _state_plan(self, fsm: AgentFSM) -> None:
211
+ """PLAN: chiama BrainPlanner per generare il piano."""
212
+ if _PLANNER_AVAILABLE and _planner is not None and _PlanReq is not None:
213
+ try:
214
+ plan = await _planner.plan(_PlanReq(
215
+ goal = fsm.context.goal,
216
+ hints = fsm.context.metadata.get("hints", []),
217
+ max_steps = fsm.context.max_steps,
218
+ ))
219
+ fsm.context.plan_id = plan.plan_id
220
+ # Carica capabilities come "tool queue" nel contesto
221
+ fsm.context.metadata["tool_queue"] = [
222
+ {"capability": s.capability, "payload": s.payload, "step_id": s.step_id}
223
+ for s in plan.steps
224
+ ]
225
+ fsm.transition(AgentState.THINK, f"piano generato: {len(plan.steps)} step")
226
+ return
227
+ except Exception as exc:
228
+ _logger.warning("[fsm:%s] planner error: %s", fsm.fsm_id, exc)
229
+ # Fallback: THINK diretto senza piano
230
+ fsm.transition(AgentState.THINK, "planner non disponibile, THINK diretto")
231
+
232
+ async def _state_think(self, fsm: AgentFSM) -> None:
233
+ """THINK: seleziona il prossimo tool da eseguire."""
234
+ tool_queue = fsm.context.metadata.get("tool_queue", [])
235
+ if not tool_queue:
236
+ fsm.transition(AgentState.DECIDE, "tool queue vuota β†’ DECIDE")
237
+ return
238
+ next_tool = tool_queue.pop(0)
239
+ fsm.context.metadata["current_tool"] = next_tool
240
+ fsm.context.last_tool = next_tool.get("capability")
241
+ fsm.context.current_step += 1
242
+ fsm.transition(AgentState.TOOL, f"eseguo tool: {next_tool.get('capability')}")
243
+
244
+ async def _state_tool(self, fsm: AgentFSM) -> None:
245
+ """TOOL: esegui il tool selezionato in THINK."""
246
+ current = fsm.context.metadata.get("current_tool", {})
247
+ cap = current.get("capability", "llm")
248
+ payload = current.get("payload", {})
249
+
250
+ if _TOOL_ENGINE_AVAILABLE and _tool_exec is not None and _TExecReq is not None:
251
+ try:
252
+ result = await _tool_exec.execute(_TExecReq(
253
+ tool_name = cap, payload = payload,
254
+ correlation_id = fsm.fsm_id,
255
+ ))
256
+ fsm.context.last_result = result.model_dump()
257
+ fsm.context.tool_results.append({"tool": cap, "result": fsm.context.last_result})
258
+ fsm.transition(AgentState.OBSERVE, f"tool {cap} eseguito: {result.status}")
259
+ return
260
+ except Exception as exc:
261
+ fsm.context.error = str(exc)
262
+ _logger.warning("[fsm:%s] tool %s error: %s", fsm.fsm_id, cap, exc)
263
+ # Fallback: submit via Kernel
264
+ if _KERNEL_AVAILABLE and _kernel is not None:
265
+ result = await _kernel.submit_task({"capability": cap, **payload})
266
+ fsm.context.last_result = result.model_dump() if hasattr(result, "model_dump") else str(result)
267
+ fsm.context.tool_results.append({"tool": cap, "result": fsm.context.last_result})
268
+ fsm.transition(AgentState.OBSERVE, f"kernel submit: {cap}")
269
+ else:
270
+ fsm.transition(AgentState.OBSERVE, f"tool {cap} skipped (no executor)")
271
+
272
+ async def _state_wait(self, fsm: AgentFSM) -> None:
273
+ """WAIT: attende risultato asincrono (polling con backoff)."""
274
+ await asyncio.sleep(1)
275
+ fsm.transition(AgentState.OBSERVE, "wait complete")
276
+
277
+ async def _state_observe(self, fsm: AgentFSM) -> None:
278
+ """OBSERVE: elabora risultato, aggiorna messages."""
279
+ result = fsm.context.last_result
280
+ if result:
281
+ fsm.context.messages.append({
282
+ "role": "tool", "content": str(result)[:2000], # truncate
283
+ "tool": fsm.context.last_tool,
284
+ })
285
+ fsm.transition(AgentState.DECIDE, "osservazione completata")
286
+
287
+ async def _state_decide(self, fsm: AgentFSM) -> None:
288
+ """DECIDE: goal raggiunto? Continua o termina."""
289
+ tool_queue = fsm.context.metadata.get("tool_queue", [])
290
+ if tool_queue:
291
+ fsm.transition(AgentState.THINK, f"coda non vuota: {len(tool_queue)} step rimanenti")
292
+ elif fsm.context.error:
293
+ fsm.transition(AgentState.FAILED, fsm.context.error)
294
+ else:
295
+ fsm.transition(AgentState.DONE, "tutti gli step completati")
296
+
297
+
298
+ # ── AgentFSMManager (registry delle FSM attive) ─────────────────────────────────
299
+
300
+ class AgentFSMManager:
301
+ def __init__(self) -> None:
302
+ self._instances: dict[str, AgentFSM] = {}
303
+ self._runner = AgentFSMRunner()
304
+ self._tasks: dict[str, asyncio.Task] = {}
305
+ self._lock = asyncio.Lock()
306
+ self._MAX_STORE = 200
307
+
308
+ async def start(self, req: FSMRunRequest) -> FSMStatus:
309
+ fsm_id = str(uuid.uuid4())
310
+ fsm = AgentFSM(fsm_id, req.goal, req.max_steps, req.hints)
311
+ if req.session_id:
312
+ fsm.context.session_id = req.session_id
313
+ async with self._lock:
314
+ self._instances[fsm_id] = fsm
315
+ if len(self._instances) > self._MAX_STORE:
316
+ oldest = sorted(self._instances, key=lambda k: self._instances[k].started_at)
317
+ for k in oldest[:10]:
318
+ self._instances.pop(k, None)
319
+
320
+ task = asyncio.create_task(self._runner.run(fsm))
321
+ self._tasks[fsm_id] = task
322
+ _logger.info("[fsm-manager] started fsm_id=%s goal=%s", fsm_id, req.goal[:60])
323
+ return fsm.to_status()
324
+
325
+ def get(self, fsm_id: str) -> FSMStatus | None:
326
+ fsm = self._instances.get(fsm_id)
327
+ return fsm.to_status() if fsm else None
328
+
329
+ def list_active(self, limit: int = 20) -> list[FSMStatus]:
330
+ all_fsm = sorted(self._instances.values(), key=lambda f: f.started_at, reverse=True)
331
+ return [f.to_status() for f in all_fsm[:limit]]
332
+
333
+ def status(self) -> dict:
334
+ instances = list(self._instances.values())
335
+ return {
336
+ "total": len(instances),
337
+ "running": sum(1 for f in instances if not f.done),
338
+ "done": sum(1 for f in instances if f.state == AgentState.DONE),
339
+ "failed": sum(1 for f in instances if f.state == AgentState.FAILED),
340
+ }
341
+
342
+
343
+ # ── Singletons ──────────────────────────────────────────────────────────────────
344
+ fsm_manager = AgentFSMManager()
345
+
346
+ # ── HTTP Router ──────────────────────────────────────────────────────────────────
347
+ router = APIRouter(
348
+ prefix="/api/agent-fsm",
349
+ tags=["agent-fsm"],
350
+ dependencies=[Depends(require_role(AuthRole.MACHINE))],
351
+ )
352
+
353
+
354
+ @router.post("/run", summary="Avvia un AgentFSM run (goal β†’ DONE|FAILED in background)")
355
+ async def route_run(req: FSMRunRequest) -> FSMStatus:
356
+ return await fsm_manager.start(req)
357
+
358
+
359
+ @router.get("/runs/{fsm_id}", summary="Stato di un AgentFSM run")
360
+ async def route_get(fsm_id: str) -> FSMStatus:
361
+ status = fsm_manager.get(fsm_id)
362
+ if not status:
363
+ raise HTTPException(404, f"FSM '{fsm_id}' non trovata")
364
+ return status
365
+
366
+
367
+ @router.get("/runs", summary="Lista AgentFSM runs attivi")
368
+ async def route_list(limit: int = 20) -> dict:
369
+ runs = fsm_manager.list_active(limit)
370
+ return {"count": len(runs), "runs": [r.model_dump() for r in runs]}
371
+
372
+
373
+ @router.get("/status", summary="Stato aggregato AgentFSM manager")
374
+ async def route_status() -> dict:
375
+ return fsm_manager.status()
api/agent_loop_routes.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """agent_loop_routes.py β€” Route SSE legacy, persona helpers, reason/unified/loop, agent-kernel.
2
+
3
+ Estratto da agent.py (split 2026-06-30).
4
+ Route coperte:
5
+ POST /run_loop (deprecated 410)
6
+ POST /api/agent/run-stream (SSE legacy loop)
7
+ POST /api/reason/loop
8
+ POST /api/unified/loop
9
+ GET /api/agent-kernel/status
10
+ POST /api/agent-kernel/dispatch
11
+ """
12
+ from __future__ import annotations
13
+ import os, asyncio, json, uuid, time, re
14
+ import re as _re_persona
15
+ from fastapi import APIRouter, HTTPException, Request, Body
16
+ router = APIRouter()
17
+ from fastapi.responses import StreamingResponse
18
+ from pydantic import BaseModel, field_validator
19
+ from typing import Literal
20
+ from .state import (
21
+ _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
22
+ _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
23
+ _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
24
+ ReasonLoopIn, AgentTaskIn,
25
+ write_ahead_task_created,
26
+ )
27
+ from .speculative import fire_speculative_tools
28
+ try:
29
+ from .quality_guardian import run_quality_check as _run_quality_check
30
+ except Exception as _qg_err:
31
+ import logging as _qg_log; _qg_log.getLogger(__name__).warning("[routes] quality_guardian import failed: %s", _qg_err)
32
+ _run_quality_check = None
33
+ import logging
34
+ _logger = logging.getLogger("api.agent")
35
+ from .persistence import (
36
+ sb_upsert_task, sb_update_status, sb_append_event,
37
+ sb_restore_task, sb_get_events, sb_delete_task_events,
38
+ sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
39
+ sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
40
+ )
41
+ from ._agent_helpers import (
42
+ _RE_SURROGATES, _ss, _log_task_exc,
43
+ _PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
44
+ _build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
45
+ )
46
+ async def run_loop_removed():
47
+ """S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
48
+ raise HTTPException(
49
+ status_code=410,
50
+ detail={
51
+ "error": "Gone",
52
+ "message": "Endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream",
53
+ "migration": "/api/agent/tasks",
54
+ },
55
+ )
56
+
57
+
58
+ # ── SSE run-stream ────────────────────────────────────────────────────────────
59
+
60
+ @router.post('/api/agent/run-stream')
61
+ async def agent_run_stream(body: ReasonLoopIn, request: Request):
62
+ # S-BENCH: auth guard β€” consistente con /api/exec e /api/execute-shell
63
+ _itok = os.getenv('INTERNAL_TOKEN', '')
64
+ if _itok and request.headers.get('X-Internal-Token') != _itok:
65
+ raise HTTPException(401, 'Unauthorized')
66
+ async def generate():
67
+ queue: asyncio.Queue = asyncio.Queue()
68
+
69
+ async def step_cb(step: dict) -> None:
70
+ await queue.put(step)
71
+
72
+ async def run_loop() -> None:
73
+ try:
74
+ from agents.unified_loop import UnifiedAgentLoop
75
+ # S388: usa singleton _get_ai_client() β€” nessuna re-istanziazione OpenAI() per request
76
+ client = _get_ai_client()
77
+ try:
78
+ from agents.critic import Critic
79
+ from agents.response_verifier import ResponseVerifier
80
+ _critic = Critic(llm_client=client)
81
+ _verifier = ResponseVerifier()
82
+ except Exception as _cv_err:
83
+ _logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
84
+ _critic = None
85
+ _verifier = None
86
+ # Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through)
87
+ _resume_ctx = getattr(body, '_resume_context', None)
88
+ _resume_max = getattr(body, '_resume_max_steps', None) or body.max_steps
89
+ context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
90
+ # Bug-5-FIX: resume context iniettato DOPO che context_str Γ¨ definito (era NameError)
91
+ if _resume_ctx:
92
+ context_str = f"[RIPRESA AUTOMATICA]\n{_resume_ctx}\n\n{context_str}".strip()
93
+
94
+ loop = UnifiedAgentLoop(
95
+ llm_client=client, critic=_critic, verifier=_verifier,
96
+ memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
97
+ )
98
+ # S456-X5: prepend project context (projectMemory.getContext() dal frontend)
99
+ if body.project_context:
100
+ context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
101
+ # S456-X4: inject top failure patterns appresi dal selfLearning frontend
102
+ if body.learning_hints:
103
+ # S591: learning_hints[:3]β†’[:5] β€” piΓΉ pattern appresi nel context
104
+ hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
105
+ context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
106
+ # P35: vincoli negativi dal frontend (agentConstraints.ts β†’ VFS /.agent/constraints.json)
107
+ _neg_c = getattr(body, 'negative_constraints', '') or ''
108
+ if _neg_c:
109
+ context_str = f"[VINCOLI OPERATIVI APPRESI β€” NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
110
+ result = await loop.run(
111
+ goal=body.goal, context=context_str,
112
+ max_steps=body.max_steps, on_step=step_cb,
113
+ session_id=getattr(body, "session_id", "") or "",
114
+ )
115
+ await queue.put({
116
+ '__done__': True,
117
+ 'result': result.get('output', ''),
118
+ 'engine': result.get('engine', 'fallback'),
119
+ 'success': result.get('success', False),
120
+ })
121
+ except Exception as exc:
122
+ # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
123
+ try:
124
+ from api.incident_registry import log_incident as _log_inc
125
+ asyncio.create_task(_log_inc(
126
+ task_id=body.goal[:32].replace(' ', '_'),
127
+ goal=body.goal, error=str(exc), source="agent",
128
+ )).add_done_callback(_log_task_exc)
129
+ except Exception as _exc:
130
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
131
+ await queue.put({'__error__': str(exc)})
132
+
133
+ task = asyncio.create_task(run_loop())
134
+ task_id = body.goal[:32].replace(' ', '_')
135
+ # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
136
+ _run_stream_tasks[task_id] = {"task": task, "queue": queue}
137
+ yield "retry: 3000\n\n"
138
+ yield f"data: {json.dumps({'type': 'task_start', 'taskId': task_id})}\n\n"
139
+
140
+ # S386: fast-fail β€” se tutti i provider sono down (heartbeat lo sa giΓ ),
141
+ # non aspettare 120s di tentativi: rispondi subito con errore chiaro.
142
+ try:
143
+ from api.state import _heartbeat_state
144
+ _providers = _heartbeat_state.get("providers", [])
145
+ if _providers and not any(p.get("ok") for p in _providers):
146
+ task.cancel()
147
+ _names = ", ".join(p["name"] for p in _providers)
148
+ yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': 'system', 'abort_source': 'no_providers', 'error': f'Nessun provider AI disponibile ({_names})'})}\n\n" # MX18-ABORT: no providers β†’ system abort
149
+ yield "data: [DONE]\n\n"
150
+ return
151
+ except Exception as _hb_err:
152
+ _logger.debug("[routes] heartbeat skip silenced: %s", type(_hb_err).__name__) # non inizializzato, prosegui normalmente
153
+
154
+ # S386: timeout ridotto 120β†’60s β€” risposta entro 1 minuto o errore esplicito
155
+ timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60'))
156
+ heartbeat_secs = 15.0
157
+ elapsed = 0.0
158
+ try:
159
+ while True:
160
+ try:
161
+ item = await asyncio.wait_for(queue.get(), timeout=heartbeat_secs)
162
+ elapsed = 0.0
163
+ except asyncio.TimeoutError:
164
+ elapsed += heartbeat_secs
165
+ if elapsed >= timeout_secs:
166
+ yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': 'timeout', 'abort_source': 'stream_timeout'})}\n\n" # MX18-ABORT: timeout β†’ task_aborted
167
+ break
168
+ yield 'data: {"type":"ping"}\n\n'
169
+ continue
170
+ # ABORT-2: segnale abort dall'endpoint POST /api/agent/abort
171
+ if "__abort__" in item:
172
+ _ar = item.get('abort_reason', 'user_stop') # MX18-ABORT: dynamic reason
173
+ _src = item.get('abort_source', 'backend_abort_queue')
174
+ yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': _ar, 'abort_source': _src})}\n\n" # MX16+MX18-ABORT
175
+ break
176
+ if '__error__' in item:
177
+ yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': _ss(item['__error__'])})}\n\n"
178
+ break
179
+ # S420: streaming token β€” emetti subito al frontend senza accumulare
180
+ if item.get('action') == 'text_chunk':
181
+ yield f"data: {json.dumps({'type': 'text_chunk', 'token': _ss(item.get('token', '')), 'taskId': task_id})}\n\n"
182
+ continue
183
+ # S758-P4.1: tool_use β€” chip pre-esecuzione (agent_run_stream path)
184
+ _rs_act = item.get('action', '')
185
+ _rs_st = item.get('status', '')
186
+ if ((_rs_act == 'tool_start' and _rs_st == 'running') or
187
+ (_rs_act.startswith('executor:') and _rs_st == 'started')):
188
+ _rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
189
+ yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': item.get('title', _rs_tool.replace('_', ' ').capitalize())})}\n\n"
190
+ if '__done__' in item:
191
+ yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n"
192
+ break
193
+ # S393 Priority 1: Narrative Streaming β€” arricchisce step_done con explanation
194
+ _NARR_QUICK = {
195
+ 'llm': 'Elaborazione risposta AI',
196
+ 'direct_tools': 'Strumenti diretti',
197
+ 'web_search': 'Ricerca web', 'get_weather': 'Dati meteo',
198
+ 'read_page': 'Lettura pagina', 'calculate': 'Calcolo matematico',
199
+ 'generate_image': 'Generazione immagine AI',
200
+ 'execution_validator_fix': 'Auto-correzione codice (S393)',
201
+ 'tool_governor_skip': 'Tool giΓ  eseguito β€” risultato riutilizzato',
202
+ # S661: label narrative per tool aggiunti in S648-S659 β€” prima usavano
203
+ # _act_q.replace('_',' ').capitalize() β†’ "Apply patch", "Call api" (generico)
204
+ 'apply_patch': 'Applico patch al file…',
205
+ 'call_api': 'Chiamo API REST…',
206
+ 'send_email': 'Invio email…',
207
+ 'create_pdf': 'Genero documento PDF…',
208
+ 'web_research': 'Ricerca multi-fonte…',
209
+ 'write_file': 'Scrivo file…',
210
+ 'read_file': 'Leggo file…',
211
+ 'execute_shell': 'Eseguo comando shell…',
212
+ 'analyze_image': 'Analizzo immagine…',
213
+ 'run_python': 'Eseguo Python (Pyodide)…',
214
+ # S-GAP1: narrative fasi strategiche
215
+ 'plan': 'Analizzo la richiesta e preparo un piano di esecuzione…',
216
+ 'reflective_debug': 'Ho incontrato un ostacolo β€” ricalcolo una strategia piΓΉ efficiente…',
217
+ 'fallback': 'Adotto un approccio alternativo per completare il task…',
218
+ 'smolagents': 'Orchestro gli strumenti necessari…',
219
+ }
220
+ _act_q = item.get('action', '')
221
+ if 'explanation' not in item:
222
+ item['explanation'] = _NARR_QUICK.get(_act_q, _act_q.replace('_', ' ').capitalize())
223
+ if 'title' not in item:
224
+ item['title'] = item['explanation']
225
+
226
+ # S403: SSE Visibility Guard β€” classifica ogni step event:
227
+ # "internal" β†’ mai visibile (pipeline internals: planner, llm, reflection)
228
+ # "progress" β†’ visibile come progress card (tool reali, auto-fix)
229
+ # "debug" β†’ visibile solo in dev mode (direct_tools, fast_path)
230
+ # Il frontend filtra per visibility β€” solo "progress" mostrato all'utente.
231
+ _STEP_VISIBILITY: dict[str, str] = {
232
+ # Internal pipeline β€” never shown to user
233
+ 'plan': 'progress', # S-GAP1
234
+ 'llm': 'internal',
235
+ 'smolagents': 'internal',
236
+ 'fallback': 'progress', # S-GAP1
237
+ 'reflective_debug': 'progress', # S-GAP1
238
+ 'fast_path': 'internal',
239
+ 'executor': 'internal',
240
+ # Progress β€” shown as step cards (user-visible)
241
+ 'tool_start': 'progress',
242
+ 'execution_validator_fix': 'progress',
243
+ 'goal_verifier': 'progress',
244
+ 'web_search': 'progress',
245
+ 'get_weather': 'progress',
246
+ 'read_page': 'progress',
247
+ 'calculate': 'progress',
248
+ 'generate_image': 'progress',
249
+ 'run_python': 'progress',
250
+ 'tool_governor_skip': 'progress',
251
+ # S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY β†’
252
+ # fallback rule: _act_q.startswith('tool_') era False per questi β†’
253
+ # classificati 'debug' β†’ nascosti all'utente durante esecuzione.
254
+ 'apply_patch': 'progress',
255
+ 'call_api': 'progress',
256
+ 'send_email': 'progress',
257
+ 'create_pdf': 'progress',
258
+ 'web_research': 'progress',
259
+ 'write_file': 'progress',
260
+ 'read_file': 'progress',
261
+ 'execute_shell': 'progress',
262
+ 'analyze_image': 'progress',
263
+ # Debug β€” shown only when devMode active
264
+ 'direct_tools': 'debug',
265
+ # S-LOOP2: fase esecuzione avanzata β€” visibili come progress card
266
+ 'reasoning_core': 'progress', # S-LOOP2: ReasoningCore multi-step
267
+ 'browser_verifier': 'progress', # S-LOOP2: Browser Goal Verification live
268
+ }
269
+ # Fallback: azioni sconosciute con "tool_" prefix β†’ progress; resto β†’ debug
270
+ _vis = _STEP_VISIBILITY.get(_act_q)
271
+ if _vis is None:
272
+ _vis = 'progress' if _act_q.startswith('tool_') or _act_q.startswith('executor:') else 'debug'
273
+ item['visibility'] = _vis
274
+
275
+ yield f"data: {json.dumps({'type': 'step_done', 'step': item, 'taskId': task_id})}\n\n"
276
+ finally:
277
+ task.cancel()
278
+ # ABORT-3: cleanup registro β€” libera memoria e impedisce abort su task giΓ  terminati
279
+ _run_stream_tasks.pop(task_id, None)
280
+ yield "data: [DONE]\n\n"
281
+
282
+ return StreamingResponse(generate(), media_type="text/event-stream",
283
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
284
+
285
+
286
+ # ── Reason loop / Unified loop ─────────────────────────────────────────────────
287
+
288
+ @router.post('/api/reason/loop')
289
+ async def reason_loop(body: ReasonLoopIn):
290
+ try:
291
+ from agents.unified_loop import UnifiedAgentLoop
292
+ # S388: singleton β€” riusa il client giΓ  inizializzato
293
+ client = _get_ai_client()
294
+ try:
295
+ from agents.critic import Critic
296
+ from agents.response_verifier import ResponseVerifier
297
+ _critic = Critic(llm_client=client)
298
+ _verifier = ResponseVerifier()
299
+ except Exception as _cv_err:
300
+ _logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
301
+ _critic = None
302
+ _verifier = None
303
+ loop = UnifiedAgentLoop(
304
+ llm_client=client, critic=_critic, verifier=_verifier,
305
+ memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
306
+ )
307
+ context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
308
+ # N-2-FIX: accumula step intermedi tramite on_step β€” inclusi nel response JSON per debug frontend
309
+ _steps_log: list[dict] = []
310
+ async def _on_step(step_data: dict) -> None:
311
+ _steps_log.append({
312
+ 'action': step_data.get('action', ''),
313
+ 'output': str(step_data.get('output', ''))[:400], # S577: 200β†’400
314
+ })
315
+ result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
316
+ if isinstance(result, dict):
317
+ output_text = result.get('output', '') or result.get('answer', '') or ''
318
+ engine_used = result.get('engine', 'ambiguity-gate' if result.get('answer') else 'unknown')
319
+ errors_list = result.get('errors', [])
320
+ else:
321
+ output_text = str(result)
322
+ engine_used = 'unknown'
323
+ errors_list = []
324
+ return {
325
+ 'ok': bool(output_text and output_text.strip()),
326
+ 'success': bool(output_text and output_text.strip()), # alias compat frontend
327
+ 'output': output_text, # alias compat frontend
328
+ 'result': output_text,
329
+ 'source': 'backend_loop',
330
+ 'engine': engine_used,
331
+ 'errors': errors_list,
332
+ 'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
333
+ }
334
+ except Exception as e:
335
+ _logger.error("[reason/loop] Error: %s", e)
336
+ return {
337
+ 'ok': False,
338
+ 'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
339
+ 'source': 'fallback',
340
+ 'steps': [],
341
+ }
342
+
343
+
344
+ @router.post('/api/unified/loop')
345
+ async def unified_loop(body: ReasonLoopIn):
346
+ """Alias di /api/reason/loop β€” compatibilitΓ  con tutte le versioni frontend."""
347
+ return await reason_loop(body)
348
+
349
+
350
+ # ── Agent kernel ───────────────────────────────────────────────────────────────
351
+
352
+ @router.get('/api/agent-kernel/status')
353
+ async def agent_kernel_status():
354
+ gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
355
+ return {
356
+ 'dispatch_available': bool(gh_token),
357
+ 'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
358
+ 'mobile_url': 'https://github.com/Baida98/AI/actions',
359
+ 'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN', 'NVIDIA_API_KEY'],
360
+ 'usage': 'Vai su GitHub Actions β†’ Agent Kernel β€” no PC β†’ Run workflow β†’ inserisci il goal',
361
+ }
362
+
363
+
364
+ # S442-FIX3: modello Pydantic per agent_kernel_dispatch.
365
+ # Prima: body: dict grezzo β†’ mode non validato, goal controllato solo dopo estrazione.
366
+ # Ora: validazione in ingresso β†’ 422 chiaro invece di 500 a runtime.
367
+ class AgentKernelDispatchIn(BaseModel):
368
+ goal: str
369
+ mode: Literal["plan", "execute", "analyze"] = "plan"
370
+
371
+ @field_validator('goal', mode='before')
372
+ @classmethod
373
+ def validate_goal(cls, v: object) -> str:
374
+ if not isinstance(v, str) or not str(v).strip():
375
+ raise ValueError('goal must be a non-empty string')
376
+ return str(v).strip()
377
+
378
+
379
+ @router.post('/api/agent-kernel/dispatch')
380
+ async def agent_kernel_dispatch(body: AgentKernelDispatchIn):
381
+ gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
382
+ if not gh_token:
383
+ raise HTTPException(503, detail={
384
+ 'error': 'no_github_token',
385
+ 'message': 'GITHUB_TOKEN non configurato nel backend.',
386
+ })
387
+ goal = body.goal
388
+ mode = body.mode
389
+ import httpx as _httpx
390
+ try:
391
+ async with _httpx.AsyncClient(timeout=15) as _hc:
392
+ _resp = await _hc.post(
393
+ 'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
394
+ json={'ref': 'main', 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'}},
395
+ headers={
396
+ 'Authorization': f'Bearer {gh_token}',
397
+ 'Accept': 'application/vnd.github+json',
398
+ 'X-GitHub-Api-Version': '2022-11-28',
399
+ },
400
+ )
401
+ if _resp.status_code >= 400:
402
+ raise HTTPException(_resp.status_code, detail=_resp.text[:500])
403
+ return {'ok': True, 'status': _resp.status_code, 'goal': goal, 'mode': mode}
404
+ except _httpx.HTTPError as e:
405
+ raise HTTPException(502, detail=str(e)[:500])
406
+
407
+
408
+ # ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
409
+
410
+
api/agent_memory.py CHANGED
@@ -1,20 +1,25 @@
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
@@ -23,14 +28,15 @@ class MemoryEntry(BaseModel):
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
@@ -46,60 +52,40 @@ async def _reconcile_fallback() -> int:
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):
@@ -108,25 +94,31 @@ async def set_agent_memory(entry: MemoryEntry):
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}
 
1
+ """backend/api/agent_memory.py β€” Agent memory CRUD (S354).
2
+
3
  GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback β†’ Supabase.
4
+ Problema confermato: quando Supabase Γ¨ temporaneamente offline, le voci
5
+ finiscono solo in _mem_fallback (dict in-process). Al restart del backend
6
+ (HF Space free-tier riavvia spesso) il fallback viene perso completamente.
7
+ Fix: dopo ogni write Supabase riuscita, schedula un tentativo di sync del
8
+ fallback β€” se ci sono voci orfane le pubblica su Supabase e le rimuove dal
9
+ fallback locale. Nessun job periodico (troppo pesante su free-tier) β€” lazy
10
+ reconciliation al primo write riuscito dopo un periodo di downtime Supabase.
11
  """
12
  import time, asyncio
 
13
  from fastapi import APIRouter, Depends
14
  from .auth_guard import require_role, AuthRole
15
  from pydantic import BaseModel
16
+ from .state import _sb, _mem_fallback
 
17
 
18
+ import logging
19
  _logger = logging.getLogger("api.agent_memory")
20
 
21
+ router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
22
+
23
 
24
  class MemoryEntry(BaseModel):
25
  key: str
 
28
  createdAt: int = 0
29
  updatedAt: int = 0
30
 
 
 
 
 
 
31
 
32
  async def _reconcile_fallback() -> int:
33
+ """GAP-MEM-FIX: sincronizza voci _mem_fallback β†’ Supabase.
34
+
35
+ Chiama dopo ogni write Supabase riuscita: se ci sono voci scritte
36
+ solo in fallback (es. dopo un periodo di downtime Supabase), le pubblica.
37
+ Ritorna il numero di voci sincronizzate.
38
+ Non solleva mai eccezioni β€” fire-and-forget.
39
+ """
40
  if not _sb or not _mem_fallback:
41
  return 0
42
  synced = 0
 
52
  synced += 1
53
  except Exception as _e:
54
  _logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
55
+ break # Supabase non disponibile β€” interrompi, riprova al prossimo write
56
  if synced:
57
  _logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
58
  return synced
59
 
60
+
61
  @router.get('/api/memory/agent')
62
  async def list_agent_memory():
 
63
  if _sb:
64
  try:
65
+ data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).limit(500).execute() # BUGFIX: LIMIT 500 β€” senza limit OOM su account grandi
66
  entries = [
67
+ {'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
68
+ 'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
 
 
 
 
 
69
  for r in (data.data or [])
70
  ]
71
  return {'entries': entries}
72
  except Exception as e:
73
  _logger.warning('[memory] Supabase list error: %s', e)
74
+ return {'entries': list(_mem_fallback.values())}
75
+
 
 
 
 
 
 
 
 
 
 
76
 
77
  @router.get('/api/memory/agent/{key}')
78
  async def get_agent_memory(key: str):
 
 
79
  if _sb:
80
  try:
81
  data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
82
  if data.data:
83
+ return {'value': data.data[0]['value']}
84
  except Exception as e:
85
  _logger.warning('[memory] Supabase get error: %s', e)
86
+ entry = _mem_fallback.get(key)
87
+ return {'value': entry['value'] if entry else None}
88
+
 
 
 
89
 
90
  @router.post('/api/memory/agent')
91
  async def set_agent_memory(entry: MemoryEntry):
 
94
  'key': entry.key, 'value': entry.value, 'category': entry.category,
95
  'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
96
  }
97
+ # Sempre scrivi in fallback prima (garanzia immediata)
98
  _mem_fallback[entry.key] = record
99
+
100
  if _sb:
101
  try:
102
  _sb.table('agent_memory').upsert({
103
  'key': entry.key, 'value': entry.value, 'category': entry.category,
104
  'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
105
  }, on_conflict='key').execute()
106
+ # GAP-MEM-FIX: Supabase disponibile β†’ schedula riconciliazione fallback orfano
107
+ # (voci scritte solo in fallback durante downtime precedente)
108
  if len(_mem_fallback) > 1:
109
  asyncio.create_task(_reconcile_fallback())
110
  except Exception as _e:
111
  _logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
112
+
113
  return {'ok': True, 'key': entry.key}
114
 
115
+
116
  @router.delete('/api/memory/agent/{key}')
117
  async def delete_agent_memory(key: str):
118
  if _sb:
119
  try:
120
  _sb.table('agent_memory').delete().eq('key', key).execute()
121
  except Exception as _exc:
122
+ _logger.debug("[agent_memory] silenced %s", type(_exc).__name__) # noqa: BLE001
123
  _mem_fallback.pop(key, None)
124
  return {'deleted': key}
api/agent_task_routes.py ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """agent_task_routes.py β€” Task CRUD: crea, lista, cancella, stato, stream SSE.
2
+
3
+ Estratto da agent.py (split 2026-06-30).
4
+ Route coperte:
5
+ POST /api/agent/tasks
6
+ GET /api/agent/tasks
7
+ DELETE /api/agent/tasks/{task_id}
8
+ GET /api/agent/tasks/{task_id}/status
9
+ GET /api/agent/tasks/{task_id}/stream (SSE principale, S359 persist)
10
+ """
11
+ from __future__ import annotations
12
+ import os, asyncio, json, uuid, time, re
13
+ import re as _re_persona
14
+ from fastapi import APIRouter, HTTPException, Request, Body
15
+ from fastapi.responses import StreamingResponse
16
+ from pydantic import BaseModel, field_validator
17
+ from typing import Literal
18
+ from .state import (
19
+ _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
20
+ _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
21
+ _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
22
+ ReasonLoopIn, AgentTaskIn,
23
+ write_ahead_task_created,
24
+ )
25
+ from .speculative import fire_speculative_tools
26
+ from .prewarm import fire_predictive_prewarm # S950
27
+ try:
28
+ from .quality_guardian import run_quality_check as _run_quality_check
29
+ except Exception:
30
+ _run_quality_check = None
31
+ import logging
32
+ _logger = logging.getLogger("api.agent")
33
+ from .persistence import (
34
+ sb_upsert_task, sb_update_status, sb_append_event,
35
+ sb_restore_task, sb_get_events, sb_delete_task_events,
36
+ sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
37
+ sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
38
+ )
39
+ from ._agent_helpers import (
40
+ _RE_SURROGATES, _ss, _log_task_exc,
41
+ _PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
42
+ _build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
43
+ )
44
+ router = APIRouter()
45
+
46
+ @router.post('/api/agent/tasks')
47
+ async def create_agent_task(body: AgentTaskIn):
48
+ """
49
+ Crea o recupera un task agent.
50
+
51
+ S359: se task_id non Γ¨ in memoria ma esiste su Supabase (backend ha riavviato),
52
+ il task viene ripristinato dallo store persistente invece di essere riavviato.
53
+ Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
54
+ """
55
+ _prune_agent_tasks()
56
+ task_id = body.taskId or str(uuid.uuid4())
57
+
58
+ # Already in memory β†’ return immediately (normal path, includes S358 reconnect)
59
+ if task_id in _agent_tasks:
60
+ return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
61
+
62
+ # S359: try Supabase lazy restore (only hit network after backend restart)
63
+ restored = await sb_restore_task(task_id)
64
+ if restored:
65
+ # Put restored metadata back into memory so stream_agent_task can use it.
66
+ # Use context from the incoming request (not persisted to save space).
67
+ restored['context'] = body.context
68
+ _agent_tasks[task_id] = restored
69
+ return {'taskId': task_id, 'status': restored['status'], 'restored': True}
70
+
71
+ # Brand new task
72
+ created_at = int(time.time() * 1000)
73
+ _agent_tasks[task_id] = {
74
+ 'id': task_id,
75
+ 'status': 'QUEUED',
76
+ 'goal': body.goal,
77
+ 'context': body.context,
78
+ 'max_steps': body.max_steps,
79
+ 'created_at': created_at,
80
+ 'project_context': body.project_context, # S456-X5
81
+ 'learning_hints': body.learning_hints, # S456-X4
82
+ 'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
83
+ 'persona': body.persona, # P17-F5: expertise persona hint
84
+ 'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
85
+ }
86
+ # WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
87
+ # periodico (15-60s). Finestra di perdita per la fase di creazione β†’ zero.
88
+ asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
89
+ # BG-4: restore cross-session handoff context (async, non-blocking)
90
+ if body.session_id:
91
+ _hctx = await sb_restore_handoff_context(body.session_id)
92
+ if _hctx:
93
+ _agent_tasks[task_id]['_handoff_context'] = _hctx
94
+ asyncio.create_task(sb_delete_handoff(body.session_id)).add_done_callback(_log_task_exc)
95
+ # Persist asynchronously β€” never block the response
96
+ asyncio.create_task(
97
+ sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
98
+ ).add_done_callback(_log_task_exc)
99
+ # S361: Speculative Tool Firing β€” pre-fires read-only tools in parallel
100
+ # while the main model processes. Results cached for _run_direct_tools to consume.
101
+ asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
102
+ # S950: Predictive Pre-warming cluster 4x4
103
+ fire_predictive_prewarm(body.goal)
104
+ return {'taskId': task_id, 'status': 'QUEUED'}
105
+
106
+
107
+ # ── S369: List agent tasks (in-memory + Supabase merge) ─────────────────────
108
+
109
+ @router.get('/api/agent/tasks')
110
+ async def list_agent_tasks(limit: int = 50, status: str = ''):
111
+ """
112
+ S369 β€” Lista tutti i task agent: unione di in-memory (_agent_tasks) e
113
+ Supabase (ultimi N task persistiti). In-memory ha sempre precedenza.
114
+
115
+ Query params:
116
+ limit β€” max task da Supabase (default 50, max 200)
117
+ status β€” filtra per status (es. RUNNING, SUCCESS, ERROR); vuoto = tutti
118
+ """
119
+ _prune_agent_tasks()
120
+ now_ms = int(time.time() * 1000)
121
+ limit = min(max(limit, 1), 200)
122
+
123
+ # 1. Task in-memory (live)
124
+ mem_tasks = []
125
+ for tid, t in _agent_tasks.items():
126
+ reg = _loop_registry.get(tid)
127
+ is_live = reg is not None and not reg.get('done', True)
128
+ mem_tasks.append({
129
+ 'taskId': tid,
130
+ 'goal': (t.get('goal') or '')[:300], # S606: 200β†’300
131
+ 'status': t.get('status', 'UNKNOWN'),
132
+ 'maxSteps': t.get('max_steps', 8),
133
+ 'createdAt': t.get('created_at', 0),
134
+ 'ageMs': now_ms - t.get('created_at', now_ms),
135
+ 'source': 'memory',
136
+ 'isLive': is_live,
137
+ })
138
+
139
+ mem_ids = {t['taskId'] for t in mem_tasks}
140
+
141
+ # 2. Supabase recent tasks (only if Supabase available)
142
+ sb_tasks = []
143
+ try:
144
+ sb_rows = await sb_list_tasks(limit=limit, status_filter=status or None)
145
+ for r in sb_rows:
146
+ if r['task_id'] in mem_ids:
147
+ continue # already included from memory
148
+ sb_tasks.append({
149
+ 'taskId': r['task_id'],
150
+ 'goal': (r.get('goal') or '')[:300], # S606: 200β†’300
151
+ 'status': r.get('status', 'UNKNOWN'),
152
+ 'maxSteps': r.get('max_steps', 8),
153
+ 'createdAt': r.get('created_at', 0),
154
+ 'ageMs': now_ms - r.get('created_at', now_ms),
155
+ 'source': 'supabase',
156
+ 'isLive': False,
157
+ })
158
+ except Exception as _exc:
159
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
160
+
161
+ all_tasks = mem_tasks + sb_tasks
162
+ # Apply status filter to in-memory tasks too
163
+ if status:
164
+ all_tasks = [t for t in all_tasks if t['status'] == status.upper()]
165
+
166
+ # Sort by createdAt desc (newest first)
167
+ all_tasks.sort(key=lambda t: t['createdAt'], reverse=True)
168
+
169
+ return {
170
+ 'count': len(all_tasks),
171
+ 'memory': len(mem_tasks),
172
+ 'supabase': len(sb_tasks),
173
+ 'tasks': all_tasks[:limit],
174
+ }
175
+
176
+
177
+ @router.delete('/api/agent/tasks/{task_id}')
178
+ async def cancel_agent_task(task_id: str):
179
+ if task_id in _agent_tasks:
180
+ _agent_tasks[task_id]['status'] = 'CANCELLED'
181
+ reg = _loop_registry.get(task_id)
182
+ if reg and not reg.get('done'):
183
+ at = reg.get('asyncio_task')
184
+ if at and not at.done():
185
+ at.cancel()
186
+ # Persist status + clean up events
187
+ asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
188
+ asyncio.create_task(sb_delete_task_events(task_id)).add_done_callback(_log_task_exc)
189
+ # S361: clean speculative cache for cancelled task
190
+ try:
191
+ goal = _agent_tasks.get(task_id, {}).get('goal', '')
192
+ if goal:
193
+ from .speculative import purge_speculative
194
+ purge_speculative(goal)
195
+ except Exception as _exc:
196
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
197
+ return {'cancelled': task_id}
198
+
199
+
200
+
201
+ @router.get('/api/agent/tasks/{task_id}/status')
202
+ async def get_agent_task_status(task_id: str):
203
+ """
204
+ Controlla lo stato di un task agent senza aprire un SSE stream.
205
+ Usato dal frontend per recovery al boot: verifica se un task in sospeso
206
+ e` ancora in esecuzione, completato, o scomparso dopo riavvio HF Space.
207
+ Returns: {taskId, status, goal, source: 'memory'|'supabase'|'not_found'}
208
+ """
209
+ if task_id in _agent_tasks:
210
+ t = _agent_tasks[task_id]
211
+ return {'taskId': task_id, 'status': t.get('status', 'UNKNOWN'),
212
+ 'goal': (t.get('goal') or '')[:300], 'source': 'memory'}
213
+ restored = await sb_restore_task(task_id)
214
+ if restored:
215
+ return {'taskId': task_id, 'status': restored.get('status', 'UNKNOWN'),
216
+ 'goal': (restored.get('goal') or '')[:300], 'source': 'supabase'}
217
+ return {'taskId': task_id, 'status': 'NOT_FOUND', 'source': None}
218
+
219
+
220
+ @router.get('/api/agent/tasks/{task_id}/stream')
221
+ async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
222
+ """
223
+ SSE stream per un task agent.
224
+
225
+ S358: reconnect-safe via _loop_registry fanout (no re-run mentre il backend gira).
226
+ S359: lazy restore da Supabase dopo restart HF Space:
227
+ - Task SUCCESS/ERROR β†’ replay event buffer da Supabase β†’ chiusura immediata.
228
+ - Task era RUNNING β†’ replay buffer parziale + evento task_interrupted.
229
+ - Task non trovato β†’ prova sb_restore_task prima di 404.
230
+ """
231
+ # S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
232
+ if task_id not in _agent_tasks:
233
+ restored = await sb_restore_task(task_id)
234
+ if restored:
235
+ restored['context'] = []
236
+ _agent_tasks[task_id] = restored
237
+ else:
238
+ raise HTTPException(404, detail=f'Task {task_id} non trovato')
239
+
240
+ task = _agent_tasks[task_id]
241
+ _last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
242
+ _resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
243
+
244
+ sub_q: asyncio.Queue[str | None] = asyncio.Queue()
245
+
246
+ async def generate():
247
+ yield "retry: 3000\n\n"
248
+
249
+ reg = _loop_registry.get(task_id)
250
+
251
+ is_done_reconnect = reg is not None and reg.get('done', False)
252
+ is_reconnect = reg is not None and not reg.get('done', False)
253
+
254
+ # ── Case 1: loop giΓ  finito in questa sessione β†’ replay buffer in-memory ──
255
+ if is_done_reconnect:
256
+ for evt_str in reg['event_buffer'][_resume_from:]:
257
+ yield evt_str
258
+ yield "data: [DONE]\n\n"
259
+ return
260
+
261
+ # ── Case 2: loop attivo in questa sessione β†’ reconnect SSE (S358) ─────────
262
+ if is_reconnect:
263
+ join_idx = len(reg['event_buffer'])
264
+ reg['subscriber_queues'].append(sub_q)
265
+ try:
266
+ for evt_str in reg['event_buffer'][_resume_from:join_idx]:
267
+ yield evt_str
268
+ while True:
269
+ if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
270
+ break
271
+ try:
272
+ item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
273
+ if item is None:
274
+ break
275
+ yield item
276
+ except asyncio.TimeoutError:
277
+ yield ': heartbeat\n\n'
278
+ finally:
279
+ try:
280
+ reg['subscriber_queues'].remove(sub_q)
281
+ except ValueError as _exc:
282
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
283
+ yield "data: [DONE]\n\n"
284
+ return
285
+
286
+ # ── Case 2.5 (S359): backend riavviato β†’ prova Supabase event buffer ──────
287
+ sb_events = await sb_get_events(task_id)
288
+ if sb_events:
289
+ task_status = task.get('status', 'UNKNOWN')
290
+ terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
291
+ # Replay buffer from resume point
292
+ for evt_str in sb_events[_resume_from:]:
293
+ yield evt_str
294
+ if terminal:
295
+ # Task giΓ  completato β†’ niente da fare, client ha tutto
296
+ yield "data: [DONE]\n\n"
297
+ return
298
+ else:
299
+ # Task era in esecuzione quando il backend Γ¨ crashato β€” prova resume automatico
300
+ _cp_sb = _task_checkpoints.get(task_id) or await sb_get_checkpoint(task_id)
301
+ _can_resume = (
302
+ _cp_sb is not None and
303
+ len(_cp_sb.get('plan', [])) >= 1 and
304
+ len(_cp_sb.get('logs', [])) >= 2
305
+ )
306
+ if _can_resume:
307
+ # GAP-SYNC-FIX: usa _backend_steps se disponibili (context preciso per resume)
308
+ _bsteps = _cp_sb.get('_backend_steps', [])
309
+ if _bsteps:
310
+ _steps_text = '\n'.join(
311
+ f" Passo {s['step']}: {s['action']} β†’ {s['result'][:80]}"
312
+ for s in _bsteps[-8:]
313
+ )
314
+ _rctx = (
315
+ f"[RESUME AUTOMATICO] Step giΓ  completati dal backend:\n{_steps_text}\n"
316
+ f"Riprendi dal passo {_cp_sb.get('step', 0)+1} senza ripetere quelli giΓ  eseguiti."
317
+ )
318
+ else:
319
+ # Fallback: context semantico (piano + log riassuntivi)
320
+ _rctx = (
321
+ f"Piano giΓ  definito: {' | '.join((_cp_sb.get('plan') or [])[:5])}\n"
322
+ f"Log fin qui: {' | '.join((_cp_sb.get('logs') or [])[-5:])}\n"
323
+ f"Riprendi dal passo {_cp_sb.get('step', 0)} senza ripetere gli step giΓ  fatti."
324
+ )
325
+ task['_resume_context'] = _rctx
326
+ task['_resume_max_steps'] = max(1, task.get('max_steps', 8) - _cp_sb.get('step', 0))
327
+ # Fall through a Case 3 β€” NON fare return
328
+ else:
329
+ # Nessun checkpoint utile β†’ fallback onesto (comportamento precedente)
330
+ interrupted_evt = json.dumps({
331
+ 'event': 'task_interrupted',
332
+ 'taskId': task_id,
333
+ 'reason': 'backend_restarted',
334
+ 'message': 'Il backend si Γ¨ riavviato durante l\'esecuzione. '
335
+ 'Premi "Riprova" per rieseguire il task.',
336
+ })
337
+ yield f"data: {interrupted_evt}\n\n"
338
+ _agent_tasks[task_id]['status'] = 'ERROR'
339
+ asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
340
+ yield "data: [DONE]\n\n"
341
+ return
342
+ # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
343
+ _prune_loop_registry()
344
+ reg_entry: dict = {
345
+ 'asyncio_task': None,
346
+ 'event_buffer': [],
347
+ 'subscriber_queues': [sub_q],
348
+ 'done': False,
349
+ 'finished_at': 0.0,
350
+ }
351
+ _loop_registry[task_id] = reg_entry
352
+ _ctr = [0]
353
+
354
+ def _sse(event: str, data: dict) -> None:
355
+ """Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
356
+ _ctr[0] += 1
357
+ s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
358
+ # GAP-3-FIX: text_chunk bypass buffer β€” fanout diretto, no persist.
359
+ # 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
360
+ # Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
361
+ if event == 'text_chunk':
362
+ for q in list(reg_entry['subscriber_queues']):
363
+ try:
364
+ q.put_nowait(s)
365
+ except Exception as _exc:
366
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
367
+ return
368
+ reg_entry['event_buffer'].append(s)
369
+ # N-5-FIX: cap buffer a 500 eventi β€” evita crescita illimitata su task lunghi
370
+ if len(reg_entry['event_buffer']) > 500:
371
+ reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
372
+ for q in list(reg_entry['subscriber_queues']):
373
+ try:
374
+ q.put_nowait(s)
375
+ except Exception as _exc:
376
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
377
+ # S359: persist event asynchronously (fire-and-forget)
378
+ asyncio.create_task(sb_append_event(task_id, _ctr[0], s)).add_done_callback(_log_task_exc)
379
+
380
+ _agent_tasks[task_id]['status'] = 'RUNNING'
381
+ asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
382
+ _prune_agent_tasks()
383
+
384
+ async def run_loop() -> None:
385
+ try:
386
+ from agents.unified_loop import UnifiedAgentLoop
387
+ # S388: singleton β€” evita OpenAI() per ogni task
388
+ client = _get_ai_client()
389
+ try:
390
+ from agents.critic import Critic
391
+ from agents.response_verifier import ResponseVerifier
392
+ _critic = Critic(llm_client=client)
393
+ _verifier = ResponseVerifier()
394
+ except Exception:
395
+ _critic = None
396
+ _verifier = None
397
+
398
+ context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
399
+ # S456-X5/X4: inject project context + learning hints stored at task creation
400
+ _proj_ctx = task.get('project_context', '')
401
+ if _proj_ctx:
402
+ context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
403
+ _hints = task.get('learning_hints', [])
404
+ if _hints:
405
+ # S591: _hints[:3]β†’[:5] β€” piΓΉ pattern appresi nel context (task replay)
406
+ hints_str = "\n".join(f"- {h}" for h in _hints[:5])
407
+ context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
408
+ # P16-F3: inject resume hint if task was promoted from queue at a specific step
409
+ _resume_step = task.get('resume_from_step')
410
+ if _resume_step:
411
+ context_str = f"[RIPRESA DA PASSO {_resume_step}] Riprendi dall'iterazione {_resume_step} del task.\n\n{context_str}".strip()
412
+ # P39-UX: Tocco Finale Manus β€” spiega all'agente come segnalare OAuth mancante
413
+ _connector_hint = (
414
+ "[CONNETTORI OAUTH]\n"
415
+ "Se durante il task hai bisogno di un accesso OAuth (GitHub, Google Calendar, Instagram)\n"
416
+ "ma non hai il token disponibile, includi nella tua risposta finale o parziale:\n"
417
+ " [CONNECTOR_NEEDED:github] oppure [CONNECTOR_NEEDED:google] oppure [CONNECTOR_NEEDED:instagram]\n"
418
+ "Il frontend mostrerΓ  automaticamente un pulsante 'Connetti' all'utente."
419
+ )
420
+ context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
421
+ # GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
422
+ # Bug: _resume_context era settato su task{} ma mai letto qui β†’ context perduto su resume.
423
+ _resume_ctx = task.get('_resume_context', '')
424
+ if _resume_ctx:
425
+ context_str = f"{_resume_ctx}\n\n{context_str}".strip()
426
+ # P17-F5: inject Expertise Persona hint se specificato
427
+ _PERSONA_HINTS = {
428
+ "researcher": (
429
+ "[PERSONA: RICERCATORE ESPERTO]\n"
430
+ "- Priorizza sempre la ricerca web aggiornata prima di rispondere\n"
431
+ "- Cita fonti specifiche (URL, titolo, data) per ogni claim importante\n"
432
+ "- Struttura le risposte: Sommario β†’ Dettaglio β†’ Fonti\n"
433
+ "- Verifica incrociando piΓΉ fonti prima di concludere\n"
434
+ "- Strumenti preferiti: web_search, read_page, fetch_url, research"
435
+ ),
436
+ "coder": (
437
+ "[PERSONA: SENIOR ENGINEER]\n"
438
+ "- Scrivi codice production-ready: tipizzato, documentato, con error handling\n"
439
+ "- Esegui il codice per verificare il funzionamento prima di rispondere\n"
440
+ "- Preferisci soluzioni robuste e testate su approcci creativi ma fragili\n"
441
+ "- Documenta funzioni e classi con docstring/JSDoc\n"
442
+ "- Strumenti preferiti: run_python, write_file, read_file, pip_install"
443
+ ),
444
+ "architect": (
445
+ "[PERSONA: ARCHITECT]\n"
446
+ "- Priorizza analisi, design di sistema e decisioni strategiche\n"
447
+ "- Struttura l'architettura in componenti chiari e mantenibili\n"
448
+ "- Considera scalabilitΓ , manutenibilitΓ  e trade-off tecnici\n"
449
+ "- Documenta le decisioni architetturali e il loro razionale"
450
+ ),
451
+ "reasoner": (
452
+ "[PERSONA: RAGIONATORE STRATEGICO]\n"
453
+ "- Usa ragionamento step-by-step esplicito: mostra il processo di pensiero\n"
454
+ "- Analizza ogni prospettiva prima di concludere\n"
455
+ "- Struttura la risposta: Analisi β†’ Pro/Contro β†’ Raccomandazione\n"
456
+ "- Considera le implicazioni di lungo termine delle scelte"
457
+ ),
458
+ "analyst": (
459
+ "[PERSONA: ANALISTA DATI]\n"
460
+ "- Usa Python per elaborare e analizzare dati quando disponibili\n"
461
+ "- Produci visualizzazioni chiare (grafici, tabelle) ove possibile\n"
462
+ "- Interpreta i risultati con rigore: distingui correlazione da causalitΓ \n"
463
+ "- Struttura i report: Executive Summary β†’ Metodologia β†’ Risultati β†’ Conclusioni\n"
464
+ "- Strumenti preferiti: run_python, web_search, vision"
465
+ ),
466
+ }
467
+ _persona = task.get('persona') or ''
468
+ # P17-F5-IMPROVED: server-side classification se persona vuota/auto
469
+ _persona_auto = False
470
+ if not _persona:
471
+ _persona = _classify_persona_server(task.get('goal', ''))
472
+ if _persona:
473
+ _persona_auto = True
474
+ task['persona'] = _persona # persist per history/resume
475
+ _persona_hint = _PERSONA_HINTS.get(_persona.lower().strip(), '')
476
+ if _persona_hint:
477
+ context_str = f"{_persona_hint}\n\n{context_str}".strip()
478
+ # P17-F5: emit persona_classified SSE event β€” UI badge feedback
479
+ if _persona:
480
+ _persona_conf = 0.85 if not _persona_auto else 0.78
481
+ _sse('persona_classified', {
482
+ 'taskId': task_id,
483
+ 'persona': _persona,
484
+ 'confidence': _persona_conf,
485
+ 'auto': _persona_auto,
486
+ })
487
+ # BG-4: inject cross-session handoff context if available
488
+ _hctx = task.get("_handoff_context", "")
489
+ if _hctx:
490
+ context_str = f"{_hctx}\n\n{context_str}".strip()
491
+ # P17-F5: route primary LLM to persona-appropriate client
492
+ _persona_client = _get_persona_llm_client(_persona, client)
493
+ loop = UnifiedAgentLoop(
494
+ llm_client=_persona_client, critic=_critic, verifier=_verifier,
495
+ memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
496
+ )
497
+ step_idx = [0]
498
+ _backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
499
+
500
+ async def step_cb(step_data: dict) -> None:
501
+ step_idx[0] += 1
502
+ _action = step_data.get('action', f'Step {step_idx[0]}')
503
+ # S420: streaming token β€” emetti direttamente senza passare dal buffer step
504
+ if _action == 'text_chunk':
505
+ _sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
506
+ return
507
+
508
+ # S363-Blueprint: Narrative Streaming β€” explanation lookup for ALL step_done events
509
+ # S376: _STEP_NARRATIONS espanso β€” aggiunge 12 tool mancanti
510
+ # Il fallback `_action.replace('_', ' ').capitalize()` Γ¨ troppo generico
511
+ # per tool composti β€” narrativa esplicita migliora la UX del LiveStreamBlock
512
+ _STEP_NARRATIONS = {
513
+ 'plan': 'Analisi del goal e creazione piano di azione',
514
+ 'llm': 'Elaborazione risposta AI',
515
+ 'fallback': 'Completamento task',
516
+ 'smolagents': 'Esecuzione agente autonomo con strumenti',
517
+ 'web_search': 'Cerco informazioni aggiornate sul web',
518
+ 'read_page': 'Leggo il contenuto della pagina web',
519
+ 'fetch_url': 'Recupero dati dall\'URL richiesto',
520
+ 'fetch_url_content': 'Scarico il contenuto dell\'URL',
521
+ 'run_code': 'Eseguo il codice nel sandbox',
522
+ 'write_file': 'Scrivo il file nel progetto',
523
+ 'read_file': 'Leggo il file dal VFS',
524
+ 'delete_file': 'Rimuovo il file dal progetto',
525
+ 'create_file': 'Creo il file nel progetto',
526
+ 'list_files': 'Elenco i file del progetto',
527
+ 'search_github': 'Cerco codice e repository su GitHub',
528
+ 'search_github_code': 'Cerco snippet di codice su GitHub',
529
+ 'search_wikipedia': 'Consulto Wikipedia per informazioni',
530
+ 'get_weather': 'Recupero le previsioni meteo',
531
+ 'get_news': 'Carico le ultime notizie',
532
+ 'get_currency': 'Consulto il tasso di cambio',
533
+ 'get_location': 'Rilevo la posizione geografica',
534
+ 'calculate': 'Calcolo l\'espressione matematica',
535
+ 'math_eval': 'Valuto l\'espressione matematica',
536
+ 'generate_image': 'Genero l\'immagine con AI (Pollinations)',
537
+ 'remember': 'Salvo informazioni in memoria',
538
+ 'recall': 'Recupero informazioni dalla memoria',
539
+ 'direct_tools': 'Utilizzo strumenti diretti',
540
+ 'critic_retry': 'Auto-correzione risposta (Quality Gate)',
541
+ 'execution_validator_fix': 'Auto-fix codice rilevato (ExecutionValidator)',
542
+ '__thinking__': 'Ragionamento interno in corso',
543
+ '__plan__': 'Pianificazione step successivo',
544
+ '__verify__': 'Verifica e validazione risposta',
545
+ 'reflective_debug': 'Analisi root cause errore (Chain-of-Verification)',
546
+ 'lint_result': 'Validazione sintattica file',
547
+ 'lint_code': 'Analisi statica del codice',
548
+ 'project_skeleton': 'Mappa aggiornata del progetto',
549
+ 'tool_governor_skip': 'Tool giΓ  eseguito β€” risultato riutilizzato',
550
+ 'severity_retry': 'Retry adattivo per tipologia errore (S376)',
551
+ # S-LOOP2: narrations per fasi avanzate
552
+ 'reasoning_core': 'Ragionamento multi-step (ReasoningCore attivo)',
553
+ 'browser_verifier': 'Verifica app live in tempo reale (Playwright)',
554
+ }
555
+ _tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
556
+ _narration = _STEP_NARRATIONS.get(_tool_key_narr,
557
+ _action.replace('executor:', '').replace('_', ' ').capitalize())
558
+ # P16-B4: propaga 'truncated' dal loop (finish_reason==length) β†’ frontend
559
+ _step_truncated = bool(step_data.get('truncated', False))
560
+ _sse('step_done', {
561
+ 'taskId': task_id,
562
+ 'step': {
563
+ 'name': _action,
564
+ 'index': step_idx[0],
565
+ 'status': step_data.get('status', 'done'),
566
+ 'result': str(step_data.get('result', step_data.get('output', '')))[:500],
567
+ 'explanation': _narration, # S363-Blueprint: narrative field
568
+ 'truncated': _step_truncated, # P16-B4: segnala max_tokens raggiunto
569
+ },
570
+ })
571
+ # P39-UX: rileva [CONNECTOR_NEEDED:provider] nel result β†’ emetti SSE connector_needed
572
+ import re as _re_cn
573
+ _cn_result = str(step_data.get('result', step_data.get('output', '')))
574
+ _cn_matches = _re_cn.findall(r'\[CONNECTOR_NEEDED:([\w]+)\]', _cn_result)
575
+ for _cn_prov in _cn_matches:
576
+ _PROVIDER_LABELS = {'github': 'GitHub', 'google': 'Google Calendar', 'instagram': 'Instagram'}
577
+ _cn_label = _PROVIDER_LABELS.get(_cn_prov.lower(), _cn_prov.capitalize())
578
+ _sse('connector_needed', {
579
+ 'taskId': task_id,
580
+ 'provider': _cn_prov.lower(),
581
+ 'label': _cn_label,
582
+ 'message': f"Per completare il task ho bisogno di accedere a {_cn_label}. Connettiti con un tap.",
583
+ })
584
+ # GAP-SYNC-FIX: accumula step results per resume preciso (checkpoint backend-side)
585
+ _backend_steps.append({
586
+ 'step': step_idx[0],
587
+ 'action': _action,
588
+ 'result': str(step_data.get('result', step_data.get('output', '')))[:150],
589
+ 'ok': step_data.get('status', 'done') not in ('error', 'failed'),
590
+ })
591
+ # Ogni 2 step: persisti il log su Supabase (non saturare Supabase su loop lunghi)
592
+ if step_idx[0] % 2 == 0:
593
+ asyncio.create_task(
594
+ sb_save_checkpoint(task_id, step_idx[0], {
595
+ '_backend_steps': _backend_steps[-10:], # ultime 10 step
596
+ 'step': step_idx[0],
597
+ })
598
+ ).add_done_callback(_log_task_exc)
599
+ # TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
600
+ asyncio.create_task(_tg_step(task_id, _action, _narration)).add_done_callback(_log_task_exc)
601
+ # S362: emit vfs_update when a file operation is detected
602
+ # SYNC-1: file_written (da unified_loop GAP-1) incluso + content forwarding
603
+ _VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete', 'file_written')
604
+ if _action in _VFS_ACTIONS or step_data.get('file_path'):
605
+ # S581: 120β†’200 β€” path file spesso 120-200 chars
606
+ # S596: 200β†’400 β€” result/output puΓ² contenere path completo di progetto
607
+ # S604: 400β†’500 β€” parity con altri campi step
608
+ # SYNC-1: file_written porta path in 'path', non 'file_path'
609
+ _vfs_file = (step_data.get('path') or
610
+ step_data.get('file_path') or
611
+ step_data.get('result', '')[:500] or
612
+ step_data.get('output', '')[:500])
613
+ _vfs_op = 'delete' if 'delete' in _action else 'write'
614
+ _vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
615
+ # SYNC-1: includi content nel SSE event per file_written (≀60KB)
616
+ # Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
617
+ if _action == 'file_written' and step_data.get('content'):
618
+ _vfs_evt['content'] = str(step_data['content'])[:60_000]
619
+ _sse('vfs_update', _vfs_evt)
620
+
621
+ # S363-UI: thought event β€” emitted when planner completes
622
+ if _action == 'plan' and step_data.get('status') == 'done':
623
+ _plan_obj = step_data.get('result', step_data.get('output', ''))
624
+ _thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280β†’400
625
+ if _thought:
626
+ _sse('thought', {'taskId': task_id, 'text': _thought,
627
+ 'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
628
+ # S367: plan_update β€” structured subtask list for live plan tracking UI
629
+ if isinstance(_plan_obj, dict) and _plan_obj.get('subtasks'):
630
+ _sse('plan_update', {
631
+ 'taskId': task_id,
632
+ 'subtasks': [
633
+ {
634
+ 'id': s.get('id', _si + 1),
635
+ 'description': s.get('description', '')[:200], # S581: 80β†’200
636
+ 'tool': s.get('tool', ''),
637
+ 'status': 'pending',
638
+ }
639
+ for _si, s in enumerate(_plan_obj['subtasks'])
640
+ ],
641
+ 'goal': _plan_obj.get('goal', ''),
642
+ })
643
+
644
+ # S367: subtask_done β€” mark individual subtask complete for live checkbox update
645
+ if step_data.get('subtask_id') and step_data.get('status') == 'done':
646
+ _sse('plan_update', {
647
+ 'taskId': task_id,
648
+ 'subtask_done': step_data['subtask_id'],
649
+ })
650
+
651
+ # S363-UI: action event β€” tool execution phase
652
+ _TOOL_EXPLAINS_S363 = {
653
+ 'web_search': 'Cerco informazioni in rete',
654
+ 'get_weather': 'Recupero dati meteo',
655
+ 'get_news': 'Carico notizie recenti',
656
+ 'search_wikipedia': 'Consulto Wikipedia',
657
+ 'fetch_url': 'Leggo la pagina web',
658
+ 'search_github': 'Cerco su GitHub',
659
+ 'run_code': 'Eseguo il codice',
660
+ 'write_file': 'Scrivo il file',
661
+ 'read_file': 'Leggo il file',
662
+ 'direct_tools': 'Eseguo strumenti diretti',
663
+ }
664
+ _tool_key = _action.replace('executor:', '') if _action.startswith('executor:') else _action
665
+ if _action.startswith('executor:') or _tool_key in _TOOL_EXPLAINS_S363:
666
+ _sse('action', {
667
+ 'taskId': task_id,
668
+ 'log': _tool_key.upper().replace('_', ' ')[:30],
669
+ 'explain': _TOOL_EXPLAINS_S363.get(_tool_key, f'Esecuzione: {_tool_key}'),
670
+ })
671
+ # S758-P4.1: tool_use β€” chip pre-esecuzione (stream_agent_task path)
672
+ _is_pre_exec = (
673
+ (_action == 'tool_start' and step_data.get('status') == 'running') or
674
+ (_action.startswith('executor:') and step_data.get('status') == 'started')
675
+ )
676
+ if _is_pre_exec:
677
+ _sse('tool_use', {
678
+ 'taskId': task_id,
679
+ 'tool': _tool_key,
680
+ 'name': _tool_key,
681
+ 'label': (step_data.get('title') or
682
+ _TOOL_EXPLAINS_S363.get(_tool_key,
683
+ _tool_key.replace('_', ' ').capitalize())),
684
+ 'args': {},
685
+ })
686
+ # S758-P4.1: task_thinking β€” chip ragionamento LLM
687
+ if (_action in ('__thinking__', 'reflective_debug') and
688
+ step_data.get('status') in ('started', 'running', 'running_deep')):
689
+ _sse('task_thinking', {
690
+ 'taskId': task_id,
691
+ 'message': (step_data.get('explanation') or step_data.get('title') or
692
+ "L’agente sta elaborando…"),
693
+ })
694
+
695
+ _sse('task_start', {'taskId': task_id, 'goal': task['goal']})
696
+ _task_started_ms = int(time.time() * 1000) # NOTIFY-BOT: elapsed tracking
697
+ asyncio.create_task(_tg_start(task_id, task['goal'])).add_done_callback(_log_task_exc)
698
+ _sse('step_start', {'taskId': task_id, 'step': {'name': 'Analisi goal', 'index': 0}})
699
+
700
+ # S364: inject project skeleton into context from VFS (Gap 4)
701
+ if task.get('conversation_id'):
702
+ try:
703
+ from api.project_manifest import build_manifest_from_vfs, get_skeleton
704
+ await asyncio.wait_for(
705
+ build_manifest_from_vfs(task['conversation_id']),
706
+ timeout=3.0,
707
+ )
708
+ _skeleton = await get_skeleton(task['conversation_id'])
709
+ if _skeleton:
710
+ context_str = (_skeleton + '\n\n' + context_str).strip()
711
+ except Exception:
712
+ pass # S364: skeleton injection is optional
713
+
714
+ result = await loop.run(
715
+ goal=task['goal'],
716
+ context=context_str,
717
+ max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
718
+ on_step=step_cb,
719
+ session_id=task.get('session_id', '') or '',
720
+ )
721
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
722
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
723
+ _result_text = str(result.get('output', result) if isinstance(result, dict) else result)
724
+ _sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
725
+ asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
726
+
727
+ # S363: fire-and-forget quality check when code detected in output
728
+ if _run_quality_check:
729
+ _qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
730
+ if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised β€” evita QG su snippet brevi
731
+ asyncio.create_task(_run_quality_check(
732
+ task_id, task['goal'], _qg_result,
733
+ on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
734
+ )).add_done_callback(_log_task_exc)
735
+
736
+
737
+ except asyncio.CancelledError:
738
+ _agent_tasks[task_id]['status'] = 'CANCELLED'
739
+ asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
740
+ _sse('task_cancelled', {'taskId': task_id})
741
+
742
+ except (ImportError, ModuleNotFoundError):
743
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
744
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
745
+ _sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
746
+ _sse('task_done', {'taskId': task_id, 'result': (
747
+ f'Goal ricevuto: {task["goal"]}\n\n'
748
+ 'Il backend non ha il modulo agents.unified_loop. '
749
+ 'Configura HuggingFace Spaces con smolagents per l\'esecuzione autonoma.'
750
+ )})
751
+
752
+ except Exception as err:
753
+ _agent_tasks[task_id]['status'] = 'ERROR'
754
+ asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
755
+ _logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
756
+ _sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
757
+ asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
758
+
759
+ finally:
760
+ reg_entry['done'] = True
761
+ reg_entry['finished_at'] = time.time()
762
+ for q in list(reg_entry['subscriber_queues']):
763
+ try:
764
+ q.put_nowait(None)
765
+ except Exception as _exc:
766
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
767
+
768
+ reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
769
+
770
+ try:
771
+ while True:
772
+ if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
773
+ at = reg_entry.get('asyncio_task')
774
+ if at and not at.done():
775
+ at.cancel()
776
+ break
777
+ try:
778
+ item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
779
+ if item is None:
780
+ break
781
+ yield item
782
+ except asyncio.TimeoutError:
783
+ yield ': heartbeat\n\n'
784
+ finally:
785
+ try:
786
+ reg_entry['subscriber_queues'].remove(sub_q)
787
+ except ValueError as _exc:
788
+ _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
789
+
790
+ yield "data: [DONE]\n\n"
791
+
792
+ return StreamingResponse(
793
+ generate(),
794
+ media_type='text/event-stream',
795
+ headers={
796
+ 'Cache-Control': 'no-cache',
797
+ 'X-Accel-Buffering': 'no',
798
+ }
799
+ )
800
+
api/agent_telemetry.py CHANGED
@@ -34,52 +34,6 @@ _MAX_STORE = 500 # max entry totali prima del pruning
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
 
@@ -161,11 +115,6 @@ class TelemetrySyncBody(BaseModel):
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
  """
 
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
 
38
  # ─── Store helpers ────────────────────────────────────────────────────────────
39
 
 
115
 
116
  # ─── Endpoints ────────────────────────────────────────────────────────────────
117
 
 
 
 
 
 
118
  @router.post("/api/agent-telemetry/sync")
119
  async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
120
  """
api/auth_guard.py CHANGED
@@ -4,7 +4,7 @@ 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 = INTERNAL_TOKEN oppure X-Machine-Token = MACHINE_TOKEN
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
 
@@ -20,8 +20,7 @@ Utilizzo:
20
  ...
21
 
22
  Token env vars (Railway):
23
- INTERNAL_TOKEN β€” canale legacy/backend↔backend per X-Internal-Token
24
- MACHINE_TOKEN β€” canale machine dedicato per X-Machine-Token
25
  OPERATOR_TOKEN β€” opzionale; se assente, endpoint OPERATOR bloccati
26
  ADMIN_TOKEN β€” opzionale; se assente, endpoint ADMIN bloccati
27
 
@@ -34,7 +33,7 @@ from __future__ import annotations
34
  import logging
35
  import os
36
  from enum import IntEnum
37
- from typing import Optional, Any
38
 
39
  from fastapi import Depends, Header, HTTPException, Request
40
 
@@ -97,48 +96,19 @@ _RATE_LIMITS: dict[int, int] = {
97
  _RATE_WINDOW_S = 60 # finestra sliding 60s
98
  _rate_store: dict[str, _col.deque] = {} # token_hash β†’ deque di timestamps
99
 
100
- # Lo store Γ¨ usato anche quando Redis non Γ¨ disponibile. Un client una tantum
101
- # lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
102
- # sweep ammortizzato: il lavoro resta O(1) per la quasi totalitΓ  delle richieste
103
- # e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
104
- _RATE_STORE_SWEEP_EVERY = 128
105
- _rate_store_checks = 0
106
-
107
-
108
- def _prune_expired_rate_keys(now: float, window_s: float) -> None:
109
- """Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
110
- global _rate_store_checks
111
- _rate_store_checks += 1
112
- if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
113
- return
114
-
115
- window_start = now - window_s
116
- stale_keys = [
117
- stored_key
118
- for stored_key, timestamps in _rate_store.items()
119
- if not timestamps or timestamps[-1] < window_start
120
- ]
121
- for stored_key in stale_keys:
122
- _rate_store.pop(stored_key, None)
123
-
124
 
125
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
126
  """Chiave rate limiter: hash(role + discriminante) β€” non espone token nΓ© IP in chiaro.
127
 
128
- USER usa sempre l'IP come discriminante. MACHINE usa l'IP quando il proxy
129
- fidato lo inoltra: Cloudflare usa un unico token interno per tutti i browser,
130
- quindi il solo token renderebbe globale il limite di 30 richieste/minuto.
131
- In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
132
- e ADMIN mantengono il bucket per-token.
133
  """
134
  if role == 0:
135
- # USER: bucket per IP, mai globale condiviso.
136
  raw = f"0:{client_ip or 'unknown'}"
137
- elif role == 1 and client_ip:
138
- # MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
139
- raw = f"1:{client_ip}"
140
  else:
141
- # Chiamate server-to-server e ruoli elevati: bucket per token.
142
  raw = f"{role}:{token_header or 'anonymous'}"
143
  return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
144
 
@@ -153,7 +123,6 @@ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]
153
  """
154
  now = _rl_time.monotonic()
155
  window_start = now - window_s
156
- _prune_expired_rate_keys(now, window_s)
157
 
158
  if key not in _rate_store:
159
  _rate_store[key] = _col.deque()
@@ -193,66 +162,6 @@ def _check_rate_limit(
193
  return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
194
 
195
 
196
- async def require_supabase_user(request: Request) -> dict[str, Any]:
197
- """Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
198
-
199
- La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
200
- Authorization del chiamante e non viene scritto nei log.
201
- """
202
- import httpx
203
-
204
- authorization = request.headers.get("Authorization", "")
205
- if not authorization.lower().startswith("bearer "):
206
- raise HTTPException(status_code=401, detail="Bearer token richiesto")
207
- jwt = authorization[7:].strip()
208
- if not jwt:
209
- raise HTTPException(status_code=401, detail="Bearer token non valido")
210
-
211
- supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
212
- api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
213
- if not supabase_url or not api_key:
214
- raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
215
-
216
- try:
217
- async with httpx.AsyncClient(timeout=5) as client:
218
- response = await client.get(
219
- f"{supabase_url}/auth/v1/user",
220
- headers={
221
- "apikey": api_key,
222
- "Authorization": f"Bearer {jwt}",
223
- "Accept": "application/json",
224
- },
225
- )
226
- except httpx.HTTPError as exc:
227
- logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
228
- raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
229
-
230
- if response.status_code != 200:
231
- raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
232
- try:
233
- user = response.json()
234
- except ValueError as exc:
235
- raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
236
- if not isinstance(user, dict) or not user.get("id"):
237
- raise HTTPException(status_code=401, detail="Utente Supabase non valido")
238
- return user
239
-
240
-
241
- async def require_admin_user(request: Request) -> dict[str, Any]:
242
- """Richiede un JWT Supabase con app_metadata.role=admin.
243
-
244
- app_metadata Γ¨ server-controlled; user_metadata non viene mai considerato
245
- per autorizzare l’area amministrativa.
246
- """
247
- user = await require_supabase_user(request)
248
- app_metadata = user.get("app_metadata") or {}
249
- roles = app_metadata.get("roles") or []
250
- is_admin = app_metadata.get("role") == "admin" or "admin" in roles
251
- if not is_admin:
252
- raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
253
- return user
254
-
255
-
256
  class AuthRole(IntEnum):
257
  """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
258
  USER = 0
@@ -267,7 +176,6 @@ def _get_token(env_var: str) -> str:
267
 
268
  async def _resolve_role(
269
  x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
270
- x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
271
  x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
272
  x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
273
  ) -> AuthRole:
@@ -285,55 +193,16 @@ async def _resolve_role(
285
  logger.debug("auth: OPERATOR role granted")
286
  return AuthRole.OPERATOR
287
 
288
- # MACHINE: i due canali hanno secret distinti e non devono essere
289
- # intercambiabili. Questo evita che un INTERNAL_TOKEN stale o ruotato
290
- # sovrascriva il MACHINE_TOKEN dedicato.
291
- internal_tok = _get_token("INTERNAL_TOKEN")
292
- if internal_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, internal_tok):
293
- logger.debug("auth: MACHINE role granted via X-Internal-Token")
294
- return AuthRole.MACHINE
295
-
296
- machine_tok = _get_token("MACHINE_TOKEN")
297
- if machine_tok and x_machine_token and _sec_comp.compare_digest(x_machine_token, machine_tok):
298
- logger.debug("auth: MACHINE role granted via X-Machine-Token")
299
  return AuthRole.MACHINE
300
 
301
  # Nessun token valido β†’ ruolo USER (minimo)
302
  return AuthRole.USER
303
 
304
 
305
- async def require_private_state_machine(
306
- request: 'Request',
307
- x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
308
- ) -> AuthRole:
309
- """Autorizza esclusivamente il proxy Pages dello stato privato.
310
-
311
- Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
312
- dipendono le integrazioni legacy del master B. Il token non conferisce un
313
- ruolo piΓΉ ampio del canale MACHINE e resta soggetto allo stesso rate limit.
314
- """
315
- import secrets as _sec_comp
316
- private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
317
- if not private_token:
318
- raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
319
- if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
320
- raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
321
-
322
- client_ip = (
323
- request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
324
- or request.headers.get('X-Real-IP', '')
325
- or (request.client.host if request.client else None)
326
- ) or None
327
- allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
328
- if not allowed:
329
- raise HTTPException(
330
- status_code=429,
331
- detail="Rate limit stato privato superato",
332
- headers={'Retry-After': str(retry_after)},
333
- )
334
- return AuthRole.MACHINE
335
-
336
-
337
  def require_role(min_role: AuthRole):
338
  """
339
  FastAPI Depends factory per autorizzazione granulare.
@@ -354,8 +223,7 @@ def require_role(min_role: AuthRole):
354
  _token_hdr = (
355
  request.headers.get('X-Admin-Token') or
356
  request.headers.get('X-Operator-Token') or
357
- request.headers.get('X-Internal-Token') or
358
- request.headers.get('X-Machine-Token')
359
  )
360
  # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy β†’ X-Forwarded-For)
361
  _client_ip: str | None = (
 
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
 
 
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
 
 
33
  import logging
34
  import os
35
  from enum import IntEnum
36
+ from typing import Optional
37
 
38
  from fastapi import Depends, Header, HTTPException, Request
39
 
 
96
  _RATE_WINDOW_S = 60 # finestra sliding 60s
97
  _rate_store: dict[str, _col.deque] = {} # token_hash β†’ deque di timestamps
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
101
  """Chiave rate limiter: hash(role + discriminante) β€” non espone token nΓ© IP in chiaro.
102
 
103
+ GAP-AUTH-FIX: USER (role=0) usa client_ip come discriminante β€” bucket per IP,
104
+ non bucket globale condiviso. Previene DoS a costo zero (un client svuota tutti).
105
+ Ruoli autenticati (MACHINE/OPERATOR/ADMIN) continuano a usare il token hash.
 
 
106
  """
107
  if role == 0:
108
+ # USER: discrimina per IP β€” ogni client ha il proprio bucket
109
  raw = f"0:{client_ip or 'unknown'}"
 
 
 
110
  else:
111
+ # Ruoli autenticati: discrimina per token (piΓΉ preciso dell'IP)
112
  raw = f"{role}:{token_header or 'anonymous'}"
113
  return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
114
 
 
123
  """
124
  now = _rl_time.monotonic()
125
  window_start = now - window_s
 
126
 
127
  if key not in _rate_store:
128
  _rate_store[key] = _col.deque()
 
162
  return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  class AuthRole(IntEnum):
166
  """Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
167
  USER = 0
 
176
 
177
  async def _resolve_role(
178
  x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
 
179
  x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
180
  x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
181
  ) -> AuthRole:
 
193
  logger.debug("auth: OPERATOR role granted")
194
  return AuthRole.OPERATOR
195
 
196
+ # MACHINE (INTERNAL_TOKEN, giΓ  generato al boot da main.py)
197
+ int_tok = _get_token("INTERNAL_TOKEN")
198
+ if int_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, int_tok):
199
+ logger.debug("auth: MACHINE role granted")
 
 
 
 
 
 
 
200
  return AuthRole.MACHINE
201
 
202
  # Nessun token valido β†’ ruolo USER (minimo)
203
  return AuthRole.USER
204
 
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def require_role(min_role: AuthRole):
207
  """
208
  FastAPI Depends factory per autorizzazione granulare.
 
223
  _token_hdr = (
224
  request.headers.get('X-Admin-Token') or
225
  request.headers.get('X-Operator-Token') or
226
+ request.headers.get('X-Internal-Token')
 
227
  )
228
  # GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy β†’ X-Forwarded-For)
229
  _client_ip: str | None = (
api/auth_managed.py CHANGED
@@ -11,7 +11,7 @@ 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
@@ -21,29 +21,6 @@ import httpx
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.
 
11
 
12
  Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) β€” richiede cryptography>=42.
13
  """
14
+ import os, time, secrets, json, logging, asyncio
15
  from typing import Optional
16
  from fastapi import APIRouter, Depends, Request, HTTPException
17
  from .auth_guard import require_role, AuthRole
 
21
  _logger = logging.getLogger('api.auth_managed')
22
  router = APIRouter()
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # ── Fernet encryption setup ──────────────────────────────────────────────────
25
  # Salt statico pubblico: accettabile per chiave macchina (non password utente).
26
  # Il VAULT_KEY Γ¨ il segreto; il salt previene rainbow-table cross-application.
api/background_tasks.py DELETED
@@ -1,53 +0,0 @@
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 CHANGED
@@ -373,7 +373,7 @@ async def run_benchmark(
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
  #
 
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 = llama-3.3-70b-versatile) 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
  #
api/benchmark_handler.py CHANGED
@@ -20,89 +20,64 @@ from typing import Any
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:
@@ -111,30 +86,26 @@ async def run_benchmark_task(chat_id: int, send_reply_fn, mode: str = "full") ->
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", " ")
@@ -152,8 +123,7 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
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"
@@ -166,24 +136,15 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
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()):
@@ -191,16 +152,6 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
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:
 
20
  logger = logging.getLogger("agente_ai.benchmark_handler")
21
 
22
  # ── Percorsi server Railway ────────────────────────────────────────────────────
23
+ _REPO_ROOT = os.getenv("REPO_ROOT", "/home/ubuntu/Baida98_AI")
24
+
25
+ # v7 (GAP-BENCH-2)
26
+ _BENCH_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "benchmark-extended.mjs")
27
+ _REPORT_V7 = "/tmp/agente-ai/benchmark-v7-latest.json"
28
+ _BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "720")) # 12 min (era 360s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  # v6.2 β€” usato come fallback in get_smart_summary per compatibilitΓ 
31
  _REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
32
 
33
 
34
+ async def run_benchmark_task(chat_id: int, send_reply_fn) -> None:
35
+ """Esegue benchmark-extended.mjs v7 con --json e invia risultati via Telegram.
 
 
 
 
36
 
37
+ Flag --json β†’ scrive /tmp/agente-ai/benchmark-v7-latest.json.
38
+ Variabili env richieste (Railway): GROQ_API_KEY, INTERNAL_TOKEN.
39
+ """
40
+ await send_reply_fn(
41
+ chat_id,
42
+ "πŸš€ <b>Avvio Benchmark Extended v7…</b>\n"
43
+ "<i>10+ categorie Β· HF datasets Β· ref vs Replit/Cursor/Devin/Manus Β· ~10-12 min.</i>",
44
+ )
 
 
 
 
 
 
 
 
 
 
 
 
45
  env = {
46
  **os.environ,
47
+ "GROQ_API_KEY": os.getenv("GROQ_API_KEY", ""),
48
+ "NVIDIA_API_KEY": os.getenv("NVIDIA_API_KEY", ""),
49
  "INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
 
50
  }
51
  process: asyncio.subprocess.Process | None = None
52
  try:
53
  process = await asyncio.create_subprocess_exec(
54
+ "node", _BENCH_SCRIPT, "--json",
55
  stdout=asyncio.subprocess.PIPE,
56
  stderr=asyncio.subprocess.PIPE,
57
  env=env,
58
  )
59
+ stdout, stderr = await asyncio.wait_for(
60
+ process.communicate(), timeout=_BENCH_TIMEOUT
61
+ )
62
  if process.returncode != 0:
63
  err = stderr.decode(errors="replace")[:400]
64
+ logger.error("Benchmark v7 failed rc=%d: %s", process.returncode, err)
65
+ await send_reply_fn(chat_id, f"❌ <b>Errore benchmark v7:</b>\n<code>{err}</code>")
66
  return
67
  except asyncio.TimeoutError:
68
+ # FIX-1: kill del processo figlio prima di notificare
69
  if process is not None:
70
  try:
71
  process.kill()
72
  await process.wait()
73
  except Exception:
74
  pass
75
+ logger.warning("Benchmark v7 timeout (>%.0fs) β€” process killed", _BENCH_TIMEOUT)
76
+ await send_reply_fn(
77
+ chat_id,
78
+ f"⏱ <b>Timeout benchmark v7</b> (>{int(_BENCH_TIMEOUT // 60)} min) β€” "
79
+ "processo terminato, controlla log Railway.",
80
+ )
81
  return
82
  except Exception as exc:
83
  if process is not None:
 
86
  await process.wait()
87
  except Exception:
88
  pass
89
+ logger.error("run_benchmark_task v7 error: %s", exc, exc_info=True)
90
+ await send_reply_fn(chat_id, f"πŸ’₯ <b>Errore critico:</b> <code>{exc}</code>")
91
  return
92
 
93
+ report_exists = await asyncio.to_thread(os.path.exists, _REPORT_V7)
94
  if not report_exists:
95
+ await send_reply_fn(chat_id, "⚠️ <b>Benchmark terminato ma report v7 non trovato.</b>")
96
  return
97
+
98
  try:
99
+ # FIX-3: lettura file in thread β€” non blocca l'event loop
100
+ report: dict[str, Any] = await asyncio.to_thread(_read_json, _REPORT_V7)
101
  except Exception as exc:
102
+ await send_reply_fn(chat_id, f"⚠️ <b>Report v7 non leggibile:</b> <code>{exc}</code>")
103
  return
104
 
105
+ await send_reply_fn(chat_id, _format_v7_report(report))
 
 
 
 
 
 
106
 
107
 
108
+ def _format_v7_report(report: dict[str, Any]) -> str:
109
  """Formatta il report v7 per Telegram HTML."""
110
  s = report.get("summary", {})
111
  ts = (report.get("timestamp") or "")[:16].replace("T", " ")
 
123
  lines: list[str] = [
124
  f"πŸ† <b>Benchmark {ver} completato!</b>\n\n"
125
  f"πŸ“Š <b>Score agente:</b> <code>{avg}/100</code>\n"
126
+ f"πŸ“… <b>Run:</b> <code>{ts}</code>\n\n"
 
127
  "πŸ“ˆ <b>Confronto vs riferimenti:</b>\n"
128
  f" β€’ Replit: <code>{repl}/100</code>\n"
129
  f" β€’ Cursor: <code>{curs}/100</code>\n"
 
136
  if canary:
137
  lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
138
 
139
+ # Score per categoria
 
140
  tasks = report.get("tasks", [])
141
  if tasks:
 
142
  by_cat: dict[str, list[float]] = {}
143
  for t in tasks:
144
  cat = t.get("cat", "?")
145
  sc = t.get("score")
146
  if isinstance(sc, (int, float)):
147
  by_cat.setdefault(cat, []).append(float(sc))
 
 
 
 
 
 
 
148
  if by_cat:
149
  lines.append("\nπŸ“‚ <b>Per categoria:</b>\n")
150
  for cat, scores in sorted(by_cat.items()):
 
152
  icon = "🟒" if avg_cat >= 70 else "🟑" if avg_cat >= 50 else "πŸ”΄"
153
  lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
154
 
 
 
 
 
 
 
 
 
 
 
155
  # Gap cards (prime 3)
156
  gap_cards = report.get("gapCards", [])
157
  if gap_cards:
api/benchmarks_hub.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/benchmarks_hub.py β€” Gap 3: router per i 3 benchmark standalone
3
+
4
+ Espone come endpoint HTTP le classi benchmark finora orfane:
5
+ POST /api/debug/benchmark/advanced ← AdvancedBenchmark (concorrenza)
6
+ POST /api/debug/benchmark/fabric ← FabricBenchmark (token rotation)
7
+ POST /api/debug/benchmark/extended ← ExtendedBenchmark (multi-scenario)
8
+
9
+ Tutti usano mock interni (asyncio.sleep + dict simulati) β€” sicuri da eseguire
10
+ in produzione senza side-effect su provider reali.
11
+ """
12
+ import asyncio, logging, time
13
+ from fastapi import APIRouter
14
+ from fastapi.responses import JSONResponse
15
+
16
+ router = APIRouter()
17
+ _logger = logging.getLogger("api.benchmarks_hub")
18
+
19
+
20
+ @router.post("/api/debug/benchmark/advanced")
21
+ async def run_advanced() -> JSONResponse:
22
+ """Stress test concorrenza: 3 scenari Γ— N task simulati."""
23
+ t0 = time.monotonic()
24
+ try:
25
+ from api.advanced_complex_benchmark import AdvancedBenchmark
26
+ b = AdvancedBenchmark()
27
+ await b.setup()
28
+ await asyncio.gather(
29
+ b.run_complex_task("complex_reasoning", "reasoning", count=5),
30
+ b.run_complex_task("data_heavy_sync", "memory", count=5),
31
+ b.run_complex_task("multi_provider_chain","compute", count=5),
32
+ )
33
+ elapsed = round((time.monotonic() - t0) * 1000)
34
+ total = b.metrics["successes"] + b.metrics["failures"]
35
+ return JSONResponse({
36
+ "ok": True,
37
+ "type": "advanced",
38
+ "elapsed_ms": elapsed,
39
+ "success_rate": round(b.metrics["successes"] / max(1, total) * 100, 1),
40
+ "total_calls": total,
41
+ "successes": b.metrics["successes"],
42
+ "failures": b.metrics["failures"],
43
+ "latencies": {
44
+ k: {"avg_ms": round(sum(v) / len(v), 1), "max_ms": round(max(v), 1), "n": len(v)}
45
+ for k, v in b.metrics.items() if isinstance(v, list) and v
46
+ },
47
+ })
48
+ except Exception as exc:
49
+ _logger.exception("[advanced_bench]")
50
+ return JSONResponse({"ok": False, "type": "advanced", "error": str(exc)}, status_code=500)
51
+
52
+
53
+ @router.post("/api/debug/benchmark/fabric")
54
+ async def run_fabric() -> JSONResponse:
55
+ """Benchmark rotazione token e fallback provider."""
56
+ t0 = time.monotonic()
57
+ try:
58
+ from api.fabric_benchmark import FabricBenchmark
59
+ b = FabricBenchmark()
60
+ await b.setup_simulated_environment()
61
+ await b.run_benchmark()
62
+ elapsed = round((time.monotonic() - t0) * 1000)
63
+ lats = b.stats.get("latencies", [])
64
+ return JSONResponse({
65
+ "ok": True,
66
+ "type": "fabric",
67
+ "elapsed_ms": elapsed,
68
+ "rotations": b.stats.get("rotations", 0),
69
+ "fallbacks": b.stats.get("fallbacks", 0),
70
+ "errors": len(b.stats.get("errors", [])),
71
+ "avg_latency_ms": round(sum(lats) / len(lats), 1) if lats else None,
72
+ })
73
+ except Exception as exc:
74
+ _logger.exception("[fabric_bench]")
75
+ return JSONResponse({"ok": False, "type": "fabric", "error": str(exc)}, status_code=500)
76
+
77
+
78
+ @router.post("/api/debug/benchmark/extended")
79
+ async def run_extended() -> JSONResponse:
80
+ """Multi-scenario: normal load + cascading failure + oracle stress."""
81
+ t0 = time.monotonic()
82
+ try:
83
+ from api.extended_benchmark import ExtendedBenchmark
84
+ b = ExtendedBenchmark()
85
+ await b.setup()
86
+ await asyncio.gather(
87
+ b.scenario_normal_load(),
88
+ b.scenario_cascading_failure(),
89
+ b.scenario_oracle_stress(),
90
+ )
91
+ elapsed = round((time.monotonic() - t0) * 1000)
92
+ return JSONResponse({
93
+ "ok": True,
94
+ "type": "extended",
95
+ "elapsed_ms": elapsed,
96
+ "scenarios": b.results,
97
+ })
98
+ except Exception as exc:
99
+ _logger.exception("[extended_bench]")
100
+ return JSONResponse({"ok": False, "type": "extended", "error": str(exc)}, status_code=500)
api/bootstrap_tools.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/bootstrap_tools.py β€” Inizializza e registra i tool predefiniti all'avvio.
3
+ """
4
+ import logging
5
+ from .tool_engine import tool_registry, ToolDescriptor
6
+
7
+ _logger = logging.getLogger("api.bootstrap_tools")
8
+
9
+ async def bootstrap_all_tools():
10
+ """
11
+ Registra i tool fondamentali nel ToolEngine.
12
+ Questi tool diventano capability disponibili per il Brain.
13
+ """
14
+ tools = [
15
+ ToolDescriptor(
16
+ name="web_search",
17
+ description="Cerca informazioni sul web in tempo reale",
18
+ provider_id="hf-space-1",
19
+ input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
20
+ tags=["search", "web"]
21
+ ),
22
+ ToolDescriptor(
23
+ name="python_interpreter",
24
+ description="Esegue codice Python in una sandbox sicura",
25
+ provider_id="oracle-cloud-vm-01",
26
+ input_schema={"type": "object", "properties": {"code": {"type": "string"}}},
27
+ tags=["code", "sandbox"]
28
+ ),
29
+ ToolDescriptor(
30
+ name="vision_analysis",
31
+ description="Analizza immagini e screenshot tramite OCR e modelli Vision",
32
+ provider_id="hf-space-2",
33
+ input_schema={"type": "object", "properties": {"image_url": {"type": "string"}}},
34
+ tags=["vision", "ocr"]
35
+ )
36
+ ]
37
+
38
+ for tool in tools:
39
+ try:
40
+ await tool_registry.register(tool, force=True)
41
+ except Exception as e:
42
+ _logger.error(f"Errore registrazione tool {tool.name}: {e}")
43
+
44
+ _logger.info(f"Bootstrap completato: {len(tools)} tool registrati.")
api/brain_planner.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/brain_planner.py β€” Brain Planner (ARCH-I4.1)
3
+
4
+ Componente Planner del Brain: reasoning, planning, decomposition, reflection.
5
+ Produce WorkflowPlan (lista ordinata di PlanStep) senza sapere come eseguirli.
6
+
7
+ Il Brain Γ¨ ora separato in:
8
+ BrainPlanner β†’ "cosa fare e in che ordine" (questo file)
9
+ BrainExecutor β†’ "come eseguire lo stato" (brain_executor.py)
10
+
11
+ Flusso:
12
+ Kernel.submit_task(payload, capability="plan") β†’ BrainPlanner.plan(goal)
13
+ β†’ WorkflowPlan(steps=[PlanStep,...])
14
+ β†’ BrainExecutor.execute_plan(plan) β†’ [Kernel.submit_task per ogni step]
15
+
16
+ Invarianti ADR:
17
+ S4: Brain non conosce l'infrastruttura
18
+ S9: Planner ignora come Executor esegue
19
+ S21: Brain dipende solo dal Kernel
20
+ S27: ogni piano tracciato via plan_id
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ import time
27
+ import uuid
28
+ from typing import Any, Literal
29
+
30
+ from fastapi import APIRouter, Depends, HTTPException
31
+ from pydantic import BaseModel, Field
32
+
33
+ from .auth_guard import AuthRole, require_role
34
+
35
+ _logger = logging.getLogger("api.brain_planner")
36
+
37
+ # ── Resolver guard ─────────────────────────────────────────────────────────────
38
+ try:
39
+ from .capability_resolver import resolver as _resolver, ResolveRequest as _RReq
40
+ _RESOLVER_AVAILABLE = True
41
+ except Exception:
42
+ _resolver = None # type: ignore[assignment]
43
+ _RESOLVER_AVAILABLE = False
44
+
45
+ # ── Models ──────────────────────────────────────────────────────────────────────
46
+
47
+ class PlanStep(BaseModel):
48
+ """Singolo step del piano β€” capability + payload + dipendenze."""
49
+ step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
50
+ capability: str = Field(..., description="Capability richiesta per questo step")
51
+ description: str = Field("", description="Descrizione human-readable")
52
+ payload: dict[str, Any] = Field(default_factory=dict)
53
+ depends_on: list[str] = Field(default_factory=list,
54
+ description="step_id degli step da completare prima")
55
+ timeout_s: int = Field(60)
56
+ retry_max: int = Field(2)
57
+ optional: bool = Field(False, description="Se True, fallimento non blocca il piano")
58
+ requires_gpu: bool = Field(False)
59
+ metadata: dict[str, Any] = Field(default_factory=dict)
60
+
61
+
62
+ class WorkflowPlan(BaseModel):
63
+ """Piano di esecuzione prodotto dal Planner β€” input per il Workflow Engine."""
64
+ plan_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
65
+ goal: str = Field(..., description="Obiettivo originale")
66
+ strategy: Literal["sequential", "parallel", "dag"] = Field(
67
+ "sequential",
68
+ description="sequential=step uno alla volta, "
69
+ "parallel=tutti in parallelo, "
70
+ "dag=dipendenze esplicite tra step")
71
+ steps: list[PlanStep] = Field(default_factory=list)
72
+ context: dict[str, Any] = Field(default_factory=dict,
73
+ description="Contesto condiviso tra gli step")
74
+ created_at: float = Field(default_factory=time.time)
75
+ metadata: dict[str, Any] = Field(default_factory=dict)
76
+
77
+ def validate_dag(self) -> list[str]:
78
+ """Verifica che il DAG non abbia cicli. Ritorna errori (lista vuota = ok)."""
79
+ step_ids = {s.step_id for s in self.steps}
80
+ errors = []
81
+ for step in self.steps:
82
+ for dep in step.depends_on:
83
+ if dep not in step_ids:
84
+ errors.append(f"Step {step.step_id}: dipende da {dep} che non esiste nel piano")
85
+ return errors
86
+
87
+
88
+ class PlanRequest(BaseModel):
89
+ goal: str = Field(..., description="Obiettivo da pianificare")
90
+ context: dict[str, Any] = Field(default_factory=dict)
91
+ strategy: Literal["sequential", "parallel", "dag"] = "sequential"
92
+ max_steps: int = Field(10, description="Numero massimo di step nel piano")
93
+ session_id: str | None = None
94
+ correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
95
+ hints: list[str] = Field(default_factory=list,
96
+ description="Capability suggerite dal caller")
97
+
98
+
99
+ class ReflectRequest(BaseModel):
100
+ """Richiesta di reflection su un piano eseguito (per migliorare piani futuri)."""
101
+ plan_id: str
102
+ outcome: Literal["success", "partial", "failure"]
103
+ failed_steps: list[str] = Field(default_factory=list)
104
+ notes: str = ""
105
+ correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
106
+
107
+
108
+ # ── BrainPlanner singleton ──────────────────────────────────────────────────────
109
+
110
+ class BrainPlanner:
111
+ """
112
+ Planner del Brain: data un goal, produce un WorkflowPlan.
113
+
114
+ Strategia di planning (senza LLM, rule-based per ora):
115
+ 1. Decomposizione goal β†’ capability necessarie (via Resolver + hints)
116
+ 2. Ordinamento step (sequenziale per default, DAG se dipendenze esplicite)
117
+ 3. Validazione piano (no cicli, capabilities disponibili)
118
+ 4. Reflection store (memorizza piani e outcome per futuro miglioramento)
119
+
120
+ Nota: la versione LLM-based (reasoning con modello) sarΓ  ARCH-I4.1 Phase 2.
121
+ Questa implementazione Γ¨ rule-based e serve a definire il contratto.
122
+ """
123
+
124
+ def __init__(self) -> None:
125
+ self._plans: dict[str, WorkflowPlan] = {} # plan_id β†’ plan
126
+ self._reflections: list[dict[str, Any]] = [] # storia reflection
127
+ self._lock = asyncio.Lock()
128
+
129
+ # ── Plan ──────────────────────────────────────────────────────────────────
130
+
131
+ async def plan(self, req: PlanRequest) -> WorkflowPlan:
132
+ """
133
+ Produce un WorkflowPlan dal goal.
134
+
135
+ Step logic (rule-based):
136
+ - Se hints presenti: crea uno step per ogni hint
137
+ - Altrimenti: step singolo con capability "llm" (Brain decide runtime)
138
+ - Verifica che ogni capability sia risolvibile dal Resolver
139
+ """
140
+ capabilities = req.hints if req.hints else ["llm"]
141
+ # Verifica capabilities via Resolver
142
+ verified_caps = []
143
+ for cap in capabilities[:req.max_steps]:
144
+ if _RESOLVER_AVAILABLE and _resolver is not None:
145
+ if _resolver.can_resolve(cap):
146
+ verified_caps.append(cap)
147
+ else:
148
+ _logger.warning("[planner] capability '%s' non risolvibile β€” esclusa dal piano", cap)
149
+ else:
150
+ verified_caps.append(cap) # no resolver: trust hints
151
+
152
+ if not verified_caps:
153
+ verified_caps = ["llm"] # fallback sempre
154
+
155
+ # Costruisci steps
156
+ steps = []
157
+ prev_id: str | None = None
158
+ for cap in verified_caps:
159
+ step = PlanStep(
160
+ capability = cap,
161
+ description = f"Esegui capability '{cap}' per goal: {req.goal[:80]}",
162
+ payload = {"goal": req.goal, **req.context},
163
+ depends_on = [prev_id] if (req.strategy == "sequential" and prev_id) else [],
164
+ timeout_s = 120,
165
+ )
166
+ steps.append(step)
167
+ prev_id = step.step_id
168
+
169
+ plan = WorkflowPlan(
170
+ goal = req.goal,
171
+ strategy = req.strategy,
172
+ steps = steps,
173
+ context = req.context,
174
+ metadata = {"session_id": req.session_id, "correlation_id": req.correlation_id},
175
+ )
176
+
177
+ # Validate DAG
178
+ errors = plan.validate_dag()
179
+ if errors:
180
+ raise ValueError("Piano DAG non valido: " + "; ".join(errors))
181
+
182
+ async with self._lock:
183
+ self._plans[plan.plan_id] = plan
184
+ _logger.info("[planner] plan created plan_id=%s goal=%s steps=%d strategy=%s",
185
+ plan.plan_id, req.goal[:40], len(steps), req.strategy)
186
+ return plan
187
+
188
+ def get_plan(self, plan_id: str) -> WorkflowPlan | None:
189
+ return self._plans.get(plan_id)
190
+
191
+ # ── Reflect ───────────────────────────────────────────────────────────────
192
+
193
+ async def reflect(self, req: ReflectRequest) -> dict:
194
+ """Registra l'outcome di un piano β€” base per miglioramento futuro."""
195
+ plan = self._plans.get(req.plan_id)
196
+ entry = {
197
+ "plan_id": req.plan_id,
198
+ "goal": plan.goal if plan else "unknown",
199
+ "outcome": req.outcome,
200
+ "failed_steps": req.failed_steps,
201
+ "notes": req.notes,
202
+ "ts": time.time(),
203
+ }
204
+ async with self._lock:
205
+ self._reflections.append(entry)
206
+ if len(self._reflections) > 500: # cap
207
+ self._reflections = self._reflections[-500:]
208
+ _logger.info("[planner] reflect plan=%s outcome=%s", req.plan_id, req.outcome)
209
+ return {"reflected": True, "plan_id": req.plan_id, "outcome": req.outcome}
210
+
211
+ def status(self) -> dict:
212
+ return {
213
+ "total_plans": len(self._plans),
214
+ "total_reflections": len(self._reflections),
215
+ "resolver_available": _RESOLVER_AVAILABLE,
216
+ }
217
+
218
+
219
+ # ── Singleton ───────��────────────────────────────────────────────────────────────
220
+ planner = BrainPlanner()
221
+
222
+ # ── HTTP Router ──────────────────────────────────────────────────────────────────
223
+ router = APIRouter(
224
+ prefix="/api/brain",
225
+ tags=["brain-planner"],
226
+ dependencies=[Depends(require_role(AuthRole.MACHINE))],
227
+ )
228
+
229
+
230
+ @router.post("/plan", summary="Pianifica un goal β†’ WorkflowPlan")
231
+ async def route_plan(req: PlanRequest) -> WorkflowPlan:
232
+ try:
233
+ return await planner.plan(req)
234
+ except ValueError as exc:
235
+ raise HTTPException(400, str(exc))
236
+
237
+
238
+ @router.get("/plan/{plan_id}", summary="Recupera un piano esistente")
239
+ async def route_get_plan(plan_id: str) -> WorkflowPlan:
240
+ p = planner.get_plan(plan_id)
241
+ if not p:
242
+ raise HTTPException(404, f"Piano '{plan_id}' non trovato")
243
+ return p
244
+
245
+
246
+ @router.post("/reflect", summary="Reflection su un piano eseguito")
247
+ async def route_reflect(req: ReflectRequest) -> dict:
248
+ return await planner.reflect(req)
249
+
250
+
251
+ @router.get("/planner/status", summary="Stato del BrainPlanner")
252
+ async def route_status() -> dict:
253
+ return planner.status()
api/browser.py CHANGED
@@ -260,16 +260,16 @@ def _trim_ax_tree(node: dict, depth: int) -> dict:
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
 
@@ -713,47 +713,6 @@ async def verify_goal_browser(
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)
 
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
 
 
713
  return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
714
 
715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  # ─── /screenshot ─────────────────────────────────────────────────────────────
717
 
718
  @router.post("/screenshot", response_model=BrowserResult)
api/cache_endpoints.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_endpoints.py β€” Endpoint API per Gestione Cache
3
+
4
+ Endpoint:
5
+ GET /api/cache/stats β€” Statistiche cache
6
+ POST /api/cache/invalidate β€” Invalida un entry
7
+ GET /api/cache/health β€” Health check cache
8
+ POST /api/cache/reset-stats β€” Resetta statistiche
9
+ """
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+ from pydantic import BaseModel
13
+ from typing import Optional
14
+ import logging
15
+
16
+ # Import cache manager
17
+ try:
18
+ from backend.api.cache_manager import (
19
+ get_cache_stats,
20
+ reset_cache_stats,
21
+ invalidate_cache,
22
+ CacheStrategy,
23
+ CACHE_ENABLED,
24
+ )
25
+ except ImportError:
26
+ from cache_manager import (
27
+ get_cache_stats,
28
+ reset_cache_stats,
29
+ invalidate_cache,
30
+ CacheStrategy,
31
+ CACHE_ENABLED,
32
+ )
33
+
34
+ _logger = logging.getLogger("cache_endpoints")
35
+
36
+ router = APIRouter(prefix="/api/cache", tags=["cache"])
37
+
38
+
39
+ # ── Modelli Pydantic ──────────────────────────────────────────────────────
40
+ class InvalidateCacheRequest(BaseModel):
41
+ strategy: str # "query", "memory", "embedding", "conversation", "analytics"
42
+ identifier: str
43
+
44
+
45
+ class CacheStatsResponse(BaseModel):
46
+ enabled: bool
47
+ hits: int
48
+ misses: int
49
+ sets: int
50
+ deletes: int
51
+ evictions: int
52
+ hit_rate_percent: float
53
+ total_requests: int
54
+
55
+
56
+ class CacheHealthResponse(BaseModel):
57
+ ok: bool
58
+ cache_enabled: bool
59
+ message: str
60
+
61
+
62
+ # ── Endpoint: Statistiche Cache ────────────────────────────────────────────
63
+ @router.get("/stats", response_model=CacheStatsResponse)
64
+ async def cache_stats():
65
+ """Ritorna le statistiche del cache layer."""
66
+ try:
67
+ stats = get_cache_stats()
68
+ return CacheStatsResponse(**stats)
69
+ except Exception as exc:
70
+ _logger.error(f"Error fetching cache stats: {exc}")
71
+ raise HTTPException(500, "Error fetching cache stats")
72
+
73
+
74
+ # ── Endpoint: Invalida Cache ──────────────────────────────────────────────
75
+ @router.post("/invalidate")
76
+ async def invalidate_cache_entry(req: InvalidateCacheRequest):
77
+ """Invalida un entry specifico dalla cache."""
78
+ try:
79
+ # Valida strategy
80
+ try:
81
+ strategy = CacheStrategy(req.strategy)
82
+ except ValueError:
83
+ raise HTTPException(
84
+ 400,
85
+ f"Invalid strategy. Must be one of: {', '.join([s.value for s in CacheStrategy])}",
86
+ )
87
+
88
+ # Invalida
89
+ success = await invalidate_cache(strategy, req.identifier)
90
+
91
+ return {
92
+ "ok": success,
93
+ "strategy": req.strategy,
94
+ "identifier": req.identifier,
95
+ "message": "Cache entry invalidated" if success else "Failed to invalidate cache entry",
96
+ }
97
+ except HTTPException:
98
+ raise
99
+ except Exception as exc:
100
+ _logger.error(f"Error invalidating cache: {exc}")
101
+ raise HTTPException(500, "Error invalidating cache")
102
+
103
+
104
+ # ── Endpoint: Health Check ────────────────────────────────────────────────
105
+ @router.get("/health", response_model=CacheHealthResponse)
106
+ async def cache_health():
107
+ """Health check per il cache layer."""
108
+ try:
109
+ stats = get_cache_stats()
110
+
111
+ return CacheHealthResponse(
112
+ ok=True,
113
+ cache_enabled=CACHE_ENABLED,
114
+ message=f"Cache layer operational. Hit rate: {stats.get('hit_rate_percent', 0):.1f}%",
115
+ )
116
+ except Exception as exc:
117
+ _logger.error(f"Cache health check failed: {exc}")
118
+ return CacheHealthResponse(
119
+ ok=False,
120
+ cache_enabled=CACHE_ENABLED,
121
+ message=f"Cache health check failed: {str(exc)}",
122
+ )
123
+
124
+
125
+ # ── Endpoint: Reset Statistiche ───────────────────────────────────────────
126
+ @router.post("/reset-stats")
127
+ async def reset_stats():
128
+ """Resetta le statistiche del cache."""
129
+ try:
130
+ reset_cache_stats()
131
+ return {
132
+ "ok": True,
133
+ "message": "Cache statistics reset",
134
+ }
135
+ except Exception as exc:
136
+ _logger.error(f"Error resetting cache stats: {exc}")
137
+ raise HTTPException(500, "Error resetting cache stats")
138
+
139
+
140
+ # ── Endpoint: Info Cache ──────────────────────────────────────────────────
141
+ @router.get("/info")
142
+ async def cache_info():
143
+ """Ritorna informazioni sulla configurazione del cache."""
144
+ try:
145
+ from cache_manager import (
146
+ CACHE_ENABLED,
147
+ CACHE_TTL_DEFAULT,
148
+ CACHE_MAX_SIZE,
149
+ CACHE_TTL_BY_STRATEGY,
150
+ SUPABASE_A_URL,
151
+ SUPABASE_B_URL,
152
+ )
153
+
154
+ return {
155
+ "enabled": CACHE_ENABLED,
156
+ "ttl_default_seconds": CACHE_TTL_DEFAULT,
157
+ "max_size": CACHE_MAX_SIZE,
158
+ "ttl_by_strategy": {k.value: v for k, v in CACHE_TTL_BY_STRATEGY.items()},
159
+ "supabase_a_configured": bool(SUPABASE_A_URL),
160
+ "supabase_b_configured": bool(SUPABASE_B_URL),
161
+ }
162
+ except Exception as exc:
163
+ _logger.error(f"Error fetching cache info: {exc}")
164
+ raise HTTPException(500, "Error fetching cache info")