sync: 181 file da Baida98/AI@c21219e5 (2026-08-25 12:22 UTC) [deploy-all]

#44
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. .gitattributes +35 -0
  2. Dockerfile +1 -1
  3. REBUILD_TRIGGER +1 -0
  4. agents/audit_semantic_l2.py +303 -0
  5. agents/executor.py +8 -32
  6. agents/fallback_healer.py +59 -0
  7. agents/fallback_utils.py +26 -0
  8. agents/file_conversion.py +29 -105
  9. agents/grid_rag.py +124 -0
  10. agents/html_fast_path.py +0 -60
  11. agents/unified_loop.py +45 -141
  12. agents/unified_loop_delegate.py +192 -0
  13. agents/unified_loop_fallback.py +0 -0
  14. agents/unified_loop_llm.py +6 -38
  15. agents/unified_loop_routing.py +82 -0
  16. agents/unified_loop_tools.py +37 -99
  17. agents/unified_loop_vfs.py +156 -0
  18. agents/watchdog.py +67 -0
  19. api/TELEGRAM_MODULES.md +172 -0
  20. api/_agent_helpers.py +127 -0
  21. api/advanced_complex_benchmark.py +108 -0
  22. api/agent.py +28 -365
  23. api/agent_checkpoint_routes.py +279 -0
  24. api/agent_fsm.py +375 -0
  25. api/agent_loop_routes.py +410 -0
  26. api/agent_task_routes.py +800 -0
  27. api/agent_telemetry.py +0 -51
  28. api/auth_managed.py +1 -24
  29. api/background_tasks.py +0 -53
  30. api/benchmarks_hub.py +100 -0
  31. api/bootstrap_tools.py +44 -0
  32. api/brain_planner.py +253 -0
  33. api/cache_endpoints.py +164 -0
  34. api/cache_manager.py +400 -0
  35. api/capability_catalog.py +348 -0
  36. api/capability_resolver.py +441 -0
  37. api/dashboard.py +171 -0
  38. api/database.py +1 -2
  39. api/database_router.py +303 -0
  40. api/event_store.py +17 -13
  41. api/execution_fabric.py +440 -0
  42. api/extended_benchmark.py +97 -0
  43. api/fabric_benchmark.py +92 -0
  44. api/global_state_sync.py +182 -0
  45. api/global_state_sync_optimized.py +343 -0
  46. api/global_state_sync_with_oracle.py +355 -0
  47. api/grid_status.py +73 -0
  48. api/hf_monitor.py +273 -0
  49. api/hf_storage.py +95 -0
  50. api/image_provider.py +0 -226
.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/executor.py CHANGED
@@ -292,39 +292,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
 
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
  if self.memory:
296
+ # S577→S600: inputs 100→500 — parity con altri handler
297
+ await self.memory.save_episode(
298
+ "tool",
299
+ f"{tool_name}: {str(inputs)[:500]}",
300
+ str(result)[:500],
301
+ True,
302
+ )
303
+ return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
  except asyncio.TimeoutError:
306
  # FIX-GAP2: registra il timeout come durata massima per shrink futuro
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 CHANGED
@@ -1,7 +1,7 @@
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
 
@@ -16,21 +16,12 @@ _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
 
@@ -41,8 +32,6 @@ class CsvJsonConversion:
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:
@@ -61,103 +50,38 @@ def _csv_body(raw_body: str) -> str:
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
  )
 
1
+ """Conversioni tabellari deterministiche per allegati inclusi nei goal dell'agente.
2
 
3
+ Il modulo interpreta esclusivamente blocchi CSV esplicitamente allegati dal client. Non apre
4
+ path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
5
  """
6
  from __future__ import annotations
7
 
 
16
  r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
17
  re.IGNORECASE,
18
  )
 
 
 
19
  _TARGET_RE = re.compile(
20
+ r"\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+['`\"]?(?P<name>[\w.-]+\.json)\b",
 
21
  re.IGNORECASE,
22
  )
23
  _CONVERSION_RE = re.compile(
24
+ r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,180}\b(?:csv|json)\b",
 
 
 
 
 
25
  re.IGNORECASE,
26
  )
27
 
 
32
  target_name: str
33
  content: str
34
  row_count: int
 
 
35
 
36
 
37
  def _coerce_scalar(value: str) -> Any:
 
50
  return "\n".join(lines).strip()
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
54
+ """Converte il primo allegato CSV esplicitamente serializzato in JSON.
55
 
56
+ Il ritorno è ``None`` quando il goal non richiede una conversione CSV→JSON completa,
57
+ così il resto del loop conserva il comportamento esistente.
58
  """
59
  if not _CONVERSION_RE.search(goal):
60
  return None
61
+ attachment = _ATTACHMENT_RE.search(goal)
62
+ if not attachment:
63
+ return None
64
  target = _TARGET_RE.search(goal)
65
  if not target:
66
  return None
67
 
68
+ csv_body = _csv_body(attachment.group("body"))
69
+ if not csv_body:
 
 
 
 
 
 
 
 
 
70
  return None
71
+ try:
72
+ reader = csv.DictReader(io.StringIO(csv_body))
73
+ if not reader.fieldnames or any(not header or not header.strip() for header in reader.fieldnames):
74
+ return None
75
+ rows = [
76
+ {str(key).strip(): _coerce_scalar(value or "") for key, value in row.items()}
77
+ for row in reader
78
+ ]
79
+ except (csv.Error, UnicodeError):
80
+ return None
81
+
82
+ return CsvJsonConversion(
83
+ source_name=attachment.group("name").strip(),
84
+ target_name=target.group("name").strip(),
85
+ content=json.dumps(rows, ensure_ascii=False, indent=2) + "\n",
86
+ row_count=len(rows),
87
  )
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/unified_loop.py CHANGED
@@ -214,46 +214,37 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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 +550,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 +569,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 +578,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:
@@ -1328,28 +1296,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:
@@ -3552,17 +3510,13 @@ 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]}"
@@ -3576,12 +3530,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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:
@@ -3607,8 +3555,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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 +3590,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.
@@ -3739,12 +3686,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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:
@@ -3752,43 +3693,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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
 
214
  _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
215
 
216
  async def _rollback_writes(self, on_step=None) -> None:
217
+ """
218
+ GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà.
219
+ Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
220
+ Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
221
+ File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
222
  """
223
  if not self._write_snapshots or not self.executor:
224
  return
225
  if on_step:
226
  await _maybe_await(on_step({
227
  "action": "text_chunk",
228
+ "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
229
  "status": "streaming",
230
  }))
231
+ _rolled = 0
232
+ for path, original in self._write_snapshots.items():
233
+ if original is None:
234
+ continue # file non esisteva prima — saltiamo (non eliminiamo)
 
 
235
  try:
236
+ await asyncio.wait_for(
237
+ self.executor.run_tool("write_file", {"path": path, "content": original}),
238
  timeout=10.0,
239
  )
240
+ _rolled += 1
241
+ except Exception:
242
+ pass # non-fatal — best effort rollback
243
+ _total = len(self._write_snapshots) # salva prima del clear
244
+ self._write_snapshots = {}
245
+ _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
 
 
 
 
 
 
 
 
 
246
 
247
+ # ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
248
  async def _vfs_git_backup(self) -> None:
249
  """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
250
 
 
550
  # F17+B7: planner per task di progettazione/implementazione — soglia ridotta a 10 chars
551
  # Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
552
  # _NEEDS_PLAN_RE filtra già query semplici — len guard serve solo per 1-8 char input.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  _should_plan = (
554
  self.planner
555
  and not tool_results
 
556
  and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
557
  and len(state.goal) > 10
558
  )
 
569
  }
570
  _logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
571
  _t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
572
+ if _should_plan:
573
  if on_step:
574
  await _maybe_await(on_step({
575
  "loop": 0, "action": "plan", "status": "started",
 
578
  }))
579
  # S640: timeout planner + S-FMT-ORCH fast-fix bypass
580
  # Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
581
+ if _fast_fix_plan is not None:
 
 
 
582
  plan = _fast_fix_plan
583
  _logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
584
  else:
 
1296
  {"path": _wf_path, "content": _wf_generated} if rn == "write_file"
1297
  else {"path": _wf_path, "patch": _wf_generated}
1298
  )
1299
+ # GAP-3: snapshot pre-write — cattura originale per rollback atomico
1300
  if rn == "write_file" and _wf_path not in self._write_snapshots:
1301
+ try:
1302
+ _snap_r = await asyncio.wait_for(
1303
+ self.executor.run_tool("read_file", {"path": _wf_path}),
1304
+ timeout=4.0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  )
1306
+ self._write_snapshots[_wf_path] = (
1307
+ _snap_r.get("output") if _snap_r.get("success") else None
1308
+ )
1309
+ except Exception:
1310
+ self._write_snapshots[_wf_path] = None # file non esisteva
1311
  # GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file
1312
  _vfs_lock = self._get_vfs_lock(_wf_path)
1313
  async with _vfs_lock:
 
3510
 
3511
  async def run(self, goal: str, context: str = "", max_steps: int = 8,
3512
  on_step: StepCallback | None = None,
3513
+ session_id: str = "") -> dict[str, Any]:
 
3514
  """Run the loop and close unexpected exceptions as a controlled FAILED state."""
3515
  previous_state = _ACTIVE_LOOP_STATE.get()
3516
  previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
3517
  previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
3518
  try:
3519
+ return await self._run_impl(goal, context, max_steps, on_step, session_id)
 
 
 
3520
  except Exception as _run_error:
3521
  state = _ACTIVE_LOOP_STATE.get()
3522
  error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
 
3530
  }
3531
 
3532
  state.errors.append(error_text)
 
 
 
 
 
 
3533
  previous = state.state_machine.current
3534
  if previous != AgentState.FAILED:
3535
  try:
 
3555
 
3556
  async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
3557
  on_step: StepCallback | None = None,
3558
+ session_id: str = "") -> dict[str, Any]:
 
3559
  # S390-B-L: strip role prefixes che causano prompt injection
3560
  # Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
3561
  # S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso — input come
 
3590
  except Exception:
3591
  _sid_token = None # fallback silente — registry usa default "agent_default"
3592
 
3593
+ # S750-GAP-B: pre-warm sandbox backend-exec — POST /api/session in background.
3594
+ # asyncio.create_task lancia la richiesta senza bloccare il routing:
3595
+ # mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta.
3596
+ try:
3597
+ from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
3598
+ if _eurl:
3599
+ asyncio.ensure_future(
3600
+ _ce({"session_id": self._run_task_id}, endpoint="/api/session")
3601
+ )
3602
+ except Exception as _exc:
3603
+ _logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
3604
 
3605
  # S568-B: reset _session_files ogni run — previene memory leak su sessioni lunghe.
3606
  # Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
 
3686
 
3687
  async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3688
  next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
 
 
 
 
 
 
3689
  try:
3690
  await self._transition_state(state, next_state, on_step)
3691
  finally:
 
3693
  await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
3694
  return _with_state(result)
3695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3696
  # GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
3697
  try:
3698
  from agents.strategic_healer import StrategicHealer as _SHClass
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_llm.py CHANGED
@@ -119,28 +119,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
@@ -559,7 +543,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 +556,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|"
 
119
  return self._verifier_llm
120
 
121
  def _is_pure_explanation(self, goal: str) -> bool:
122
+ """B5: True se goal è domanda concettuale pura nessun tool necessario.
123
+ 4 guard fail-open: len<300 | pattern interrogativo | no action verb | no file ref."""
 
 
 
 
124
  if len(goal) > 300: return False
125
+ if not self._PURE_EXPLANATION_RE.search(goal[:200]): return False
126
+ if self._EXPL_ACTION_RE.search(goal[:200]): return False
127
+ if self._EXPL_FILE_REF_RE.search(goal[:200]): return False
 
 
 
 
 
 
 
 
 
 
 
 
128
  return True
129
 
130
  # S371: _SKIP_SMOL_RE — skippa smolagents per query semplici (notizie, cerca) → direct tools
 
543
  r"^\s*(?:"
544
  r"(?:cos'?[e\xe8]\s+)"
545
  r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
546
+ r"|(?:spiegami\b)"
547
  r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
548
  r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
549
  r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
 
556
  r")",
557
  re.IGNORECASE | re.DOTALL,
558
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  _EXPL_ACTION_RE = re.compile(
560
  r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
561
  r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
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
@@ -11,7 +11,6 @@ Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoo
11
  """
12
  from __future__ import annotations
13
  import asyncio
14
- import hashlib
15
  import os
16
  import re
17
  from typing import Any
@@ -25,7 +24,7 @@ _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.
@@ -68,15 +67,10 @@ class DirectToolsMixin:
68
  r"crypto|bitcoin|ethereum|market\s+cap|capitalizzazione)\b",
69
  re.IGNORECASE,
70
  )
71
- # Un verbo generico come «crea» non autorizza un artefatto visivo: richiedi
72
- # un output immagine esplicito, oppure un verbo artistico inequivoco. Questo
73
- # evita che «Crea un piano…» diventi una generazione Pollinations/VFS.
74
  _IMAGE_INTENT_RE = re.compile(
75
- r"(?:\b(?:genera|crea|fai|mostra|visualizza|produce|generate|create|make)\b.{0,40}\b"
76
- r"(?:immagine|foto|illustrazione|ritratto|paesaggio|logo|icona|disegno|grafica|"
77
- r"image|photo|picture|illustration|portrait|landscape|drawing|graphic|art|artwork)\b)"
78
- r"|(?:^\s*(?:disegna|illustra|dipingi|render|paint|sketch)\b"
79
- r"(?!\s+(?:(?:un|una|il|lo|la|the|a|an)\s+)?(?:diagramma|grafico|chart|schema|ui|ux|figma)\b))",
80
  re.IGNORECASE,
81
  )
82
  _CALC_INTENT_RE = re.compile(
@@ -132,13 +126,7 @@ class DirectToolsMixin:
132
  if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
133
  return candidate
134
  return "."
135
- async def _run_direct_tools(
136
- self,
137
- goal: str,
138
- on_step: StepCallback | None = None,
139
- *,
140
- local_csv_only: bool = False,
141
- ) -> tuple[str, int, int, int]:
142
  """
143
  S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
144
  Returns: 4-tuple (results_str, n_called, n_success, n_errors).
@@ -312,69 +300,43 @@ class DirectToolsMixin:
312
  return None
313
  if not _gov_check("convert_csv_to_json", conversion.target_name):
314
  return None
315
-
316
- # Il successo diretto è consentito solo dopo il confronto semantico
317
- # record-per-record. Questo blocca cataloghi generici/allucinati prima
318
- # che il loop possa dichiarare una conversione corretta.
319
- is_valid, validation_error = validate_csv_json_equivalence(
320
- conversion.source_content,
321
- conversion.content,
322
- )
323
- if not is_valid:
324
- return f"[convert_csv_to_json: validazione fallita — {validation_error}]"
325
-
326
- async def _write(path: str, content: str) -> dict[str, Any]:
327
- return await asyncio.wait_for(
328
- TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
329
- timeout=TOOL_TIMEOUT,
330
- )
331
-
332
  try:
333
  if on_step:
334
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
335
  "title": "Conversione CSV in JSON",
336
- "explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
337
  _t0 = asyncio.get_event_loop().time()
338
-
339
- # Per il CSV inline il goal richiede esplicitamente entrambi gli
340
- # artefatti. Gli allegati conservano il comportamento esistente:
341
- # viene scritto soltanto il JSON, poiché la fonte è già disponibile.
342
- written_paths: list[str] = []
343
- if conversion.source_is_inline:
344
- source_written = await _write(conversion.source_name, conversion.source_content)
345
- if not source_written.get("ok"):
346
- return f"[convert_csv_to_json: errore sorgente — {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
347
- written_paths.append(conversion.source_name)
348
- if on_step:
349
- await _maybe_await(on_step({
350
- "action": "file_written", "status": "done",
351
- "path": conversion.source_name, "content": conversion.source_content,
352
- "title": "File CSV creato",
353
- "explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
354
- }))
355
-
356
- target_written = await _write(conversion.target_name, conversion.content)
357
- if not target_written.get("ok"):
358
- return f"[convert_csv_to_json: errore JSON — {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
359
- written_paths.append(conversion.target_name)
360
  if on_step:
361
  await _maybe_await(on_step({
362
- "action": "file_written", "status": "done",
363
- "path": conversion.target_name, "content": conversion.content,
 
 
364
  "title": "File JSON creato",
365
- "explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
366
  }))
367
  try:
368
  from api.state import record_timing as _rtc
369
  _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
370
  except Exception as _e:
371
  _logger.debug('[timing/record_timing] %s', _e)
372
- paths = ", ".join(f"`{path}`" for path in written_paths)
373
  return (
374
  "[DIRECT_TERMINAL]\n"
375
- f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
376
- f"e `{conversion.target_name}`.\n\n"
377
- f"File workspace salvati: {paths}."
378
  )
379
  except asyncio.TimeoutError:
380
  return "[convert_csv_to_json: timeout]"
@@ -397,42 +359,22 @@ class DirectToolsMixin:
397
  _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
398
  if _sc is not None:
399
  return _sc
 
 
 
 
 
400
  _img_prompt = _img_prompt[:600]
401
- # Il provider autenticato rimane lato server. Il fallback storico
402
- # resta solo per garantire la creazione gratuita se il secret non è
403
- # ancora disponibile durante un riavvio del runtime.
404
- try:
405
- from api.image_provider import generate_pollinations_image
406
- remote = await generate_pollinations_image(_img_prompt, width=512, height=512)
407
- img_url = remote.url
408
- img_mime = remote.mime_type
409
- except Exception as provider_exc:
410
- _logger.info("image provider unavailable; using free URL fallback (%s)", type(provider_exc).__name__)
411
- from urllib.parse import quote
412
- _img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
413
- img_url = (
414
- f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
415
- f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
416
- )
417
- img_mime = "image/jpeg"
418
- _artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
419
- _artifact_path = f"generated-image-{_artifact_id}.jpg"
420
- if on_step:
421
- await _maybe_await(on_step({
422
- "action": "file_written",
423
- "status": "done",
424
- "path": _artifact_path,
425
- "source_url": img_url,
426
- "mime_type": img_mime,
427
- "title": "Immagine salvata nel workspace",
428
- "explanation": f"Salvo {_artifact_path} nel VFS…",
429
- }))
430
  return (
431
  "[DIRECT_TERMINAL]\n"
432
  f"![Immagine generata]({img_url})\n\n"
433
- "E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
434
  f"[Apri o scarica l’immagine]({img_url}).\n\n"
435
- f"File VFS: `{_artifact_path}`\n"
436
  f"Prompt usato: {_img_prompt[:200]}\n"
437
  "Dimensioni: 512x512 px"
438
  )
@@ -621,10 +563,6 @@ class DirectToolsMixin:
621
  return (_terminal_conversion, 1,
622
  int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
623
  int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
624
- # Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
625
- # ammessi altri direct tool, né fallback impliciti a immagine/rete.
626
- if local_csv_only:
627
- return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
628
  _terminal_image = await _t_generate_image()
629
  if _terminal_image is not None:
630
  return (_terminal_image, 1,
 
11
  """
12
  from __future__ import annotations
13
  import asyncio
 
14
  import os
15
  import re
16
  from typing import Any
 
24
  # StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
25
  # S-FIX-IMPORT: aggiunto _maybe_await mancante che causava crash nel tool layer
26
  from agents.unified_loop_types import StepCallback, _maybe_await
27
+ from agents.file_conversion import convert_csv_attachment_to_json
28
  class DirectToolsMixin:
29
  # ── Direct tool execution (S193) ─────────────────────────────────────────
30
  # Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
 
67
  r"crypto|bitcoin|ethereum|market\s+cap|capitalizzazione)\b",
68
  re.IGNORECASE,
69
  )
 
 
 
70
  _IMAGE_INTENT_RE = re.compile(
71
+ r"\b(genera|crea|disegna|illustra|fai|mostra|fammi\s+un[a']?|visualizza|produce|render|paint|sketch|"
72
+ r"immagine|foto|illustrazione|ritratto|paesaggio|logo|icona|disegno|grafica|"
73
+ r"image|photo|illustration|portrait|landscape|drawing|graphic|art|artwork)\b",
 
 
74
  re.IGNORECASE,
75
  )
