Spaces:
Running
Running
sync: 190 file da Baida98/AI@977dd543 (2026-08-26 10:41 UTC) [deploy-all]
#76
by Baida07 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .gitattributes +35 -0
- Dockerfile +1 -1
- REBUILD_TRIGGER +1 -0
- agents/audit_semantic_l2.py +303 -0
- agents/executor.py +8 -32
- agents/fallback_healer.py +59 -0
- agents/fallback_utils.py +26 -0
- agents/grid_rag.py +124 -0
- agents/html_fast_path.py +0 -60
- agents/unified_loop.py +54 -109
- agents/unified_loop_delegate.py +192 -0
- agents/unified_loop_fallback.py +0 -0
- agents/unified_loop_routing.py +82 -0
- agents/unified_loop_vfs.py +156 -0
- agents/watchdog.py +67 -0
- api/TELEGRAM_MODULES.md +172 -0
- api/_agent_helpers.py +127 -0
- api/advanced_complex_benchmark.py +108 -0
- api/agent.py +27 -307
- api/agent_checkpoint_routes.py +279 -0
- api/agent_fsm.py +375 -0
- api/agent_loop_routes.py +410 -0
- api/agent_task_routes.py +800 -0
- api/agent_telemetry.py +0 -51
- api/auth_managed.py +1 -24
- api/background_tasks.py +0 -53
- api/benchmarks_hub.py +100 -0
- api/bootstrap_tools.py +44 -0
- api/brain_planner.py +253 -0
- api/cache_endpoints.py +164 -0
- api/cache_manager.py +400 -0
- api/capability_catalog.py +348 -0
- api/capability_resolver.py +441 -0
- api/dashboard.py +171 -0
- api/database.py +1 -2
- api/database_router.py +303 -0
- api/execution_fabric.py +440 -0
- api/extended_benchmark.py +97 -0
- api/fabric_benchmark.py +92 -0
- api/global_state_sync.py +182 -0
- api/global_state_sync_optimized.py +343 -0
- api/global_state_sync_with_oracle.py +355 -0
- api/grid_status.py +73 -0
- api/hf_monitor.py +273 -0
- api/hf_storage.py +95 -0
- api/job_queue.py +11 -8
- api/large_payload_benchmark.py +61 -0
- api/llm_router.py +354 -0
- api/load_balancer.py +104 -0
- api/oci_gateway_manager.py +63 -0
.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
|
|
|
|
| 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 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 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/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 |
-
"""
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 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":
|
| 228 |
"status": "streaming",
|
| 229 |
}))
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 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 |
-
|
| 238 |
-
self.executor.run_tool(
|
| 239 |
timeout=10.0,
|
| 240 |
)
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 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
|
| 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
|
| 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
|
| 1332 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 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:
|
|
@@ -3576,12 +3534,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:
|
|
@@ -3739,12 +3691,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:
|
|
@@ -3831,6 +3777,29 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3831 |
except Exception:
|
| 3832 |
pass # decision_memory non disponibile — continua normalmente
|
| 3833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3834 |
# P29-B1: gate ambiguità strutturale — _is_goal_ambiguous() era P28-B2 dead code (mai chiamata).
|
| 3835 |
# Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing.
|
| 3836 |
if _is_goal_ambiguous(goal):
|
|
@@ -3994,9 +3963,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3994 |
return _r_bl
|
| 3995 |
|
| 3996 |
|
| 3997 |
-
# Sprint 5 ITEM 13: classify_ms â tempo routing/classificazione goal (sync, <1ms)
|
| 3998 |
-
_t0_classify = _time.monotonic()
|
| 3999 |
-
|
| 4000 |
# S402: Fast Path â greeting/ack/identità semplice â bypass tutto l'overhead
|
| 4001 |
if self._is_simple_query(goal):
|
| 4002 |
try:
|
|
@@ -4046,27 +4012,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4046 |
# già â "direct tools + fallback" â ma il codice faceva solo _run_fallback senza tool).
|
| 4047 |
# Bug: query meteo/news/cerca non chiamavano mai i tool reali â LLM allucinava i dati
|
| 4048 |
# â ResponseVerifier girava su risposta inventata â retry â 20-60s inutili.
|
| 4049 |
-
# B5: query spiegazione pura → _run_fallback diretta (-20-30s risparmio)
|
| 4050 |
-
# Scenari: "cos'è X", "spiegami Y", "how does Z work?", "explain W"
|
| 4051 |
-
# Fail-open: se regex troppo larga → path normale (nessuna perdita)
|
| 4052 |
-
if self._is_pure_explanation(goal):
|
| 4053 |
-
try:
|
| 4054 |
-
from api.state import record_timing as _rtcB5
|
| 4055 |
-
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 4056 |
-
except Exception:
|
| 4057 |
-
pass
|
| 4058 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4059 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4060 |
-
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4061 |
-
_r["effective_max_steps"] = state.max_steps
|
| 4062 |
-
if _sid_token is not None:
|
| 4063 |
-
try: _sid_var.reset(_sid_token)
|
| 4064 |
-
except Exception: pass
|
| 4065 |
-
if self._session_files:
|
| 4066 |
-
asyncio.ensure_future(self._vfs_git_backup())
|
| 4067 |
-
return _r
|
| 4068 |
-
|
| 4069 |
-
|
| 4070 |
# P36: Hybrid Execution Router — Python code analysis fast path.
|
| 4071 |
# Se goal contiene keyword analisi + codice Python nel context/goal,
|
| 4072 |
# chiama python_analyze direttamente (<5ms) saltando planner+LLM (5-15s).
|
|
|
|
| 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:
|
|
|
|
| 3534 |
}
|
| 3535 |
|
| 3536 |
state.errors.append(error_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3537 |
previous = state.state_machine.current
|
| 3538 |
if previous != AgentState.FAILED:
|
| 3539 |
try:
|
|
|
|
| 3691 |
|
| 3692 |
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3693 |
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3694 |
try:
|
| 3695 |
await self._transition_state(state, next_state, on_step)
|
| 3696 |
finally:
|
|
|
|
| 3777 |
except Exception:
|
| 3778 |
pass # decision_memory non disponibile — continua normalmente
|
| 3779 |
|
| 3780 |
+
# B5-ORDER: una spiegazione completa e non operativa è già un intento
|
| 3781 |
+
# sufficiente. Deve bypassare le guardie di ambiguità, che sono riservate a
|
| 3782 |
+
# comandi realmente vaghi; le guardie in _is_pure_explanation() proteggono
|
| 3783 |
+
# file, mutazioni, dati realtime e richieste troppo lunghe.
|
| 3784 |
+
# Sprint 5 ITEM 13: classify_ms — tempo routing/classificazione goal (sync, <1ms)
|
| 3785 |
+
_t0_classify = _time.monotonic()
|
| 3786 |
+
if self._is_pure_explanation(goal):
|
| 3787 |
+
try:
|
| 3788 |
+
from api.state import record_timing as _rtcB5
|
| 3789 |
+
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3790 |
+
except Exception:
|
| 3791 |
+
pass
|
| 3792 |
+
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 3793 |
+
_r = await _finish(await self._run_fallback(state, on_step))
|
| 3794 |
+
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3795 |
+
_r["effective_max_steps"] = state.max_steps
|
| 3796 |
+
if _sid_token is not None:
|
| 3797 |
+
try: _sid_var.reset(_sid_token)
|
| 3798 |
+
except Exception: pass
|
| 3799 |
+
if self._session_files:
|
| 3800 |
+
asyncio.ensure_future(self._vfs_git_backup())
|
| 3801 |
+
return _r
|
| 3802 |
+
|
| 3803 |
# P29-B1: gate ambiguità strutturale — _is_goal_ambiguous() era P28-B2 dead code (mai chiamata).
|
| 3804 |
# Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing.
|
| 3805 |
if _is_goal_ambiguous(goal):
|
|
|
|
| 3963 |
return _r_bl
|
| 3964 |
|
| 3965 |
|
|
|
|
|
|
|
|
|
|
| 3966 |
# S402: Fast Path â greeting/ack/identità semplice â bypass tutto l'overhead
|
| 3967 |
if self._is_simple_query(goal):
|
| 3968 |
try:
|
|
|
|
| 4012 |
# già â "direct tools + fallback" â ma il codice faceva solo _run_fallback senza tool).
|
| 4013 |
# Bug: query meteo/news/cerca non chiamavano mai i tool reali â LLM allucinava i dati
|
| 4014 |
# â ResponseVerifier girava su risposta inventata â retry â 20-60s inutili.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4015 |
# P36: Hybrid Execution Router — Python code analysis fast path.
|
| 4016 |
# Se goal contiene keyword analisi + codice Python nel context/goal,
|
| 4017 |
# chiama python_analyze direttamente (<5ms) saltando planner+LLM (5-15s).
|
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_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_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,
|
| 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,
|
|
@@ -102,83 +89,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 +204,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 +247,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 +270,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 |
-
|
| 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 +333,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 |
-
|
| 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 +456,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 |
-
|
| 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 +465,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 +474,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 +605,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,
|
| 735 |
"""
|
| 736 |
Crea o recupera un task agent.
|
| 737 |
|
|
@@ -739,27 +613,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 |
-
|
| 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
|
| 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']}
|
|
@@ -790,7 +648,6 @@ 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,
|
|
@@ -798,6 +655,7 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 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,7 +666,6 @@ 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:
|
|
@@ -959,7 +816,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)
|
|
@@ -993,8 +849,8 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 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'] = '
|
| 997 |
-
asyncio.create_task(sb_update_status(task_id, '
|
| 998 |
literal_event = json.dumps(_sanitize_for_json({
|
| 999 |
'event': 'task_done', 'taskId': task_id, 'result': _literal_response,
|
| 1000 |
}))
|
|
@@ -1043,7 +899,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 ('
|
| 1047 |
# Replay buffer from resume point
|
| 1048 |
for evt_str in sb_events[_resume_from:]:
|
| 1049 |
yield evt_str
|
|
@@ -1096,18 +952,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,
|
|
@@ -1161,8 +1005,8 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 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'] = '
|
| 1165 |
-
asyncio.create_task(sb_update_status(task_id, '
|
| 1166 |
_sse('task_done', {'taskId': task_id, 'result': _literal_response})
|
| 1167 |
return
|
| 1168 |
|
|
@@ -1202,24 +1046,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 +1137,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 |
-
|
| 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':
|
|
@@ -1557,7 +1377,6 @@ 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,
|
|
@@ -1565,64 +1384,10 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1565 |
on_step=step_cb,
|
| 1566 |
session_id=task.get('session_id', '') or '',
|
| 1567 |
allow_tools=not bool(task.get('forbid_tools', False)),
|
| 1568 |
-
|
| 1569 |
)
|
| 1570 |
-
|
| 1571 |
-
|
| 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 +1403,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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 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 +1428,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'] = '
|
| 1706 |
-
asyncio.create_task(sb_update_status(task_id, '
|
| 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 +1462,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 |
-
|
| 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,
|
|
|
|
| 89 |
router = APIRouter()
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def _attach_byok_client(task_id: str, credentials: object) -> None:
|
| 93 |
"""Create a task-scoped LLM client without persisting or logging credentials."""
|
| 94 |
if credentials is None:
|
|
|
|
| 204 |
body: ReasonLoopIn, request: Request,
|
| 205 |
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
|
| 206 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
async def generate():
|
| 208 |
queue: asyncio.Queue = asyncio.Queue()
|
| 209 |
|
| 210 |
async def step_cb(step: dict) -> None:
|
|
|
|
|
|
|
| 211 |
await queue.put(step)
|
| 212 |
|
| 213 |
async def run_loop() -> None:
|
|
|
|
| 247 |
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 248 |
if _neg_c:
|
| 249 |
context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
|
|
|
| 250 |
result = await loop.run(
|
| 251 |
goal=body.goal, context=context_str,
|
| 252 |
max_steps=body.max_steps, on_step=step_cb,
|
| 253 |
session_id=getattr(body, "session_id", "") or "",
|
| 254 |
)
|
|
|
|
| 255 |
await queue.put({
|
| 256 |
'__done__': True,
|
| 257 |
'result': result.get('output', ''),
|
| 258 |
'engine': result.get('engine', 'fallback'),
|
| 259 |
'success': result.get('success', False),
|
|
|
|
| 260 |
})
|
| 261 |
except Exception as exc:
|
| 262 |
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
|
|
|
| 270 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 271 |
await queue.put({'__error__': str(exc)})
|
| 272 |
|
| 273 |
+
task = asyncio.create_task(run_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
task.add_done_callback(_log_task_exc) # BUG-CB-1
|
| 275 |
task_id = str(uuid.uuid4())
|
| 276 |
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
|
|
|
| 333 |
_err_detail = _ss(item.get('error', ''))
|
| 334 |
_final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
|
| 335 |
else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
|
| 336 |
+
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
break
|
| 338 |
# S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
|
| 339 |
_NARR_QUICK = {
|
|
|
|
| 456 |
'action': step_data.get('action', ''),
|
| 457 |
'output': str(step_data.get('output', ''))[:400], # S577: 200→400
|
| 458 |
})
|
| 459 |
+
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 "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
if isinstance(result, dict):
|
| 461 |
output_text = result.get('output', '') or ''
|
| 462 |
engine_used = result.get('engine', 'unknown')
|
|
|
|
| 465 |
output_text = str(result)
|
| 466 |
engine_used = 'unknown'
|
| 467 |
errors_list = []
|
|
|
|
| 468 |
return {
|
| 469 |
'ok': bool(output_text and output_text.strip()),
|
| 470 |
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
|
|
|
| 474 |
'engine': engine_used,
|
| 475 |
'errors': errors_list,
|
| 476 |
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
|
|
|
| 477 |
}
|
| 478 |
except Exception as e:
|
| 479 |
_logger.error("[reason/loop] Error: %s", e)
|
|
|
|
| 605 |
|
| 606 |
|
| 607 |
@router.post('/api/agent/tasks')
|
| 608 |
+
async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 609 |
"""
|
| 610 |
Crea o recupera un task agent.
|
| 611 |
|
|
|
|
| 613 |
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 614 |
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 615 |
"""
|
|
|
|
| 616 |
_prune_agent_tasks()
|
| 617 |
+
task_id = body.taskId or str(uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
|
| 619 |
# Already in memory → return immediately (normal path, includes S358 reconnect)
|
| 620 |
+
if task_id in _agent_tasks:
|
| 621 |
if task_id not in _task_ai_clients:
|
| 622 |
_attach_byok_client(task_id, body.provider_credentials)
|
| 623 |
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
|
|
|
| 648 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 649 |
'persona': body.persona, # P17-F5: expertise persona hint
|
| 650 |
'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
|
|
|
|
| 651 |
'forbid_tools': _tool_policy.forbid_tools,
|
| 652 |
'literal_response': _tool_policy.literal_response,
|
| 653 |
'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
|
|
|
|
| 655 |
# Le credenziali BYOK restano in una mappa runtime separata dai metadata task
|
| 656 |
# e non raggiungono Supabase, checkpoint o buffer SSE.
|
| 657 |
_attach_byok_client(task_id, body.provider_credentials)
|
| 658 |
+
|
| 659 |
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 660 |
if body.session_id:
|
| 661 |
_hctx = await sb_restore_handoff_context(body.session_id)
|
|
|
|
| 666 |
asyncio.create_task(
|
| 667 |
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 668 |
).add_done_callback(_log_task_exc)
|
|
|
|
| 669 |
# S361: gli strumenti speculativi sono consentiti solo quando il messaggio
|
| 670 |
# utente non li vieta esplicitamente. La policy è fail-closed per questo task.
|
| 671 |
if not _tool_policy.forbid_tools:
|
|
|
|
| 816 |
- Task era RUNNING → replay buffer parziale + evento task_interrupted.
|
| 817 |
- Task non trovato → prova sb_restore_task prima di 404.
|
| 818 |
"""
|
|
|
|
| 819 |
# S359: se task_id non è in memoria, prova il restore da Supabase
|
| 820 |
if task_id not in _agent_tasks:
|
| 821 |
restored = await sb_restore_task(task_id)
|
|
|
|
| 849 |
# tool. È il backstop per client SSE che non applicano il fast path UI.
|
| 850 |
_literal_response = task.get('literal_response')
|
| 851 |
if isinstance(_literal_response, str) and _literal_response:
|
| 852 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 853 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 854 |
literal_event = json.dumps(_sanitize_for_json({
|
| 855 |
'event': 'task_done', 'taskId': task_id, 'result': _literal_response,
|
| 856 |
}))
|
|
|
|
| 899 |
sb_events = await sb_get_events(task_id)
|
| 900 |
if sb_events:
|
| 901 |
task_status = task.get('status', 'UNKNOWN')
|
| 902 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 903 |
# Replay buffer from resume point
|
| 904 |
for evt_str in sb_events[_resume_from:]:
|
| 905 |
yield evt_str
|
|
|
|
| 952 |
yield "data: [DONE]\n\n"
|
| 953 |
return
|
| 954 |
# ── Case 3: nuova esecuzione ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 955 |
_prune_loop_registry()
|
| 956 |
reg_entry: dict = {
|
| 957 |
'asyncio_task': None,
|
|
|
|
| 1005 |
# È emesso direttamente nello stream affinché i client SSE non possano bypassarlo.
|
| 1006 |
_literal_response = task.get('literal_response')
|
| 1007 |
if isinstance(_literal_response, str) and _literal_response:
|
| 1008 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1009 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1010 |
_sse('task_done', {'taskId': task_id, 'result': _literal_response})
|
| 1011 |
return
|
| 1012 |
|
|
|
|
| 1046 |
"Il frontend mostrerà automaticamente un pulsante 'Connetti' all'utente."
|
| 1047 |
)
|
| 1048 |
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1049 |
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 1050 |
# Bug: _resume_context era settato su task{} ma mai letto qui → context perduto su resume.
|
| 1051 |
_resume_ctx = task.get('_resume_context', '')
|
|
|
|
| 1137 |
# soltanto quelli con contenuto così il commit atomico può avvenire
|
| 1138 |
# una sola volta dopo un task riuscito.
|
| 1139 |
_vfs_written_paths: set[str] = set()
|
|
|
|
|
|
|
|
|
|
| 1140 |
|
| 1141 |
async def step_cb(step_data: dict) -> None:
|
| 1142 |
step_idx[0] += 1
|
| 1143 |
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 1144 |
# S420: streaming token — emetti direttamente senza passare dal buffer step
|
| 1145 |
if _action == 'text_chunk':
|
| 1146 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
|
|
|
|
|
|
|
|
|
| 1147 |
return
|
| 1148 |
# RECOV-P1: engineering_state event — forward projection to frontend
|
| 1149 |
if _action == 'engineering_state':
|
|
|
|
| 1377 |
except Exception:
|
| 1378 |
pass # S364: skeleton injection is optional
|
| 1379 |
|
|
|
|
| 1380 |
result = await loop.run(
|
| 1381 |
goal=task['goal'],
|
| 1382 |
context=context_str,
|
|
|
|
| 1384 |
on_step=step_cb,
|
| 1385 |
session_id=task.get('session_id', '') or '',
|
| 1386 |
allow_tools=not bool(task.get('forbid_tools', False)),
|
| 1387 |
+
allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
|
| 1388 |
)
|
| 1389 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1390 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1391 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1392 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1393 |
asyncio.create_task(_kernel.publish_event(
|
|
|
|
| 1403 |
# confermato il task. Su errore/cancellazione lo staging rimane
|
| 1404 |
# intenzionalmente non committato.
|
| 1405 |
_sse('vfs_sync_complete', _vfs_commit)
|
| 1406 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1408 |
|
| 1409 |
+
# S363: fire-and-forget quality check when code detected in output
|
|
|
|
| 1410 |
if _run_quality_check:
|
| 1411 |
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1412 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised — evita QG su snippet brevi
|
| 1413 |
+
asyncio.create_task(_run_quality_check(
|
| 1414 |
+
task_id, task['goal'], _qg_result,
|
| 1415 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1416 |
+
)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1417 |
|
| 1418 |
|
| 1419 |
except asyncio.CancelledError:
|
|
|
|
| 1428 |
_sse('task_cancelled', {'taskId': task_id})
|
| 1429 |
|
| 1430 |
except (ImportError, ModuleNotFoundError):
|
| 1431 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1432 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1433 |
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1434 |
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1435 |
f'Goal ricevuto: {task["goal"]}\n\n'
|
|
|
|
| 1462 |
except Exception as _exc:
|
| 1463 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 1464 |
|
| 1465 |
+
reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1466 |
reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
|
| 1467 |
|
| 1468 |
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
|
| 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
|
| 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/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/job_queue.py
CHANGED
|
@@ -355,11 +355,11 @@ async def _hands_consumer_loop() -> None:
|
|
| 355 |
continue
|
| 356 |
|
| 357 |
# Esegui in background — non blocca il loop consumer
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
)
|
| 363 |
|
| 364 |
except Exception as exc:
|
| 365 |
_logger.debug("[jq] consumer tick error: %s", exc)
|
|
@@ -377,15 +377,18 @@ async def start_job_queue_consumer() -> None:
|
|
| 377 |
return
|
| 378 |
|
| 379 |
# Load publisher su tutti gli Space
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
# Consumer per tutti i ruoli worker o legacy 'hands'
|
| 384 |
_IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
|
| 385 |
|
| 386 |
if _IS_WORKER:
|
| 387 |
_logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
|
| 388 |
-
|
| 389 |
else:
|
| 390 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 391 |
|
|
|
|
| 355 |
continue
|
| 356 |
|
| 357 |
# Esegui in background — non blocca il loop consumer
|
| 358 |
+
t = asyncio.create_task(_execute_queued_job(job))
|
| 359 |
+
t.add_done_callback(lambda task: (
|
| 360 |
+
_logger.error("[jq] job task crashed: %s", task.exception(), exc_info=task.exception())
|
| 361 |
+
if not task.cancelled() and task.exception() else None
|
| 362 |
+
))
|
| 363 |
|
| 364 |
except Exception as exc:
|
| 365 |
_logger.debug("[jq] consumer tick error: %s", exc)
|
|
|
|
| 377 |
return
|
| 378 |
|
| 379 |
# Load publisher su tutti gli Space
|
| 380 |
+
def _log_jq_exc(t):
|
| 381 |
+
if not t.cancelled() and t.exception():
|
| 382 |
+
_logger.warning("[job_queue] bg loop raised: %s", t.exception())
|
| 383 |
+
|
| 384 |
+
asyncio.create_task(_load_publisher_loop()).add_done_callback(_log_jq_exc)
|
| 385 |
|
| 386 |
# Consumer per tutti i ruoli worker o legacy 'hands'
|
| 387 |
_IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
|
| 388 |
|
| 389 |
if _IS_WORKER:
|
| 390 |
_logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
|
| 391 |
+
asyncio.create_task(_hands_consumer_loop()).add_done_callback(_log_jq_exc)
|
| 392 |
else:
|
| 393 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 394 |
|
api/large_payload_benchmark.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/large_payload_benchmark.py — Test per payload di grandi dimensioni.
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
import sys
|
| 7 |
+
from .execution_fabric import ExecutionFabric, DispatchRequest, ProviderKind, ProviderSpec, AlwaysOn, ProviderHealth
|
| 8 |
+
|
| 9 |
+
class LargePayloadBenchmark:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.fabric = ExecutionFabric()
|
| 12 |
+
|
| 13 |
+
async def setup(self):
|
| 14 |
+
self.fabric._specs["oracle-heavy"] = ProviderSpec(
|
| 15 |
+
provider_id="oracle-heavy", name="Oracle Heavy", kind=ProviderKind.ORACLE,
|
| 16 |
+
capabilities=["code_analysis"], always_on=AlwaysOn.YES, timeout=120.0
|
| 17 |
+
)
|
| 18 |
+
self.fabric._states["oracle-heavy"] = type('State', (), {"health": ProviderHealth.OK})()
|
| 19 |
+
self.fabric._initialized = True
|
| 20 |
+
|
| 21 |
+
async def _mock_heavy_call(self, spec, req):
|
| 22 |
+
payload_size = sys.getsizeof(str(req.payload)) / 1024
|
| 23 |
+
# Simula tempo di elaborazione proporzionale alla dimensione (1s ogni 100KB)
|
| 24 |
+
processing_time = payload_size / 100.0
|
| 25 |
+
await asyncio.sleep(processing_time)
|
| 26 |
+
return {"status": "ok", "processed_kb": payload_size}
|
| 27 |
+
|
| 28 |
+
async def run_test(self, kb_size):
|
| 29 |
+
print(f"📄 Testando payload da {kb_size} KB...")
|
| 30 |
+
large_text = "A" * (kb_size * 1024)
|
| 31 |
+
req = DispatchRequest(
|
| 32 |
+
capability="code_analysis",
|
| 33 |
+
payload={"code": large_text, "filename": "large_source.py"}
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
t0 = time.time()
|
| 37 |
+
result = await self.fabric.dispatch(req)
|
| 38 |
+
duration = (time.time() - t0) * 1000
|
| 39 |
+
|
| 40 |
+
if result.status == "executed":
|
| 41 |
+
print(f"✅ Successo! Tempo: {duration:.2f}ms (Dimensione reale: {result.response.get('processed_kb'):.2f} KB)")
|
| 42 |
+
else:
|
| 43 |
+
print(f"❌ Fallito: {result.status} - {result.response.get('error')}")
|
| 44 |
+
return duration
|
| 45 |
+
|
| 46 |
+
async def run(self):
|
| 47 |
+
await self.setup()
|
| 48 |
+
self.fabric._call_provider = self._mock_heavy_call
|
| 49 |
+
|
| 50 |
+
results = {}
|
| 51 |
+
# Testiamo diverse dimensioni: 100KB, 250KB, 500KB (limite attuale), 1MB (over limit)
|
| 52 |
+
for size in [100, 250, 500, 1000]:
|
| 53 |
+
duration = await self.run_test(size)
|
| 54 |
+
results[size] = duration
|
| 55 |
+
|
| 56 |
+
print("\n--- ANALISI SCALABILITÀ ---")
|
| 57 |
+
for size, dur in results.items():
|
| 58 |
+
print(f" - {size} KB: {dur:.2f} ms")
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
asyncio.run(LargePayloadBenchmark().run())
|
api/llm_router.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/llm_router.py — Provider Layer LLM con Provider Router (ARCH-I4.4)
|
| 3 |
+
|
| 4 |
+
Interfaccia astratta per provider LLM. Il Brain richiede una capability LLM
|
| 5 |
+
(es. "vision", "embedding", "reasoning") — il router sceglie il provider
|
| 6 |
+
corretto senza che il Brain conosca i provider specifici.
|
| 7 |
+
|
| 8 |
+
Routing table (sovrascrivibile via env LLM_ROUTING_TABLE_JSON):
|
| 9 |
+
chat → groq, cerebras, sambanova, gemini, openrouter
|
| 10 |
+
vision → gemini, openai, openrouter
|
| 11 |
+
embedding → local (HF), openai, openrouter
|
| 12 |
+
code → groq(qwen), openrouter(deepseek), sambanova
|
| 13 |
+
reasoning → gemini, deepseek, openrouter
|
| 14 |
+
fast → groq, cerebras (latenza < 500ms target)
|
| 15 |
+
creative → openrouter, gemini, openai
|
| 16 |
+
math → deepseek, gemini, openrouter
|
| 17 |
+
|
| 18 |
+
ADR: S19 S20 S27 S30 — nessun vendor lock-in, routing intent-based.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import os
|
| 25 |
+
import time
|
| 26 |
+
import uuid
|
| 27 |
+
from enum import Enum
|
| 28 |
+
from typing import Any, Literal
|
| 29 |
+
|
| 30 |
+
from .health_manager import HealthManager
|
| 31 |
+
from .telemetry import _percentile
|
| 32 |
+
from .state import _TIMING_STORE, _REPAIR_STATS # Importa i dati di telemetria da state
|
| 33 |
+
|
| 34 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 35 |
+
from pydantic import BaseModel, Field
|
| 36 |
+
|
| 37 |
+
from .auth_guard import AuthRole, require_role
|
| 38 |
+
|
| 39 |
+
_logger = logging.getLogger("api.llm_router")
|
| 40 |
+
|
| 41 |
+
# ── LLM Capability enum ────────────────────────────────────────────────────────
|
| 42 |
+
|
| 43 |
+
class LLMCapability(str, Enum):
|
| 44 |
+
CHAT = "chat"
|
| 45 |
+
VISION = "vision"
|
| 46 |
+
EMBEDDING = "embedding"
|
| 47 |
+
CODE = "code"
|
| 48 |
+
REASONING = "reasoning"
|
| 49 |
+
FAST = "fast"
|
| 50 |
+
CREATIVE = "creative"
|
| 51 |
+
MATH = "math"
|
| 52 |
+
|
| 53 |
+
# ── Default routing table ──────────────────────────────────────────────────────
|
| 54 |
+
# Ordinati per preferenza (primo = preferito). Aggiornabile via env.
|
| 55 |
+
_DEFAULT_ROUTING: dict[str, list[str]] = {
|
| 56 |
+
LLMCapability.CHAT: ["groq", "cerebras", "sambanova", "gemini", "openrouter"],
|
| 57 |
+
LLMCapability.VISION: ["gemini", "openai", "openrouter"],
|
| 58 |
+
LLMCapability.EMBEDDING: ["local", "openai", "openrouter"],
|
| 59 |
+
LLMCapability.CODE: ["groq", "openrouter", "sambanova", "gemini"],
|
| 60 |
+
LLMCapability.REASONING: ["gemini", "openrouter", "groq", "sambanova"],
|
| 61 |
+
LLMCapability.FAST: ["groq", "cerebras", "sambanova"],
|
| 62 |
+
LLMCapability.CREATIVE: ["openrouter", "gemini", "openai", "groq"],
|
| 63 |
+
LLMCapability.MATH: ["openrouter", "gemini", "groq", "sambanova"],
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# Provider → model mapping (default, sovrascrivibile via env LLM_MODEL_MAP_JSON)
|
| 67 |
+
_DEFAULT_MODEL_MAP: dict[str, dict[str, str]] = {
|
| 68 |
+
"groq": {LLMCapability.CHAT: "llama-3.3-70b-versatile",
|
| 69 |
+
LLMCapability.CODE: "qwen-2.5-coder-32b-instruct",
|
| 70 |
+
LLMCapability.FAST: "llama-3.1-8b-instant"},
|
| 71 |
+
"cerebras": {LLMCapability.CHAT: "llama-3.3-70b", LLMCapability.FAST: "llama-3.1-8b"},
|
| 72 |
+
"sambanova": {LLMCapability.CHAT: "Meta-Llama-3.3-70B-Instruct",
|
| 73 |
+
LLMCapability.REASONING: "DeepSeek-R1-Distill-Llama-70B"},
|
| 74 |
+
"gemini": {LLMCapability.CHAT: "gemini-2.0-flash-exp",
|
| 75 |
+
LLMCapability.VISION: "gemini-2.0-flash-exp",
|
| 76 |
+
LLMCapability.REASONING: "gemini-2.0-flash-thinking-exp",
|
| 77 |
+
LLMCapability.MATH: "gemini-2.0-flash-exp"},
|
| 78 |
+
"openai": {LLMCapability.CHAT: "gpt-4o-mini",
|
| 79 |
+
LLMCapability.VISION: "gpt-4o",
|
| 80 |
+
LLMCapability.EMBEDDING: "text-embedding-3-small"},
|
| 81 |
+
"openrouter": {LLMCapability.CREATIVE: "anthropic/claude-3.5-sonnet",
|
| 82 |
+
LLMCapability.REASONING: "deepseek/deepseek-r1",
|
| 83 |
+
LLMCapability.CODE: "deepseek/deepseek-coder-v2"},
|
| 84 |
+
"local": {LLMCapability.EMBEDDING: "sentence-transformers/all-MiniLM-L6-v2"},
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
def _load_routing() -> dict[str, list[str]]:
|
| 88 |
+
raw = os.getenv("LLM_ROUTING_TABLE_JSON")
|
| 89 |
+
if raw:
|
| 90 |
+
try:
|
| 91 |
+
return json.loads(raw)
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
_logger.warning("[llm-router] LLM_ROUTING_TABLE_JSON parse error: %s", exc)
|
| 94 |
+
return _DEFAULT_ROUTING
|
| 95 |
+
|
| 96 |
+
def _load_model_map() -> dict[str, dict[str, str]]:
|
| 97 |
+
raw = os.getenv("LLM_MODEL_MAP_JSON")
|
| 98 |
+
if raw:
|
| 99 |
+
try:
|
| 100 |
+
return json.loads(raw)
|
| 101 |
+
except Exception as exc:
|
| 102 |
+
_logger.warning("[llm-router] LLM_MODEL_MAP_JSON parse error: %s", exc)
|
| 103 |
+
return _DEFAULT_MODEL_MAP
|
| 104 |
+
|
| 105 |
+
# ── Models ─────────────────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
class LLMRouteRequest(BaseModel):
|
| 108 |
+
capability: LLMCapability = Field(LLMCapability.CHAT)
|
| 109 |
+
model_hint: str | None = Field(None,
|
| 110 |
+
description="Provider specifico richiesto (override router)")
|
| 111 |
+
exclude: list[str] = Field(default_factory=list,
|
| 112 |
+
description="Provider da escludere (es. dopo fallback)")
|
| 113 |
+
require_vision: bool = Field(False)
|
| 114 |
+
require_fast: bool = Field(False,
|
| 115 |
+
description="Target latency < 500ms — forza FAST chain")
|
| 116 |
+
context_tokens: int = Field(0,
|
| 117 |
+
description="Token contesto stimati (per scegliere modello con context window adeguato)")
|
| 118 |
+
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class LLMRouteResult(BaseModel):
|
| 122 |
+
provider: str
|
| 123 |
+
model: str
|
| 124 |
+
capability: str
|
| 125 |
+
fallback_chain: list[str] = Field(default_factory=list)
|
| 126 |
+
resolved_at: float = Field(default_factory=time.time)
|
| 127 |
+
correlation_id: str = ""
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class LLMCallRequest(BaseModel):
|
| 131 |
+
messages: list[dict]
|
| 132 |
+
capability: LLMCapability = LLMCapability.CHAT
|
| 133 |
+
model_hint: str | None = None
|
| 134 |
+
exclude: list[str] = Field(default_factory=list)
|
| 135 |
+
max_tokens: int = 4096
|
| 136 |
+
temperature: float = 0.7
|
| 137 |
+
session_id: str | None = None
|
| 138 |
+
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ── LLMProviderRouter singleton ────────────────────────────────────────────────
|
| 142 |
+
|
| 143 |
+
class LLMProviderRouter:
|
| 144 |
+
"""
|
| 145 |
+
Router LLM intent-based. Il Brain chiede una capability, il router sceglie.
|
| 146 |
+
|
| 147 |
+
Priorità di selezione:
|
| 148 |
+
1. model_hint esplicito (override diretto)
|
| 149 |
+
2. require_fast → FAST chain
|
| 150 |
+
3. require_vision → VISION chain
|
| 151 |
+
4. capability routing table
|
| 152 |
+
5. exclude filter (rimuove provider già falliti)
|
| 153 |
+
|
| 154 |
+
Il router consulta opzionalmente il CapabilityResolver per la salute
|
| 155 |
+
dei provider (se disponibile).
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
def __init__(self) -> None:
|
| 159 |
+
self._routing = _load_routing()
|
| 160 |
+
self._model_map = _load_model_map()
|
| 161 |
+
self._stats: dict[str, dict[str, int]] = {} # provider → {ok, fail}
|
| 162 |
+
self._health_manager = HealthManager()
|
| 163 |
+
self._telemetry_timing = _TIMING_STORE
|
| 164 |
+
self._telemetry_repair = _REPAIR_STATS
|
| 165 |
+
|
| 166 |
+
def route(self, req: LLMRouteRequest) -> LLMRouteResult:
|
| 167 |
+
"""Selezione sincrona del provider. No I/O — solo dict lookup."""
|
| 168 |
+
cap = LLMCapability.FAST if req.require_fast else (
|
| 169 |
+
LLMCapability.VISION if req.require_vision else req.capability)
|
| 170 |
+
|
| 171 |
+
# Override esplicito
|
| 172 |
+
if req.model_hint:
|
| 173 |
+
provider = req.model_hint
|
| 174 |
+
model = self._model_map.get(provider, {}).get(cap.value, "")
|
| 175 |
+
chain = self._routing.get(cap.value, [])
|
| 176 |
+
_logger.debug("[llm-router] hint override provider=%s cap=%s", provider, cap.value)
|
| 177 |
+
return LLMRouteResult(provider=provider, model=model, capability=cap.value,
|
| 178 |
+
fallback_chain=chain, correlation_id=req.correlation_id)
|
| 179 |
+
|
| 180 |
+
chain = self._routing.get(cap.value, self._routing[LLMCapability.CHAT])
|
| 181 |
+
|
| 182 |
+
# Ottieni decisioni sul traffico dal HealthManager
|
| 183 |
+
traffic_decisions = self._health_manager.get_traffic_decisions()
|
| 184 |
+
blacklisted_providers = set()
|
| 185 |
+
for decision in traffic_decisions:
|
| 186 |
+
blacklisted_providers.update(decision.blacklisted)
|
| 187 |
+
|
| 188 |
+
# Rimuovi esclusi e provider in blacklist
|
| 189 |
+
candidates = [p for p in chain if p not in req.exclude and p not in blacklisted_providers]
|
| 190 |
+
|
| 191 |
+
if not candidates:
|
| 192 |
+
candidates = chain # tutti falliti → riprova dall\'inizio
|
| 193 |
+
|
| 194 |
+
# Ordina i candidati in base a benchmark, health e telemetria
|
| 195 |
+
candidates = self._sort_candidates_by_metrics(candidates, cap, req.require_fast)
|
| 196 |
+
|
| 197 |
+
provider = candidates[0]
|
| 198 |
+
model = self._model_map.get(provider, {}).get(cap.value, "")
|
| 199 |
+
fallback = candidates[1:]
|
| 200 |
+
|
| 201 |
+
_logger.debug("[llm-router] routed cap=%s → provider=%s model=%s fallbacks=%d",
|
| 202 |
+
cap.value, provider, model or "?", len(fallback))
|
| 203 |
+
return LLMRouteResult(provider=provider, model=model, capability=cap.value,
|
| 204 |
+
fallback_chain=fallback, correlation_id=req.correlation_id)
|
| 205 |
+
|
| 206 |
+
async def call(self, req: LLMCallRequest) -> dict:
|
| 207 |
+
"""
|
| 208 |
+
Chiama il Kernel.chat() con il provider scelto dal router.
|
| 209 |
+
Implementa fallback automatico lungo la chain se il provider fallisce.
|
| 210 |
+
"""
|
| 211 |
+
route_req = LLMRouteRequest(
|
| 212 |
+
capability = req.capability,
|
| 213 |
+
model_hint = req.model_hint,
|
| 214 |
+
exclude = list(req.exclude),
|
| 215 |
+
correlation_id = req.correlation_id,
|
| 216 |
+
)
|
| 217 |
+
routed = self.route(route_req)
|
| 218 |
+
chain = [routed.provider] + routed.fallback_chain
|
| 219 |
+
last_error = ""
|
| 220 |
+
|
| 221 |
+
for provider in chain:
|
| 222 |
+
try:
|
| 223 |
+
from .kernel import kernel as _kernel
|
| 224 |
+
result = await _kernel.chat(
|
| 225 |
+
messages = req.messages,
|
| 226 |
+
model_hint = routed.model or provider,
|
| 227 |
+
session_id = req.session_id,
|
| 228 |
+
correlation_id = req.correlation_id,
|
| 229 |
+
max_tokens = req.max_tokens,
|
| 230 |
+
temperature = req.temperature,
|
| 231 |
+
)
|
| 232 |
+
self._record_ok(provider)
|
| 233 |
+
return {
|
| 234 |
+
"provider": provider,
|
| 235 |
+
"model": getattr(result, "model", ""),
|
| 236 |
+
"content": getattr(result, "content", ""),
|
| 237 |
+
"capability": req.capability.value,
|
| 238 |
+
"cached": getattr(result, "cached", False),
|
| 239 |
+
}
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
last_error = str(exc)
|
| 242 |
+
self._record_fail(provider)
|
| 243 |
+
_logger.warning("[llm-router] provider=%s failed: %s — trying next", provider, exc)
|
| 244 |
+
|
| 245 |
+
raise RuntimeError(f"Tutti i provider LLM falliti per cap={req.capability.value}. Last: {last_error}")
|
| 246 |
+
|
| 247 |
+
def _record_ok(self, provider: str) -> None:
|
| 248 |
+
s = self._stats.setdefault(provider, {"ok": 0, "fail": 0})
|
| 249 |
+
s["ok"] += 1
|
| 250 |
+
|
| 251 |
+
def _record_fail(self, provider: str) -> None:
|
| 252 |
+
s = self._stats.setdefault(provider, {"ok": 0, "fail": 0})
|
| 253 |
+
s["fail"] += 1
|
| 254 |
+
|
| 255 |
+
def _sort_candidates_by_metrics(self, candidates: list[str], cap: LLMCapability, require_fast: bool) -> list[str]:
|
| 256 |
+
"""Ordina i candidati in base a metriche di salute, telemetria e benchmark."""
|
| 257 |
+
try:
|
| 258 |
+
from api.state import _TIMING_STORE
|
| 259 |
+
except ImportError:
|
| 260 |
+
return candidates # Fallback se state non è disponibile
|
| 261 |
+
|
| 262 |
+
def get_score(provider: str) -> float:
|
| 263 |
+
score = 0.0
|
| 264 |
+
|
| 265 |
+
# 0. INT-1: score da benchmark/heartbeat (ciclo chiuso)
|
| 266 |
+
try:
|
| 267 |
+
from api.state import _PROVIDER_SCORES
|
| 268 |
+
bench = _PROVIDER_SCORES.get(provider)
|
| 269 |
+
if bench is not None:
|
| 270 |
+
if bench == 0.0:
|
| 271 |
+
score -= 200.0 # provider down → praticamente escluso
|
| 272 |
+
elif bench == 20.0:
|
| 273 |
+
score -= 30.0 # quota esaurita → penalizzato ma non escluso
|
| 274 |
+
else:
|
| 275 |
+
score += (bench - 50.0) * 0.6 # 50→0, 100→+30
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
# 1. Penalizza provider con molti fallimenti recenti
|
| 280 |
+
stats = self._stats.get(provider, {"ok": 0, "fail": 0})
|
| 281 |
+
total_calls = stats["ok"] + stats["fail"]
|
| 282 |
+
if total_calls > 0:
|
| 283 |
+
fail_rate = stats["fail"] / total_calls
|
| 284 |
+
score -= fail_rate * 100 # Penalità forte per alto fail rate
|
| 285 |
+
|
| 286 |
+
# 2. Valuta la latenza (telemetria)
|
| 287 |
+
timing_key = f"llm_call_{provider}"
|
| 288 |
+
if timing_key in _TIMING_STORE:
|
| 289 |
+
samples = list(_TIMING_STORE[timing_key])
|
| 290 |
+
if samples:
|
| 291 |
+
p50_latency = _percentile(samples, 50)
|
| 292 |
+
if require_fast:
|
| 293 |
+
# Penalizza fortemente la latenza se è richiesto fast
|
| 294 |
+
score -= p50_latency / 100
|
| 295 |
+
else:
|
| 296 |
+
score -= p50_latency / 500
|
| 297 |
+
|
| 298 |
+
# 3. Considera le decisioni di traffico (preferenze)
|
| 299 |
+
for decision in self._health_manager.get_traffic_decisions():
|
| 300 |
+
if provider in decision.preferred:
|
| 301 |
+
score += 50 # Bonus per provider preferiti
|
| 302 |
+
|
| 303 |
+
return score
|
| 304 |
+
|
| 305 |
+
# Ordina in modo decrescente (score più alto = migliore)
|
| 306 |
+
return sorted(candidates, key=get_score, reverse=True)
|
| 307 |
+
|
| 308 |
+
def status(self) -> dict:
|
| 309 |
+
return {
|
| 310 |
+
"routing_table": self._routing,
|
| 311 |
+
"provider_stats": self._stats,
|
| 312 |
+
"capabilities": list(self._routing.keys()),
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
def reload(self) -> None:
|
| 316 |
+
"""Ricarica routing table e model map da env (hot-reload)."""
|
| 317 |
+
self._routing = _load_routing()
|
| 318 |
+
self._model_map = _load_model_map()
|
| 319 |
+
_logger.info("[llm-router] routing table reloaded")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ── Singleton ────────────────────────────────────────────────────────────────────
|
| 323 |
+
llm_router = LLMProviderRouter()
|
| 324 |
+
|
| 325 |
+
# ── HTTP Router ──────────────────────────────────────────────────────────────────
|
| 326 |
+
router = APIRouter(
|
| 327 |
+
prefix="/api/llm",
|
| 328 |
+
tags=["llm-router"],
|
| 329 |
+
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.post("/route", summary="Risolve capability LLM → provider + model senza chiamarlo")
|
| 334 |
+
async def route_route(req: LLMRouteRequest) -> LLMRouteResult:
|
| 335 |
+
return llm_router.route(req)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@router.post("/call", summary="Chiama LLM con routing automatico e fallback")
|
| 339 |
+
async def route_call(req: LLMCallRequest) -> dict:
|
| 340 |
+
try:
|
| 341 |
+
return await llm_router.call(req)
|
| 342 |
+
except RuntimeError as exc:
|
| 343 |
+
raise HTTPException(503, str(exc))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
@router.get("/status", summary="Routing table attiva e statistiche provider")
|
| 347 |
+
async def route_status() -> dict:
|
| 348 |
+
return llm_router.status()
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@router.post("/reload", summary="Hot-reload routing table da env")
|
| 352 |
+
async def route_reload() -> dict:
|
| 353 |
+
llm_router.reload()
|
| 354 |
+
return {"reloaded": True}
|
api/load_balancer.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/load_balancer.py — Dynamic Load Balancer (S951)
|
| 3 |
+
Analizza il carico dei nodi (A, B, C, D) e decide a chi assegnare il task.
|
| 4 |
+
Supporta il Cross-Node Offloading: se il nodo target è saturo, delega al nodo più scarico.
|
| 5 |
+
M1-FIX: timeout Redis configurabile via env REDIS_TIMEOUT_S (default 5.0s, allineato a job_queue e state_sync).
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from typing import Optional, Dict, Any, List
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
_logger = logging.getLogger("agente_ai.balancer")
|
| 15 |
+
|
| 16 |
+
# M1-FIX: timeout Redis allineato agli altri moduli (job_queue: 5.0s, state_sync: 5.0s)
|
| 17 |
+
_REDIS_TIMEOUT = float(os.getenv("REDIS_TIMEOUT_S", "5.0"))
|
| 18 |
+
|
| 19 |
+
# Ruoli del cluster
|
| 20 |
+
ROLES = ["brain", "hands", "memory", "audit"]
|
| 21 |
+
|
| 22 |
+
class LoadBalancer:
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.redis_url = os.getenv("UPSTASH_REDIS_REST_URL")
|
| 25 |
+
self.redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN")
|
| 26 |
+
self.internal_token = os.getenv("INTERNAL_TOKEN", "")
|
| 27 |
+
|
| 28 |
+
async def _rcmd(self, cmd: List[Any]) -> Optional[Dict[str, Any]]:
|
| 29 |
+
if not self.redis_url or not self.redis_token: return None
|
| 30 |
+
try:
|
| 31 |
+
async with httpx.AsyncClient() as client:
|
| 32 |
+
r = await client.post(
|
| 33 |
+
self.redis_url,
|
| 34 |
+
headers={"Authorization": f"Bearer {self.redis_token}"},
|
| 35 |
+
json=cmd,
|
| 36 |
+
timeout=_REDIS_TIMEOUT, # M1-FIX: era hardcodato 2.0
|
| 37 |
+
)
|
| 38 |
+
return r.json()
|
| 39 |
+
except Exception as e:
|
| 40 |
+
_logger.error("[S951] Redis error: %s", e)
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
async def get_node_load(self, role: str) -> Optional[Dict[str, Any]]:
|
| 44 |
+
"""Recupera le metriche di carico per un nodo specifico."""
|
| 45 |
+
res = await self._rcmd(["GET", f"jq:load:{role}"])
|
| 46 |
+
if res and res.get("result"):
|
| 47 |
+
try:
|
| 48 |
+
return json.loads(res["result"])
|
| 49 |
+
except (json.JSONDecodeError, TypeError):
|
| 50 |
+
return None
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
async def get_best_node(self, preferred_role: str = "hands") -> str:
|
| 54 |
+
"""
|
| 55 |
+
Determina il miglior nodo per un task.
|
| 56 |
+
Se il preferred_role è sotto soglia di carico, lo mantiene.
|
| 57 |
+
Altrimenti cerca il nodo con meno active_tasks.
|
| 58 |
+
"""
|
| 59 |
+
pref_load = await self.get_node_load(preferred_role)
|
| 60 |
+
if pref_load:
|
| 61 |
+
active = pref_load.get("active_tasks", 0)
|
| 62 |
+
if active < 3:
|
| 63 |
+
return preferred_role
|
| 64 |
+
|
| 65 |
+
_logger.info("[S951] Nodo %s saturo o offline. Ricerca miglior nodo alternativo...", preferred_role)
|
| 66 |
+
|
| 67 |
+
best_role = preferred_role
|
| 68 |
+
min_load = 999
|
| 69 |
+
|
| 70 |
+
for role in ROLES:
|
| 71 |
+
load = await self.get_node_load(role)
|
| 72 |
+
if load:
|
| 73 |
+
active = load.get("active_tasks", 0)
|
| 74 |
+
ts = load.get("ts", 0)
|
| 75 |
+
if (time.time() * 1000) - ts < 90000:
|
| 76 |
+
if active < min_load:
|
| 77 |
+
min_load = active
|
| 78 |
+
best_role = role
|
| 79 |
+
|
| 80 |
+
if best_role != preferred_role:
|
| 81 |
+
_logger.info("[S951] Offloading dinamico: %s -> %s (carico: %d)", preferred_role, best_role, min_load)
|
| 82 |
+
|
| 83 |
+
return best_role
|
| 84 |
+
|
| 85 |
+
async def get_cluster_status(self) -> Dict[str, Any]:
|
| 86 |
+
"""Ritorna lo stato di salute di tutto il cluster."""
|
| 87 |
+
status = {}
|
| 88 |
+
for role in ROLES:
|
| 89 |
+
load = await self.get_node_load(role)
|
| 90 |
+
if load:
|
| 91 |
+
ts = load.get("ts", 0)
|
| 92 |
+
is_online = (time.time() * 1000) - ts < 90000
|
| 93 |
+
status[role] = {
|
| 94 |
+
"online": is_online,
|
| 95 |
+
"active_tasks": load.get("active_tasks", 0),
|
| 96 |
+
"mem_mb": load.get("mem", 0),
|
| 97 |
+
"last_seen": ts,
|
| 98 |
+
}
|
| 99 |
+
else:
|
| 100 |
+
status[role] = {"online": False}
|
| 101 |
+
return status
|
| 102 |
+
|
| 103 |
+
# Singleton instance
|
| 104 |
+
balancer = LoadBalancer()
|
api/oci_gateway_manager.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
# Configurazione OCI API Gateway
|
| 7 |
+
OCI_GATEWAY_ID = "ocid1.apigatewayapi.oc1.eu-milan-1.amaaaaaavx6czpaabfdwezue3fo7jpfsrxqwlo2nt7pwhs7o2ezvqztuaffa"
|
| 8 |
+
ORACLE_NODE_E_IP = os.getenv("ORACLE_NODE_E_IP", "80.225.89.217")
|
| 9 |
+
ORACLE_NODE_E_PORT = int(os.getenv("ORACLE_NODE_E_PORT", "8080"))
|
| 10 |
+
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("agente_ai.oci_gateway")
|
| 13 |
+
|
| 14 |
+
class OCIGatewayManager:
|
| 15 |
+
"""
|
| 16 |
+
Gestore per Oracle Cloud API Gateway.
|
| 17 |
+
Funge da ponte tra il cluster AI e il Nodo E (Milano) per bypassare
|
| 18 |
+
il Network Firewall (Palo Alto) tramite endpoint HTTPS gestiti.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.gateway_id = OCI_GATEWAY_ID
|
| 23 |
+
self.base_url = f"https://{self.gateway_id}.apigateway.eu-milan-1.oci.customer-oci.com"
|
| 24 |
+
self.token = INTERNAL_TOKEN
|
| 25 |
+
|
| 26 |
+
async def get_gateway_status(self) -> Dict[str, Any]:
|
| 27 |
+
"""Verifica lo stato del gateway e la connettività verso il Nodo E."""
|
| 28 |
+
try:
|
| 29 |
+
async with httpx.AsyncClient() as client:
|
| 30 |
+
# Nota: L'URL reale dipenderà dal deployment specifico su OCI
|
| 31 |
+
# Questo è un placeholder per la logica di health check via gateway
|
| 32 |
+
response = await client.get(
|
| 33 |
+
f"{self.base_url}/health",
|
| 34 |
+
headers={"Authorization": f"Bearer {self.token}"},
|
| 35 |
+
timeout=10.0
|
| 36 |
+
)
|
| 37 |
+
return {
|
| 38 |
+
"ok": response.status_code == 200,
|
| 39 |
+
"gateway_id": self.gateway_id,
|
| 40 |
+
"status_code": response.status_code,
|
| 41 |
+
"provider": "Oracle Cloud API Gateway"
|
| 42 |
+
}
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Errore health check gateway: {e}")
|
| 45 |
+
return {"ok": False, "error": str(e)}
|
| 46 |
+
|
| 47 |
+
async def proxy_to_node_e(self, path: str, method: str = "GET", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 48 |
+
"""Invia una richiesta al Nodo E passando attraverso l'API Gateway."""
|
| 49 |
+
url = f"{self.base_url}{path}"
|
| 50 |
+
try:
|
| 51 |
+
async with httpx.AsyncClient() as client:
|
| 52 |
+
if method == "POST":
|
| 53 |
+
response = await client.post(url, json=body, headers={"Authorization": f"Bearer {self.token}"})
|
| 54 |
+
else:
|
| 55 |
+
response = await client.get(url, headers={"Authorization": f"Bearer {self.token}"})
|
| 56 |
+
|
| 57 |
+
return response.json() if response.status_code == 200 else {"ok": False, "status": response.status_code}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Errore proxy gateway: {e}")
|
| 60 |
+
return {"ok": False, "error": str(e)}
|
| 61 |
+
|
| 62 |
+
# Istanza globale
|
| 63 |
+
oci_gateway = OCIGatewayManager()
|