76
  _CALC_INTENT_RE = re.compile(
 
126
  if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
127
  return candidate
128
  return "."
129
+ async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
 
 
 
 
 
 
130
  """
131
  S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
132
  Returns: 4-tuple (results_str, n_called, n_success, n_errors).
 
300
  return None
301
  if not _gov_check("convert_csv_to_json", conversion.target_name):
302
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  try:
304
  if on_step:
305
  await _maybe_await(on_step({"action": "tool_start", "status": "running",
306
  "title": "Conversione CSV in JSON",
307
+ "explanation": f"Converto {conversion.source_name} in {conversion.target_name}…"}))
308
  _t0 = asyncio.get_event_loop().time()
309
+ written = await asyncio.wait_for(
310
+ TOOL_REGISTRY["write_file"]["_fn"](
311
+ path=conversion.target_name,
312
+ content=conversion.content,
313
+ ),
314
+ timeout=TOOL_TIMEOUT,
315
+ )
316
+ # Il registry restituisce `ok` per i tool filesystem; questa
317
+ # scorciatoia diretta aggira l'executor che normalmente adatta
318
+ # l'esito al contratto `success`.
319
+ if not written.get("ok"):
320
+ return f"[convert_csv_to_json: errore — {str(written.get('error', 'scrittura non riuscita'))[:300]}]"
 
 
 
 
 
 
 
 
 
 
321
  if on_step:
322
  await _maybe_await(on_step({
323
+ "action": "file_written",
324
+ "status": "done",
325
+ "path": conversion.target_name,
326
+ "content": conversion.content,
327
  "title": "File JSON creato",
328
+ "explanation": f"Creato {conversion.target_name} con {conversion.row_count} record.",
329
  }))
330
  try:
331
  from api.state import record_timing as _rtc
332
  _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
333
  except Exception as _e:
334
  _logger.debug('[timing/record_timing] %s', _e)
 
335
  return (
336
  "[DIRECT_TERMINAL]\n"
337
+ f"E2E_CONVERSION_OK: creato `{conversion.target_name}` da `{conversion.source_name}` "
338
+ f"con {conversion.row_count} record.\n\n"
339
+ f"Percorso workspace: `{conversion.target_name}`."
340
  )
341
  except asyncio.TimeoutError:
342
  return "[convert_csv_to_json: timeout]"
 
359
  _sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
360
  if _sc is not None:
361
  return _sc
362
+ # Pollinations genera l'immagine quando il browser richiede questo
363
+ # URL. Costruirlo qui rende il direct tool autosufficiente: non
364
+ # dipende da cold-start HF, dal contratto base64 dell'endpoint
365
+ # vision né da un provider LLM per verbalizzare il risultato.
366
+ from urllib.parse import quote
367
  _img_prompt = _img_prompt[:600]
368
+ _img_seed = sum(ord(char) for char in _img_prompt) % 9999 + 1
369
+ img_url = (
370
+ f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
371
+ f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
372
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  return (
374
  "[DIRECT_TERMINAL]\n"
375
  f"![Immagine generata]({img_url})\n\n"
376
+ "E2E_IMAGE_OK: immagine generata e visualizzata qui sopra. "
377
  f"[Apri o scarica l’immagine]({img_url}).\n\n"
 
378
  f"Prompt usato: {_img_prompt[:200]}\n"
379
  "Dimensioni: 512x512 px"
380
  )
 
563
  return (_terminal_conversion, 1,
564
  int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
565
  int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
 
 
 
 
566
  _terminal_image = await _t_generate_image()
567
  if _terminal_image is not None:
568
  return (_terminal_image, 1,
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
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/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,21 +42,8 @@ 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,
@@ -65,7 +52,6 @@ from .state import (
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,83 +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:
@@ -294,17 +203,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 +246,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 +269,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 +332,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 +455,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 +464,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 +473,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)
@@ -731,7 +604,7 @@ async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
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,27 +612,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']}
@@ -774,9 +631,7 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
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 +645,11 @@ 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 +660,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(
@@ -959,7 +809,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 +819,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 +827,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 +868,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 +921,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,
@@ -1157,15 +970,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
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.
@@ -1202,24 +1006,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', '')
@@ -1311,19 +1097,13 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
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':
@@ -1442,26 +1222,11 @@ 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,72 +1322,15 @@ 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(
@@ -1638,56 +1346,17 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
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:
@@ -1702,8 +1371,8 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
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'
@@ -1736,13 +1405,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:
 
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_ai_clients, _task_checkpoints, _loop_registry, _run_stream_tasks,
49
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
 
52
  )
53
  from .speculative import fire_speculative_tools
54
  from .vfs_sync import build_vfs_sync_complete
 
55
  try:
56
  from .quality_guardian import run_quality_check as _run_quality_check
57
  except Exception:
 
88
  router = APIRouter()
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _attach_byok_client(task_id: str, credentials: object) -> None:
92
  """Create a task-scoped LLM client without persisting or logging credentials."""
93
  if credentials is None:
 
203
  body: ReasonLoopIn, request: Request,
204
  role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
205
  ):
 
 
 
 
 
206
  async def generate():
207
  queue: asyncio.Queue = asyncio.Queue()
208
 
209
  async def step_cb(step: dict) -> None:
 
 
210
  await queue.put(step)
211
 
212
  async def run_loop() -> None:
 
246
  _neg_c = getattr(body, 'negative_constraints', '') or ''
247
  if _neg_c:
248
  context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
 
249
  result = await loop.run(
250
  goal=body.goal, context=context_str,
251
  max_steps=body.max_steps, on_step=step_cb,
252
  session_id=getattr(body, "session_id", "") or "",
253
  )
 
254
  await queue.put({
255
  '__done__': True,
256
  'result': result.get('output', ''),
257
  'engine': result.get('engine', 'fallback'),
258
  'success': result.get('success', False),
 
259
  })
260
  except Exception as exc:
261
  # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
 
269
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
270
  await queue.put({'__error__': str(exc)})
271
 
272
+ task = asyncio.create_task(run_loop())
 
 
 
 
 
 
273
  task.add_done_callback(_log_task_exc) # BUG-CB-1
274
  task_id = str(uuid.uuid4())
275
  # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
 
332
  _err_detail = _ss(item.get('error', ''))
333
  _final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
334
  else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
335
+ 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"
 
 
 
 
 
 
336
  break
337
  # S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
338
  _NARR_QUICK = {
 
455
  'action': step_data.get('action', ''),
456
  'output': str(step_data.get('output', ''))[:400], # S577: 200→400
457
  })
458
+ 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 "")
 
 
 
 
 
 
 
 
 
 
 
 
459
  if isinstance(result, dict):
460
  output_text = result.get('output', '') or ''
461
  engine_used = result.get('engine', 'unknown')
 
464
  output_text = str(result)
465
  engine_used = 'unknown'
466
  errors_list = []
 
467
  return {
468
  'ok': bool(output_text and output_text.strip()),
469
  'success': bool(output_text and output_text.strip()), # alias compat frontend
 
473
  'engine': engine_used,
474
  'errors': errors_list,
475
  'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
 
476
  }
477
  except Exception as e:
478
  _logger.error("[reason/loop] Error: %s", e)
 
604
 
605
 
606
  @router.post('/api/agent/tasks')
607
+ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
608
  """
609
  Crea o recupera un task agent.
610
 
 
612
  il task viene ripristinato dallo store persistente invece di essere riavviato.
613
  Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
614
  """
 
615
  _prune_agent_tasks()
616
+ task_id = body.taskId or str(uuid.uuid4())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
 
618
  # Already in memory → return immediately (normal path, includes S358 reconnect)
619
+ if task_id in _agent_tasks:
620
  if task_id not in _task_ai_clients:
621
  _attach_byok_client(task_id, body.provider_credentials)
622
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
 
631
  _attach_byok_client(task_id, body.provider_credentials)
632
  return {'taskId': task_id, 'status': restored['status'], 'restored': True}
633
 
634
+ # Brand new task
 
 
635
  created_at = int(time.time() * 1000)
636
  _agent_tasks[task_id] = {
637
  'id': task_id,
 
645
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
646
  'persona': body.persona, # P17-F5: expertise persona hint
647
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
 
 
 
 
648
  }
649
  # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
650
  # e non raggiungono Supabase, checkpoint o buffer SSE.
651
  _attach_byok_client(task_id, body.provider_credentials)
652
+
653
  # BG-4: restore cross-session handoff context (async, non-blocking)
654
  if body.session_id:
655
  _hctx = await sb_restore_handoff_context(body.session_id)
 
660
  asyncio.create_task(
661
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
662
  ).add_done_callback(_log_task_exc)
663
+ # S361: Speculative Tool Firing — pre-fires read-only tools in parallel
664
+ # while the main model processes. Results cached for _run_direct_tools to consume.
665
+ asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
 
 
666
  # ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
667
  if _KERNEL_AVAILABLE and _kernel is not None:
668
  asyncio.create_task(_kernel.submit_task(
 
809
  - Task era RUNNING → replay buffer parziale + evento task_interrupted.
810
  - Task non trovato → prova sb_restore_task prima di 404.
811
  """
 
812
  # S359: se task_id non è in memoria, prova il restore da Supabase
813
  if task_id not in _agent_tasks:
814
  restored = await sb_restore_task(task_id)
 
819
  raise HTTPException(404, detail=f'Task {task_id} non trovato')
820
 
821
  task = _agent_tasks[task_id]
 
 
 
 
 
 
 
 
 
 
 
822
  _last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
823
  _resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
824
 
 
827
  async def generate():
828
  yield "retry: 3000\n\n"
829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  reg = _loop_registry.get(task_id)
831
 
832
  is_done_reconnect = reg is not None and reg.get('done', False)
 
868
  sb_events = await sb_get_events(task_id)
869
  if sb_events:
870
  task_status = task.get('status', 'UNKNOWN')
871
+ terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
872
  # Replay buffer from resume point
873
  for evt_str in sb_events[_resume_from:]:
874
  yield evt_str
 
921
  yield "data: [DONE]\n\n"
922
  return
923
  # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
924
  _prune_loop_registry()
925
  reg_entry: dict = {
926
  'asyncio_task': None,
 
970
 
971
  async def run_loop() -> None:
972
  try:
 
 
 
 
 
 
 
 
 
973
  from agents.unified_loop import UnifiedAgentLoop
974
  # Ogni task BYOK usa il suo client effimero; gli altri mantengono
975
  # il singleton runtime. Le credenziali non entrano nel task dict.
 
1006
  "Il frontend mostrerà automaticamente un pulsante 'Connetti' all'utente."
1007
  )
1008
  context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
  # GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
1010
  # Bug: _resume_context era settato su task{} ma mai letto qui → context perduto su resume.
1011
  _resume_ctx = task.get('_resume_context', '')
 
1097
  # soltanto quelli con contenuto così il commit atomico può avvenire
1098
  # una sola volta dopo un task riuscito.
1099
  _vfs_written_paths: set[str] = set()
 
 
 
1100
 
1101
  async def step_cb(step_data: dict) -> None:
1102
  step_idx[0] += 1
1103
  _action = step_data.get('action', f'Step {step_idx[0]}')
1104
  # S420: streaming token — emetti direttamente senza passare dal buffer step
1105
  if _action == 'text_chunk':
1106
+ _sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
 
 
 
1107
  return
1108
  # RECOV-P1: engineering_state event — forward projection to frontend
1109
  if _action == 'engineering_state':
 
1222
  step_data.get('output', '')[:500])
1223
  _vfs_op = 'delete' if 'delete' in _action else 'write'
1224
  _vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
1225
+ # SYNC-1: includi content nel SSE event per file_written (≤60KB)
1226
+ # Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
 
 
1227
  if _action == 'file_written' and step_data.get('content'):
1228
  _vfs_evt['content'] = str(step_data['content'])[:60_000]
1229
  _vfs_written_paths.add(str(_vfs_file)[:500])
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  _sse('vfs_update', _vfs_evt)
1231
 
1232
  # S363-UI: thought event — emitted when planner completes
 
1322
  except Exception:
1323
  pass # S364: skeleton injection is optional
1324
 
 
1325
  result = await loop.run(
1326
  goal=task['goal'],
1327
  context=context_str,
1328
  max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
1329
  on_step=step_cb,
1330
  session_id=task.get('session_id', '') or '',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1331
  )
1332
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
1333
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
1334
  # ARCH-K2.2: pubblica lifecycle event via Kernel
1335
  if _KERNEL_AVAILABLE and _kernel is not None:
1336
  asyncio.create_task(_kernel.publish_event(
 
1346
  # confermato il task. Su errore/cancellazione lo staging rimane
1347
  # intenzionalmente non committato.
1348
  _sse('vfs_sync_complete', _vfs_commit)
1349
+ _sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1350
  asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
1351
 
1352
+ # S363: fire-and-forget quality check when code detected in output
 
1353
  if _run_quality_check:
1354
  _qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
1355
+ if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised — evita QG su snippet brevi
1356
+ asyncio.create_task(_run_quality_check(
1357
+ task_id, task['goal'], _qg_result,
1358
+ on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
1359
+ )).add_done_callback(_log_task_exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1360
 
1361
 
1362
  except asyncio.CancelledError:
 
1371
  _sse('task_cancelled', {'taskId': task_id})
1372
 
1373
  except (ImportError, ModuleNotFoundError):
1374
+ _agent_tasks[task_id]['status'] = 'SUCCESS'
1375
+ asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
1376
  _sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
1377
  _sse('task_done', {'taskId': task_id, 'result': (
1378
  f'Goal ricevuto: {task["goal"]}\n\n'
 
1405
  except Exception as _exc:
1406
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
1407
 
1408
+ reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
 
 
 
 
 
 
1409
  reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
1410
 
1411
  try:
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_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_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/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/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")
api/cache_manager.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_manager.py — Cache Layer Distribuito su Supabase A
3
+
4
+ Architettura:
5
+ - Supabase A funge da archivio cache distribuito (read-only, non-critical)
6
+ - TTL configurabile per invalidazione automatica
7
+ - Fallback a B se A non disponibile
8
+ - Supporta cache per query, memoria, embeddings, conversazioni
9
+
10
+ Strategie di Caching:
11
+ 1. Query Cache: Risultati query SELECT memorizzati con TTL
12
+ 2. Memory Cache: Memoria agente memorizzata per accesso veloce
13
+ 3. Embedding Cache: Embeddings pre-calcolati per RAG
14
+ 4. Conversation Cache: Conversazioni recenti per accesso veloce
15
+
16
+ Invalidazione:
17
+ - TTL-based: Scadenza automatica dopo TTL
18
+ - Event-based: Invalidazione su INSERT/UPDATE/DELETE su B
19
+ - Manual: Invalidazione esplicita via API
20
+
21
+ Statistiche:
22
+ - Hit rate: % di richieste servite da cache
23
+ - Miss rate: % di richieste non in cache
24
+ - Eviction rate: % di entry rimosse per TTL
25
+ """
26
+
27
+ import os
28
+ import asyncio
29
+ import logging
30
+ import hashlib
31
+ import json
32
+ from typing import Optional, Dict, List, Any, Callable
33
+ from datetime import datetime, timedelta
34
+ from enum import Enum
35
+ import time
36
+
37
+ _logger = logging.getLogger("cache_manager")
38
+
39
+ # ── Configurazione Cache ───────────────────────────────────────────────────
40
+ CACHE_ENABLED = os.getenv("SUPABASE_CACHE_ENABLED", "true").lower() == "true"
41
+ CACHE_TTL_DEFAULT = int(os.getenv("SUPABASE_CACHE_TTL", "3600")) # 1 ora
42
+ CACHE_MAX_SIZE = int(os.getenv("SUPABASE_CACHE_MAX_SIZE", "10000")) # max entries
43
+ CACHE_STATS_ENABLED = os.getenv("SUPABASE_CACHE_STATS_ENABLED", "true").lower() == "true"
44
+
45
+ # ── Enumerazione Strategie Cache ───────────────────────────────────────────
46
+ class CacheStrategy(str, Enum):
47
+ QUERY = "query" # Cache risultati query
48
+ MEMORY = "memory" # Cache memoria agente
49
+ EMBEDDING = "embedding" # Cache embeddings
50
+ CONVERSATION = "conversation" # Cache conversazioni
51
+ ANALYTICS = "analytics" # Cache analytics/reporting
52
+
53
+
54
+ # ── TTL per Strategia ─────────────────────────────────────────────────────
55
+ CACHE_TTL_BY_STRATEGY = {
56
+ CacheStrategy.QUERY: int(os.getenv("CACHE_TTL_QUERY", "600")), # 10 min
57
+ CacheStrategy.MEMORY: int(os.getenv("CACHE_TTL_MEMORY", "1800")), # 30 min
58
+ CacheStrategy.EMBEDDING: int(os.getenv("CACHE_TTL_EMBEDDING", "7200")), # 2 ore
59
+ CacheStrategy.CONVERSATION: int(os.getenv("CACHE_TTL_CONVERSATION", "3600")), # 1 ora
60
+ CacheStrategy.ANALYTICS: int(os.getenv("CACHE_TTL_ANALYTICS", "300")), # 5 min
61
+ }
62
+
63
+ # ── Configurazione Supabase A ──────────────────────────────────────────────
64
+ SUPABASE_A_URL = os.getenv("SUPABASE_URL_A", "")
65
+ SUPABASE_A_KEY = os.getenv("SUPABASE_KEY_A", "")
66
+ SUPABASE_B_URL = os.getenv("SUPABASE_URL", "")
67
+ SUPABASE_B_KEY = os.getenv("SUPABASE_KEY", "")
68
+
69
+
70
+ # ── Client Cache ───────────────────────────────────────────────────────────
71
+ class CacheClient:
72
+ """Client per gestire cache su Supabase A."""
73
+
74
+ def __init__(self, url: str, key: str):
75
+ self.url = url
76
+ self.key = key
77
+ self.base_url = f"{url}/rest/v1" if url else None
78
+ self._stats = {
79
+ "hits": 0,
80
+ "misses": 0,
81
+ "sets": 0,
82
+ "deletes": 0,
83
+ "evictions": 0,
84
+ }
85
+
86
+ def _generate_cache_key(self, strategy: str, identifier: str) -> str:
87
+ """Genera una chiave cache univoca."""
88
+ combined = f"{strategy}:{identifier}"
89
+ return hashlib.sha256(combined.encode()).hexdigest()[:32]
90
+
91
+ async def get(self, strategy: CacheStrategy, identifier: str) -> Optional[Dict]:
92
+ """Recupera un valore dalla cache."""
93
+ if not CACHE_ENABLED or not self.base_url:
94
+ return None
95
+
96
+ cache_key = self._generate_cache_key(strategy.value, identifier)
97
+
98
+ try:
99
+ import httpx
100
+
101
+ url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
102
+ headers = {
103
+ "apikey": self.key,
104
+ "Authorization": f"Bearer {self.key}",
105
+ "Content-Type": "application/json",
106
+ }
107
+
108
+ async with httpx.AsyncClient() as client:
109
+ response = await client.get(url, headers=headers, timeout=5.0)
110
+
111
+ if response.status_code == 200:
112
+ rows = response.json()
113
+ if rows:
114
+ entry = rows[0]
115
+
116
+ # Verifica TTL
117
+ if self._is_expired(entry):
118
+ # Elimina entry scaduta
119
+ await self._delete_entry(cache_key)
120
+ if CACHE_STATS_ENABLED:
121
+ self._stats["evictions"] += 1
122
+ return None
123
+
124
+ # Hit
125
+ if CACHE_STATS_ENABLED:
126
+ self._stats["hits"] += 1
127
+
128
+ return {
129
+ "value": entry.get("value"),
130
+ "cached_at": entry.get("created_at"),
131
+ "ttl_remaining": self._get_ttl_remaining(entry),
132
+ }
133
+
134
+ # Miss
135
+ if CACHE_STATS_ENABLED:
136
+ self._stats["misses"] += 1
137
+ return None
138
+
139
+ except Exception as exc:
140
+ _logger.error(f"Cache get error: {exc}")
141
+ return None
142
+
143
+ async def set(
144
+ self,
145
+ strategy: CacheStrategy,
146
+ identifier: str,
147
+ value: Any,
148
+ ttl: Optional[int] = None,
149
+ ) -> bool:
150
+ """Memorizza un valore nella cache."""
151
+ if not CACHE_ENABLED or not self.base_url:
152
+ return False
153
+
154
+ cache_key = self._generate_cache_key(strategy.value, identifier)
155
+ ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
156
+
157
+ try:
158
+ import httpx
159
+
160
+ url = f"{self.base_url}/cache_entries"
161
+ headers = {
162
+ "apikey": self.key,
163
+ "Authorization": f"Bearer {self.key}",
164
+ "Content-Type": "application/json",
165
+ }
166
+
167
+ data = {
168
+ "cache_key": cache_key,
169
+ "strategy": strategy.value,
170
+ "identifier": identifier,
171
+ "value": json.dumps(value) if not isinstance(value, str) else value,
172
+ "ttl_seconds": ttl,
173
+ "created_at": datetime.now().isoformat(),
174
+ "expires_at": (datetime.now() + timedelta(seconds=ttl)).isoformat(),
175
+ }
176
+
177
+ async with httpx.AsyncClient() as client:
178
+ response = await client.post(url, json=data, headers=headers, timeout=5.0)
179
+
180
+ if response.status_code in (200, 201):
181
+ if CACHE_STATS_ENABLED:
182
+ self._stats["sets"] += 1
183
+ return True
184
+ else:
185
+ _logger.warning(f"Cache set failed: {response.status_code}")
186
+ return False
187
+
188
+ except Exception as exc:
189
+ _logger.error(f"Cache set error: {exc}")
190
+ return False
191
+
192
+ async def delete(self, strategy: CacheStrategy, identifier: str) -> bool:
193
+ """Elimina un valore dalla cache."""
194
+ if not CACHE_ENABLED or not self.base_url:
195
+ return False
196
+
197
+ cache_key = self._generate_cache_key(strategy.value, identifier)
198
+ return await self._delete_entry(cache_key)
199
+
200
+ async def _delete_entry(self, cache_key: str) -> bool:
201
+ """Elimina un entry dalla cache per chiave."""
202
+ try:
203
+ import httpx
204
+
205
+ url = f"{self.base_url}/cache_entries?cache_key=eq.{cache_key}"
206
+ headers = {
207
+ "apikey": self.key,
208
+ "Authorization": f"Bearer {self.key}",
209
+ }
210
+
211
+ async with httpx.AsyncClient() as client:
212
+ response = await client.delete(url, headers=headers, timeout=5.0)
213
+
214
+ if response.status_code in (200, 204):
215
+ if CACHE_STATS_ENABLED:
216
+ self._stats["deletes"] += 1
217
+ return True
218
+ return False
219
+
220
+ except Exception as exc:
221
+ _logger.error(f"Cache delete error: {exc}")
222
+ return False
223
+
224
+ def _is_expired(self, entry: Dict) -> bool:
225
+ """Verifica se un entry è scaduto."""
226
+ expires_at = entry.get("expires_at")
227
+ if not expires_at:
228
+ return False
229
+
230
+ try:
231
+ expires_dt = datetime.fromisoformat(expires_at)
232
+ return datetime.now() > expires_dt
233
+ except:
234
+ return False
235
+
236
+ def _get_ttl_remaining(self, entry: Dict) -> int:
237
+ """Calcola il TTL rimanente in secondi."""
238
+ expires_at = entry.get("expires_at")
239
+ if not expires_at:
240
+ return 0
241
+
242
+ try:
243
+ expires_dt = datetime.fromisoformat(expires_at)
244
+ remaining = (expires_dt - datetime.now()).total_seconds()
245
+ return max(0, int(remaining))
246
+ except:
247
+ return 0
248
+
249
+ def get_stats(self) -> Dict[str, Any]:
250
+ """Ritorna statistiche cache."""
251
+ total = self._stats["hits"] + self._stats["misses"]
252
+ hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0
253
+
254
+ return {
255
+ "enabled": CACHE_ENABLED,
256
+ "hits": self._stats["hits"],
257
+ "misses": self._stats["misses"],
258
+ "sets": self._stats["sets"],
259
+ "deletes": self._stats["deletes"],
260
+ "evictions": self._stats["evictions"],
261
+ "hit_rate_percent": round(hit_rate, 2),
262
+ "total_requests": total,
263
+ }
264
+
265
+ def reset_stats(self):
266
+ """Resetta le statistiche."""
267
+ self._stats = {
268
+ "hits": 0,
269
+ "misses": 0,
270
+ "sets": 0,
271
+ "deletes": 0,
272
+ "evictions": 0,
273
+ }
274
+
275
+
276
+ # ── Cache Wrapper con Fallback ─────────────────────────────────────────────
277
+ class CacheManager:
278
+ """Gestore cache con fallback intelligente."""
279
+
280
+ def __init__(self):
281
+ self.cache_client = CacheClient(SUPABASE_A_URL, SUPABASE_A_KEY) if SUPABASE_A_URL else None
282
+ self.fallback_client = CacheClient(SUPABASE_B_URL, SUPABASE_B_KEY) if SUPABASE_B_URL else None
283
+
284
+ async def get_or_fetch(
285
+ self,
286
+ strategy: CacheStrategy,
287
+ identifier: str,
288
+ fetch_fn: Callable,
289
+ ttl: Optional[int] = None,
290
+ ) -> Any:
291
+ """
292
+ Recupera valore da cache o lo genera con fetch_fn.
293
+
294
+ Logica:
295
+ 1. Prova a leggere da cache A
296
+ 2. Se miss, chiama fetch_fn
297
+ 3. Memorizza risultato in cache A
298
+ 4. Ritorna valore
299
+ """
300
+ # Prova cache A
301
+ if self.cache_client:
302
+ cached = await self.cache_client.get(strategy, identifier)
303
+ if cached:
304
+ _logger.debug(f"Cache hit: {strategy.value}:{identifier}")
305
+ return json.loads(cached["value"]) if isinstance(cached["value"], str) else cached["value"]
306
+
307
+ # Cache miss → fetch
308
+ _logger.debug(f"Cache miss: {strategy.value}:{identifier}, fetching...")
309
+ try:
310
+ value = await fetch_fn() if asyncio.iscoroutinefunction(fetch_fn) else fetch_fn()
311
+ except Exception as exc:
312
+ _logger.error(f"Fetch error: {exc}")
313
+ return None
314
+
315
+ # Memorizza in cache A
316
+ if self.cache_client and value is not None:
317
+ ttl = ttl or CACHE_TTL_BY_STRATEGY.get(strategy, CACHE_TTL_DEFAULT)
318
+ await self.cache_client.set(strategy, identifier, value, ttl)
319
+
320
+ return value
321
+
322
+ async def invalidate(self, strategy: CacheStrategy, identifier: str) -> bool:
323
+ """Invalida un entry cache."""
324
+ if self.cache_client:
325
+ return await self.cache_client.delete(strategy, identifier)
326
+ return False
327
+
328
+ async def invalidate_pattern(self, strategy: CacheStrategy, pattern: str) -> int:
329
+ """Invalida tutti gli entry che corrispondono a un pattern."""
330
+ # TODO: Implementare pattern matching
331
+ return 0
332
+
333
+ def get_stats(self) -> Dict[str, Any]:
334
+ """Ritorna statistiche cache."""
335
+ if self.cache_client:
336
+ return self.cache_client.get_stats()
337
+ return {"enabled": False}
338
+
339
+ def reset_stats(self):
340
+ """Resetta statistiche."""
341
+ if self.cache_client:
342
+ self.cache_client.reset_stats()
343
+
344
+
345
+ # ── Istanza Globale ───────────────────────────────────────────────────────
346
+ _cache_manager = CacheManager()
347
+
348
+
349
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
350
+ async def get_cached(
351
+ strategy: CacheStrategy,
352
+ identifier: str,
353
+ fetch_fn: Callable,
354
+ ttl: Optional[int] = None,
355
+ ) -> Any:
356
+ """Recupera valore da cache o lo genera."""
357
+ return await _cache_manager.get_or_fetch(strategy, identifier, fetch_fn, ttl)
358
+
359
+
360
+ async def invalidate_cache(strategy: CacheStrategy, identifier: str) -> bool:
361
+ """Invalida un entry cache."""
362
+ return await _cache_manager.invalidate(strategy, identifier)
363
+
364
+
365
+ def get_cache_stats() -> Dict[str, Any]:
366
+ """Ritorna statistiche cache."""
367
+ return _cache_manager.get_stats()
368
+
369
+
370
+ def reset_cache_stats():
371
+ """Resetta statistiche cache."""
372
+ _cache_manager.reset_stats()
373
+
374
+
375
+ # ── Decorator per Caching Automatico ───────────────────────────────────────
376
+ def cached(strategy: CacheStrategy, ttl: Optional[int] = None):
377
+ """
378
+ Decorator per caching automatico di funzioni.
379
+
380
+ Uso:
381
+ @cached(CacheStrategy.QUERY, ttl=600)
382
+ async def get_user_data(user_id: str):
383
+ return await fetch_user_from_db(user_id)
384
+ """
385
+ def decorator(func: Callable):
386
+ async def wrapper(*args, **kwargs):
387
+ # Genera identifier da args/kwargs
388
+ identifier = f"{func.__name__}:{str(args)}:{str(kwargs)}"
389
+
390
+ # Usa cache manager
391
+ return await _cache_manager.get_or_fetch(
392
+ strategy,
393
+ identifier,
394
+ lambda: func(*args, **kwargs),
395
+ ttl,
396
+ )
397
+
398
+ return wrapper
399
+
400
+ return decorator
api/capability_catalog.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/capability_catalog.py — Capability Marketplace Catalog (ARCH-E3.1)
3
+
4
+ Catalogo dinamico dove ogni Worker registra le proprie capabilities con metadati
5
+ completi: versione, SLA, latenza target, GPU, costo, tag, disponibilità, regione.
6
+
7
+ Il Brain NON conosce i Worker — chiede una capability, il Kernel + Fabric scelgono.
8
+ Il Catalog è il registro centrale di discovery; il Fabric usa il Catalog per lo scoring.
9
+
10
+ Flusso:
11
+ Worker → POST /api/catalog/register → entry creata/aggiornata con TTL
12
+ Worker → POST /api/catalog/heartbeat → TTL rinnovato
13
+ Fabric → (auto) register all'init → fleet registrata automaticamente
14
+ Client → GET /api/catalog/capabilities → lista capabilities vive
15
+ Client → GET /api/catalog/capabilities/{name} → providers per una capability
16
+ Client → GET /api/catalog/status → diagnostica + contatori
17
+
18
+ Invarianti ADR:
19
+ S9: ogni servizio ignora l'impl interna degli altri
20
+ S19: nessun vendor lock-in — qualsiasi Worker può registrarsi
21
+ S20: routing intent-based, non hardcoded
22
+ S27: ogni capability tracciabile via provider_id + correlation_id
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ import time
29
+ from typing import Any
30
+
31
+ from fastapi import APIRouter, Depends, HTTPException
32
+ from pydantic import BaseModel, Field
33
+
34
+ from .auth_guard import AuthRole, require_role
35
+
36
+ _logger = logging.getLogger("api.capability_catalog")
37
+
38
+ # ── TTL / cleanup config ────────────────────────────────────────────────────────
39
+ _ENTRY_TTL_S: float = float(__import__("os").getenv("CATALOG_TTL_S", "300")) # 5 min
40
+ _CLEANUP_INTERVAL_S: int = int(__import__("os").getenv("CATALOG_CLEANUP_S", "60")) # 1 min
41
+
42
+ # ── Models ──────────────────────────────────────────────────────────────────────
43
+
44
+ class CapabilityDescriptor(BaseModel):
45
+ """Descrittore completo di una singola capability offerta da un provider."""
46
+ name: str = Field(..., description="Nome capability, es. 'browser', 'python_sandbox'")
47
+ version: str = Field("1.0.0", description="Versione semantica (semver)")
48
+ provider_id: str = Field(..., description="ID univoco del provider")
49
+ provider_name: str = Field("", description="Nome human-readable del provider")
50
+ description: str = Field("", description="Descrizione funzionale breve")
51
+ tags: list[str] = Field(default_factory=list, description="Tag per discovery intent-based")
52
+ requires_gpu: bool = Field(False, description="Richiede GPU")
53
+ sla_ms: float = Field(5000.0, description="Target latency SLA in ms (p95)")
54
+ max_payload_kb: int = Field(1024, description="Payload massimo accettato in KB")
55
+ cost_unit: float = Field(0.0, description="Costo per invocazione (0 = free)")
56
+ region: str = Field("us", description="Regione di deployment")
57
+ always_on: bool = Field(True, description="Provider sempre attivo (no cold start)")
58
+ registered_at: float = Field(default_factory=time.time)
59
+ last_heartbeat: float = Field(default_factory=time.time)
60
+ metadata: dict[str, Any] = Field(default_factory=dict, description="Metadati extra provider-specifici")
61
+
62
+
63
+ class RegisterRequest(BaseModel):
64
+ descriptors: list[CapabilityDescriptor] = Field(
65
+ ..., description="Lista capability da registrare per questo provider"
66
+ )
67
+
68
+
69
+ class HeartbeatRequest(BaseModel):
70
+ provider_id: str
71
+ capability_names: list[str] | None = None # None = tutte le capability del provider
72
+
73
+
74
+ # ── CapabilityCatalog singleton ─────────────────────────────────────────────────
75
+
76
+ class CapabilityCatalog:
77
+ """
78
+ Registro dinamico di tutte le capabilities disponibili nel sistema.
79
+
80
+ Struttura interna:
81
+ _entries: { (provider_id, capability_name) → CapabilityDescriptor }
82
+
83
+ Thread/task safety: lock asyncio su tutte le mutazioni.
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self._entries: dict[tuple[str, str], CapabilityDescriptor] = {}
88
+ self._lock = asyncio.Lock()
89
+ self._cleanup_task: asyncio.Task | None = None
90
+
91
+ # ── Registration ──────────────────────────────────────────────────────────
92
+
93
+ async def register(self, descriptors: list[CapabilityDescriptor]) -> int:
94
+ """Registra/aggiorna N capabilities. Ritorna il numero di entry salvate."""
95
+ async with self._lock:
96
+ now = time.time()
97
+ for d in descriptors:
98
+ d.registered_at = now
99
+ d.last_heartbeat = now
100
+ self._entries[(d.provider_id, d.name)] = d
101
+ count = len(descriptors)
102
+ _logger.info("[catalog] registered %d capabilities from provider=%s",
103
+ count, descriptors[0].provider_id if descriptors else "?")
104
+ return count
105
+
106
+ async def deregister(self, provider_id: str, capability_names: list[str] | None = None) -> int:
107
+ """Rimuove capability di un provider (o subset se specificato)."""
108
+ async with self._lock:
109
+ to_del = [
110
+ k for k in self._entries
111
+ if k[0] == provider_id and (capability_names is None or k[1] in capability_names)
112
+ ]
113
+ for k in to_del:
114
+ del self._entries[k]
115
+ if to_del:
116
+ _logger.info("[catalog] deregistered %d capabilities from provider=%s",
117
+ len(to_del), provider_id)
118
+ return len(to_del)
119
+
120
+ async def heartbeat(self, provider_id: str, capability_names: list[str] | None = None) -> int:
121
+ """Aggiorna last_heartbeat. Ritorna il numero di entry aggiornate."""
122
+ async with self._lock:
123
+ now = time.time()
124
+ count = 0
125
+ for (pid, cname), d in self._entries.items():
126
+ if pid == provider_id and (capability_names is None or cname in capability_names):
127
+ d.last_heartbeat = now
128
+ count += 1
129
+ return count
130
+
131
+ # ── Query ─────────────────────────────────────────────────────────────────
132
+
133
+ def query(
134
+ self,
135
+ name: str | None = None,
136
+ tags: list[str] | None = None,
137
+ requires_gpu: bool | None = None,
138
+ max_sla_ms: float | None = None,
139
+ region: str | None = None,
140
+ include_stale: bool = False,
141
+ ) -> list[CapabilityDescriptor]:
142
+ """
143
+ Ricerca nel catalogo con filtri combinabili.
144
+ Di default ritorna solo entry vive (last_heartbeat entro TTL).
145
+ """
146
+ now = time.time()
147
+ results = []
148
+ for d in self._entries.values():
149
+ if not include_stale and (now - d.last_heartbeat) > _ENTRY_TTL_S:
150
+ continue
151
+ if name and d.name != name:
152
+ continue
153
+ if tags and not any(t in d.tags for t in tags):
154
+ continue
155
+ if requires_gpu is not None and d.requires_gpu != requires_gpu:
156
+ continue
157
+ if max_sla_ms is not None and d.sla_ms > max_sla_ms:
158
+ continue
159
+ if region and d.region != region:
160
+ continue
161
+ results.append(d)
162
+ return results
163
+
164
+ def get_sla(self, capability_name: str, provider_id: str) -> float:
165
+ """
166
+ Ritorna sla_ms per una capability specifica, o 9999.0 se non trovata.
167
+ Non-blocking: lookup puro dict — safe da chiamare in _select().
168
+ """
169
+ d = self._entries.get((provider_id, capability_name))
170
+ return d.sla_ms if d else 9999.0
171
+
172
+ def all_entries(self, include_stale: bool = False) -> list[CapabilityDescriptor]:
173
+ """Lista completa (per diagnostica)."""
174
+ if include_stale:
175
+ return list(self._entries.values())
176
+ now = time.time()
177
+ return [d for d in self._entries.values() if (now - d.last_heartbeat) <= _ENTRY_TTL_S]
178
+
179
+ # ── Cleanup ───────────────────────────────────────────────────────────────
180
+
181
+ async def cleanup_stale(self) -> int:
182
+ """Rimuove entry con TTL scaduto. Chiamato dal loop interno."""
183
+ async with self._lock:
184
+ now = time.time()
185
+ stale = [k for k, d in self._entries.items()
186
+ if (now - d.last_heartbeat) > _ENTRY_TTL_S]
187
+ for k in stale:
188
+ del self._entries[k]
189
+ if stale:
190
+ _logger.warning("[catalog] cleanup: removed %d stale entries", len(stale))
191
+ return len(stale)
192
+
193
+ async def _cleanup_loop(self) -> None:
194
+ while True:
195
+ await asyncio.sleep(_CLEANUP_INTERVAL_S)
196
+ try:
197
+ await self.cleanup_stale()
198
+ except Exception as exc:
199
+ _logger.warning("[catalog] cleanup error: %s", exc)
200
+
201
+ def start_cleanup_loop(self) -> None:
202
+ """Avvia background cleanup. Chiamare in on_startup."""
203
+ if self._cleanup_task is None or self._cleanup_task.done():
204
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
205
+ _logger.info("[catalog] cleanup loop started (TTL=%ds, interval=%ds)",
206
+ int(_ENTRY_TTL_S), _CLEANUP_INTERVAL_S)
207
+
208
+
209
+ # ── Singleton ───────────────────────────────────────────────────────────────────
210
+ catalog = CapabilityCatalog()
211
+
212
+ # ── HTTP Router ─────────────────────────────────────────────────────────────────
213
+ router = APIRouter(
214
+ prefix="/api/catalog",
215
+ tags=["capability-catalog"],
216
+ dependencies=[Depends(require_role(AuthRole.MACHINE))],
217
+ )
218
+
219
+
220
+ @router.post("/register", summary="Registra capabilities di un Worker nel catalogo")
221
+ async def route_register(req: RegisterRequest) -> dict:
222
+ if not req.descriptors:
223
+ raise HTTPException(400, "descriptors lista vuota")
224
+ count = await catalog.register(req.descriptors)
225
+ return {
226
+ "registered": count,
227
+ "provider_id": req.descriptors[0].provider_id,
228
+ }
229
+
230
+
231
+ @router.post("/heartbeat", summary="Rinnova TTL capabilities (keep-alive)")
232
+ async def route_heartbeat(req: HeartbeatRequest) -> dict:
233
+ count = await catalog.heartbeat(req.provider_id, req.capability_names)
234
+ return {"updated": count, "provider_id": req.provider_id}
235
+
236
+
237
+ @router.delete("/providers/{provider_id}", summary="Deregistra capabilities di un provider")
238
+ async def route_deregister(provider_id: str) -> dict:
239
+ count = await catalog.deregister(provider_id)
240
+ return {"removed": count, "provider_id": provider_id}
241
+
242
+
243
+ @router.get("/capabilities", summary="Lista capabilities disponibili con filtri")
244
+ async def route_list_capabilities(
245
+ name: str | None = None,
246
+ tag: str | None = None,
247
+ requires_gpu: bool | None = None,
248
+ max_sla_ms: float | None = None,
249
+ region: str | None = None,
250
+ ) -> dict:
251
+ tags = [tag] if tag else None
252
+ entries = catalog.query(name=name, tags=tags, requires_gpu=requires_gpu,
253
+ max_sla_ms=max_sla_ms, region=region)
254
+ return {
255
+ "count": len(entries),
256
+ "capabilities": [e.model_dump() for e in entries],
257
+ }
258
+
259
+
260
+ @router.get("/capabilities/{capability_name}", summary="Dettaglio capability per nome")
261
+ async def route_get_capability(capability_name: str) -> dict:
262
+ entries = catalog.query(name=capability_name)
263
+ if not entries:
264
+ raise HTTPException(404, f"Capability '{capability_name}' non trovata nel catalogo")
265
+ best = min(entries, key=lambda e: e.sla_ms)
266
+ return {
267
+ "capability": capability_name,
268
+ "providers": len(entries),
269
+ "best_sla_ms": best.sla_ms,
270
+ "best_provider": best.provider_id,
271
+ "descriptors": [e.model_dump() for e in sorted(entries, key=lambda e: e.sla_ms)],
272
+ }
273
+
274
+
275
+ @router.get("/status", summary="Stato del catalogo e contatori")
276
+ async def route_status() -> dict:
277
+ all_e = catalog.all_entries(include_stale=True)
278
+ live = catalog.all_entries()
279
+ stale = len(all_e) - len(live)
280
+ by_prov: dict[str, int] = {}
281
+ for e in live:
282
+ by_prov[e.provider_id] = by_prov.get(e.provider_id, 0) + 1
283
+ unique_caps = sorted({e.name for e in live})
284
+ return {
285
+ "total_entries": len(all_e),
286
+ "live_entries": len(live),
287
+ "stale_entries": stale,
288
+ "unique_capabilities": unique_caps,
289
+ "providers": by_prov,
290
+ "ttl_s": _ENTRY_TTL_S,
291
+ "cleanup_interval_s": _CLEANUP_INTERVAL_S,
292
+ }
293
+
294
+
295
+ # ── ARCH-E3.4: Worker self-announcement ────────────────────────────────────────
296
+
297
+ class WorkerAnnouncement(BaseModel):
298
+ """
299
+ Payload che ogni Worker invia al boot per auto-registrare le proprie capabilities.
300
+ Sostituisce la registrazione manuale — il Worker conosce se stesso.
301
+ """
302
+ worker_id: str = Field(..., description="ID univoco del Worker, es. 'hf-space-browser'")
303
+ worker_name: str = Field(...)
304
+ worker_kind: str = Field("http", description="http | grpc | ws")
305
+ base_url: str = Field(..., description="URL base del Worker")
306
+ capabilities: list[str] = Field(..., description="Lista capability esposte")
307
+ region: str = Field("global")
308
+ requires_gpu: bool = Field(False)
309
+ sla_ms: dict[str, float] = Field(default_factory=dict,
310
+ description="SLA per capability, es. {'browser': 8000}")
311
+ cost_unit: float = Field(0.0)
312
+ version: str = Field("1.0.0")
313
+ always_on: bool = Field(True)
314
+ tags: list[str] = Field(default_factory=list)
315
+ metadata: dict = Field(default_factory=dict)
316
+
317
+
318
+ @router.post("/worker-announce", summary="Worker auto-registra le proprie capabilities al boot (ARCH-E3.4)")
319
+ async def route_worker_announce(ann: WorkerAnnouncement) -> dict:
320
+ """
321
+ Endpoint chiamato dai Worker all'avvio per registrare capabilities nel Catalog.
322
+ Ogni capability riceve un CapabilityDescriptor auto-costruito dall'annuncio.
323
+ Esegue anche heartbeat se il worker è già registrato (idempotente).
324
+ """
325
+ descs = [
326
+ CapabilityDescriptor(
327
+ name = cap,
328
+ version = ann.version,
329
+ provider_id = ann.worker_id,
330
+ provider_name = ann.worker_name,
331
+ sla_ms = ann.sla_ms.get(cap, 5000.0),
332
+ requires_gpu = ann.requires_gpu,
333
+ cost_unit = ann.cost_unit,
334
+ region = ann.region,
335
+ always_on = ann.always_on,
336
+ tags = ann.tags + ["worker", ann.worker_kind],
337
+ metadata = {"base_url": ann.base_url, **ann.metadata},
338
+ )
339
+ for cap in ann.capabilities
340
+ ]
341
+ if descs:
342
+ await catalog.register(descs)
343
+ return {
344
+ "announced": True,
345
+ "worker_id": ann.worker_id,
346
+ "capabilities": ann.capabilities,
347
+ "registered": len(descs),
348
+ }
api/capability_resolver.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/capability_resolver.py — Capability Resolver (ARCH-E3.2)
3
+
4
+ Mappa le capability richieste dal Brain ai Worker disponibili nel Capability
5
+ Catalog (ARCH-E3.1), scegliendo il provider ottimale in base a SLA, GPU,
6
+ regione e salute operativa.
7
+
8
+ Posizione nel flusso:
9
+ Brain → Kernel.submit_task(capability) → Resolver.resolve() → provider_hint
10
+ → ExecutionFabric.dispatch(provider_hint) → Worker
11
+
12
+ Differenza con ExecutionFabric._select():
13
+ - Resolver: decisione DICHIARATIVA dal Catalog (metadata statici, SLA contratto)
14
+ - Fabric._select(): decisione OPERATIVA (health live, circuit breaker, concurrency)
15
+ Il resolver fornisce l'hint; il Fabric può ignorarlo se il provider è down.
16
+
17
+ Funzionalità:
18
+ resolve(ResolveRequest) → ResolveResult (migliore provider + alternative)
19
+ resolve_many([ResolveReq]) → list[ResolveResult] (bulk per Workflow Engine)
20
+ can_resolve(capability) → bool (quick check senza scoring)
21
+
22
+ HTTP Endpoints (auth: MACHINE):
23
+ POST /api/resolver/resolve — risolve una singola capability
24
+ POST /api/resolver/resolve-many — risolve N capability in bulk (workflow planning)
25
+ GET /api/resolver/status — diagnostica: capabilities risolvibili, contatori
26
+
27
+ Invarianti ADR:
28
+ S4: Brain non conosce l'infrastruttura
29
+ S9: ogni servizio ignora l'impl interna degli altri
30
+ S19: nessun vendor lock-in — chiunque nel Catalog è eleggibile
31
+ S20: routing intent-based, non hardcoded
32
+ S27: ogni risoluzione tracciata via resolve_id
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import time
38
+ import uuid
39
+ from typing import Any
40
+
41
+ from fastapi import APIRouter, Depends, HTTPException
42
+ from pydantic import BaseModel, Field
43
+
44
+ from .auth_guard import AuthRole, require_role
45
+
46
+ _logger = logging.getLogger("api.capability_resolver")
47
+
48
+ # ── Catalog import (guard) ─────────────────────────────────────────────────────
49
+ try:
50
+ from .capability_catalog import catalog as _catalog, CapabilityDescriptor as _CapDesc
51
+ _CATALOG_AVAILABLE = True
52
+ except Exception:
53
+ _catalog = None # type: ignore[assignment]
54
+ _CapDesc = None # type: ignore[assignment]
55
+ _CATALOG_AVAILABLE = False
56
+
57
+ # ── Fabric state import (guard) — per leggere health live senza accoppiamento ──
58
+ try:
59
+ from .execution_fabric import fabric as _fabric
60
+ _FABRIC_AVAILABLE = True
61
+ except Exception:
62
+ _fabric = None # type: ignore[assignment]
63
+ _FABRIC_AVAILABLE = False
64
+
65
+ # ── Scoring weights ────────────────────────────────────────────────────────────
66
+ # ARCH-RESOLVER-FB: aggiunto _W_FEEDBACK — pesi bilanciati a 1.00
67
+ _W_SLA = 0.35 # peso SLA target (latenza dichiarata)
68
+ _W_COST = 0.15 # peso costo (free > paid)
69
+ _W_ALWAYS_ON = 0.20 # peso always-on vs on-demand
70
+ _W_REGION = 0.15 # peso preferenza regione
71
+ _W_FEEDBACK = 0.15 # peso feedback storico real-world (success rate + latency delta)
72
+
73
+ # ── Feedback Tracker — EWA in-memory, zero I/O ────────────────────────────────
74
+ class _FeedbackRecord:
75
+ """Record EWA (Exponentially Weighted Average) per provider."""
76
+ __slots__ = ("success_ewa", "latency_ratio_ewa", "calls", "_alpha")
77
+
78
+ def __init__(self, alpha: float = 0.2) -> None:
79
+ self.success_ewa = 1.0 # parte ottimista (assume ok fino a prova contraria)
80
+ self.latency_ratio_ewa = 1.0 # actual_ms / declared_sla_ms (1.0 = rispetta SLA)
81
+ self.calls = 0
82
+ self._alpha = alpha
83
+
84
+ def record(self, success: bool, actual_ms: float | None, declared_sla_ms: float) -> None:
85
+ a = self._alpha
86
+ self.success_ewa = (1 - a) * self.success_ewa + a * (1.0 if success else 0.0)
87
+ if actual_ms is not None and declared_sla_ms > 0:
88
+ ratio = actual_ms / declared_sla_ms
89
+ self.latency_ratio_ewa = (1 - a) * self.latency_ratio_ewa + a * ratio
90
+ self.calls += 1
91
+
92
+ def score(self) -> float:
93
+ """Score [0,1]: 1.0 = perfetto (success rate 100%, rispetta SLA), 0 = pessimo."""
94
+ # success rate: 1.0 → bonus, 0.0 → forte penalità
95
+ s_score = self.success_ewa
96
+ # latency ratio: ratio ≤ 1 (batte SLA) → bonus, ratio > 2 → forte penalità
97
+ l_score = min(1.0, 1.0 / max(self.latency_ratio_ewa, 0.5))
98
+ return 0.7 * s_score + 0.3 * l_score
99
+
100
+
101
+ class FeedbackTracker:
102
+ """Registry in-memory di feedback per provider_id. Thread-safe tramite GIL."""
103
+
104
+ def __init__(self) -> None:
105
+ self._records: dict[str, _FeedbackRecord] = {}
106
+
107
+ def record(self, provider_id: str, success: bool,
108
+ actual_ms: float | None = None, declared_sla_ms: float = 1000.0) -> None:
109
+ if provider_id not in self._records:
110
+ self._records[provider_id] = _FeedbackRecord()
111
+ self._records[provider_id].record(success, actual_ms, declared_sla_ms)
112
+
113
+ def score(self, provider_id: str) -> float:
114
+ """Restituisce feedback score [0,1]. Default ottimistico 0.85 se nessun dato."""
115
+ rec = self._records.get(provider_id)
116
+ return rec.score() if rec else 0.85
117
+
118
+ def stats(self) -> dict:
119
+ return {
120
+ pid: {"calls": r.calls, "success_ewa": round(r.success_ewa, 3),
121
+ "latency_ratio_ewa": round(r.latency_ratio_ewa, 3), "score": round(r.score(), 3)}
122
+ for pid, r in self._records.items()
123
+ }
124
+
125
+ # ── Models ─────────────────────────────────────────────────────────────────────
126
+
127
+ class ResolveRequest(BaseModel):
128
+ """Richiesta di risoluzione capability da parte del Brain (via Kernel)."""
129
+ capability: str = Field(..., description="Nome capability richiesta, es. 'browser'")
130
+ require_gpu: bool = Field(False)
131
+ max_sla_ms: float | None = Field(None, description="SLA massimo accettato in ms")
132
+ prefer_region: str | None = Field(None, description="Regione preferita, es. 'eu'")
133
+ tags: list[str] = Field(default_factory=list, description="Tag intent-based extra")
134
+ payload_kb: int = Field(0, description="Stima dimensione payload in KB")
135
+ exclude_providers: list[str] = Field(default_factory=list, description="Provider da escludere")
136
+ correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
137
+
138
+
139
+ class ProviderCandidate(BaseModel):
140
+ """Provider candidato per una capability con score e metadati."""
141
+ provider_id: str
142
+ provider_name: str
143
+ sla_ms: float
144
+ version: str
145
+ cost_unit: float
146
+ region: str
147
+ always_on: bool
148
+ requires_gpu: bool
149
+ score: float = Field(description="Score composito [0,1]")
150
+ tags: list[str] = Field(default_factory=list)
151
+
152
+
153
+ class ResolveResult(BaseModel):
154
+ """Risultato della risoluzione — provider ottimale + alternative ordinate."""
155
+ resolve_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
156
+ capability: str
157
+ resolved: bool = False
158
+ provider_id: str | None = None
159
+ provider_name: str | None = None
160
+ sla_ms: float | None = None
161
+ version: str = "1.0.0"
162
+ score: float = 0.0
163
+ alternatives: list[ProviderCandidate] = Field(default_factory=list)
164
+ reason: str = ""
165
+ resolved_at: float = Field(default_factory=time.time)
166
+ catalog_source: bool = True # True = da Catalog, False = fallback fabric
167
+
168
+
169
+ class ResolveManyRequest(BaseModel):
170
+ requests: list[ResolveRequest] = Field(..., description="Lista richieste da risolvere in bulk")
171
+
172
+
173
+ # ── CapabilityResolver singleton ───────────────────────────────────────────────
174
+
175
+ class CapabilityResolver:
176
+ """
177
+ Risolve capability → provider ottimale usando il Capability Catalog.
178
+
179
+ Algoritmo (puramente dichiarativo, non modifica stato):
180
+ 1. Query catalog per capability (+ filtri hard: GPU, SLA, payload_kb)
181
+ 2. Per ogni candidato, calcola score composito:
182
+ score = W_SLA * sla_score + W_COST * cost_score
183
+ + W_ALWAYS_ON * aon_score + W_REGION * region_score
184
+ 3. Ordina candidati per score desc
185
+ 4. Ritorna best + ordered alternatives
186
+
187
+ Se il Catalog non è disponibile o vuoto, tenta fallback sul Fabric
188
+ (usa _fabric._specs per lista provider registrati).
189
+ """
190
+
191
+ # ── Resolve (singola) ─────────────────────────────────────────────────────
192
+
193
+ def resolve(self, req: ResolveRequest) -> ResolveResult:
194
+ """
195
+ Risoluzione sincrona — il Catalog è un dict in-memory, nessuna I/O.
196
+ Chiamabile sia da codice sync che async.
197
+ """
198
+ rid = str(uuid.uuid4())
199
+
200
+ candidates = self._query_candidates(req)
201
+ if not candidates:
202
+ # Fallback: prova dal Fabric se il Catalog è vuoto
203
+ candidates = self._fallback_from_fabric(req)
204
+
205
+ if not candidates:
206
+ _logger.warning("[resolver] no provider for capability=%s", req.capability)
207
+ return ResolveResult(
208
+ resolve_id=rid, capability=req.capability, resolved=False,
209
+ reason=f"Nessun provider disponibile per capability '{req.capability}'",
210
+ )
211
+
212
+ scored = sorted(candidates, key=lambda c: c.score, reverse=True)
213
+ best = scored[0]
214
+ alts = scored[1:]
215
+
216
+ _logger.info("[resolver] resolved cap=%s → provider=%s sla=%.0fms score=%.3f alts=%d",
217
+ req.capability, best.provider_id, best.sla_ms, best.score, len(alts))
218
+
219
+ return ResolveResult(
220
+ resolve_id = rid,
221
+ capability = req.capability,
222
+ resolved = True,
223
+ provider_id = best.provider_id,
224
+ provider_name = best.provider_name,
225
+ sla_ms = best.sla_ms,
226
+ version = best.version,
227
+ score = best.score,
228
+ alternatives = alts[:5], # max 5 alternative
229
+ reason = "ok",
230
+ )
231
+
232
+ # ── Resolve Many (bulk, per Workflow Engine) ───────────────────────────────
233
+
234
+ def resolve_many(self, requests: list[ResolveRequest]) -> list[ResolveResult]:
235
+ """Risolve N capability in bulk. Usato dal Workflow Engine (ARCH-I4.2)."""
236
+ return [self.resolve(r) for r in requests]
237
+
238
+ # ── can_resolve (quick check) ─────────────────────────────────────────────
239
+
240
+ def can_resolve(self, capability: str) -> bool:
241
+ """Ritorna True se esiste almeno un provider vivo per questa capability."""
242
+ if _CATALOG_AVAILABLE and _catalog is not None:
243
+ return len(_catalog.query(name=capability)) > 0
244
+ if _FABRIC_AVAILABLE and _fabric is not None:
245
+ return any(
246
+ capability in spec.capabilities
247
+ for spec in _fabric._specs.values()
248
+ if spec.base_url
249
+ )
250
+ return False
251
+
252
+ # ── Internal: query candidates ────────────────────────────────────────────
253
+
254
+ def _query_candidates(self, req: ResolveRequest) -> list[ProviderCandidate]:
255
+ if not _CATALOG_AVAILABLE or _catalog is None:
256
+ return []
257
+
258
+ entries = _catalog.query(
259
+ name = req.capability,
260
+ requires_gpu = req.require_gpu or None, # None = non filtrare
261
+ max_sla_ms = req.max_sla_ms,
262
+ region = None, # regione usata solo per scoring, non filtro hard
263
+ )
264
+
265
+ candidates = []
266
+ for e in entries:
267
+ # Filtri hard addizionali
268
+ if req.require_gpu and not e.requires_gpu:
269
+ continue
270
+ if e.provider_id in req.exclude_providers:
271
+ continue
272
+ if req.payload_kb and req.payload_kb > e.max_payload_kb:
273
+ continue
274
+
275
+ score = self._score(e, req)
276
+ candidates.append(ProviderCandidate(
277
+ provider_id = e.provider_id,
278
+ provider_name = e.provider_name,
279
+ sla_ms = e.sla_ms,
280
+ version = e.version,
281
+ cost_unit = e.cost_unit,
282
+ region = e.region,
283
+ always_on = e.always_on,
284
+ requires_gpu = e.requires_gpu,
285
+ tags = e.tags,
286
+ score = score,
287
+ ))
288
+ return candidates
289
+
290
+ def _fallback_from_fabric(self, req: ResolveRequest) -> list[ProviderCandidate]:
291
+ """
292
+ Fallback: legge _specs dal Fabric se il Catalog è vuoto o non disponibile.
293
+ Usato solo quando il Fabric non ha ancora fatto initialize() + auto-register.
294
+ """
295
+ if not _FABRIC_AVAILABLE or _fabric is None:
296
+ return []
297
+ candidates = []
298
+ for pid, spec in _fabric._specs.items():
299
+ if req.capability not in spec.capabilities:
300
+ continue
301
+ if req.require_gpu and not spec.gpu:
302
+ continue
303
+ if pid in req.exclude_providers:
304
+ continue
305
+ if not spec.base_url:
306
+ continue
307
+ sla = 9000.0 # default conservativo
308
+ cost = spec.cost_unit
309
+ always_on = hasattr(spec, 'always_on') and str(spec.always_on) not in ("on-demand", "no")
310
+ score = self._score_raw(sla, cost, always_on, spec.region, req.prefer_region)
311
+ candidates.append(ProviderCandidate(
312
+ provider_id = spec.provider_id,
313
+ provider_name = spec.name,
314
+ sla_ms = sla,
315
+ version = "1.0.0",
316
+ cost_unit = cost,
317
+ region = spec.region,
318
+ always_on = always_on,
319
+ requires_gpu = spec.gpu,
320
+ score = score,
321
+ ))
322
+ return candidates
323
+
324
+ # ── Scoring ───────────────────────────────────────────────────────────────
325
+
326
+ def _score(self, e: "_CapDesc", req: ResolveRequest) -> float: # type: ignore[name-defined]
327
+ always_on = e.always_on
328
+ feedback_score = self._feedback.score(e.name) # e.name = provider_id nel catalog
329
+ return self._score_raw(e.sla_ms, e.cost_unit, always_on, e.region, req.prefer_region, feedback_score)
330
+
331
+ @staticmethod
332
+ def _score_raw(sla_ms: float, cost: float, always_on: bool, region: str,
333
+ prefer_region: str | None, feedback_score: float = 0.85) -> float:
334
+ # SLA score: SLA bassa → score alto. Riferimento 5000ms.
335
+ sla_score = min(1.0, 5000.0 / max(sla_ms, 100.0))
336
+ # Cost score: free → 1.0, 1 unit → 0.5
337
+ cost_score = 1.0 / (1.0 + cost * 10)
338
+ # Always-on score
339
+ aon_score = 1.0 if always_on else 0.4
340
+ # Region score
341
+ region_score = 1.0 if (not prefer_region or region == prefer_region) else 0.7
342
+ # Feedback score: EWA di success rate + latency ratio reale (ARCH-RESOLVER-FB)
343
+
344
+ return (
345
+ _W_SLA * sla_score +
346
+ _W_COST * cost_score +
347
+ _W_ALWAYS_ON * aon_score +
348
+ _W_REGION * region_score +
349
+ _W_FEEDBACK * feedback_score
350
+ )
351
+
352
+ # ── Feedback recording ────────────────────────────────────────────────────
353
+
354
+ def record_feedback(self, provider_id: str, success: bool,
355
+ actual_ms: float | None = None, declared_sla_ms: float = 1000.0) -> None:
356
+ """
357
+ Registra il risultato reale di una chiamata al provider (ARCH-RESOLVER-FB).
358
+ Chiamato dall'Executor/Brain dopo ogni tool execution.
359
+ """
360
+ self._feedback.record(provider_id, success, actual_ms, declared_sla_ms)
361
+ _logger.debug("[resolver] feedback %s → success=%s actual_ms=%s",
362
+ provider_id, success, actual_ms)
363
+
364
+ # ── Status ────────────────────────────────────────────────────────────────
365
+
366
+ def status(self) -> dict:
367
+ resolvable: list[str] = []
368
+ if _CATALOG_AVAILABLE and _catalog is not None:
369
+ entries = _catalog.all_entries()
370
+ resolvable = sorted({e.name for e in entries})
371
+ return {
372
+ "catalog_available": _CATALOG_AVAILABLE,
373
+ "fabric_available": _FABRIC_AVAILABLE,
374
+ "resolvable_capabilities": resolvable,
375
+ "total_resolvable": len(resolvable),
376
+ "weights": {
377
+ "sla": _W_SLA,
378
+ "cost": _W_COST,
379
+ "always_on": _W_ALWAYS_ON,
380
+ "region": _W_REGION,
381
+ "feedback": _W_FEEDBACK,
382
+ },
383
+ "feedback_stats": self._feedback.stats(),
384
+ }
385
+
386
+
387
+ # ── Singleton ───────────────────────────────────────────────────────────────────
388
+ resolver = CapabilityResolver()
389
+
390
+ # ── HTTP Router ─────────────────────────────────────────────────────────────────
391
+ router = APIRouter(
392
+ prefix="/api/resolver",
393
+ tags=["capability-resolver"],
394
+ dependencies=[Depends(require_role(AuthRole.MACHINE))],
395
+ )
396
+
397
+
398
+ @router.post("/resolve", summary="Risolve una capability → provider ottimale")
399
+ async def route_resolve(req: ResolveRequest) -> ResolveResult:
400
+ result = resolver.resolve(req)
401
+ if not result.resolved:
402
+ raise HTTPException(404, result.reason)
403
+ return result
404
+
405
+
406
+ @router.post("/resolve-many", summary="Risolve N capability in bulk (workflow planning)")
407
+ async def route_resolve_many(req: ResolveManyRequest) -> dict:
408
+ if not req.requests:
409
+ raise HTTPException(400, "requests lista vuota")
410
+ results = resolver.resolve_many(req.requests)
411
+ resolved = sum(1 for r in results if r.resolved)
412
+ unresolved = len(results) - resolved
413
+ return {
414
+ "total": len(results),
415
+ "resolved": resolved,
416
+ "unresolved": unresolved,
417
+ "results": [r.model_dump() for r in results],
418
+ }
419
+
420
+
421
+ @router.get("/status", summary="Stato resolver — capabilities risolvibili e pesi scoring")
422
+ async def route_status() -> dict:
423
+ return resolver.status()
424
+
425
+
426
+ class ResolverFeedback(BaseModel):
427
+ """Feedback da inviare dopo l'esecuzione di un tool (ARCH-RESOLVER-FB)."""
428
+ provider_id: str
429
+ success: bool
430
+ actual_ms: float | None = None
431
+ declared_sla_ms: float = 1000.0
432
+
433
+
434
+ @router.post("/feedback", summary="Registra feedback reale su un provider (latenza, successo)")
435
+ async def route_feedback(body: ResolverFeedback) -> dict:
436
+ """
437
+ Chiamato dall'Executor dopo ogni tool execution per aggiornare lo scoring EWA.
438
+ Non-critico: un errore qui non deve mai bloccare l'esecuzione dell'agente.
439
+ """
440
+ resolver.record_feedback(body.provider_id, body.success, body.actual_ms, body.declared_sla_ms)
441
+ return {"recorded": True, "provider_id": body.provider_id}
api/dashboard.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """backend/api/dashboard.py — Unified observability snapshot (INT-2).
2
+
3
+ GET /api/dashboard/snapshot
4
+
5
+ Aggrega in una sola chiamata HTTP:
6
+ • provider health + score (da heartbeat + benchmark)
7
+ • telemetry timing p50/p90
8
+ • _BENCH_LAST_RUN (ultimo quality benchmark)
9
+ • incident count + ultimi 5
10
+ • LLM router stats (routing table + provider ok/fail)
11
+ • plugin registry (total/loaded/list)
12
+
13
+ Auth: nessuna (zero PII, solo aggregate). I valori sensibili rimangono mascherati.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ import logging
19
+
20
+ from fastapi import APIRouter
21
+ from fastapi.responses import JSONResponse
22
+
23
+ router = APIRouter()
24
+ _logger = logging.getLogger("api.dashboard")
25
+
26
+
27
+ @router.get("/api/dashboard/snapshot")
28
+ async def dashboard_snapshot() -> JSONResponse:
29
+ """Snapshot unificato — un endpoint, tutte le metriche operative."""
30
+ t0 = time.monotonic()
31
+ out: dict = {
32
+ "ok": True,
33
+ "generated_at": int(time.time()),
34
+ "version": "1.0",
35
+ }
36
+
37
+ # ── 1. Provider health + scores ──────────────────────────────────────────
38
+ try:
39
+ from api.state import _ai_health_cache, _PROVIDER_SCORES, _BENCH_LAST_RUN
40
+ cached_data = _ai_health_cache.get("data") or {}
41
+ raw_providers = cached_data.get("providers", [])
42
+
43
+ out["providers"] = {
44
+ r.get("name", r.get("provider", "?")): {
45
+ "ok": r.get("ok", False),
46
+ "status": r.get("status", "unknown"),
47
+ "latency_ms": r.get("latency_ms"),
48
+ "model": r.get("model"),
49
+ "error": r.get("error") if not r.get("ok") else None,
50
+ "score": _PROVIDER_SCORES.get(
51
+ r.get("name", r.get("provider", "")), None
52
+ ),
53
+ }
54
+ for r in raw_providers
55
+ }
56
+
57
+ # Sintesi: quanti ok, best provider per score
58
+ scores_live = {k: v for k, v in _PROVIDER_SCORES.items() if v > 0}
59
+ best = max(scores_live, key=scores_live.get) if scores_live else None
60
+ out["provider_summary"] = {
61
+ "total": len(raw_providers),
62
+ "ok": sum(1 for r in raw_providers if r.get("ok")),
63
+ "best_provider": best,
64
+ "best_score": scores_live.get(best) if best else None,
65
+ "checked_at_ago_s": round(time.monotonic() - _ai_health_cache.get("at", 0)),
66
+ }
67
+
68
+ out["bench_last_run"] = {
69
+ "total_score": _BENCH_LAST_RUN.get("total_score"),
70
+ "categories_run": _BENCH_LAST_RUN.get("categories_run", 0),
71
+ "age_s": round(time.time() - _BENCH_LAST_RUN.get("timestamp", 0))
72
+ if _BENCH_LAST_RUN.get("timestamp") else None,
73
+ }
74
+ except Exception as exc:
75
+ out["providers"] = {"_error": str(exc)}
76
+
77
+ # ── 2. Telemetry (timing + repair) ──────────────────────────────────────
78
+ try:
79
+ from api.state import _TIMING_STORE, _REPAIR_STATS
80
+ from api.telemetry import _percentile
81
+
82
+ timing: dict = {}
83
+ for key, buf in _TIMING_STORE.items():
84
+ samples = list(buf)
85
+ if samples:
86
+ timing[key] = {
87
+ "p50": _percentile(samples, 50),
88
+ "p90": _percentile(samples, 90),
89
+ "n": len(samples),
90
+ }
91
+
92
+ # Repair summary: solo contatori non-zero
93
+ repair_nonzero = {k: v for k, v in _REPAIR_STATS.items() if v > 0}
94
+ out["telemetry"] = {
95
+ "timing": timing,
96
+ "repair_summary": repair_nonzero,
97
+ "repair_total": sum(_REPAIR_STATS.values()),
98
+ }
99
+ except Exception as exc:
100
+ out["telemetry"] = {"_error": str(exc)}
101
+
102
+ # ── 3. Incidents ─────────────────────────────────────────────────────────
103
+ try:
104
+ from api.incident_registry import _incidents # type: ignore[attr-defined]
105
+ incidents_list = list(_incidents.values()) if isinstance(_incidents, dict) else []
106
+ recent = sorted(
107
+ incidents_list,
108
+ key=lambda i: i.get("created_at", 0),
109
+ reverse=True,
110
+ )[:5]
111
+ out["incidents"] = {
112
+ "total": len(incidents_list),
113
+ "recent_5": [
114
+ {
115
+ "id": i.get("id", "?"),
116
+ "provider": i.get("provider"),
117
+ "severity": i.get("severity"),
118
+ "message": str(i.get("message", ""))[:120],
119
+ "at": i.get("created_at"),
120
+ }
121
+ for i in recent
122
+ ],
123
+ }
124
+ except Exception as exc:
125
+ out["incidents"] = {"_error": str(exc), "total": 0}
126
+
127
+ # ── 4. LLM Router stats ──────────────────────────────────────────────────
128
+ try:
129
+ # LLMProviderRouter è un singleton lazy — usa la classe direttamente
130
+ from api.llm_router import LLMProviderRouter
131
+ rtr = LLMProviderRouter()
132
+ out["llm_router"] = rtr.status()
133
+ except Exception as exc:
134
+ out["llm_router"] = {"_error": str(exc)}
135
+
136
+ # ── 5. Plugin registry ────────────────────────────────────────────────────
137
+ try:
138
+ from api.plugin_system import registry as _plugin_reg
139
+ # list_plugins() ritorna lista di dict con stato
140
+ plugins_raw = [
141
+ {
142
+ "id": pid,
143
+ "version": m.version,
144
+ "state": _plugin_reg._states.get(pid, "unknown"),
145
+ "caps": m.capabilities,
146
+ }
147
+ for pid, m in _plugin_reg._plugins.items()
148
+ ]
149
+ out["plugins"] = {
150
+ "total": len(plugins_raw),
151
+ "loaded": sum(1 for p in plugins_raw if p["state"] == "loaded"),
152
+ "list": plugins_raw,
153
+ }
154
+ except Exception as exc:
155
+ out["plugins"] = {"_error": str(exc), "total": 0}
156
+
157
+ # ── 6. System health (da HealthManager) ─────────────────────────────────
158
+ try:
159
+ from api.health_manager import HealthManager
160
+ hm = HealthManager()
161
+ report = await hm.get_report()
162
+ out["system_health"] = {
163
+ "status": report.system_health.value if report else "unknown",
164
+ "active_alerts": report.active_alerts if report else [],
165
+ "recovery_actions": report.recovery_actions if report else [],
166
+ }
167
+ except Exception as exc:
168
+ out["system_health"] = {"_error": str(exc)}
169
+
170
+ out["elapsed_ms"] = round((time.monotonic() - t0) * 1000, 1)
171
+ return JSONResponse(out)
api/database.py CHANGED
@@ -113,10 +113,9 @@ async def _pg_query(db_url: str, sql: str, params: list):
113
  return {"ok": False, "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt."}
114
 
115
  def _run():
116
- conn = psycopg2.connect(db_url, connect_timeout=5)
117
  try:
118
  cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
119
- cur.execute("SET statement_timeout = '5000ms'")
120
  cur.execute(sql, params or None)
121
  try:
122
  rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
 
113
  return {"ok": False, "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt."}
114
 
115
  def _run():
116
+ conn = psycopg2.connect(db_url)
117
  try:
118
  cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
 
119
  cur.execute(sql, params or None)
120
  try:
121
  rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
api/database_router.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database_router.py — Router Query Intelligente per Separazione Workload Supabase A/B/C/D
3
+
4
+ Architettura:
5
+ A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito)
6
+ B: Sync/State/Transazioni (stato globale, sincronizzazione cluster — PRIMARY)
7
+ C: Memory/RAG/Embeddings (backend memoria, vector search, skill index)
8
+ D: Audit/Logging/Compliance (event log, audit trail, compliance records)
9
+
10
+ Routing Logic:
11
+ 1. Query di LETTURA (SELECT) → Preferisci A (read replica), fallback a B
12
+ 2. Query di SCRITTURA (INSERT/UPDATE) → Usa B (PRIMARY)
13
+ 3. Query su MEMORIA/RAG (skill_memory, embeddings, conversations) → Usa C
14
+ 4. Query su AUDIT/LOG (audit_events, compliance_log) → Usa D
15
+ 5. Query di SINCRONIZZAZIONE (cluster_state, global_state) → Usa B
16
+ """
17
+
18
+ import asyncio
19
+ import os
20
+ import logging
21
+ import re as _re
22
+ from typing import Optional, Literal
23
+ from enum import Enum
24
+ from fastapi import APIRouter, HTTPException, Request
25
+ from pydantic import BaseModel
26
+
27
+ router = APIRouter(prefix="/api/database", tags=["database"])
28
+ _logger = logging.getLogger("database_router")
29
+
30
+ # ─── Enumerazione Nodi Supabase ───────────────────────────────────────────
31
+ class SupabaseNode(str, Enum):
32
+ A = "A" # Analytics/Cache
33
+ B = "B" # PRIMARY (Sync/State)
34
+ C = "C" # Memory/RAG
35
+ D = "D" # Audit/Logging
36
+
37
+
38
+ # ─── Configurazione Nodi ──────────────────────────────────────────────────
39
+ SUPABASE_CONFIG = {
40
+ "A": {
41
+ "url": os.getenv("SUPABASE_URL_A", ""),
42
+ "key": os.getenv("SUPABASE_KEY_A", ""),
43
+ "role": "Analytics/Cache (Read-Heavy)",
44
+ "priority": 1, # Preferito per letture
45
+ },
46
+ "B": {
47
+ "url": os.getenv("SUPABASE_URL", ""), # PRIMARY
48
+ "key": os.getenv("SUPABASE_KEY", ""),
49
+ "role": "Sync/State (PRIMARY)",
50
+ "priority": 0, # Fallback universale
51
+ },
52
+ "C": {
53
+ "url": os.getenv("SUPABASE_URL_C", ""),
54
+ "key": os.getenv("SUPABASE_KEY_C", ""),
55
+ "role": "Memory/RAG/Embeddings",
56
+ "priority": 2,
57
+ },
58
+ "D": {
59
+ "url": os.getenv("SUPABASE_URL_D", ""),
60
+ "key": os.getenv("SUPABASE_KEY_D", ""),
61
+ "role": "Audit/Logging/Compliance",
62
+ "priority": 3,
63
+ },
64
+ }
65
+
66
+ # ─── Keyword Pericolosi (per read-only) ───────────────────────────────────
67
+ _DANGEROUS = frozenset(
68
+ {"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"}
69
+ )
70
+ _MAX_ROWS = 500
71
+
72
+
73
+ # ─── Modelli Pydantic ─────────────────────────────────────────────────────
74
+ class QueryRequest(BaseModel):
75
+ sql: str
76
+ params: list = []
77
+ read_only: bool = True
78
+ preferred_node: Optional[SupabaseNode] = None # Override routing logic
79
+
80
+
81
+ class QueryResponse(BaseModel):
82
+ ok: bool
83
+ rows: list = []
84
+ columns: list = []
85
+ count: int = 0
86
+ truncated: bool = False
87
+ node_used: Optional[str] = None
88
+ error: Optional[str] = None
89
+
90
+
91
+ # ─── Funzioni Utility ─────────────────────────────────────────────────────
92
+ def _is_dangerous(sql: str) -> Optional[str]:
93
+ """Rilevamento keyword pericolose robusto contro CTE e multi-spazio."""
94
+ s_norm = " ".join(sql.strip().lower().split())
95
+ tokens = _re.split(r"[\s\(\),;]+", s_norm)
96
+ for tok in tokens:
97
+ if tok in _DANGEROUS:
98
+ return tok
99
+ for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm):
100
+ first_word = m.group(1).lower()
101
+ if first_word in _DANGEROUS:
102
+ return first_word
103
+ return None
104
+
105
+
106
+ def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]:
107
+ """Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER)."""
108
+ s_norm = " ".join(sql.strip().upper().split())
109
+ if s_norm.startswith("SELECT"):
110
+ return "SELECT"
111
+ elif s_norm.startswith("INSERT"):
112
+ return "INSERT"
113
+ elif s_norm.startswith("UPDATE"):
114
+ return "UPDATE"
115
+ elif s_norm.startswith("DELETE"):
116
+ return "DELETE"
117
+ return "OTHER"
118
+
119
+
120
+ def _detect_table_context(sql: str) -> Optional[str]:
121
+ """Rileva il contesto della tabella per routing intelligente."""
122
+ sql_lower = sql.lower()
123
+
124
+ # Tabelle di memoria/RAG → Nodo C
125
+ if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]):
126
+ return "C"
127
+
128
+ # Tabelle di audit/logging → Nodo D
129
+ if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]):
130
+ return "D"
131
+
132
+ # Tabelle di stato globale → Nodo B
133
+ if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]):
134
+ return "B"
135
+
136
+ return None
137
+
138
+
139
+ def _choose_node(
140
+ query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"],
141
+ table_context: Optional[str],
142
+ preferred_node: Optional[SupabaseNode],
143
+ ) -> SupabaseNode:
144
+ """
145
+ Logica di routing intelligente per scegliere il nodo Supabase.
146
+
147
+ Priorità:
148
+ 1. preferred_node (override esplicito)
149
+ 2. table_context (rilevamento tabella)
150
+ 3. query_type (tipo di query)
151
+ 4. Fallback a B (PRIMARY)
152
+ """
153
+ # 1. Override esplicito
154
+ if preferred_node:
155
+ return preferred_node
156
+
157
+ # 2. Routing per contesto tabella
158
+ if table_context:
159
+ return SupabaseNode(table_context)
160
+
161
+ # 3. Routing per tipo query
162
+ if query_type == "SELECT":
163
+ # Preferisci A (read replica) se disponibile, altrimenti B
164
+ if SUPABASE_CONFIG["A"]["url"]:
165
+ return SupabaseNode.A
166
+ return SupabaseNode.B
167
+ elif query_type in ("INSERT", "UPDATE", "DELETE"):
168
+ # Sempre su B (PRIMARY)
169
+ return SupabaseNode.B
170
+
171
+ # 4. Fallback a B (PRIMARY)
172
+ return SupabaseNode.B
173
+
174
+
175
+ # ─── Endpoint Principale ──────────────────────────────────────────────────
176
+ @router.post("/query", response_model=QueryResponse)
177
+ async def database_query(req: QueryRequest, request: Request):
178
+ """
179
+ Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
180
+
181
+ Parametri:
182
+ - sql: query SQL
183
+ - params: parametri query
184
+ - read_only: blocca query pericolose (default: true)
185
+ - preferred_node: forza un nodo specifico (opzionale)
186
+
187
+ Ritorna:
188
+ - ok: successo
189
+ - rows: righe risultato
190
+ - columns: nomi colonne
191
+ - count: numero righe
192
+ - truncated: se risultato è stato troncato
193
+ - node_used: nodo Supabase utilizzato
194
+ """
195
+ # Verifica token interno
196
+ _internal_token = os.getenv("INTERNAL_TOKEN", "")
197
+ if _internal_token and request.headers.get("X-Internal-Token") != _internal_token:
198
+ raise HTTPException(401, "Unauthorized")
199
+
200
+ # Rileva tipo query e contesto
201
+ query_type = _detect_query_type(req.sql)
202
+ table_context = _detect_table_context(req.sql)
203
+
204
+ # Scegli nodo
205
+ chosen_node = _choose_node(query_type, table_context, req.preferred_node)
206
+
207
+ # Verifica configurazione nodo
208
+ node_config = SUPABASE_CONFIG.get(chosen_node.value)
209
+ if not node_config or not node_config["url"]:
210
+ # Fallback a B se nodo non configurato
211
+ if chosen_node != SupabaseNode.B:
212
+ _logger.warning(
213
+ f"Nodo {chosen_node.value} non configurato, fallback a B. "
214
+ f"Configura SUPABASE_URL_{chosen_node.value} e SUPABASE_KEY_{chosen_node.value}."
215
+ )
216
+ chosen_node = SupabaseNode.B
217
+ node_config = SUPABASE_CONFIG["B"]
218
+
219
+ if not node_config["url"]:
220
+ return QueryResponse(
221
+ ok=False,
222
+ error=f"Nodo {chosen_node.value} non configurato. Imposta SUPABASE_URL_{chosen_node.value}.",
223
+ node_used=chosen_node.value,
224
+ )
225
+
226
+ # Verifica read-only
227
+ if req.read_only:
228
+ kw = _is_dangerous(req.sql)
229
+ if kw:
230
+ return QueryResponse(
231
+ ok=False,
232
+ error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
233
+ node_used=chosen_node.value,
234
+ )
235
+
236
+ # Esegui query
237
+ try:
238
+ result = await _execute_query(
239
+ node_config["url"],
240
+ node_config["key"],
241
+ req.sql,
242
+ req.params,
243
+ )
244
+ result["node_used"] = chosen_node.value
245
+ return QueryResponse(**result)
246
+ except Exception as e:
247
+ _logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}")
248
+ return QueryResponse(
249
+ ok=False,
250
+ error=str(e)[:400],
251
+ node_used=chosen_node.value,
252
+ )
253
+
254
+
255
+ # ─── Esecuzione Query (Supabase PostgreSQL) ───────────────────────────────
256
+ async def _execute_query(url: str, key: str, sql: str, params: list) -> dict:
257
+ """Esegue query su Supabase PostgreSQL."""
258
+ try:
259
+ import psycopg2
260
+ import psycopg2.extras
261
+ except ImportError:
262
+ return {
263
+ "ok": False,
264
+ "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.",
265
+ }
266
+
267
+ def _run():
268
+ conn = psycopg2.connect(url)
269
+ try:
270
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
271
+ cur.execute(sql, params or None)
272
+ try:
273
+ rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
274
+ cols = [d.name for d in (cur.description or [])]
275
+ except psycopg2.ProgrammingError:
276
+ rows, cols = [], []
277
+ conn.commit()
278
+ finally:
279
+ conn.close()
280
+ return rows, cols
281
+
282
+ rows, cols = await asyncio.to_thread(_run)
283
+ return {
284
+ "ok": True,
285
+ "rows": rows,
286
+ "columns": cols,
287
+ "count": len(rows),
288
+ "truncated": len(rows) == _MAX_ROWS,
289
+ }
290
+
291
+
292
+ # ─── Endpoint Debug (info nodi) ───────────────────────────────────────────
293
+ @router.get("/nodes/status")
294
+ async def nodes_status():
295
+ """Ritorna lo stato di configurazione di tutti i nodi Supabase."""
296
+ status = {}
297
+ for node_id, config in SUPABASE_CONFIG.items():
298
+ status[node_id] = {
299
+ "role": config["role"],
300
+ "configured": bool(config["url"]),
301
+ "url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET",
302
+ }
303
+ return {"nodes": status}
api/event_store.py CHANGED
@@ -4,7 +4,7 @@ backend/api/event_store.py — Event Store (persistenza, Fase 1 ADR-S26-S30)
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
- Schema Supabase (tabella `event_store`, creata dalla migration versionata):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
@@ -40,24 +40,25 @@ router = APIRouter(
40
 
41
  _TABLE = "event_store"
42
 
43
- # ── Schema probe (la creazione è gestita esclusivamente dalle migration) ────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
- """Verifica che la tabella event_store creata dalla migration sia raggiungibile."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
- # La tabella deve esistere: il client runtime non esegue DDL.
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
- _logger.warning("[event_store] tabella '%s' non raggiungibile: %s", _TABLE, exc)
 
61
  return False
62
 
63
 
@@ -90,8 +91,9 @@ async def store_event(req: StoreEventRequest) -> StoredEvent:
90
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
91
  dai componenti che vogliono garantire persistenza.
92
  """
93
- if not await _ensure_table():
94
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
95
 
96
  record = {
97
  "topic": req.topic,
@@ -130,8 +132,9 @@ async def replay_events(
130
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
131
  test di regressione e audit trail.
132
  """
133
- if not await _ensure_table():
134
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
135
 
136
  try:
137
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
@@ -160,8 +163,9 @@ async def replay_events(
160
  @router.get("/store/{event_id}", summary="Recupera evento singolo")
161
  async def get_event(event_id: str) -> StoredEvent:
162
  """Recupera un evento specifico per ID."""
163
- if not await _ensure_table():
164
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
165
  try:
166
  res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
167
  if not res.data:
@@ -185,8 +189,8 @@ async def get_event(event_id: str) -> StoredEvent:
185
  @router.get("/store/status", summary="Diagnostica Event Store")
186
  async def store_status():
187
  """Verifica connettività dello store e restituisce statistiche."""
188
- if not await _ensure_table():
189
- return {"status": "unavailable", "reason": "schema event_store assente o non raggiungibile"}
190
  try:
191
  res = _sb.table(_TABLE).select("topic", count="exact").execute()
192
  total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
 
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
+ Schema Supabase (tabella `event_store`, auto-created se non esiste):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
 
40
 
41
  _TABLE = "event_store"
42
 
43
+ # ── Auto-create table (best-effort, richiede service role key) ─────────────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
+ """Crea la tabella event_store su Supabase se non esiste. Best-effort."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
+ # Prova una SELECT se la tabella non esiste, Supabase ritorna un errore
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
+ _logger.warning("[event_store] tabella '%s' non raggiungibile: %s "
61
+ "crea manualmente con migration Supabase", _TABLE, exc)
62
  return False
63
 
64
 
 
91
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
92
  dai componenti che vogliono garantire persistenza.
93
  """
94
+ await _ensure_table()
95
+ if not _sb:
96
+ raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
97
 
98
  record = {
99
  "topic": req.topic,
 
132
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
133
  test di regressione e audit trail.
134
  """
135
+ await _ensure_table()
136
+ if not _sb:
137
+ raise HTTPException(503, detail="Event Store non disponibile")
138
 
139
  try:
140
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
 
163
  @router.get("/store/{event_id}", summary="Recupera evento singolo")
164
  async def get_event(event_id: str) -> StoredEvent:
165
  """Recupera un evento specifico per ID."""
166
+ await _ensure_table()
167
+ if not _sb:
168
+ raise HTTPException(503, detail="Event Store non disponibile")
169
  try:
170
  res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
171
  if not res.data:
 
189
  @router.get("/store/status", summary="Diagnostica Event Store")
190
  async def store_status():
191
  """Verifica connettività dello store e restituisce statistiche."""
192
+ if not _sb:
193
+ return {"status": "unavailable", "reason": "Supabase non configurato"}
194
  try:
195
  res = _sb.table(_TABLE).select("topic", count="exact").execute()
196
  total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
api/execution_fabric.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/execution_fabric.py — Execution Fabric (ARCH-K2.5 + ARCH-K2.6 Health Manager)
3
+
4
+ CRIT-B fix: circuit breaker Oracle (3 errori → blocco 300s) + fallback Railway con auth completa.
5
+ CRIT-C fix: parser 402/429 robusto — body scan + pattern estesi nell'exception handler.
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import time
12
+ import uuid
13
+ from enum import Enum
14
+ from typing import Any, Optional
15
+ import httpx
16
+ from fastapi import APIRouter, Depends
17
+ from pydantic import BaseModel, Field
18
+
19
+ from .auth_guard import AuthRole, require_role
20
+ from .token_rotator import rotator as _token_rotator
21
+
22
+ try:
23
+ from .telemetry import record_kernel_event as _rke
24
+ except Exception:
25
+ def _rke(*_a, **_kw): pass
26
+
27
+ _logger = logging.getLogger("api.execution_fabric")
28
+
29
+ # ── Configurazione Timing (ARCH-T1.1) ──────────────────────────────────────────
30
+ _DEFAULT_TIMEOUT_S = 30.0 # Timeout standard per chiamate API
31
+ _ORACLE_TIMEOUT_S = 60.0 # Oracle ha più tempo per il calcolo pesante
32
+ _HF_WARMUP_S = 10.0 # Tempo di attesa se lo Space è in sleep
33
+ _RETRY_DELAY_S = 1.5 # Attesa tra i tentativi di rotazione token
34
+ _HEALTH_CHECK_INT = 120.0 # Intervallo health check in background
35
+
36
+ # ── CRIT-B: Circuit Breaker Oracle ─────────────────────────────────────────────
37
+ _ORACLE_CB_THRESHOLD = 3 # errori consecutivi prima di aprire il circuit
38
+ _ORACLE_CB_TIMEOUT_S = 300.0 # secondi di blocco dopo apertura (5 min)
39
+
40
+ # ── CRIT-C: pattern quota/rate-limit (body + eccezioni) ───────────────────────
41
+ _QUOTA_PATTERNS = (
42
+ "quota", "egress", "rate limit", "rate_limit", "billing",
43
+ "402", "429", "limit exceeded", "credits", "insufficient",
44
+ "payment", "upgrade", "hours", "quota_or_ratelimit",
45
+ )
46
+
47
+
48
+ class ProviderKind(str, Enum):
49
+ HF_SPACE = "hf_space"
50
+ # RAILWAY rimosso
51
+ DOCKER = "docker"
52
+ ORACLE = "oracle"
53
+ LOCAL = "local"
54
+
55
+ class ProviderHealth(str, Enum):
56
+ OK = "ok"
57
+ DEGRADED = "degraded"
58
+ DOWN = "down"
59
+ UNKNOWN = "unknown"
60
+
61
+ class AlwaysOn(str, Enum):
62
+ YES = "yes"
63
+ ON_DEMAND = "on-demand"
64
+ NO = "no"
65
+
66
+ class ProviderSpec(BaseModel):
67
+ provider_id: str
68
+ name: str
69
+ kind: ProviderKind = ProviderKind.LOCAL
70
+ base_url: str = ""
71
+ capabilities: list[str] = Field(default_factory=list)
72
+ gpu: bool = False
73
+ always_on: AlwaysOn = AlwaysOn.ON_DEMAND
74
+ max_concurrency: int = 10
75
+ priority: int = 50
76
+ cost_unit: float = 0.0
77
+ region: str = "eu"
78
+ timeout: float = _DEFAULT_TIMEOUT_S
79
+
80
+ class ProviderState(BaseModel):
81
+ provider_id: str
82
+ health: ProviderHealth = ProviderHealth.UNKNOWN
83
+ active_tasks: int = 0
84
+ last_check_ts: float = 0.0
85
+ error_count: int = 0
86
+
87
+ class DispatchRequest(BaseModel):
88
+ capability: str
89
+ task_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
90
+ payload: dict = Field(default_factory=dict)
91
+ require_gpu: bool = False
92
+ require_isolation: bool = False
93
+ prefer_region: str | None = None
94
+ provider_hint: str | None = None
95
+ correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
96
+
97
+ class DispatchResult(BaseModel):
98
+ task_id: str
99
+ provider_id: str
100
+ provider_name: str
101
+ provider_kind: str
102
+ capability: str
103
+ status: str
104
+ response: dict = Field(default_factory=dict)
105
+ latency_ms: float = 0.0
106
+ correlation_id: str = ""
107
+
108
+
109
+ class ExecutionFabric:
110
+ def __init__(self):
111
+ self._specs: dict[str, ProviderSpec] = {}
112
+ self._states: dict[str, ProviderState] = {}
113
+ self._initialized = False
114
+
115
+ # CRIT-B: circuit breaker state per provider (in-memory, per istanza)
116
+ # { provider_id: {"errors": int, "blocked_until": float} }
117
+ self._circuit: dict[str, dict] = {}
118
+
119
+ # ── Inizializzazione provider ─────────────────────────────────────────────
120
+
121
+ async def initialize(self):
122
+ if self._initialized:
123
+ return
124
+ self._initialized = True
125
+
126
+ providers: list[ProviderSpec] = []
127
+
128
+ # Local fallback — sempre disponibile, nessun HTTP
129
+ providers.append(ProviderSpec(
130
+ provider_id="local",
131
+ name="Local",
132
+ kind=ProviderKind.LOCAL,
133
+ base_url="",
134
+ capabilities=["exec", "tool", "shell"],
135
+ priority=10,
136
+ always_on=AlwaysOn.YES,
137
+ ))
138
+
139
+ # HF Space principale — no-op se HF_SPACE_URL non configurata
140
+ hf_url = os.getenv("HF_SPACE_URL", "").rstrip("/")
141
+ if hf_url:
142
+ providers.append(ProviderSpec(
143
+ provider_id="hf-space-main",
144
+ name="HF Space Main",
145
+ kind=ProviderKind.HF_SPACE,
146
+ base_url=hf_url,
147
+ capabilities=["exec", "tool", "llm", "browse"],
148
+ priority=50,
149
+ always_on=AlwaysOn.ON_DEMAND,
150
+ timeout=_DEFAULT_TIMEOUT_S,
151
+ ))
152
+
153
+ # Railway core backend rimosso (migrato su HF Spaces)
154
+
155
+ # Oracle Cloud VM — no-op se ORACLE_CLOUD_VM_URL non impostata
156
+ oracle_url = os.getenv("ORACLE_CLOUD_VM_URL", "").rstrip("/")
157
+ if oracle_url:
158
+ providers.append(ProviderSpec(
159
+ provider_id="oracle-cloud-vm-01",
160
+ name="Oracle Cloud VM",
161
+ kind=ProviderKind.ORACLE,
162
+ base_url=oracle_url,
163
+ capabilities=["exec", "tool", "llm", "gpu"],
164
+ gpu=True,
165
+ priority=80,
166
+ always_on=AlwaysOn.ON_DEMAND,
167
+ timeout=_ORACLE_TIMEOUT_S,
168
+ ))
169
+
170
+ for spec in providers:
171
+ self._specs[spec.provider_id] = spec
172
+ self._states[spec.provider_id] = ProviderState(provider_id=spec.provider_id)
173
+ self._circuit[spec.provider_id] = {"errors": 0, "blocked_until": 0.0}
174
+
175
+ _logger.info(
176
+ "[fabric] Providers registrati: %s",
177
+ ", ".join(f"{p.provider_id}({p.kind.value})" for p in providers)
178
+ )
179
+
180
+ # ── CRIT-B: Circuit Breaker helpers ──────────────────────────────────────
181
+
182
+ def _is_circuit_open(self, provider_id: str) -> bool:
183
+ """True se il provider è in blocco circuit breaker."""
184
+ cb = self._circuit.get(provider_id, {})
185
+ blocked_until = cb.get("blocked_until", 0.0)
186
+ if blocked_until > time.time():
187
+ return True
188
+ # Reset automatico dopo il timeout
189
+ if blocked_until > 0.0:
190
+ self._circuit[provider_id]["errors"] = 0
191
+ self._circuit[provider_id]["blocked_until"] = 0.0
192
+ _logger.info("[fabric] Circuit breaker RESET per %s", provider_id)
193
+ return False
194
+
195
+ def _record_oracle_error(self, provider_id: str) -> bool:
196
+ """
197
+ Registra un errore Oracle. Se si supera la soglia, apre il circuit.
198
+ Ritorna True se il circuit è stato appena aperto.
199
+ """
200
+ cb = self._circuit.setdefault(provider_id, {"errors": 0, "blocked_until": 0.0})
201
+ cb["errors"] += 1
202
+ if cb["errors"] >= _ORACLE_CB_THRESHOLD:
203
+ cb["blocked_until"] = time.time() + _ORACLE_CB_TIMEOUT_S
204
+ _logger.error(
205
+ "[fabric] Circuit breaker APERTO per %s (%d errori consecutivi) "
206
+ "— blocco per %.0fs fino a %s",
207
+ provider_id, cb["errors"], _ORACLE_CB_TIMEOUT_S,
208
+ time.strftime("%H:%M:%S", time.localtime(cb["blocked_until"]))
209
+ )
210
+ return True
211
+ return False
212
+
213
+ def _reset_oracle_errors(self, provider_id: str) -> None:
214
+ """Azzera il contatore errori dopo un successo."""
215
+ if provider_id in self._circuit:
216
+ self._circuit[provider_id]["errors"] = 0
217
+
218
+ # ── Selezione provider ────────────────────────────────────────────────────
219
+
220
+ def _cb_allow(self, provider_id: str) -> bool:
221
+ return True
222
+
223
+ def _select(self, capability: str, exclude: set[str] | None = None) -> Optional[str]:
224
+ """
225
+ Seleziona il provider con la priorità più alta che:
226
+ - supporta la capability richiesta
227
+ - non è DOWN
228
+ - non è in circuit breaker aperto
229
+ - non è nella lista exclude
230
+ Ordine: priority DESC (higher = preferred).
231
+ """
232
+ exclude = exclude or set()
233
+ candidates = [
234
+ (spec.priority, pid, spec)
235
+ for pid, spec in self._specs.items()
236
+ if capability in spec.capabilities
237
+ and self._states[pid].health != ProviderHealth.DOWN
238
+ and not self._is_circuit_open(pid)
239
+ and pid not in exclude
240
+ ]
241
+ if not candidates:
242
+ return None
243
+ # Ordina per priority decrescente
244
+ candidates.sort(key=lambda x: x[0], reverse=True)
245
+ return candidates[0][1]
246
+
247
+ # ── CRIT-C: rilevamento quota/rate-limit ──────────────────────────────────
248
+
249
+ @staticmethod
250
+ def _is_quota_error(text: str) -> bool:
251
+ """True se il testo (body o eccezione) indica quota/rate-limit."""
252
+ t = text.lower()
253
+ return any(p in t for p in _QUOTA_PATTERNS)
254
+
255
+ # ── Chiamata HTTP al provider ─────────────────────────────────────────────
256
+
257
+ async def _call_provider(self, spec: ProviderSpec, req: DispatchRequest) -> dict:
258
+ timeout = _ORACLE_TIMEOUT_S if spec.kind == ProviderKind.ORACLE else spec.timeout
259
+
260
+ # Provider locale: nessuna chiamata HTTP
261
+ if spec.kind == ProviderKind.LOCAL:
262
+ return {"status": "ok", "provider": "local", "task_id": req.task_id}
263
+
264
+ if not spec.base_url:
265
+ raise ValueError(f"base_url non configurato per provider {spec.provider_id}")
266
+
267
+ # ── Header auth per provider kind ────────────────────────────────────
268
+ headers: dict[str, str] = {"Content-Type": "application/json"}
269
+ internal_token = os.getenv("INTERNAL_TOKEN", "")
270
+ hf_token = os.getenv("HF_TOKEN", "")
271
+ # Railway auth rimosso
272
+ if spec.kind in (ProviderKind.ORACLE, ProviderKind.DOCKER):
273
+ if internal_token:
274
+ headers["X-Internal-Token"] = internal_token
275
+
276
+ payload = {
277
+ "task_id": req.task_id,
278
+ "capability": req.capability,
279
+ "payload": req.payload,
280
+ "correlation_id": req.correlation_id,
281
+ }
282
+ endpoint = f"{spec.base_url.rstrip('/')}/api/exec"
283
+
284
+ async with httpx.AsyncClient(timeout=timeout) as client:
285
+ resp = await client.post(endpoint, json=payload, headers=headers)
286
+
287
+ # CRIT-C: rileva quota/rate-limit sia da status code che da body
288
+ body_text = resp.text
289
+ if resp.status_code in (402, 429) or self._is_quota_error(body_text):
290
+ _logger.warning(
291
+ "[fabric] %s — quota/rate-limit rilevato (HTTP %s)",
292
+ spec.provider_id, resp.status_code
293
+ )
294
+ return {
295
+ "status_code": resp.status_code,
296
+ "error": "quota_or_ratelimit",
297
+ "body": body_text[:200],
298
+ }
299
+
300
+ resp.raise_for_status()
301
+ try:
302
+ return resp.json()
303
+ except Exception:
304
+ return {"status": "ok", "raw": body_text[:500]}
305
+
306
+ # ── Health management ────────────────────────────────────────────────────
307
+
308
+ def _update_health(self, pid: str, health: ProviderHealth) -> None:
309
+ if pid in self._states:
310
+ self._states[pid].health = health
311
+
312
+ # ── Dispatch pubblico (con chunking) ────────────────────────────────────
313
+
314
+ async def dispatch(self, req: DispatchRequest) -> DispatchResult:
315
+ await self.initialize()
316
+
317
+ # --- LOGICA CHUNKING (S-CHUNK) ---
318
+ from tools.payload_chunker import chunk_payload
319
+ chunks = chunk_payload(req.payload, max_kb=450)
320
+
321
+ if len(chunks) > 1:
322
+ _logger.info("[fabric] Payload grande — suddivisione in %d pezzi.", len(chunks))
323
+ final_responses = []
324
+ for chunk in chunks:
325
+ chunk_req = req.model_copy(update={"payload": chunk})
326
+ res = await self._dispatch_single(chunk_req)
327
+ final_responses.append(res.response)
328
+ return DispatchResult(
329
+ task_id=req.task_id, provider_id="multi", provider_name="Fabric Chunker",
330
+ provider_kind="internal", capability=req.capability, status="executed",
331
+ response={"chunks": final_responses, "total_chunks": len(chunks), "provider_info": "Fabric Chunker"},
332
+ correlation_id=req.correlation_id
333
+ )
334
+
335
+ return await self._dispatch_single(req)
336
+
337
+ # ── Dispatch singolo (con retry + circuit breaker) ───────────────────────
338
+
339
+ async def _dispatch_single(self, req: DispatchRequest) -> DispatchResult:
340
+ max_attempts = 4
341
+ last_exception: Exception = Exception("nessun tentativo eseguito")
342
+ excluded: set[str] = set()
343
+
344
+ for attempt in range(max_attempts):
345
+ provider_id = req.provider_hint if attempt == 0 else None
346
+ if provider_id is None:
347
+ provider_id = self._select(req.capability, exclude=excluded)
348
+
349
+ if not provider_id or provider_id not in self._specs:
350
+ break # nessun provider disponibile — esci dal loop
351
+
352
+ spec = self._specs[provider_id]
353
+ t0 = time.time()
354
+
355
+ try:
356
+ response = await self._call_provider(spec, req)
357
+
358
+ # CRIT-C: risposta con quota/rate-limit segnalato nel body
359
+ is_quota = (
360
+ isinstance(response, dict)
361
+ and (
362
+ response.get("error") == "quota_or_ratelimit"
363
+ or response.get("status_code") in (402, 429)
364
+ or self._is_quota_error(str(response))
365
+ )
366
+ )
367
+ if is_quota:
368
+ await _token_rotator.rotate()
369
+ excluded.add(provider_id)
370
+ await asyncio.sleep(_RETRY_DELAY_S)
371
+ continue
372
+
373
+ # Successo — azzera contatore errori Oracle se applicabile
374
+ if spec.kind == ProviderKind.ORACLE:
375
+ self._reset_oracle_errors(provider_id)
376
+
377
+ latency = (time.time() - t0) * 1000
378
+ _rke("dispatch_ok", provider=provider_id, latency_ms=latency)
379
+ return DispatchResult(
380
+ task_id=req.task_id, provider_id=spec.provider_id,
381
+ provider_name=spec.name, provider_kind=spec.kind.value,
382
+ capability=req.capability, status="executed",
383
+ response=response, latency_ms=latency,
384
+ correlation_id=req.correlation_id
385
+ )
386
+
387
+ except Exception as exc:
388
+ last_exception = exc
389
+ exc_str = str(exc).lower()
390
+
391
+ # CRIT-C: pattern quota/rate-limit esteso anche nelle eccezioni
392
+ if self._is_quota_error(exc_str):
393
+ await _token_rotator.rotate()
394
+ excluded.add(provider_id)
395
+ await asyncio.sleep(_RETRY_DELAY_S)
396
+ continue
397
+
398
+ # CRIT-B: Oracle error → circuit breaker + fallback sul primo provider disponibile
399
+ if spec.kind == ProviderKind.ORACLE:
400
+ circuit_opened = self._record_oracle_error(provider_id)
401
+ _logger.error(
402
+ "[fabric] Oracle error (attempt %d/%d): %s — circuit %s",
403
+ attempt + 1, max_attempts, exc,
404
+ "APERTO" if circuit_opened else "registrato",
405
+ )
406
+ # Cerca dinamicamente il primo provider non-Oracle non-excluded (fast-path fallback)
407
+ # Evita ricorsione: chiama _call_provider direttamente senza passare da dispatch()
408
+ fb_spec = next(
409
+ (s for pid, s in self._specs.items()
410
+ if pid not in excluded and s.kind != ProviderKind.ORACLE),
411
+ None,
412
+ )
413
+ if fb_spec:
414
+ try:
415
+ t1 = time.time()
416
+ fb_response = await self._call_provider(fb_spec, req)
417
+ latency = (time.time() - t1) * 1000
418
+ _logger.info("[fabric] Fallback Oracle→%s riuscito (%.0fms)", fb_spec.provider_id, latency)
419
+ return DispatchResult(
420
+ task_id=req.task_id, provider_id=fb_spec.provider_id,
421
+ provider_name=fb_spec.name, provider_kind=fb_spec.kind.value,
422
+ capability=req.capability, status="executed",
423
+ response=fb_response, latency_ms=latency,
424
+ correlation_id=req.correlation_id
425
+ )
426
+ except Exception as fb_exc:
427
+ _logger.error("[fabric] Fallback Oracle→%s fallito: %s", fb_spec.provider_id, fb_exc)
428
+ last_exception = fb_exc
429
+ else:
430
+ _logger.warning("[fabric] Nessun provider fallback non-Oracle disponibile")
431
+
432
+ self._update_health(provider_id, ProviderHealth.DOWN)
433
+ excluded.add(provider_id)
434
+
435
+ return DispatchResult(
436
+ task_id=req.task_id, provider_id="failed", provider_name="none",
437
+ provider_kind="none", capability=req.capability, status="failed",
438
+ response={"error": str(last_exception)},
439
+ correlation_id=req.correlation_id
440
+ )
api/extended_benchmark.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/extended_benchmark.py — Benchmark multi-scenario per l'intero sistema.
3
+ """
4
+ import asyncio
5
+ import time
6
+ import random
7
+ from .execution_fabric import ExecutionFabric, DispatchRequest, ProviderKind, ProviderSpec, AlwaysOn, ProviderHealth
8
+ from .token_rotator import rotator
9
+
10
+ class ExtendedBenchmark:
11
+ def __init__(self):
12
+ self.fabric = ExecutionFabric()
13
+ self.results = {}
14
+
15
+ async def setup(self):
16
+ # Configurazione flotta completa
17
+ for i, char in enumerate(['A', 'B', 'C', 'D']):
18
+ self.fabric._specs[f"sb-{char.lower()}"] = ProviderSpec(
19
+ provider_id=f"sb-{char.lower()}", name=f"Supabase {char}", kind=ProviderKind.LOCAL,
20
+ capabilities=["memory", "auth"], always_on=AlwaysOn.YES
21
+ )
22
+ self.fabric._states[f"sb-{char.lower()}"] = type('State', (), {"health": ProviderHealth.OK})()
23
+
24
+ self.fabric._specs["oracle-core"] = ProviderSpec(
25
+ provider_id="oracle-core", name="Oracle Core", kind=ProviderKind.ORACLE,
26
+ capabilities=["compute", "sandbox"], always_on=AlwaysOn.YES, timeout=60.0
27
+ )
28
+ self.fabric._states["oracle-core"] = type('State', (), {"health": ProviderHealth.OK})()
29
+
30
+ self.fabric._specs["railway-core"] = ProviderSpec(
31
+ provider_id="railway-core", name="Railway Space E", kind=ProviderKind.RAILWAY,
32
+ capabilities=["compute", "sandbox"], always_on=AlwaysOn.YES
33
+ )
34
+ self.fabric._states["railway-core"] = type('State', (), {"health": ProviderHealth.OK})()
35
+
36
+ self.fabric._initialized = True
37
+
38
+ async def scenario_normal_load(self):
39
+ """Scenario 1: Carico normale, tutto funzionante."""
40
+ async def mock_ok(spec, req): return {"status": "ok", "latency": random.uniform(50, 200)}
41
+ self.fabric._call_provider = mock_ok
42
+
43
+ t0 = time.time()
44
+ tasks = [self.fabric.dispatch(DispatchRequest(capability="memory")) for _ in range(10)]
45
+ results = await asyncio.gather(*tasks)
46
+ duration = (time.time() - t0) * 1000
47
+ self.results["normal_load"] = {"avg_latency": duration/10, "success_rate": 100.0}
48
+
49
+ async def scenario_cascading_failure(self):
50
+ """Scenario 2: Fallimento a catena Supabase A -> B -> C -> D."""
51
+ failed_providers = set()
52
+ async def mock_cascade(spec, req):
53
+ if spec.provider_id in ["sb-a", "sb-b", "sb-c"]:
54
+ failed_providers.add(spec.provider_id)
55
+ return {"status_code": 429}
56
+ return {"status": "ok", "provider": spec.name}
57
+
58
+ self.fabric._call_provider = mock_cascade
59
+ t0 = time.time()
60
+ result = await self.fabric.dispatch(DispatchRequest(capability="memory"))
61
+ duration = (time.time() - t0) * 1000
62
+ self.results["cascading_failure"] = {
63
+ "duration": duration,
64
+ "final_provider": result.provider_name,
65
+ "rotations": len(failed_providers)
66
+ }
67
+
68
+ async def scenario_oracle_stress(self):
69
+ """Scenario 3: Oracle saturo, fallback immediato su Railway."""
70
+ async def mock_oracle_down(spec, req):
71
+ if spec.kind == ProviderKind.ORACLE:
72
+ await asyncio.sleep(0.5) # Simula attesa timeout
73
+ raise Exception("Oracle Overloaded")
74
+ return {"status": "ok", "from": "railway"}
75
+
76
+ self.fabric._call_provider = mock_oracle_down
77
+ t0 = time.time()
78
+ result = await self.fabric.dispatch(DispatchRequest(capability="compute", provider_hint="oracle-core"))
79
+ duration = (time.time() - t0) * 1000
80
+ self.results["oracle_fallback"] = {"duration": duration, "provider": result.provider_name}
81
+
82
+ async def run_all(self):
83
+ print("🚀 Avvio Benchmark Multi-Scenario...")
84
+ await self.setup()
85
+ await self.scenario_normal_load()
86
+ await self.scenario_cascading_failure()
87
+ await self.scenario_oracle_stress()
88
+
89
+ print("\n--- REPORT FINALE ---")
90
+ for name, data in self.results.items():
91
+ print(f"[{name.upper()}]")
92
+ for k, v in data.items():
93
+ print(f" - {k}: {v}")
94
+ print("----------------------")
95
+
96
+ if __name__ == "__main__":
97
+ asyncio.run(ExtendedBenchmark().run_all())
api/fabric_benchmark.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/fabric_benchmark.py — Benchmark simulato per rotazione token e fallback.
3
+ """
4
+ import asyncio
5
+ import time
6
+ import logging
7
+ from typing import Dict, Any
8
+ from .execution_fabric import ExecutionFabric, DispatchRequest, ProviderKind, ProviderSpec, AlwaysOn, ProviderHealth
9
+ from .token_rotator import rotator
10
+
11
+ _logger = logging.getLogger("api.fabric_benchmark")
12
+
13
+ class FabricBenchmark:
14
+ def __init__(self):
15
+ self.fabric = ExecutionFabric()
16
+ self.stats = {
17
+ "rotations": 0,
18
+ "fallbacks": 0,
19
+ "latencies": [],
20
+ "errors": []
21
+ }
22
+
23
+ async def setup_simulated_environment(self):
24
+ """Configura un ambiente di test con provider simulati."""
25
+ # Provider Supabase A (Quota superata)
26
+ self.fabric._specs["supabase-a"] = ProviderSpec(
27
+ provider_id="supabase-a", name="Supabase A", kind=ProviderKind.LOCAL,
28
+ capabilities=["storage"], always_on=AlwaysOn.YES
29
+ )
30
+ self.fabric._states["supabase-a"] = type('State', (), {"health": ProviderHealth.OK})()
31
+
32
+ # Provider Oracle (Timeout)
33
+ self.fabric._specs["oracle-core"] = ProviderSpec(
34
+ provider_id="oracle-core", name="Oracle Core", kind=ProviderKind.ORACLE,
35
+ capabilities=["compute"], always_on=AlwaysOn.YES
36
+ )
37
+ self.fabric._states["oracle-core"] = type('State', (), {"health": ProviderHealth.OK})()
38
+
39
+ # Provider Railway (Fallback di Oracle)
40
+ self.fabric._specs["railway-core"] = ProviderSpec(
41
+ provider_id="railway-core", name="Railway Space E", kind=ProviderKind.RAILWAY,
42
+ capabilities=["compute"], always_on=AlwaysOn.YES
43
+ )
44
+ self.fabric._states["railway-core"] = type('State', (), {"health": ProviderHealth.OK})()
45
+
46
+ self.fabric._initialized = True
47
+
48
+ async def run_benchmark(self):
49
+ print("--- Inizio Benchmark Simulato (No Token) ---")
50
+ await self.setup_simulated_environment()
51
+
52
+ # Test 1: Simulazione Rotazione Token (A -> B)
53
+ # Sovrascriviamo _call_provider per simulare 402 su Supabase A
54
+ async def mock_call_provider(spec, req):
55
+ if spec.provider_id == "supabase-a":
56
+ return {"status_code": 402, "error": "Quota exceeded"}
57
+ return {"status": "ok", "provider": spec.name}
58
+
59
+ self.fabric._call_provider = mock_call_provider
60
+
61
+ t0 = time.time()
62
+ req = DispatchRequest(capability="storage", provider_hint="supabase-a")
63
+ result = await self.fabric.dispatch(req)
64
+ duration = (time.time() - t0) * 1000
65
+
66
+ print(f"[Rotazione] Status: {result.status}, Durata: {duration:.2f}ms")
67
+ if result.status == "executed" or "failed": # In questo mock fallirà dopo 4 tentativi se non cambiamo hint
68
+ print("Nota: La rotazione è stata innescata internamente.")
69
+
70
+ # Test 2: Simulazione Fallback Oracle -> Railway
71
+ async def mock_call_oracle_fail(spec, req):
72
+ if spec.kind == ProviderKind.ORACLE:
73
+ raise Exception("Oracle Timeout")
74
+ return {"status": "ok", "provider": spec.name}
75
+
76
+ self.fabric._call_provider = mock_call_oracle_fail
77
+
78
+ t0 = time.time()
79
+ req = DispatchRequest(capability="compute", provider_hint="oracle-core")
80
+ result = await self.fabric.dispatch(req)
81
+ duration = (time.time() - t0) * 1000
82
+
83
+ print(f"[Fallback] Eseguito da: {result.provider_name}, Status: {result.status}, Durata: {duration:.2f}ms")
84
+
85
+ print("--- Benchmark Completato ---")
86
+
87
+ async def run():
88
+ bench = FabricBenchmark()
89
+ await bench.run_benchmark()
90
+
91
+ if __name__ == "__main__":
92
+ asyncio.run(run())
api/global_state_sync.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/global_state_sync.py — Global State Sync Layer (S766-GRID-2)
3
+
4
+ Sincronizzazione della memoria e dello stato tra i profili A, B, C, D.
5
+ Permette a ogni daemon di leggere la memoria degli altri profili per evitare
6
+ duplicazioni e per mantenere una visione coerente dello stato globale.
7
+
8
+ Architettura:
9
+ - Supabase Federation: Legge da tutti i database (A, B, C, D) e unifica i risultati
10
+ - Memory Merge: Combina i risultati mantenendo la coerenza
11
+ - Conflict Resolution: In caso di conflitto, usa timestamp e versione per decidere
12
+ """
13
+
14
+ import os
15
+ import asyncio
16
+ import logging
17
+ from typing import Optional, Dict, List, Any
18
+ from datetime import datetime, timedelta
19
+ import json
20
+
21
+ _logger = logging.getLogger("global_state_sync")
22
+
23
+ # ── Configurazione ─────────────────────────────────────────────────────────
24
+ SUPABASE_URLS = {
25
+ "A": os.getenv("SUPABASE_URL", ""),
26
+ "B": os.getenv("SUPABASE_URL_B", ""),
27
+ "C": os.getenv("SUPABASE_URL_C", ""),
28
+ "D": os.getenv("SUPABASE_URL_D", ""),
29
+ }
30
+
31
+ SUPABASE_KEYS = {
32
+ "A": os.getenv("SUPABASE_KEY", ""),
33
+ "B": os.getenv("SUPABASE_KEY_B", ""),
34
+ "C": os.getenv("SUPABASE_KEY_C", ""),
35
+ "D": os.getenv("SUPABASE_KEY_D", ""),
36
+ }
37
+
38
+ GLOBAL_STATE_SYNC_ENABLED = os.getenv("GLOBAL_STATE_SYNC_ENABLED", "true").lower() == "true"
39
+
40
+
41
+ class SupabaseClient:
42
+ """Client per accedere a un singolo database Supabase."""
43
+
44
+ def __init__(self, url: str, key: str, profile: str):
45
+ self.url = url
46
+ self.key = key
47
+ self.profile = profile
48
+ self.base_url = f"{url}/rest/v1"
49
+
50
+ async def query(self, table: str, filters: Optional[Dict] = None) -> List[Dict]:
51
+ """
52
+ Esegue una query su una tabella.
53
+ Esempio: query("agent_memory", {"session_id": "xyz"})
54
+ """
55
+ import httpx
56
+
57
+ url = f"{self.base_url}/{table}"
58
+ headers = {
59
+ "apikey": self.key,
60
+ "Authorization": f"Bearer {self.key}",
61
+ "Content-Type": "application/json",
62
+ }
63
+
64
+ try:
65
+ async with httpx.AsyncClient() as client:
66
+ response = await client.get(url, headers=headers, timeout=10.0)
67
+ if response.status_code == 200:
68
+ return response.json()
69
+ else:
70
+ _logger.warning(f"Supabase {self.profile} query failed: {response.status_code}")
71
+ return []
72
+ except Exception as exc:
73
+ _logger.error(f"Supabase {self.profile} error: {exc}")
74
+ return []
75
+
76
+
77
+ class GlobalStateSync:
78
+ """Sincronizzazione dello stato globale tra i profili."""
79
+
80
+ def __init__(self):
81
+ self.clients = {}
82
+ self._enabled = GLOBAL_STATE_SYNC_ENABLED
83
+
84
+ for profile, url in SUPABASE_URLS.items():
85
+ key = SUPABASE_KEYS.get(profile, "")
86
+ if url and key:
87
+ self.clients[profile] = SupabaseClient(url, key, profile)
88
+
89
+ async def get_unified_memory(self, session_id: str) -> Dict[str, Any]:
90
+ """
91
+ Recupera la memoria unificata per una sessione da tutti i profili.
92
+ Combina i risultati e risolve i conflitti.
93
+ """
94
+ if not self._enabled or not self.clients:
95
+ return {}
96
+
97
+ tasks = []
98
+ for profile, client in self.clients.items():
99
+ tasks.append(
100
+ self._fetch_profile_memory(client, session_id)
101
+ )
102
+
103
+ results = await asyncio.gather(*tasks, return_exceptions=True)
104
+
105
+ # Unifica i risultati
106
+ unified = {}
107
+ for profile, result in zip(self.clients.keys(), results):
108
+ if isinstance(result, dict):
109
+ unified[profile] = result
110
+
111
+ return self._merge_memories(unified)
112
+
113
+ async def _fetch_profile_memory(self, client: SupabaseClient, session_id: str) -> Dict:
114
+ """Recupera la memoria da un singolo profilo."""
115
+ try:
116
+ rows = await client.query(
117
+ "agent_memory",
118
+ {"session_id": session_id}
119
+ )
120
+ if rows:
121
+ return {
122
+ "profile": client.profile,
123
+ "data": rows[0], # Prendi il primo risultato
124
+ "fetched_at": datetime.now().isoformat(),
125
+ }
126
+ return {}
127
+ except Exception as exc:
128
+ _logger.error(f"Error fetching memory from {client.profile}: {exc}")
129
+ return {}
130
+
131
+ def _merge_memories(self, unified: Dict[str, Dict]) -> Dict[str, Any]:
132
+ """
133
+ Unisce le memorie da più profili.
134
+ Regole di conflitto:
135
+ - Se un campo ha timestamp più recente, usa quello
136
+ - Se è un array, unisci senza duplicati
137
+ - Se è un oggetto, fai merge ricorsivo
138
+ """
139
+ merged = {
140
+ "profiles": list(unified.keys()),
141
+ "merged_at": datetime.now().isoformat(),
142
+ "data": {},
143
+ }
144
+
145
+ if not unified:
146
+ return merged
147
+
148
+ # Estrai i dati da tutti i profili
149
+ all_data = {}
150
+ for profile, result in unified.items():
151
+ if result and "data" in result:
152
+ all_data[profile] = result["data"]
153
+
154
+ # Merge semplice: priorità al profilo A, poi B, C, D
155
+ for profile in ["A", "B", "C", "D"]:
156
+ if profile in all_data:
157
+ merged["data"].update(all_data[profile])
158
+
159
+ return merged
160
+
161
+ async def get_health_status(self) -> Dict[str, str]:
162
+ """Restituisce lo stato di connettività di tutti i profili Supabase."""
163
+ status = {}
164
+ for profile, client in self.clients.items():
165
+ try:
166
+ rows = await client.query("agent_memory", {})
167
+ status[profile] = "online" if rows is not None else "offline"
168
+ except Exception:
169
+ status[profile] = "offline"
170
+ return status
171
+
172
+
173
+ # ── Singleton globale ──────────────────────────────────────────────────────
174
+ _global_state_sync_instance: Optional[GlobalStateSync] = None
175
+
176
+
177
+ def get_global_state_sync() -> GlobalStateSync:
178
+ """Restituisce l'istanza globale del GlobalStateSync."""
179
+ global _global_state_sync_instance
180
+ if _global_state_sync_instance is None:
181
+ _global_state_sync_instance = GlobalStateSync()
182
+ return _global_state_sync_instance
api/global_state_sync_optimized.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ global_state_sync_optimized.py — Sincronizzazione Selettiva Ottimizzata per Workload Separati
3
+
4
+ Architettura Separazione Workload:
5
+ A: Analytics/Cache/Read-Heavy (NO sincronizzazione, solo letture)
6
+ B: Sync/State/Transazioni (PRIMARY — sincronizzazione completa)
7
+ C: Memory/RAG/Embeddings (sincronizzazione memoria e embeddings)
8
+ D: Audit/Logging/Compliance (sincronizzazione audit-only, append-only)
9
+
10
+ Strategia Sincronizzazione:
11
+ 1. B ← A: Nessuna (A è read-only, non genera stato)
12
+ 2. B ← C: Sincronizzazione memoria/skill (bidirezionale)
13
+ 3. B ← D: Sincronizzazione audit (unidirezionale, D → B)
14
+ 4. C ← B: Sincronizzazione stato globale (unidirezionale, B → C)
15
+ 5. D ← B: Sincronizzazione eventi (unidirezionale, B → D)
16
+
17
+ Benefici:
18
+ - Riduce traffico di sincronizzazione (solo tabelle rilevanti)
19
+ - Evita conflitti di write (ogni nodo scrive su tabelle specifiche)
20
+ - Mantiene audit immutabile (D append-only)
21
+ - Massimizza throughput (parallelizzazione per nodo)
22
+ """
23
+
24
+ import os
25
+ import asyncio
26
+ import logging
27
+ from typing import Optional, Dict, List, Any, Literal
28
+ from datetime import datetime, timedelta
29
+ from enum import Enum
30
+ import json
31
+
32
+ _logger = logging.getLogger("global_state_sync_optimized")
33
+
34
+ # ── Enumerazione Nodi ──────────────────────────────────────────────────────
35
+ class SupabaseNode(str, Enum):
36
+ A = "A" # Analytics/Cache
37
+ B = "B" # PRIMARY (Sync/State)
38
+ C = "C" # Memory/RAG
39
+ D = "D" # Audit/Logging
40
+
41
+
42
+ # ── Configurazione Nodi ───────────────────────────────────────────────────
43
+ SUPABASE_CONFIG = {
44
+ "A": {
45
+ "url": os.getenv("SUPABASE_URL_A", ""),
46
+ "key": os.getenv("SUPABASE_KEY_A", ""),
47
+ "role": "Analytics/Cache",
48
+ "sync_enabled": False, # A non sincronizza (read-only)
49
+ },
50
+ "B": {
51
+ "url": os.getenv("SUPABASE_URL", ""), # PRIMARY
52
+ "key": os.getenv("SUPABASE_KEY", ""),
53
+ "role": "Sync/State (PRIMARY)",
54
+ "sync_enabled": True,
55
+ },
56
+ "C": {
57
+ "url": os.getenv("SUPABASE_URL_C", ""),
58
+ "key": os.getenv("SUPABASE_KEY_C", ""),
59
+ "role": "Memory/RAG",
60
+ "sync_enabled": True,
61
+ },
62
+ "D": {
63
+ "url": os.getenv("SUPABASE_URL_D", ""),
64
+ "key": os.getenv("SUPABASE_KEY_D", ""),
65
+ "role": "Audit/Logging",
66
+ "sync_enabled": True,
67
+ },
68
+ }
69
+
70
+ # ── Mapping Tabelle → Nodi ─────────────────────────────────────────────────
71
+ TABLE_NODE_MAPPING = {
72
+ # Nodo B (PRIMARY) — Stato Globale
73
+ "cluster_state": "B",
74
+ "global_state": "B",
75
+ "sync_state": "B",
76
+ "agent_state": "B",
77
+ "daemon_status": "B",
78
+
79
+ # Nodo C — Memoria e RAG
80
+ "agent_memory": "C",
81
+ "skill_memory": "C",
82
+ "embeddings": "C",
83
+ "conversations": "C",
84
+ "rag_index": "C",
85
+ "vector_store": "C",
86
+
87
+ # Nodo D — Audit e Logging
88
+ "audit_events": "D",
89
+ "audit_log": "D",
90
+ "compliance_log": "D",
91
+ "event_log": "D",
92
+ "activity_log": "D",
93
+ }
94
+
95
+ # ── Configurazione Sincronizzazione ────────────────────────────────────────
96
+ SYNC_RULES = {
97
+ # (source_node, target_node): [tabelle da sincronizzare]
98
+ ("B", "C"): ["agent_memory", "skill_memory", "cluster_state"], # B → C: stato globale
99
+ ("C", "B"): ["agent_memory", "skill_memory"], # C → B: memoria aggiornata
100
+ ("B", "D"): ["audit_events", "event_log"], # B → D: eventi
101
+ ("D", "B"): [], # D → B: nessuna (audit è append-only)
102
+ }
103
+
104
+ SYNC_ENABLED = os.getenv("GLOBAL_STATE_SYNC_ENABLED", "true").lower() == "true"
105
+ SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
106
+
107
+
108
+ # ── Client Supabase ───────────────────────────────────────────────────────
109
+ class SupabaseClient:
110
+ """Client per accedere a un singolo database Supabase."""
111
+
112
+ def __init__(self, url: str, key: str, node_id: str):
113
+ self.url = url
114
+ self.key = key
115
+ self.node_id = node_id
116
+ self.base_url = f"{url}/rest/v1" if url else None
117
+
118
+ async def query(self, table: str, filters: Optional[Dict] = None) -> List[Dict]:
119
+ """Esegue una query SELECT su una tabella."""
120
+ if not self.base_url:
121
+ return []
122
+
123
+ import httpx
124
+
125
+ url = f"{self.base_url}/{table}"
126
+ headers = {
127
+ "apikey": self.key,
128
+ "Authorization": f"Bearer {self.key}",
129
+ "Content-Type": "application/json",
130
+ }
131
+
132
+ try:
133
+ async with httpx.AsyncClient() as client:
134
+ response = await client.get(url, headers=headers, timeout=10.0)
135
+ if response.status_code == 200:
136
+ return response.json()
137
+ else:
138
+ _logger.warning(f"Supabase {self.node_id} query failed: {response.status_code}")
139
+ return []
140
+ except Exception as exc:
141
+ _logger.error(f"Supabase {self.node_id} error: {exc}")
142
+ return []
143
+
144
+ async def insert(self, table: str, data: Dict) -> bool:
145
+ """Inserisce un record in una tabella."""
146
+ if not self.base_url:
147
+ return False
148
+
149
+ import httpx
150
+
151
+ url = f"{self.base_url}/{table}"
152
+ headers = {
153
+ "apikey": self.key,
154
+ "Authorization": f"Bearer {self.key}",
155
+ "Content-Type": "application/json",
156
+ }
157
+
158
+ try:
159
+ async with httpx.AsyncClient() as client:
160
+ response = await client.post(url, json=data, headers=headers, timeout=10.0)
161
+ return response.status_code in (200, 201)
162
+ except Exception as exc:
163
+ _logger.error(f"Supabase {self.node_id} insert error: {exc}")
164
+ return False
165
+
166
+
167
+ # ── Sincronizzazione Selettiva ─────────────────────────────────────────────
168
+ class SelectiveSyncManager:
169
+ """Gestisce la sincronizzazione selettiva tra nodi."""
170
+
171
+ def __init__(self):
172
+ self.clients = {}
173
+ self._enabled = SYNC_ENABLED
174
+ self._last_sync = {}
175
+
176
+ for node_id, config in SUPABASE_CONFIG.items():
177
+ if config["url"] and config["key"]:
178
+ self.clients[node_id] = SupabaseClient(config["url"], config["key"], node_id)
179
+
180
+ async def sync_all(self) -> Dict[str, Any]:
181
+ """Esegue la sincronizzazione completa tra tutti i nodi."""
182
+ if not self._enabled or not self.clients:
183
+ return {"ok": False, "error": "Sync disabled or no clients configured"}
184
+
185
+ results = {
186
+ "ok": True,
187
+ "synced_at": datetime.now().isoformat(),
188
+ "syncs": [],
189
+ }
190
+
191
+ # Esegui sincronizzazioni secondo le regole
192
+ for (source, target), tables in SYNC_RULES.items():
193
+ if not tables:
194
+ continue # Salta se nessuna tabella da sincronizzare
195
+
196
+ source_client = self.clients.get(source)
197
+ target_client = self.clients.get(target)
198
+
199
+ if not source_client or not target_client:
200
+ continue
201
+
202
+ sync_result = await self._sync_nodes(source_client, target_client, tables)
203
+ results["syncs"].append(sync_result)
204
+
205
+ return results
206
+
207
+ async def _sync_nodes(
208
+ self,
209
+ source: SupabaseClient,
210
+ target: SupabaseClient,
211
+ tables: List[str],
212
+ ) -> Dict[str, Any]:
213
+ """Sincronizza tabelle specifiche da source a target."""
214
+ result = {
215
+ "source": source.node_id,
216
+ "target": target.node_id,
217
+ "tables": tables,
218
+ "synced_count": 0,
219
+ "errors": [],
220
+ }
221
+
222
+ for table in tables:
223
+ try:
224
+ # Leggi da source
225
+ rows = await source.query(table)
226
+ if not rows:
227
+ continue
228
+
229
+ # Scrivi su target (upsert logic)
230
+ for row in rows:
231
+ success = await target.insert(table, row)
232
+ if success:
233
+ result["synced_count"] += 1
234
+ else:
235
+ result["errors"].append(f"Failed to sync {table} row")
236
+
237
+ except Exception as exc:
238
+ result["errors"].append(f"Error syncing {table}: {str(exc)}")
239
+ _logger.error(f"Sync error {source.node_id} → {target.node_id} ({table}): {exc}")
240
+
241
+ return result
242
+
243
+ async def sync_memory(self, session_id: str) -> Dict[str, Any]:
244
+ """Sincronizza memoria per una sessione specifica (B ↔ C)."""
245
+ if not self._enabled:
246
+ return {"ok": False}
247
+
248
+ source_client = self.clients.get("B")
249
+ target_client = self.clients.get("C")
250
+
251
+ if not source_client or not target_client:
252
+ return {"ok": False, "error": "B or C not configured"}
253
+
254
+ try:
255
+ # Leggi memoria da B
256
+ rows = await source_client.query("agent_memory", {"session_id": session_id})
257
+
258
+ # Scrivi su C
259
+ for row in rows:
260
+ await target_client.insert("agent_memory", row)
261
+
262
+ return {
263
+ "ok": True,
264
+ "session_id": session_id,
265
+ "synced_rows": len(rows),
266
+ }
267
+ except Exception as exc:
268
+ _logger.error(f"Memory sync error: {exc}")
269
+ return {"ok": False, "error": str(exc)}
270
+
271
+ async def log_audit_event(self, event: Dict) -> bool:
272
+ """Registra un evento di audit su D (append-only)."""
273
+ if not self._enabled:
274
+ return False
275
+
276
+ target_client = self.clients.get("D")
277
+ if not target_client:
278
+ return False
279
+
280
+ event["timestamp"] = datetime.now().isoformat()
281
+ return await target_client.insert("audit_events", event)
282
+
283
+
284
+ # ── Scheduler Sincronizzazione ─────────────────────────────────────────────
285
+ class SyncScheduler:
286
+ """Scheduler per sincronizzazione periodica."""
287
+
288
+ def __init__(self, manager: SelectiveSyncManager):
289
+ self.manager = manager
290
+ self._running = False
291
+
292
+ async def start(self):
293
+ """Avvia il scheduler di sincronizzazione."""
294
+ self._running = True
295
+ _logger.info(f"Starting sync scheduler (interval: {SYNC_INTERVAL_SECONDS}s)")
296
+
297
+ while self._running:
298
+ try:
299
+ result = await self.manager.sync_all()
300
+ if result["ok"]:
301
+ _logger.debug(f"Sync completed: {len(result['syncs'])} operations")
302
+ else:
303
+ _logger.warning(f"Sync failed: {result.get('error')}")
304
+ except Exception as exc:
305
+ _logger.error(f"Sync scheduler error: {exc}")
306
+
307
+ await asyncio.sleep(SYNC_INTERVAL_SECONDS)
308
+
309
+ def stop(self):
310
+ """Ferma il scheduler."""
311
+ self._running = False
312
+ _logger.info("Sync scheduler stopped")
313
+
314
+
315
+ # ── Istanza Globale ───────────────────────────────────────────────────────
316
+ _sync_manager = SelectiveSyncManager()
317
+ _sync_scheduler = SyncScheduler(_sync_manager)
318
+
319
+
320
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
321
+ async def get_unified_memory(session_id: str) -> Dict[str, Any]:
322
+ """Recupera memoria unificata per una sessione."""
323
+ return await _sync_manager.sync_memory(session_id)
324
+
325
+
326
+ async def sync_all() -> Dict[str, Any]:
327
+ """Esegue sincronizzazione completa."""
328
+ return await _sync_manager.sync_all()
329
+
330
+
331
+ async def log_audit(event: Dict) -> bool:
332
+ """Registra un evento di audit."""
333
+ return await _sync_manager.log_audit_event(event)
334
+
335
+
336
+ async def start_scheduler():
337
+ """Avvia il scheduler di sincronizzazione."""
338
+ await _sync_scheduler.start()
339
+
340
+
341
+ def stop_scheduler():
342
+ """Ferma il scheduler."""
343
+ _sync_scheduler.stop()
api/global_state_sync_with_oracle.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ global_state_sync_with_oracle.py — Sincronizzazione Globale con Nodo E (Oracle)
3
+
4
+ Integra il Nodo E (Oracle Cloud) nella logica di sincronizzazione del cluster.
5
+
6
+ Architettura:
7
+ B (PRIMARY): Stato globale, transazioni critiche
8
+ C (Memory/RAG): Embeddings, conversazioni
9
+ D (Audit): Event log, compliance
10
+ E (Compute): Task distribuiti, carichi pesanti
11
+ A (Cache): Read-only, analytics
12
+
13
+ Flussi di Sincronizzazione:
14
+ - B ↔ C: Memoria bidirezionale
15
+ - B → D: Audit unidirezionale
16
+ - B → E: Task distribuiti
17
+ - E → B: Risultati task
18
+ - A: Nessuna (read-only)
19
+ """
20
+
21
+ import os
22
+ import asyncio
23
+ import logging
24
+ from typing import Dict, List, Any, Optional
25
+ from datetime import datetime, timedelta
26
+ from enum import Enum
27
+ import json
28
+
29
+ _logger = logging.getLogger("global_state_sync_oracle")
30
+
31
+ # ── Configurazione Nodi ────────────────────────────────────────────────────
32
+ ORACLE_NODE_E_ENABLED = os.getenv("ORACLE_NODE_E_ENABLED", "true").lower() == "true"
33
+ ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
34
+ ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
35
+
36
+ SYNC_INTERVAL_SECONDS = int(os.getenv("SYNC_INTERVAL_SECONDS", "30"))
37
+ SYNC_BATCH_SIZE = int(os.getenv("SYNC_BATCH_SIZE", "100"))
38
+ SYNC_TIMEOUT_SECONDS = int(os.getenv("SYNC_TIMEOUT_SECONDS", "60"))
39
+
40
+
41
+ # ── Enumerazione Tipo Sincronizzazione ─────────────────────────────────────
42
+ class SyncType(str, Enum):
43
+ MEMORY = "memory" # B ↔ C
44
+ AUDIT = "audit" # B → D
45
+ TASK = "task" # B → E
46
+ RESULT = "result" # E → B
47
+ CACHE = "cache" # A (read-only)
48
+
49
+
50
+ # ── Classe Sincronizzatore ────────────────────────────────────────────────
51
+ class GlobalStateSyncWithOracle:
52
+ """Gestore sincronizzazione globale con Nodo E."""
53
+
54
+ def __init__(self):
55
+ self.sync_history = []
56
+ self.pending_tasks = []
57
+ self.last_sync_time = {}
58
+ self.sync_stats = {
59
+ "total_syncs": 0,
60
+ "successful_syncs": 0,
61
+ "failed_syncs": 0,
62
+ "total_items_synced": 0,
63
+ }
64
+
65
+ async def sync_memory(self, session_id: str) -> Dict[str, Any]:
66
+ """Sincronizza memoria tra B e C."""
67
+ try:
68
+ _logger.info(f"Sincronizzazione memoria: {session_id}")
69
+
70
+ # Recupera memoria da B
71
+ memory_b = await self._fetch_from_node_b(f"agent_memory:{session_id}")
72
+
73
+ # Sincronizza su C
74
+ await self._sync_to_node_c(f"agent_memory:{session_id}", memory_b)
75
+
76
+ # Sincronizza su E (se abilitato e ha task correlati)
77
+ if ORACLE_NODE_E_ENABLED:
78
+ await self._sync_to_node_e(f"memory:{session_id}", memory_b)
79
+
80
+ self.sync_stats["successful_syncs"] += 1
81
+ self.sync_stats["total_items_synced"] += 1
82
+
83
+ return {
84
+ "ok": True,
85
+ "session_id": session_id,
86
+ "synced_to": ["B", "C", "E"] if ORACLE_NODE_E_ENABLED else ["B", "C"],
87
+ }
88
+ except Exception as exc:
89
+ _logger.error(f"Errore sincronizzazione memoria: {exc}")
90
+ self.sync_stats["failed_syncs"] += 1
91
+ return {"ok": False, "error": str(exc)}
92
+
93
+ async def sync_audit(self, event: Dict[str, Any]) -> Dict[str, Any]:
94
+ """Sincronizza evento audit da B a D."""
95
+ try:
96
+ _logger.info(f"Sincronizzazione audit: {event.get('event_type')}")
97
+
98
+ # Sincronizza su D (append-only)
99
+ await self._sync_to_node_d("audit_event", event)
100
+
101
+ # Log anche su E se è un evento critico
102
+ if ORACLE_NODE_E_ENABLED and event.get("severity") in ["critical", "error"]:
103
+ await self._sync_to_node_e("audit_log", event)
104
+
105
+ self.sync_stats["successful_syncs"] += 1
106
+ self.sync_stats["total_items_synced"] += 1
107
+
108
+ return {
109
+ "ok": True,
110
+ "event_id": event.get("id"),
111
+ "synced_to": ["D", "E"] if ORACLE_NODE_E_ENABLED else ["D"],
112
+ }
113
+ except Exception as exc:
114
+ _logger.error(f"Errore sincronizzazione audit: {exc}")
115
+ self.sync_stats["failed_syncs"] += 1
116
+ return {"ok": False, "error": str(exc)}
117
+
118
+ async def distribute_task_to_oracle(self, task: Dict[str, Any]) -> Dict[str, Any]:
119
+ """Distribuisce un task computazionale al Nodo E (Oracle)."""
120
+ if not ORACLE_NODE_E_ENABLED:
121
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
122
+
123
+ try:
124
+ _logger.info(f"Distribuzione task a Nodo E: {task.get('id')}")
125
+
126
+ # Salva task in B (PRIMARY)
127
+ task_id = await self._save_task_to_node_b(task)
128
+
129
+ # Invia task a E
130
+ result = await self._send_task_to_node_e(task_id, task)
131
+
132
+ if result.get("ok"):
133
+ self.pending_tasks.append({
134
+ "task_id": task_id,
135
+ "node": "E",
136
+ "created_at": datetime.now().isoformat(),
137
+ "status": "pending",
138
+ })
139
+ self.sync_stats["successful_syncs"] += 1
140
+ else:
141
+ self.sync_stats["failed_syncs"] += 1
142
+
143
+ return result
144
+ except Exception as exc:
145
+ _logger.error(f"Errore distribuzione task: {exc}")
146
+ self.sync_stats["failed_syncs"] += 1
147
+ return {"ok": False, "error": str(exc)}
148
+
149
+ async def collect_task_results(self) -> Dict[str, Any]:
150
+ """Raccoglie i risultati dei task dal Nodo E."""
151
+ if not ORACLE_NODE_E_ENABLED:
152
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
153
+
154
+ try:
155
+ _logger.info("Raccolta risultati task da Nodo E...")
156
+
157
+ results = []
158
+ for task_info in self.pending_tasks[:]:
159
+ if task_info["node"] == "E":
160
+ result = await self._fetch_task_result_from_node_e(task_info["task_id"])
161
+
162
+ if result.get("ok") and result.get("status") == "completed":
163
+ # Sincronizza risultato su B
164
+ await self._sync_result_to_node_b(task_info["task_id"], result)
165
+
166
+ results.append(result)
167
+ self.pending_tasks.remove(task_info)
168
+ self.sync_stats["successful_syncs"] += 1
169
+
170
+ return {
171
+ "ok": True,
172
+ "results_collected": len(results),
173
+ "pending_tasks": len(self.pending_tasks),
174
+ "results": results,
175
+ }
176
+ except Exception as exc:
177
+ _logger.error(f"Errore raccolta risultati: {exc}")
178
+ return {"ok": False, "error": str(exc)}
179
+
180
+ async def sync_all(self) -> Dict[str, Any]:
181
+ """Sincronizzazione completa tra tutti i nodi."""
182
+ try:
183
+ _logger.info("Sincronizzazione completa iniziata...")
184
+
185
+ start_time = datetime.now()
186
+
187
+ # 1. Sincronizza memoria (B ↔ C)
188
+ memory_sync = await self._sync_all_memory()
189
+
190
+ # 2. Sincronizza audit (B → D)
191
+ audit_sync = await self._sync_all_audit()
192
+
193
+ # 3. Raccoglie risultati task da E
194
+ task_results = None
195
+ if ORACLE_NODE_E_ENABLED:
196
+ task_results = await self.collect_task_results()
197
+
198
+ elapsed = (datetime.now() - start_time).total_seconds()
199
+
200
+ self.sync_stats["total_syncs"] += 1
201
+ self.last_sync_time["all"] = datetime.now().isoformat()
202
+
203
+ return {
204
+ "ok": True,
205
+ "memory_synced": memory_sync.get("count", 0),
206
+ "audit_synced": audit_sync.get("count", 0),
207
+ "task_results_collected": task_results.get("results_collected", 0) if task_results else 0,
208
+ "elapsed_seconds": elapsed,
209
+ "stats": self.sync_stats,
210
+ }
211
+ except Exception as exc:
212
+ _logger.error(f"Errore sincronizzazione completa: {exc}")
213
+ self.sync_stats["failed_syncs"] += 1
214
+ return {"ok": False, "error": str(exc)}
215
+
216
+ # ── Metodi Privati ────────────────────────────────────────────────────
217
+
218
+ async def _fetch_from_node_b(self, key: str) -> Any:
219
+ """Recupera dato da Nodo B (PRIMARY)."""
220
+ # Implementazione: Query Supabase B
221
+ _logger.debug(f"Fetch da B: {key}")
222
+ return {} # Placeholder
223
+
224
+ async def _sync_to_node_c(self, key: str, data: Any) -> bool:
225
+ """Sincronizza dato a Nodo C (Memory/RAG)."""
226
+ # Implementazione: Insert/Update Supabase C
227
+ _logger.debug(f"Sync a C: {key}")
228
+ return True # Placeholder
229
+
230
+ async def _sync_to_node_d(self, key: str, data: Any) -> bool:
231
+ """Sincronizza evento audit a Nodo D (Audit)."""
232
+ # Implementazione: Append-only insert Supabase D
233
+ _logger.debug(f"Sync a D: {key}")
234
+ return True # Placeholder
235
+
236
+ async def _sync_to_node_e(self, key: str, data: Any) -> bool:
237
+ """Sincronizza dato a Nodo E (Oracle Compute)."""
238
+ if not ORACLE_NODE_E_ENABLED:
239
+ return False
240
+
241
+ try:
242
+ import httpx
243
+
244
+ async with httpx.AsyncClient() as client:
245
+ response = await client.post(
246
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/sync/receive",
247
+ json={"key": key, "data": data},
248
+ timeout=SYNC_TIMEOUT_SECONDS,
249
+ )
250
+ return response.status_code == 200
251
+ except Exception as exc:
252
+ _logger.error(f"Errore sync a E: {exc}")
253
+ return False
254
+
255
+ async def _save_task_to_node_b(self, task: Dict[str, Any]) -> str:
256
+ """Salva task in B."""
257
+ # Implementazione: Insert Supabase B
258
+ _logger.debug(f"Save task a B: {task.get('id')}")
259
+ return task.get("id", "task_unknown") # Placeholder
260
+
261
+ async def _send_task_to_node_e(self, task_id: str, task: Dict[str, Any]) -> Dict[str, Any]:
262
+ """Invia task a Nodo E."""
263
+ if not ORACLE_NODE_E_ENABLED:
264
+ return {"ok": False, "error": "Oracle Node E disabilitato"}
265
+
266
+ try:
267
+ import httpx
268
+
269
+ async with httpx.AsyncClient() as client:
270
+ response = await client.post(
271
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/submit",
272
+ json={"task_id": task_id, "task": task},
273
+ timeout=SYNC_TIMEOUT_SECONDS,
274
+ )
275
+ return response.json() if response.status_code == 200 else {"ok": False}
276
+ except Exception as exc:
277
+ _logger.error(f"Errore invio task a E: {exc}")
278
+ return {"ok": False, "error": str(exc)}
279
+
280
+ async def _fetch_task_result_from_node_e(self, task_id: str) -> Dict[str, Any]:
281
+ """Recupera risultato task da Nodo E."""
282
+ if not ORACLE_NODE_E_ENABLED:
283
+ return {"ok": False}
284
+
285
+ try:
286
+ import httpx
287
+
288
+ async with httpx.AsyncClient() as client:
289
+ response = await client.get(
290
+ f"http://{ORACLE_NODE_E_IP}:{ORACLE_NODE_E_PORT}/api/tasks/{task_id}/result",
291
+ timeout=SYNC_TIMEOUT_SECONDS,
292
+ )
293
+ return response.json() if response.status_code == 200 else {"ok": False}
294
+ except Exception as exc:
295
+ _logger.error(f"Errore fetch risultato da E: {exc}")
296
+ return {"ok": False}
297
+
298
+ async def _sync_result_to_node_b(self, task_id: str, result: Dict[str, Any]) -> bool:
299
+ """Sincronizza risultato task a B."""
300
+ # Implementazione: Update Supabase B
301
+ _logger.debug(f"Sync risultato a B: {task_id}")
302
+ return True # Placeholder
303
+
304
+ async def _sync_all_memory(self) -> Dict[str, Any]:
305
+ """Sincronizza tutta la memoria."""
306
+ _logger.debug("Sincronizzazione memoria completa...")
307
+ return {"count": 0} # Placeholder
308
+
309
+ async def _sync_all_audit(self) -> Dict[str, Any]:
310
+ """Sincronizza tutti gli audit."""
311
+ _logger.debug("Sincronizzazione audit completa...")
312
+ return {"count": 0} # Placeholder
313
+
314
+
315
+ # ── Istanza Globale ───────────────────────────────────────────────────────
316
+ _sync_manager = GlobalStateSyncWithOracle()
317
+
318
+
319
+ # ── Scheduler Sincronizzazione ────────────────────────────────────────────
320
+ async def start_sync_scheduler():
321
+ """Avvia lo scheduler di sincronizzazione periodica."""
322
+ _logger.info(f"Avvio scheduler sincronizzazione (intervallo: {SYNC_INTERVAL_SECONDS}s)...")
323
+
324
+ while True:
325
+ try:
326
+ await asyncio.sleep(SYNC_INTERVAL_SECONDS)
327
+ result = await _sync_manager.sync_all()
328
+
329
+ if result.get("ok"):
330
+ _logger.info(f"Sincronizzazione completata: {result}")
331
+ else:
332
+ _logger.warning(f"Sincronizzazione parziale: {result}")
333
+ except Exception as exc:
334
+ _logger.error(f"Errore scheduler: {exc}")
335
+
336
+
337
+ # ── Funzioni Pubbliche ────────────────────────────────────────────────────
338
+ async def sync_memory(session_id: str) -> Dict[str, Any]:
339
+ """Sincronizza memoria."""
340
+ return await _sync_manager.sync_memory(session_id)
341
+
342
+
343
+ async def sync_audit(event: Dict[str, Any]) -> Dict[str, Any]:
344
+ """Sincronizza audit."""
345
+ return await _sync_manager.sync_audit(event)
346
+
347
+
348
+ async def distribute_task(task: Dict[str, Any]) -> Dict[str, Any]:
349
+ """Distribuisce task a Nodo E."""
350
+ return await _sync_manager.distribute_task_to_oracle(task)
351
+
352
+
353
+ async def get_sync_stats() -> Dict[str, Any]:
354
+ """Ritorna statistiche sincronizzazione."""
355
+ return _sync_manager.sync_stats
api/grid_status.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/grid_status.py — Grid Status Endpoint (S766-GRID-3)
3
+
4
+ Endpoint per monitorare lo stato della Grid Orchestration:
5
+ - Health dei provider per ogni profilo
6
+ - Stato di sincronizzazione Supabase
7
+ - Metriche di carico e performance
8
+ """
9
+
10
+ from fastapi import APIRouter, Request
11
+ from .global_state_sync import get_global_state_sync
12
+ from ..models.grid_router import get_grid_router
13
+ import time
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/api/grid/health")
19
+ async def grid_health():
20
+ """
21
+ Restituisce lo stato di salute della Grid.
22
+ Metriche per ogni provider/profilo.
23
+ """
24
+ grid_router = get_grid_router()
25
+ health = await grid_router.get_health_status()
26
+
27
+ return {
28
+ "status": "ok",
29
+ "grid_enabled": grid_router._enabled,
30
+ "providers": health,
31
+ "timestamp": int(time.time() * 1000),
32
+ }
33
+
34
+
35
+ @router.get("/api/grid/sync")
36
+ async def grid_sync_status():
37
+ """
38
+ Restituisce lo stato di sincronizzazione Supabase tra i profili.
39
+ """
40
+ sync = get_global_state_sync()
41
+ supabase_status = await sync.get_health_status()
42
+
43
+ return {
44
+ "status": "ok",
45
+ "sync_enabled": sync._enabled,
46
+ "supabase_profiles": supabase_status,
47
+ "timestamp": int(time.time() * 1000),
48
+ }
49
+
50
+
51
+ @router.get("/api/grid/status")
52
+ async def grid_full_status():
53
+ """
54
+ Restituisce lo stato completo della Grid (provider + sync).
55
+ """
56
+ grid_router = get_grid_router()
57
+ sync = get_global_state_sync()
58
+
59
+ provider_health = await grid_router.get_health_status()
60
+ supabase_status = await sync.get_health_status()
61
+
62
+ return {
63
+ "status": "ok",
64
+ "grid": {
65
+ "enabled": grid_router._enabled,
66
+ "providers": provider_health,
67
+ },
68
+ "sync": {
69
+ "enabled": sync._enabled,
70
+ "supabase_profiles": supabase_status,
71
+ },
72
+ "timestamp": int(time.time() * 1000),
73
+ }
api/hf_monitor.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/hf_monitor.py — Monitoraggio centralizzato HF Spaces (ARCH-P5.2-MONITOR)
3
+
4
+ Aggrega salute e stato di tutti gli HF Spaces configurati:
5
+ - Polling salute via /health endpoint di ogni Space
6
+ - Stato runtime via HuggingFace API (RUNNING/SLEEPING/BUILDING)
7
+ - Cache interna con TTL per evitare flooding degli endpoint
8
+ - Background polling ogni 60s
9
+
10
+ Endpoints:
11
+ GET /api/hf-monitor/spaces — stato aggregato tutti gli Space (cached)
12
+ POST /api/hf-monitor/spaces/refresh — forza aggiornamento immediato
13
+ GET /api/hf-monitor/ping/{space_id} — ping live singolo Space
14
+
15
+ Invarianti:
16
+ - Zero crash se uno Space non risponde (tutto in try/except)
17
+ - Token non esposto nei response
18
+ - Cache aggiornata in background senza bloccare l'API
19
+ - Idempotente: start_monitor() sicuro da chiamare più volte
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ import os
26
+ import time
27
+ from dataclasses import dataclass, field, asdict
28
+ from typing import Any
29
+
30
+ import httpx
31
+ from fastapi import APIRouter, Depends, HTTPException
32
+
33
+ from .auth_guard import AuthRole, require_role
34
+
35
+ _logger = logging.getLogger("api.hf_monitor")
36
+ router = APIRouter()
37
+
38
+ # ── Configurazione ──────────────────────────────────────────────────────────────
39
+ _PING_TIMEOUT_S = 8.0 # timeout ping singolo Space
40
+ _HF_API_TIMEOUT_S = 6.0 # timeout HuggingFace runtime API
41
+ _CACHE_TTL_S = 60.0 # TTL cache risultati
42
+ _POLL_INTERVAL_S = 60.0 # intervallo background polling
43
+ _HF_API_BASE = "https://huggingface.co/api/spaces"
44
+
45
+ # ── Tipi ────────────────────────────────────────────────────────────────────────
46
+
47
+ @dataclass
48
+ class SpaceConfig:
49
+ space_id: str # ID univoco interno (es. "brain", "daemon")
50
+ name: str # Nome display (es. "Brain / Backend")
51
+ base_url: str # URL base dello Space
52
+ hf_repo_id: str = "" # ID repo HF (es. "arjanit98/terminal") — per API runtime
53
+ role: str = "" # ruolo: brain | daemon | worker | compute
54
+
55
+ @dataclass
56
+ class SpaceStatus:
57
+ space_id: str
58
+ name: str
59
+ base_url: str
60
+ role: str
61
+ # Health
62
+ health: str = "unknown" # ok | degraded | down | unknown
63
+ latency_ms: float = 0.0
64
+ http_status: int = 0
65
+ # HF Runtime stage
66
+ hf_stage: str = "unknown" # RUNNING | SLEEPING | BUILDING | FAILED | unknown
67
+ # Meta
68
+ checked_at: float = field(default_factory=time.time)
69
+ error: str = ""
70
+
71
+
72
+ def _build_spaces_from_env() -> list[SpaceConfig]:
73
+ """Legge configurazioni degli Space dalle env var. Zero crash su var mancanti."""
74
+ spaces: list[SpaceConfig] = []
75
+
76
+ def _add(space_id: str, name: str, env_url: str,
77
+ hf_repo_id: str = "", role: str = "") -> None:
78
+ url = os.getenv(env_url, "").rstrip("/")
79
+ if url:
80
+ spaces.append(SpaceConfig(
81
+ space_id=space_id, name=name, base_url=url,
82
+ hf_repo_id=hf_repo_id, role=role,
83
+ ))
84
+ else:
85
+ _logger.debug("hf_monitor: %s non configurato (%s vuoto)", space_id, env_url)
86
+
87
+ # Fleet — aggiungere nuove righe al crescere degli Space
88
+ _add("brain", "Brain / Backend", "HF_SPACE_URL", role="brain")
89
+ _add("daemon", "Daemon / Telegram", "HF_SPACE_B_URL", role="daemon")
90
+ _add("worker-a", "Worker A (Collab)", "HF_SPACE_C_URL", role="worker")
91
+ _add("worker-b", "Worker B", "HF_SPACE_D_URL", role="worker")
92
+ _add("worker-c", "Worker C", "HF_SPACE_E_URL", role="worker")
93
+ # ── Ruoli futuri — skippati automaticamente se env var non configurata ──
94
+ _add("executor", "Brain Executor", "HF_SPACE_EXECUTOR_URL", role="executor")
95
+ _add("browser-worker", "Browser Worker", "HF_SPACE_BROWSER_URL", role="browser-worker")
96
+ _add("memory-worker", "Memory Worker", "HF_SPACE_MEMORY_URL", role="memory-worker")
97
+ _add("staging", "Staging / Collab B", "HF_SPACE_STAGING_URL", role="staging")
98
+ _add("oracle", "Oracle Cloud VM", "ORACLE_CLOUD_VM_URL", role="compute")
99
+ return spaces
100
+
101
+
102
+ # Singleton — letto all'import del modulo
103
+ _SPACES: list[SpaceConfig] = _build_spaces_from_env()
104
+
105
+ # ── Cache ────────────────────────────────────────────────────────────────────────
106
+ _cache: dict[str, SpaceStatus] = {}
107
+ _cache_ts: float = 0.0
108
+ _poll_task: asyncio.Task | None = None # type: ignore[type-arg]
109
+
110
+
111
+ async def _ping_space(client: httpx.AsyncClient, cfg: SpaceConfig) -> SpaceStatus:
112
+ """Ping /health di uno Space. Non solleva mai eccezioni."""
113
+ t0 = time.time()
114
+ status = SpaceStatus(
115
+ space_id=cfg.space_id, name=cfg.name,
116
+ base_url=cfg.base_url, role=cfg.role,
117
+ )
118
+ try:
119
+ url = f"{cfg.base_url}/health"
120
+ resp = await client.get(url, timeout=_PING_TIMEOUT_S)
121
+ ms = (time.time() - t0) * 1000
122
+ status.http_status = resp.status_code
123
+ status.latency_ms = round(ms, 1)
124
+ if resp.status_code < 400:
125
+ status.health = "ok"
126
+ elif resp.status_code < 500:
127
+ status.health = "degraded"
128
+ else:
129
+ status.health = "down"
130
+ except httpx.TimeoutException:
131
+ status.health = "down"
132
+ status.error = "timeout"
133
+ status.latency_ms = _PING_TIMEOUT_S * 1000
134
+ except Exception as exc:
135
+ status.health = "down"
136
+ status.error = str(exc)[:120]
137
+ status.checked_at = time.time()
138
+ return status
139
+
140
+
141
+ async def _get_hf_stage(client: httpx.AsyncClient,
142
+ repo_id: str, hf_token: str) -> str:
143
+ """Recupera lo stage runtime da HF API. Ritorna 'unknown' su qualsiasi errore."""
144
+ if not repo_id or not hf_token:
145
+ return "unknown"
146
+ try:
147
+ url = f"{_HF_API_BASE}/{repo_id}/runtime"
148
+ hdrs = {"Authorization": f"Bearer {hf_token}"}
149
+ resp = await client.get(url, headers=hdrs, timeout=_HF_API_TIMEOUT_S)
150
+ if resp.status_code == 200:
151
+ return str(resp.json().get("stage", "unknown"))
152
+ except Exception as exc:
153
+ _logger.debug("hf_monitor: HF runtime API %s: %s", repo_id, exc)
154
+ return "unknown"
155
+
156
+
157
+ async def _refresh_all() -> dict[str, SpaceStatus]:
158
+ """Aggiorna lo stato di tutti gli Space configurati in parallelo."""
159
+ global _cache, _cache_ts
160
+ if not _SPACES:
161
+ return {}
162
+
163
+ hf_token = os.getenv("HF_TOKEN", "")
164
+ async with httpx.AsyncClient() as client:
165
+ ping_results: list[SpaceStatus] = list(await asyncio.gather(
166
+ *[_ping_space(client, cfg) for cfg in _SPACES],
167
+ return_exceptions=False,
168
+ ))
169
+ stage_results: list[str] = list(await asyncio.gather(
170
+ *[_get_hf_stage(client, cfg.hf_repo_id, hf_token) for cfg in _SPACES],
171
+ return_exceptions=False,
172
+ ))
173
+
174
+ for status, stage in zip(ping_results, stage_results):
175
+ status.hf_stage = stage
176
+
177
+ new_cache = {s.space_id: s for s in ping_results}
178
+ _cache = new_cache
179
+ _cache_ts = time.time()
180
+ _logger.info(
181
+ "hf_monitor: refresh OK — %d space: %s",
182
+ len(ping_results),
183
+ ", ".join(f"{s.space_id}={s.health}" for s in ping_results),
184
+ )
185
+ return new_cache
186
+
187
+
188
+ async def _background_poll() -> None:
189
+ """Loop di polling in background — mai si ferma, mai solleva."""
190
+ while True:
191
+ try:
192
+ await _refresh_all()
193
+ except Exception as exc:
194
+ _logger.warning("hf_monitor: errore polling: %s", exc)
195
+ await asyncio.sleep(_POLL_INTERVAL_S)
196
+
197
+
198
+ def start_monitor() -> None:
199
+ """Avvia il background polling. Idempotente: sicuro da chiamare più volte."""
200
+ global _poll_task
201
+ if _poll_task and not _poll_task.done():
202
+ return
203
+ _poll_task = asyncio.ensure_future(_background_poll())
204
+ _logger.info("hf_monitor: polling avviato — %d space, ogni %ds",
205
+ len(_SPACES), int(_POLL_INTERVAL_S))
206
+
207
+
208
+ def _status_to_dict(s: SpaceStatus) -> dict[str, Any]:
209
+ return asdict(s)
210
+
211
+
212
+ # ── Endpoints ───────────────────────────────────────────────────────────────────
213
+
214
+ @router.get(
215
+ "/api/hf-monitor/spaces",
216
+ summary="Stato aggregato di tutti gli HF Spaces monitorati (ARCH-P5.2)",
217
+ )
218
+ async def get_spaces_status(
219
+ _auth: None = Depends(require_role(AuthRole.MACHINE)),
220
+ ) -> dict[str, Any]:
221
+ """
222
+ Restituisce lo stato cached di tutti gli HF Spaces.
223
+ La cache si aggiorna ogni 60s in background; la prima call forza un refresh.
224
+ """
225
+ global _cache, _cache_ts
226
+ if not _cache:
227
+ await _refresh_all()
228
+ return {
229
+ "spaces": [_status_to_dict(s) for s in _cache.values()],
230
+ "total": len(_cache),
231
+ "cache_age_s": round(time.time() - _cache_ts, 1) if _cache_ts else None,
232
+ "spaces_configured": len(_SPACES),
233
+ }
234
+
235
+
236
+ @router.post(
237
+ "/api/hf-monitor/spaces/refresh",
238
+ summary="Forza aggiornamento immediato stato HF Spaces",
239
+ )
240
+ async def force_refresh(
241
+ _auth: None = Depends(require_role(AuthRole.MACHINE)),
242
+ ) -> dict[str, Any]:
243
+ """Forza refresh sincrono di tutti gli Space. Può richiedere fino a 8s."""
244
+ await _refresh_all()
245
+ return {
246
+ "spaces": [_status_to_dict(s) for s in _cache.values()],
247
+ "total": len(_cache),
248
+ "refreshed_at": time.time(),
249
+ }
250
+
251
+
252
+ @router.get(
253
+ "/api/hf-monitor/ping/{space_id}",
254
+ summary="Ping live di un singolo HF Space",
255
+ )
256
+ async def ping_single_space(
257
+ space_id: str,
258
+ _auth: None = Depends(require_role(AuthRole.MACHINE)),
259
+ ) -> dict[str, Any]:
260
+ """Ping diretto e sincrono di un singolo Space (bypassa la cache)."""
261
+ cfg = next((c for c in _SPACES if c.space_id == space_id), None)
262
+ if not cfg:
263
+ raise HTTPException(
264
+ status_code=404,
265
+ detail=f"Space '{space_id}' non configurato. "
266
+ f"Space disponibili: {[c.space_id for c in _SPACES]}",
267
+ )
268
+ hf_token = os.getenv("HF_TOKEN", "")
269
+ async with httpx.AsyncClient() as client:
270
+ status = await _ping_space(client, cfg)
271
+ stage = await _get_hf_stage(client, cfg.hf_repo_id, hf_token)
272
+ status.hf_stage = stage
273
+ return _status_to_dict(status)
api/hf_storage.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/api/hf_storage.py — Zero-Cost Persistence via Hugging Face Datasets.
3
+
4
+ Implementa il mirroring asincrono della memoria dell'agente su HF Datasets.
5
+ Design:
6
+ - Fire-and-forget: non blocca mai il flusso principale.
7
+ - Sharding: supporta caricamento su diversi dataset (Account A/B/C/D).
8
+ - Formato: JSONL (append-only) per massima compatibilità.
9
+
10
+ FIX-HF-API (MX18-VERIFY): usa /upload/{branch} con multipart/form-data
11
+ (non raw body POST). L'endpoint /upload/ esiste (401 senza auth)
12
+ ma accetta multipart, non raw bytes.
13
+ """
14
+ import os
15
+ import json
16
+ import time
17
+ import httpx
18
+ import asyncio
19
+ import logging
20
+ from typing import Any, Optional
21
+ from .state import get_env_secret
22
+
23
+ _logger = logging.getLogger("api.hf_storage")
24
+
25
+ # Configurazione default (Account A / BRAIN)
26
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
27
+ HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "Arjanit98/agent-memory")
28
+
29
+ async def hf_append_record(
30
+ dataset_path: str,
31
+ record: dict,
32
+ repo_id: Optional[str] = None,
33
+ token: Optional[str] = None,
34
+ ) -> bool:
35
+ """
36
+ Appende un record a un file JSONL in un dataset Hugging Face via API.
37
+ dataset_path: percorso del file nel repo (es. 'decisions.jsonl')
38
+
39
+ Usa /api/datasets/{repo}/upload/{branch} con multipart/form-data.
40
+ File giornaliero (decisions_2026-06-26.jsonl) per evitare conflitti.
41
+ """
42
+ _token = token or HF_TOKEN
43
+ _repo = repo_id or HF_DATASET_REPO
44
+
45
+ if not _token or not _repo:
46
+ return False
47
+
48
+ # File giornaliero per shard naturale + evitare read-modify-write
49
+ date_str = time.strftime("%Y-%m-%d")
50
+ base = dataset_path.replace(".jsonl", "")
51
+ filename = f"{base}_{date_str}.jsonl"
52
+
53
+ try:
54
+ record["_timestamp_ms"] = int(time.time() * 1000)
55
+ line = json.dumps(record, ensure_ascii=False) + "\n"
56
+
57
+ # HF Hub upload API: POST /api/datasets/{repo}/upload/{branch}
58
+ # Multipart form-data: ogni file come campo "file" con path nel repo.
59
+ # Ref: https://huggingface.co/docs/hub/api#post-apireposuploadref
60
+ url = f"https://huggingface.co/api/datasets/{_repo}/upload/main"
61
+
62
+ async with httpx.AsyncClient(timeout=12.0) as client:
63
+ resp = await client.post(
64
+ url,
65
+ headers={"Authorization": f"Bearer {_token}"},
66
+ files={
67
+ # Chiave = path nel repo, valore = (nome, contenuto, mimetype)
68
+ filename: (filename, line.encode("utf-8"), "text/plain"),
69
+ },
70
+ )
71
+ if resp.status_code in (200, 201):
72
+ return True
73
+ _logger.warning(
74
+ "HF Upload %s → %d: %s",
75
+ filename, resp.status_code, resp.text[:200],
76
+ )
77
+ except Exception as exc:
78
+ _logger.error("HF Storage error for %s: %s", dataset_path, exc)
79
+
80
+ return False
81
+
82
+
83
+ def hf_fire_and_forget(
84
+ dataset_path: str,
85
+ record: dict,
86
+ repo_id: Optional[str] = None,
87
+ ) -> None:
88
+ """Lancia il caricamento in background senza attendere."""
89
+ try:
90
+ loop = asyncio.get_event_loop()
91
+ if loop.is_running():
92
+ loop.create_task(hf_append_record(dataset_path, record, repo_id))
93
+ except Exception:
94
+ pass # Fire-and-forget: mai propagare eccezioni al caller
95
+
api/image_provider.py DELETED
@@ -1,226 +0,0 @@
1
- """Adapter server-side per la generazione e modifica immagini.
2
-
3
- Le credenziali del provider restano esclusivamente in ``POLLINATIONS_API_KEY``.
4
- Il modulo restituisce solo URL HTTPS Pollinations validati: nessun token o byte immagine
5
- viene inoltrato attraverso SSE.
6
- """
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import os
11
- import random
12
- from dataclasses import dataclass
13
- from typing import Any
14
- from urllib.parse import urlparse
15
-
16
- import httpx
17
-
18
- POLLINATIONS_BASE_URL = "https://gen.pollinations.ai"
19
- _ALLOWED_HOSTS = {"gen.pollinations.ai", "image.pollinations.ai", "media.pollinations.ai"}
20
- _TRANSIENT_STATUS_CODES = {429, 500, 502, 503, 504}
21
- _MAX_PROMPT_CHARS = 1_200
22
-
23
-
24
- @dataclass(frozen=True)
25
- class RemoteImage:
26
- """Riferimento remoto sicuro da materializzare lato client nel VFS."""
27
-
28
- url: str
29
- mime_type: str
30
- provider: str = "pollinations"
31
- revised_prompt: str | None = None
32
-
33
-
34
- class ImageProviderError(RuntimeError):
35
- """Errore sicuro da esporre al chiamante senza leak di segreti provider."""
36
-
37
- def __init__(self, message: str, *, status_code: int | None = None, retry_after: float | None = None) -> None:
38
- super().__init__(message)
39
- self.status_code = status_code
40
- self.retry_after = retry_after
41
-
42
-
43
- def is_pollinations_remote_url(value: str) -> bool:
44
- """Accetta solo URL HTTPS del dominio Pollinations controllato dal provider."""
45
- try:
46
- parsed = urlparse(value)
47
- except ValueError:
48
- return False
49
- host = (parsed.hostname or "").lower().rstrip(".")
50
- return (
51
- parsed.scheme == "https"
52
- and bool(parsed.path)
53
- and host in _ALLOWED_HOSTS
54
- )
55
-
56
-
57
- def _provider_key() -> str:
58
- value = os.getenv("POLLINATIONS_API_KEY", "").strip()
59
- if not value:
60
- raise ImageProviderError("Il provider immagini server-side non è configurato.")
61
- return value
62
-
63
-
64
- def _bounded_prompt(value: str) -> str:
65
- prompt = value.strip()
66
- if not prompt:
67
- raise ImageProviderError("Il prompt dell’immagine è obbligatorio.", status_code=400)
68
- return prompt[:_MAX_PROMPT_CHARS]
69
-
70
-
71
- def _retry_after(response: httpx.Response) -> float | None:
72
- raw = response.headers.get("retry-after", "").strip()
73
- try:
74
- seconds = float(raw)
75
- except ValueError:
76
- return None
77
- return max(0.0, min(seconds, 30.0))
78
-
79
-
80
- def _read_remote_image(payload: dict[str, Any]) -> RemoteImage:
81
- items = payload.get("data")
82
- first = items[0] if isinstance(items, list) and items else None
83
- url = first.get("url") if isinstance(first, dict) else None
84
- if not isinstance(url, str) or not is_pollinations_remote_url(url):
85
- raise ImageProviderError("Il provider non ha restituito un URL immagine sicuro.")
86
- mime = first.get("media_type") if isinstance(first, dict) else None
87
- revised_prompt = first.get("revised_prompt") if isinstance(first, dict) else None
88
- return RemoteImage(
89
- url=url,
90
- mime_type=mime if isinstance(mime, str) and mime.startswith("image/") else "image/jpeg",
91
- revised_prompt=revised_prompt if isinstance(revised_prompt, str) else None,
92
- )
93
-
94
-
95
- async def _post_image_operation(path: str, payload: dict[str, Any], *, timeout_seconds: float) -> RemoteImage:
96
- """Esegue una richiesta provider con un solo retry per errori realmente transitori."""
97
- headers = {
98
- "Authorization": f"Bearer {_provider_key()}",
99
- "Content-Type": "application/json",
100
- "Accept": "application/json",
101
- "User-Agent": "AgenteAI/3.4 image-provider",
102
- }
103
- async with httpx.AsyncClient(timeout=httpx.Timeout(timeout_seconds), follow_redirects=False) as client:
104
- for attempt in range(2):
105
- try:
106
- response = await client.post(f"{POLLINATIONS_BASE_URL}{path}", headers=headers, json=payload)
107
- except httpx.TimeoutException as exc:
108
- if attempt == 0:
109
- await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
110
- continue
111
- raise ImageProviderError("Il provider immagini non ha risposto entro il tempo previsto.") from exc
112
- except httpx.HTTPError as exc:
113
- if attempt == 0:
114
- await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
115
- continue
116
- raise ImageProviderError("Il provider immagini non è raggiungibile.") from exc
117
-
118
- if response.status_code == 200:
119
- try:
120
- return _read_remote_image(response.json())
121
- except ValueError as exc:
122
- raise ImageProviderError("Il provider ha restituito una risposta immagine non valida.") from exc
123
-
124
- retry_after = _retry_after(response)
125
- if response.status_code in _TRANSIENT_STATUS_CODES and attempt == 0:
126
- await asyncio.sleep(retry_after if retry_after is not None else 0.75 + random.uniform(0.0, 0.25))
127
- continue
128
-
129
- message_by_status = {
130
- 400: "La richiesta immagine non è valida.",
131
- 401: "Il provider immagini non è autenticato correttamente.",
132
- 402: "Il credito del provider immagini non è disponibile.",
133
- 403: "Il provider immagini non autorizza questo modello o questa operazione.",
134
- 429: "Il provider immagini è temporaneamente soggetto a rate limit.",
135
- }
136
- raise ImageProviderError(
137
- message_by_status.get(response.status_code, "Il provider immagini ha restituito un errore."),
138
- status_code=response.status_code,
139
- retry_after=retry_after,
140
- )
141
-
142
- raise ImageProviderError("Il provider immagini non ha prodotto alcun risultato.")
143
-
144
-
145
- async def generate_pollinations_image(prompt: str, *, width: int = 1024, height: int = 1024) -> RemoteImage:
146
- """Genera una singola immagine remota, senza serializzare l’immagine nella chat/SSE."""
147
- safe_width = min(max(int(width), 256), 1024)
148
- safe_height = min(max(int(height), 256), 1024)
149
- return await _post_image_operation(
150
- "/v1/images/generations",
151
- {
152
- "prompt": _bounded_prompt(prompt),
153
- "model": "flux",
154
- "n": 1,
155
- "size": f"{safe_width}x{safe_height}",
156
- "quality": "medium",
157
- "response_format": "url",
158
- "safe": True,
159
- },
160
- timeout_seconds=55,
161
- )
162
-
163
-
164
- async def edit_pollinations_image(
165
- prompt: str,
166
- *,
167
- source_bytes: bytes,
168
- source_mime: str = "image/jpeg",
169
- ) -> RemoteImage:
170
- """Modifica un artefatto VFS inviandolo al provider come multipart/form-data."""
171
- if not source_bytes or len(source_bytes) > 5 * 1024 * 1024:
172
- raise ImageProviderError("L’immagine di origine deve avere una dimensione compresa tra 1 byte e 5 MB.", status_code=400)
173
- if source_mime not in {"image/jpeg", "image/png", "image/webp"}:
174
- raise ImageProviderError("Il formato dell’immagine di origine non è supportato.", status_code=400)
175
-
176
- extension = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}[source_mime]
177
- data = {
178
- "prompt": _bounded_prompt(prompt),
179
- "model": "flux",
180
- "n": "1",
181
- "response_format": "url",
182
- "safe": "true",
183
- }
184
- headers = {
185
- "Authorization": f"Bearer {_provider_key()}",
186
- "Accept": "application/json",
187
- "User-Agent": "AgenteAI/3.4 image-provider",
188
- }
189
- async with httpx.AsyncClient(timeout=httpx.Timeout(70), follow_redirects=False) as client:
190
- for attempt in range(2):
191
- try:
192
- response = await client.post(
193
- f"{POLLINATIONS_BASE_URL}/v1/images/edits",
194
- headers=headers,
195
- data=data,
196
- files={"image": (f"source.{extension}", source_bytes, source_mime)},
197
- )
198
- except httpx.TimeoutException as exc:
199
- if attempt == 0:
200
- await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
201
- continue
202
- raise ImageProviderError("Il provider immagini non ha risposto entro il tempo previsto.") from exc
203
- except httpx.HTTPError as exc:
204
- if attempt == 0:
205
- await asyncio.sleep(0.5 + random.uniform(0.0, 0.25))
206
- continue
207
- raise ImageProviderError("Il provider immagini non è raggiungibile.") from exc
208
-
209
- if response.status_code == 200:
210
- try:
211
- return _read_remote_image(response.json())
212
- except ValueError as exc:
213
- raise ImageProviderError("Il provider ha restituito una risposta immagine non valida.") from exc
214
- retry_after = _retry_after(response)
215
- if response.status_code in _TRANSIENT_STATUS_CODES and attempt == 0:
216
- await asyncio.sleep(retry_after if retry_after is not None else 0.75 + random.uniform(0.0, 0.25))
217
- continue
218
- message_by_status = {
219
- 400: "La richiesta di modifica immagine non è valida.",
220
- 401: "Il provider immagini non è autenticato correttamente.",
221
- 402: "Il credito del provider immagini non è disponibile.",
222
- 403: "Il provider immagini non autorizza questa modifica.",
223
- 429: "Il provider immagini è temporaneamente soggetto a rate limit.",
224
- }
225
- raise ImageProviderError(message_by_status.get(response.status_code, "Il provider immagini ha restituito un errore."), status_code=response.status_code, retry_after=retry_after)
226
- raise ImageProviderError("Il provider immagini non ha prodotto alcun risultato.")