Spaces:
Running
Running
sync: 125 file da Baida98/AI@8374b07e (2026-07-10 21:36 UTC)
#3
by Baida-A - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .env.example +8 -15
- REBUILD_TRIGGER +2 -0
- agents/audit_semantic_l2.py +303 -0
- agents/context_manager.py +0 -23
- agents/engineering_state.py +0 -255
- agents/executor.py +1 -17
- agents/fallback_healer.py +59 -0
- agents/fallback_utils.py +26 -0
- agents/file_conversion.py +0 -163
- agents/goal_verifier.py +5 -22
- agents/grid_rag.py +124 -0
- agents/html_fast_path.py +0 -60
- agents/planner.py +1 -12
- agents/reflection_sidecar.py +0 -5
- agents/strategic_healer.py +0 -11
- agents/unified_loop.py +39 -400
- agents/unified_loop_delegate.py +192 -0
- agents/unified_loop_fallback.py +0 -0
- agents/unified_loop_helpers.py +1 -13
- agents/unified_loop_llm.py +17 -88
- agents/unified_loop_prompts.py +91 -394
- agents/unified_loop_routing.py +82 -0
- agents/unified_loop_tools.py +545 -339
- agents/unified_loop_types.py +0 -52
- agents/unified_loop_vfs.py +156 -0
- agents/watchdog.py +67 -0
- agents/workflow_engine.py +0 -112
- api/TELEGRAM_MODULES.md +172 -0
- api/_agent_helpers.py +124 -0
- api/admin_state.py +0 -75
- api/ads_manager.py +50 -0
- api/agent.py +33 -369
- api/agent_checkpoint.py +0 -131
- api/agent_checkpoint_routes.py +279 -0
- api/agent_loop_routes.py +411 -0
- api/agent_memory.py +38 -46
- api/agent_task_routes.py +800 -0
- api/auth_guard.py +10 -136
- api/benchmark.py +1 -1
- api/benchmark_handler.py +43 -92
- api/browser.py +13 -59
- api/cache_endpoints.py +164 -0
- api/cache_manager.py +400 -0
- api/conversations.py +2 -2
- api/database.py +1 -2
- api/database_router.py +313 -0
- api/event_bus.py +0 -235
- api/event_store.py +0 -204
- api/exec.py +9 -10
- api/files.py +3 -8
.env.example
CHANGED
|
@@ -12,50 +12,44 @@ VAULT_KEY= # AES-256 Hex
|
|
| 12 |
NOTIFY_TOKEN= # Notifiche Interne
|
| 13 |
|
| 14 |
# ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
|
| 15 |
-
BACKEND_URL=https://
|
| 16 |
RAILWAY_TOKEN=
|
| 17 |
-
RAILWAY_PROJECT_ID=
|
| 18 |
SUPABASE_URL=
|
| 19 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 20 |
GITHUB_TOKEN=
|
| 21 |
-
# Hugging Face Router: endpoint OpenAI-compatible per inferenza.
|
| 22 |
HF_TOKEN=
|
| 23 |
-
HF_MODEL=Qwen/Qwen2.5-Coder-32B-Instruct
|
| 24 |
-
# Pool opzionale: [{"profile":"primary","api_key":"...","model":"openai/gpt-oss-120b:fastest"}]
|
| 25 |
-
HF_ROUTER_PROFILES_JSON=
|
| 26 |
|
| 27 |
# ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
|
| 28 |
RAILWAY_TOKEN_B=
|
| 29 |
-
RAILWAY_PROJECT_ID_B=
|
| 30 |
SUPABASE_URL_B=
|
| 31 |
SUPABASE_SERVICE_ROLE_KEY_B=
|
| 32 |
GITHUB_TOKEN_B=
|
| 33 |
|
| 34 |
# ── 4. Quadrante C (DAEMON - Telegram) ───────────────────────
|
| 35 |
RAILWAY_TOKEN_C=
|
| 36 |
-
RAILWAY_PROJECT_ID_C=
|
| 37 |
SUPABASE_URL_C=
|
| 38 |
SUPABASE_SERVICE_ROLE_KEY_C=
|
| 39 |
|
| 40 |
# ── 5. Quadrante D (AUDIT - Compliance) ──────────────────────
|
| 41 |
RAILWAY_TOKEN_D=
|
| 42 |
-
RAILWAY_PROJECT_ID_D=
|
| 43 |
SUPABASE_URL_D=
|
| 44 |
SUPABASE_SERVICE_ROLE_KEY_D=
|
| 45 |
|
| 46 |
# ── 6. Quadrante E (BOT-TG - Dedicated) ──────────────────────
|
| 47 |
RAILWAY_TOKEN_E=
|
| 48 |
-
RAILWAY_PROJECT_ID_E=
|
| 49 |
|
| 50 |
# ── 7. LLM Unified Providers (A-E) ───────────────────────────
|
| 51 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 52 |
GROQ_API_KEY=
|
| 53 |
OPENROUTER_API_KEY=
|
| 54 |
-
# Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
|
| 55 |
-
# Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
|
| 56 |
-
OPENROUTER_PROFILES_JSON=
|
| 57 |
GEMINI_API_KEY=
|
| 58 |
NVIDIA_API_KEY=
|
|
|
|
| 59 |
|
| 60 |
# ── 8. Sandboxes & Tools ─────────────────────────────────────
|
| 61 |
E2B_API_KEY=
|
|
@@ -68,5 +62,4 @@ UPSTASH_REDIS_REST_TOKEN=
|
|
| 68 |
# ── 9. Feature Flags ─────────────────────────────────────────
|
| 69 |
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 70 |
UNIFIED_LOOP_MAX_STEPS=8
|
| 71 |
-
LLM_MODEL=
|
| 72 |
-
|
|
|
|
| 12 |
NOTIFY_TOKEN= # Notifiche Interne
|
| 13 |
|
| 14 |
# ── 2. Quadrante A (BRAIN - Primary) ─────────────────────────
|
| 15 |
+
BACKEND_URL=https://arjanit98-terminal.hf.space
|
| 16 |
RAILWAY_TOKEN=
|
| 17 |
+
RAILWAY_PROJECT_ID=a9ce05f8-aeca-46c1-837b-8c2ca7a11081
|
| 18 |
SUPABASE_URL=
|
| 19 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 20 |
GITHUB_TOKEN=
|
|
|
|
| 21 |
HF_TOKEN=
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# ── 3. Quadrante B (HANDS - Collab/Failover) ─────────────────
|
| 24 |
RAILWAY_TOKEN_B=
|
| 25 |
+
RAILWAY_PROJECT_ID_B=51c7f764-a8ca-4dff-b3cd-d91116e09d8a
|
| 26 |
SUPABASE_URL_B=
|
| 27 |
SUPABASE_SERVICE_ROLE_KEY_B=
|
| 28 |
GITHUB_TOKEN_B=
|
| 29 |
|
| 30 |
# ── 4. Quadrante C (DAEMON - Telegram) ───────────────────────
|
| 31 |
RAILWAY_TOKEN_C=
|
| 32 |
+
RAILWAY_PROJECT_ID_C=d8843346-7c6a-48f1-adb3-0fd4a650b3e5
|
| 33 |
SUPABASE_URL_C=
|
| 34 |
SUPABASE_SERVICE_ROLE_KEY_C=
|
| 35 |
|
| 36 |
# ── 5. Quadrante D (AUDIT - Compliance) ──────────────────────
|
| 37 |
RAILWAY_TOKEN_D=
|
| 38 |
+
RAILWAY_PROJECT_ID_D=898b1c3e-6e64-4c5a-9609-afdd0dce84f8
|
| 39 |
SUPABASE_URL_D=
|
| 40 |
SUPABASE_SERVICE_ROLE_KEY_D=
|
| 41 |
|
| 42 |
# ── 6. Quadrante E (BOT-TG - Dedicated) ──────────────────────
|
| 43 |
RAILWAY_TOKEN_E=
|
| 44 |
+
RAILWAY_PROJECT_ID_E=0834551e-51c8-4eff-aa4f-65c0b04ea933
|
| 45 |
|
| 46 |
# ── 7. LLM Unified Providers (A-E) ───────────────────────────
|
| 47 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 48 |
GROQ_API_KEY=
|
| 49 |
OPENROUTER_API_KEY=
|
|
|
|
|
|
|
|
|
|
| 50 |
GEMINI_API_KEY=
|
| 51 |
NVIDIA_API_KEY=
|
| 52 |
+
OPENAI_API_KEY=
|
| 53 |
|
| 54 |
# ── 8. Sandboxes & Tools ─────────────────────────────────────
|
| 55 |
E2B_API_KEY=
|
|
|
|
| 62 |
# ── 9. Feature Flags ─────────────────────────────────────────
|
| 63 |
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 64 |
UNIFIED_LOOP_MAX_STEPS=8
|
| 65 |
+
LLM_MODEL=deepseek/deepseek-r1:free
|
|
|
REBUILD_TRIGGER
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Rebuild trigger — 2026-07-03T13:39:25.801Z
|
| 2 |
+
Fix: rootDirectory corretto da /backend a backend (Railway backend service)
|
agents/audit_semantic_l2.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audit_semantic_l2.py — S303: Audit Semantico L2 (Critico Senior) su Nodo D.
|
| 3 |
+
|
| 4 |
+
L1 (goal_verifier.py) valida se la risposta *aderisce* al goal.
|
| 5 |
+
L2 (questo file) verifica la *coerenza logica interna* dell'output:
|
| 6 |
+
- Nessuna contraddizione auto-referenziale
|
| 7 |
+
- Claim verificabili non inventati (anti-hallucination guard)
|
| 8 |
+
- Completezza rispetto ai sotto-obiettivi esplicitati nel goal
|
| 9 |
+
- Stato outcome: PASS / FAIL / UNKNOWN — mai forzare PASS
|
| 10 |
+
|
| 11 |
+
Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py.
|
| 12 |
+
Se L1 = FAIL → L2 non viene invocato (risparmio token).
|
| 13 |
+
Se L1 = PASS o UNKNOWN → L2 aggiunge una seconda garanzia semantica.
|
| 14 |
+
|
| 15 |
+
Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from enum import Enum
|
| 25 |
+
from typing import Any, Optional
|
| 26 |
+
|
| 27 |
+
_logger = logging.getLogger("agents.audit_l2")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AuditStatus(str, Enum):
|
| 31 |
+
PASS = "PASS"
|
| 32 |
+
FAIL = "FAIL"
|
| 33 |
+
UNKNOWN = "UNKNOWN"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class AuditL2Result:
|
| 38 |
+
status: AuditStatus
|
| 39 |
+
confidence: float = 0.0 # 0.0 – 1.0
|
| 40 |
+
issues: list[str] = field(default_factory=list)
|
| 41 |
+
repair_hint: str = ""
|
| 42 |
+
engine: str = "heuristic" # "heuristic" | "llm"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Pattern anti-hallucination ────────────────────────────────────────────────
|
| 46 |
+
# Claim di azioni che l'agente NON può eseguire da solo senza tool confirmation.
|
| 47 |
+
# Copre IT / EN / ES / FR — le 4 lingue attive nel cluster.
|
| 48 |
+
_HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
|
| 49 |
+
# Deploy / publish
|
| 50 |
+
(re.compile(
|
| 51 |
+
r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|"
|
| 52 |
+
r"ho caricato|ho aggiornato il server|ho rilasciato|"
|
| 53 |
+
r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|"
|
| 54 |
+
r"he desplegado|he publicado|he enviado|he subido|he lanzado|"
|
| 55 |
+
r"j'ai déployé|j'ai publié|j'ai envoyé|j'ai poussé|j'ai mis en ligne)\b",
|
| 56 |
+
re.I),
|
| 57 |
+
"claim di deploy/push/send non verificato da tool"),
|
| 58 |
+
|
| 59 |
+
# Stato esterno live
|
| 60 |
+
(re.compile(
|
| 61 |
+
r"\b(il sito è live|the site is live|ora funziona|it now works|"
|
| 62 |
+
r"è online|is online|è andato live|went live|"
|
| 63 |
+
r"the app is running|l'app è in esecuzione|"
|
| 64 |
+
r"el sitio está en vivo|el sistema funciona ahora|"
|
| 65 |
+
r"le site est en ligne|l'application fonctionne maintenant)\b",
|
| 66 |
+
re.I),
|
| 67 |
+
"claim di stato esterno non verificabile"),
|
| 68 |
+
|
| 69 |
+
# Assunzioni sull'utente
|
| 70 |
+
(re.compile(
|
| 71 |
+
r"\b(l'utente ha|the user has|hai già|you already|"
|
| 72 |
+
r"your database (is|has)|il tuo database (è|ha)|"
|
| 73 |
+
r"el usuario ya|vous avez déjà)\b",
|
| 74 |
+
re.I),
|
| 75 |
+
"assunzione su stato dell'utente non verificabile"),
|
| 76 |
+
|
| 77 |
+
# Test / CI passati senza prova
|
| 78 |
+
(re.compile(
|
| 79 |
+
r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|"
|
| 80 |
+
r"la CI è verde|CI is green|build successful|build riuscita|"
|
| 81 |
+
r"todos los tests pasan|tous les tests passent)\b",
|
| 82 |
+
re.I),
|
| 83 |
+
"claim di test/CI passati senza esecuzione verificata"),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
# ── Pattern contraddizione interna ────────────────────────────────────────────
|
| 87 |
+
_CONTRADICTION_PAIRS: list[tuple[str, str]] = [
|
| 88 |
+
("errore", "nessun errore"),
|
| 89 |
+
("error", "no error"),
|
| 90 |
+
("fallito", "completato con successo"),
|
| 91 |
+
("failed", "completed successfully"),
|
| 92 |
+
("non trovato", "trovato correttamente"),
|
| 93 |
+
("not found", "found correctly"),
|
| 94 |
+
("timeout", "risposta ricevuta"),
|
| 95 |
+
("timeout", "response received"),
|
| 96 |
+
("impossibile", "funziona"),
|
| 97 |
+
("impossible", "works"),
|
| 98 |
+
("non funziona", "funziona correttamente"),
|
| 99 |
+
("doesn't work", "works correctly"),
|
| 100 |
+
("eccezione", "nessuna eccezione"),
|
| 101 |
+
("exception", "no exception"),
|
| 102 |
+
("crash", "stabile"),
|
| 103 |
+
("crash", "stable"),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _check_hallucinations(text: str) -> list[str]:
|
| 108 |
+
issues = []
|
| 109 |
+
for pattern, label in _HALLUCINATION_PATTERNS:
|
| 110 |
+
if pattern.search(text):
|
| 111 |
+
issues.append(f"Possibile hallucination: {label}")
|
| 112 |
+
return issues
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _check_contradictions(text: str) -> list[str]:
|
| 116 |
+
issues = []
|
| 117 |
+
text_lower = text.lower()
|
| 118 |
+
for a, b in _CONTRADICTION_PAIRS:
|
| 119 |
+
if a in text_lower and b in text_lower:
|
| 120 |
+
issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti")
|
| 121 |
+
return issues
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _check_completeness(goal: str, answer: str) -> list[str]:
|
| 125 |
+
"""
|
| 126 |
+
Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate
|
| 127 |
+
o bullet points) siano almeno menzionati nella risposta.
|
| 128 |
+
"""
|
| 129 |
+
issues = []
|
| 130 |
+
sub_goals = re.findall(
|
| 131 |
+
r"(?:^|\n)\s*(?:\d+\.|[-*•])\s+(.+?)(?:\n|$)", goal
|
| 132 |
+
)
|
| 133 |
+
if not sub_goals:
|
| 134 |
+
return []
|
| 135 |
+
answer_lower = answer.lower()
|
| 136 |
+
missing = []
|
| 137 |
+
for sg in sub_goals[:8]: # max 8 sotto-obiettivi
|
| 138 |
+
words = [w for w in sg.lower().split() if len(w) > 4][:4]
|
| 139 |
+
if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2):
|
| 140 |
+
missing.append(sg.strip()[:60])
|
| 141 |
+
if missing:
|
| 142 |
+
issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}")
|
| 143 |
+
return issues
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _heuristic_audit(goal: str, answer: str) -> AuditL2Result:
|
| 147 |
+
"""Audit euristico: pattern matching su testo, senza LLM."""
|
| 148 |
+
issues: list[str] = []
|
| 149 |
+
issues.extend(_check_hallucinations(answer))
|
| 150 |
+
issues.extend(_check_contradictions(answer))
|
| 151 |
+
issues.extend(_check_completeness(goal, answer))
|
| 152 |
+
|
| 153 |
+
if not issues:
|
| 154 |
+
return AuditL2Result(
|
| 155 |
+
status=AuditStatus.PASS,
|
| 156 |
+
confidence=0.75,
|
| 157 |
+
engine="heuristic",
|
| 158 |
+
)
|
| 159 |
+
# Gravi (hallucination o contraddizione) → FAIL; solo completeness → UNKNOWN
|
| 160 |
+
has_severe = any(
|
| 161 |
+
"hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower()
|
| 162 |
+
for i in issues
|
| 163 |
+
)
|
| 164 |
+
return AuditL2Result(
|
| 165 |
+
status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN,
|
| 166 |
+
confidence=0.82 if has_severe else 0.55,
|
| 167 |
+
issues=issues,
|
| 168 |
+
repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.",
|
| 169 |
+
engine="heuristic",
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
_AUDIT_SYSTEM = (
|
| 174 |
+
"Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. "
|
| 175 |
+
"Rispondi SOLO con JSON valido, senza markdown. Formato:\n"
|
| 176 |
+
'{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,'
|
| 177 |
+
'"issues":["..."],"repair_hint":"..."}\n\n'
|
| 178 |
+
"Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). "
|
| 179 |
+
"UNKNOWN per incertezze moderate. PASS se la risposta è coerente. "
|
| 180 |
+
"Mai forzare PASS se ci sono dubbi fondati."
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _build_audit_prompt(goal: str, answer: str) -> str:
|
| 185 |
+
# Tronca intelligentemente: preserva inizio e fine dell'answer
|
| 186 |
+
max_ans = 1400
|
| 187 |
+
if len(answer) > max_ans:
|
| 188 |
+
half = max_ans // 2
|
| 189 |
+
answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:]
|
| 190 |
+
else:
|
| 191 |
+
answer_trunc = answer
|
| 192 |
+
return (
|
| 193 |
+
f"GOAL ORIGINALE:\n{goal[:500]}\n\n"
|
| 194 |
+
f"RISPOSTA AGENTE:\n{answer_trunc}\n\n"
|
| 195 |
+
"VERIFICA (rispondi solo con JSON):\n"
|
| 196 |
+
"1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n"
|
| 197 |
+
"2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n"
|
| 198 |
+
"3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class SemanticAuditorL2:
|
| 203 |
+
"""
|
| 204 |
+
S303 — Audit Semantico L2.
|
| 205 |
+
Istanziato come singleton.
|
| 206 |
+
Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 ≠ FAIL).
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
def __init__(self, ai_client: Any = None, timeout_s: float = 12.0):
|
| 210 |
+
self.ai_client = ai_client
|
| 211 |
+
self.timeout_s = timeout_s
|
| 212 |
+
|
| 213 |
+
async def audit(self, goal: str, answer: str) -> AuditL2Result:
|
| 214 |
+
"""
|
| 215 |
+
Punto di ingresso principale.
|
| 216 |
+
1. Prova audit LLM se ai_client disponibile.
|
| 217 |
+
2. Fallback a audit euristico in caso di errore o timeout.
|
| 218 |
+
"""
|
| 219 |
+
if not goal or not answer:
|
| 220 |
+
return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0,
|
| 221 |
+
issues=["goal o answer vuoti"])
|
| 222 |
+
|
| 223 |
+
# Euristico sempre eseguito — base line gratuita
|
| 224 |
+
heuristic_result = _heuristic_audit(goal, answer)
|
| 225 |
+
|
| 226 |
+
# Se euristico ha già trovato problemi gravi, non invocare LLM per efficienza
|
| 227 |
+
if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2:
|
| 228 |
+
_logger.debug("[AuditL2] heuristic FAIL con %d issues — skip LLM", len(heuristic_result.issues))
|
| 229 |
+
return heuristic_result
|
| 230 |
+
|
| 231 |
+
if self.ai_client is not None:
|
| 232 |
+
try:
|
| 233 |
+
result = await asyncio.wait_for(
|
| 234 |
+
self._llm_audit(goal, answer),
|
| 235 |
+
timeout=self.timeout_s
|
| 236 |
+
)
|
| 237 |
+
if result:
|
| 238 |
+
# Merge: se LLM dice PASS ma euristico ha trovato issue → UNKNOWN
|
| 239 |
+
if result.status == AuditStatus.PASS and heuristic_result.issues:
|
| 240 |
+
result.status = AuditStatus.UNKNOWN
|
| 241 |
+
result.issues = heuristic_result.issues
|
| 242 |
+
result.confidence = min(result.confidence, 0.65)
|
| 243 |
+
return result
|
| 244 |
+
except asyncio.TimeoutError:
|
| 245 |
+
_logger.warning("[AuditL2] timeout LLM (%.1fs) — fallback euristico", self.timeout_s)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
_logger.warning("[AuditL2] errore LLM (%s) — fallback euristico", type(e).__name__)
|
| 248 |
+
|
| 249 |
+
return heuristic_result
|
| 250 |
+
|
| 251 |
+
async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]:
|
| 252 |
+
"""Chiamata LLM reale per l'audit semantico."""
|
| 253 |
+
prompt = _build_audit_prompt(goal, answer)
|
| 254 |
+
# Preferisce modello veloce/economico (8B) — audit non richiede ragionamento profondo
|
| 255 |
+
_model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant"
|
| 256 |
+
response = await self.ai_client.chat.completions.create(
|
| 257 |
+
model=_model,
|
| 258 |
+
messages=[
|
| 259 |
+
{"role": "system", "content": _AUDIT_SYSTEM},
|
| 260 |
+
{"role": "user", "content": prompt},
|
| 261 |
+
],
|
| 262 |
+
max_tokens=256,
|
| 263 |
+
temperature=0.0, # deterministico
|
| 264 |
+
)
|
| 265 |
+
raw = response.choices[0].message.content or ""
|
| 266 |
+
match = re.search(r"\{[\s\S]*?\}", raw)
|
| 267 |
+
if not match:
|
| 268 |
+
_logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw)
|
| 269 |
+
return None
|
| 270 |
+
try:
|
| 271 |
+
parsed = json.loads(match.group(0))
|
| 272 |
+
except json.JSONDecodeError as _je:
|
| 273 |
+
_logger.warning("[AuditL2] JSON decode error: %s", _je)
|
| 274 |
+
return None
|
| 275 |
+
status_raw = parsed.get("status", "UNKNOWN").upper()
|
| 276 |
+
try:
|
| 277 |
+
status = AuditStatus(status_raw)
|
| 278 |
+
except ValueError:
|
| 279 |
+
status = AuditStatus.UNKNOWN
|
| 280 |
+
return AuditL2Result(
|
| 281 |
+
status=status,
|
| 282 |
+
confidence=float(parsed.get("confidence", 0.70)),
|
| 283 |
+
issues=parsed.get("issues", []),
|
| 284 |
+
repair_hint=parsed.get("repair_hint", ""),
|
| 285 |
+
engine="llm",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ── Singleton ─────────────────────────────────────────────────────────────────
|
| 290 |
+
_auditor: Optional[SemanticAuditorL2] = None
|
| 291 |
+
|
| 292 |
+
def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2:
|
| 293 |
+
"""
|
| 294 |
+
Ritorna o crea il singleton SemanticAuditorL2.
|
| 295 |
+
Se chiamato con ai_client=<client> e il singleton esiste già senza client,
|
| 296 |
+
aggiorna il client sul singleton esistente (upgrade lazy).
|
| 297 |
+
"""
|
| 298 |
+
global _auditor
|
| 299 |
+
if _auditor is None:
|
| 300 |
+
_auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s)
|
| 301 |
+
elif ai_client is not None and _auditor.ai_client is None:
|
| 302 |
+
_auditor.ai_client = ai_client # upgrade: inserisce client dopo init
|
| 303 |
+
return _auditor
|
agents/context_manager.py
CHANGED
|
@@ -425,26 +425,3 @@ async def get_context_for_goal(
|
|
| 425 |
return '\n\n'.join(parts) if parts else ''
|
| 426 |
except Exception:
|
| 427 |
return ''
|
| 428 |
-
|
| 429 |
-
# ── S-CONTEXT-SHARDING: Gestione intelligente del contesto lungo (S482) ──────
|
| 430 |
-
def shard_context(full_context: str, max_shard_size: int = 2000) -> list[str]:
|
| 431 |
-
"""Divide il contesto in shard logici basati sulla rilevanza semantica."""
|
| 432 |
-
shards = []
|
| 433 |
-
current_shard = []
|
| 434 |
-
current_size = 0
|
| 435 |
-
|
| 436 |
-
# Dividiamo per blocchi logici (paragrafi o sezioni di codice)
|
| 437 |
-
blocks = re.split(r'\n(?=\s*[A-Z#])', full_context)
|
| 438 |
-
|
| 439 |
-
for block in blocks:
|
| 440 |
-
block_size = len(block)
|
| 441 |
-
if current_size + block_size > max_shard_size and current_shard:
|
| 442 |
-
shards.append("\n".join(current_shard))
|
| 443 |
-
current_shard = []
|
| 444 |
-
current_size = 0
|
| 445 |
-
current_shard.append(block)
|
| 446 |
-
current_size += block_size
|
| 447 |
-
|
| 448 |
-
if current_shard:
|
| 449 |
-
shards.append("\n".join(current_shard))
|
| 450 |
-
return shards
|
|
|
|
| 425 |
return '\n\n'.join(parts) if parts else ''
|
| 426 |
except Exception:
|
| 427 |
return ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/engineering_state.py
DELETED
|
@@ -1,255 +0,0 @@
|
|
| 1 |
-
"""Versioned, bounded engineering lifecycle state for the unified agent loop.
|
| 2 |
-
|
| 3 |
-
The module is deliberately dependency-free. It mirrors the legacy lifecycle without
|
| 4 |
-
being authoritative for recovery when the rollout mode is enabled, and it never stores
|
| 5 |
-
raw prompts, credentials, or arbitrary tool output.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import hashlib
|
| 10 |
-
import os
|
| 11 |
-
import re
|
| 12 |
-
import time
|
| 13 |
-
from dataclasses import dataclass, field
|
| 14 |
-
from enum import Enum
|
| 15 |
-
from typing import Any, Mapping
|
| 16 |
-
|
| 17 |
-
SCHEMA_VERSION = 1
|
| 18 |
-
MAX_HISTORY = 64
|
| 19 |
-
MAX_DIAGNOSTICS = 24
|
| 20 |
-
MAX_PREVIEW_CHARS = 256
|
| 21 |
-
MAX_ID_CHARS = 180
|
| 22 |
-
|
| 23 |
-
_SECRET_PATTERNS = (
|
| 24 |
-
re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
|
| 25 |
-
re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
|
| 26 |
-
re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
|
| 27 |
-
re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
|
| 28 |
-
re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class EngineeringStateMode(str, Enum):
|
| 33 |
-
OFF = "off"
|
| 34 |
-
SHADOW = "shadow"
|
| 35 |
-
CANARY = "canary"
|
| 36 |
-
AUTHORITATIVE = "authoritative"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@dataclass(frozen=True)
|
| 40 |
-
class EngineeringStateConfig:
|
| 41 |
-
"""Conservative rollout configuration read once per run."""
|
| 42 |
-
|
| 43 |
-
mode: EngineeringStateMode = EngineeringStateMode.OFF
|
| 44 |
-
canary_rate: float = 0.0
|
| 45 |
-
|
| 46 |
-
@classmethod
|
| 47 |
-
def from_env(cls) -> "EngineeringStateConfig":
|
| 48 |
-
raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
|
| 49 |
-
try:
|
| 50 |
-
mode = EngineeringStateMode(raw_mode)
|
| 51 |
-
except ValueError:
|
| 52 |
-
mode = EngineeringStateMode.OFF
|
| 53 |
-
try:
|
| 54 |
-
rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
|
| 55 |
-
except (TypeError, ValueError):
|
| 56 |
-
rate = 0.0
|
| 57 |
-
return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
|
| 58 |
-
|
| 59 |
-
@property
|
| 60 |
-
def enabled(self) -> bool:
|
| 61 |
-
return self.mode is not EngineeringStateMode.OFF
|
| 62 |
-
|
| 63 |
-
def selects_canary(self, run_id: str, session_id: str) -> bool:
|
| 64 |
-
if self.mode is not EngineeringStateMode.CANARY or not session_id:
|
| 65 |
-
return False
|
| 66 |
-
if self.canary_rate >= 1.0:
|
| 67 |
-
return True
|
| 68 |
-
if self.canary_rate <= 0.0:
|
| 69 |
-
return False
|
| 70 |
-
digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
|
| 71 |
-
bucket = int.from_bytes(digest[:8], "big") / float(2**64)
|
| 72 |
-
return bucket < self.canary_rate
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _bounded_id(value: str | None) -> str:
|
| 76 |
-
return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
|
| 80 |
-
"""Redact common credential forms before anything reaches a checkpoint."""
|
| 81 |
-
text = str(value or "")[: max_chars * 4]
|
| 82 |
-
for pattern in _SECRET_PATTERNS:
|
| 83 |
-
if pattern.groups:
|
| 84 |
-
text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
|
| 85 |
-
else:
|
| 86 |
-
text = pattern.sub("[REDACTED]", text)
|
| 87 |
-
return text[:max_chars]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
|
| 91 |
-
"IDLE": frozenset({"CLASSIFYING", "FAILED"}),
|
| 92 |
-
"CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
|
| 93 |
-
"TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
|
| 94 |
-
"THINKING": frozenset({"COMPLETED", "FAILED"}),
|
| 95 |
-
"FAILED": frozenset({"IDLE"}),
|
| 96 |
-
"COMPLETED": frozenset({"IDLE", "FAILED"}),
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
@dataclass
|
| 101 |
-
class EngineeringState:
|
| 102 |
-
"""Bounded state envelope that can be persisted and safely restored."""
|
| 103 |
-
|
| 104 |
-
run_id: str
|
| 105 |
-
session_id: str
|
| 106 |
-
checkpoint_id: str
|
| 107 |
-
goal_digest: str
|
| 108 |
-
goal_preview: str
|
| 109 |
-
current_state: str = "IDLE"
|
| 110 |
-
history: list[dict[str, Any]] = field(default_factory=list)
|
| 111 |
-
diagnostics: list[str] = field(default_factory=list)
|
| 112 |
-
revision: int = 0
|
| 113 |
-
sequence: int = 0
|
| 114 |
-
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 115 |
-
updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 116 |
-
|
| 117 |
-
@classmethod
|
| 118 |
-
def start(
|
| 119 |
-
cls,
|
| 120 |
-
goal: str,
|
| 121 |
-
*,
|
| 122 |
-
run_id: str,
|
| 123 |
-
session_id: str = "",
|
| 124 |
-
checkpoint_id: str | None = None,
|
| 125 |
-
now_ms: int | None = None,
|
| 126 |
-
) -> "EngineeringState":
|
| 127 |
-
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 128 |
-
normalized_goal = str(goal or "")
|
| 129 |
-
return cls(
|
| 130 |
-
run_id=_bounded_id(run_id),
|
| 131 |
-
session_id=_bounded_id(session_id),
|
| 132 |
-
checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
|
| 133 |
-
goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
|
| 134 |
-
goal_preview=redact_text(normalized_goal),
|
| 135 |
-
created_at_ms=now,
|
| 136 |
-
updated_at_ms=now,
|
| 137 |
-
)
|
| 138 |
-
|
| 139 |
-
@property
|
| 140 |
-
def status(self) -> str:
|
| 141 |
-
if self.current_state == "COMPLETED":
|
| 142 |
-
return "completed"
|
| 143 |
-
if self.current_state == "FAILED":
|
| 144 |
-
return "failed"
|
| 145 |
-
return "active"
|
| 146 |
-
|
| 147 |
-
def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
|
| 148 |
-
"""Apply an idempotent transition; reject illegal transitions deterministically."""
|
| 149 |
-
target = str(next_state)
|
| 150 |
-
if target == self.current_state:
|
| 151 |
-
return False
|
| 152 |
-
allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
|
| 153 |
-
if target not in allowed:
|
| 154 |
-
raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
|
| 155 |
-
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 156 |
-
self.sequence += 1
|
| 157 |
-
self.revision += 1
|
| 158 |
-
self.history.append({
|
| 159 |
-
"sequence": self.sequence,
|
| 160 |
-
"from_state": self.current_state,
|
| 161 |
-
"to_state": target,
|
| 162 |
-
"at_ms": now,
|
| 163 |
-
})
|
| 164 |
-
if len(self.history) > MAX_HISTORY:
|
| 165 |
-
del self.history[:-MAX_HISTORY]
|
| 166 |
-
self.current_state = target
|
| 167 |
-
self.updated_at_ms = now
|
| 168 |
-
return True
|
| 169 |
-
|
| 170 |
-
def prepare_for_resume(self) -> None:
|
| 171 |
-
"""Normalize a restored snapshot before a new loop execution."""
|
| 172 |
-
if self.current_state != "IDLE":
|
| 173 |
-
self.current_state = "IDLE"
|
| 174 |
-
self.revision += 1
|
| 175 |
-
self.updated_at_ms = int(time.time() * 1000)
|
| 176 |
-
self.diagnostic("resume normalized state to IDLE")
|
| 177 |
-
|
| 178 |
-
def diagnostic(self, message: str) -> None:
|
| 179 |
-
value = redact_text(message, 180)
|
| 180 |
-
if not value or value in self.diagnostics:
|
| 181 |
-
return
|
| 182 |
-
self.diagnostics.append(value)
|
| 183 |
-
if len(self.diagnostics) > MAX_DIAGNOSTICS:
|
| 184 |
-
del self.diagnostics[:-MAX_DIAGNOSTICS]
|
| 185 |
-
self.revision += 1
|
| 186 |
-
self.updated_at_ms = int(time.time() * 1000)
|
| 187 |
-
|
| 188 |
-
def snapshot(self) -> dict[str, Any]:
|
| 189 |
-
"""Return a bounded JSON-compatible envelope; never expose the raw goal."""
|
| 190 |
-
return {
|
| 191 |
-
"schema_version": SCHEMA_VERSION,
|
| 192 |
-
"run_id": self.run_id,
|
| 193 |
-
"session_id": self.session_id,
|
| 194 |
-
"checkpoint_id": self.checkpoint_id,
|
| 195 |
-
"goal_digest": self.goal_digest,
|
| 196 |
-
"goal_preview": self.goal_preview,
|
| 197 |
-
"status": self.status,
|
| 198 |
-
"current_state": self.current_state,
|
| 199 |
-
"revision": self.revision,
|
| 200 |
-
"sequence": self.sequence,
|
| 201 |
-
"history": list(self.history[-MAX_HISTORY:]),
|
| 202 |
-
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 203 |
-
"created_at_ms": self.created_at_ms,
|
| 204 |
-
"updated_at_ms": self.updated_at_ms,
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
def projection(self) -> dict[str, Any]:
|
| 208 |
-
"""Small read-only view safe for API/SSE consumers."""
|
| 209 |
-
return {
|
| 210 |
-
"schema_version": SCHEMA_VERSION,
|
| 211 |
-
"status": self.status,
|
| 212 |
-
"current_state": self.current_state,
|
| 213 |
-
"revision": self.revision,
|
| 214 |
-
"sequence": self.sequence,
|
| 215 |
-
"checkpoint_id": self.checkpoint_id,
|
| 216 |
-
"history": [dict(item) for item in self.history[-16:]],
|
| 217 |
-
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
@classmethod
|
| 221 |
-
def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
|
| 222 |
-
if not isinstance(payload, Mapping):
|
| 223 |
-
raise ValueError("engineering state must be an object")
|
| 224 |
-
if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
|
| 225 |
-
raise ValueError("unsupported engineering state schema")
|
| 226 |
-
history = payload.get("history", [])
|
| 227 |
-
diagnostics = payload.get("diagnostics", [])
|
| 228 |
-
if not isinstance(history, list) or len(history) > MAX_HISTORY:
|
| 229 |
-
raise ValueError("invalid engineering state history")
|
| 230 |
-
if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
|
| 231 |
-
raise ValueError("invalid engineering state diagnostics")
|
| 232 |
-
current = str(payload.get("current_state", ""))
|
| 233 |
-
if current not in _ALLOWED_TRANSITIONS:
|
| 234 |
-
raise ValueError("invalid engineering state current state")
|
| 235 |
-
revision = int(payload.get("revision", -1))
|
| 236 |
-
sequence = int(payload.get("sequence", -1))
|
| 237 |
-
if revision < 0 or sequence < 0 or revision < sequence:
|
| 238 |
-
raise ValueError("invalid engineering state revision")
|
| 239 |
-
state = cls(
|
| 240 |
-
run_id=_bounded_id(str(payload.get("run_id", ""))),
|
| 241 |
-
session_id=_bounded_id(str(payload.get("session_id", ""))),
|
| 242 |
-
checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
|
| 243 |
-
goal_digest=str(payload.get("goal_digest", "")),
|
| 244 |
-
goal_preview=redact_text(payload.get("goal_preview", "")),
|
| 245 |
-
current_state=current,
|
| 246 |
-
history=[dict(item) for item in history if isinstance(item, Mapping)],
|
| 247 |
-
diagnostics=[redact_text(item, 180) for item in diagnostics],
|
| 248 |
-
revision=revision,
|
| 249 |
-
sequence=sequence,
|
| 250 |
-
created_at_ms=int(payload.get("created_at_ms", 0)),
|
| 251 |
-
updated_at_ms=int(payload.get("updated_at_ms", 0)),
|
| 252 |
-
)
|
| 253 |
-
if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
|
| 254 |
-
raise ValueError("invalid engineering state goal digest")
|
| 255 |
-
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/executor.py
CHANGED
|
@@ -213,26 +213,11 @@ class Executor:
|
|
| 213 |
|
| 214 |
# ── run_tool ─────────────────────────────────────────────────────────────
|
| 215 |
|
| 216 |
-
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0
|
| 217 |
-
"""
|
| 218 |
-
Esegue un tool. Se worker_hint è fornito, tenta l'esecuzione sul worker specifico.
|
| 219 |
-
ARCH-I4.3: Tool Engine evoluto con Capability Resolver.
|
| 220 |
-
"""
|
| 221 |
tool = TOOL_REGISTRY.get(tool_name)
|
| 222 |
if not tool:
|
| 223 |
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 224 |
|
| 225 |
-
# ARCH-E3.2/ARCH-I4.3: Risoluzione dinamica della capability via Kernel
|
| 226 |
-
if not worker_hint:
|
| 227 |
-
try:
|
| 228 |
-
from api.kernel import kernel
|
| 229 |
-
res = await kernel.resolve_capability(tool_name)
|
| 230 |
-
if res.get("status") == "resolved":
|
| 231 |
-
worker_hint = res["worker"]["id"]
|
| 232 |
-
_logger.info(f"[executor] capability '{tool_name}' risolta su worker: {worker_hint}")
|
| 233 |
-
except Exception as e:
|
| 234 |
-
_logger.debug(f"[executor] resolver bypass: {e}")
|
| 235 |
-
|
| 236 |
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 237 |
if missing:
|
| 238 |
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
|
@@ -333,4 +318,3 @@ class Executor:
|
|
| 333 |
await asyncio.sleep(0.5)
|
| 334 |
|
| 335 |
return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
|
| 336 |
-
|
|
|
|
| 213 |
|
| 214 |
# ── run_tool ─────────────────────────────────────────────────────────────
|
| 215 |
|
| 216 |
+
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
tool = TOOL_REGISTRY.get(tool_name)
|
| 218 |
if not tool:
|
| 219 |
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 222 |
if missing:
|
| 223 |
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
|
|
|
| 318 |
await asyncio.sleep(0.5)
|
| 319 |
|
| 320 |
return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
|
|
|
agents/fallback_healer.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fallback_healer.py — Logica di Self-Healing strategico per il loop di fallback.
|
| 2 |
+
Estratto da unified_loop_fallback.py (split 2026-06-30).
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
_logger = logging.getLogger("api.agent.healer")
|
| 8 |
+
|
| 9 |
+
class StrategicHealer:
|
| 10 |
+
@staticmethod
|
| 11 |
+
def analyze_errors(exec_errors: list, exec_warn: list) -> None:
|
| 12 |
+
"""
|
| 13 |
+
Analizza gli errori ripetuti e inietta messaggi di 'CAMBIO STRATEGIA' (GAP-SELFHEAL v2).
|
| 14 |
+
"""
|
| 15 |
+
if not exec_errors:
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
# Fingerprinting degli errori (Dual-mode: raw + error-class)
|
| 19 |
+
_selfheal_raw = {}
|
| 20 |
+
_selfheal_cls = {}
|
| 21 |
+
|
| 22 |
+
for _err in exec_errors:
|
| 23 |
+
if not isinstance(_err, str): continue
|
| 24 |
+
# Mode 1: raw fingerprinting
|
| 25 |
+
_fp = _err[:120]
|
| 26 |
+
_selfheal_raw[_fp] = _selfheal_raw.get(_fp, 0) + 1
|
| 27 |
+
# Mode 2: error-class extraction
|
| 28 |
+
_m = re.search(r"([A-Z][a-z]+Error):", _err)
|
| 29 |
+
if _m:
|
| 30 |
+
_c = _m.group(1).lower()
|
| 31 |
+
_selfheal_cls[_c] = _selfheal_cls.get(_c, 0) + 1
|
| 32 |
+
|
| 33 |
+
_selfheal_raw_max = max(_selfheal_raw.values()) if _selfheal_raw else 0
|
| 34 |
+
_selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0
|
| 35 |
+
_selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max)
|
| 36 |
+
|
| 37 |
+
if _selfheal_max >= 2:
|
| 38 |
+
_ERRCLASS_HINTS = {
|
| 39 |
+
"typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite.",
|
| 40 |
+
"keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza.",
|
| 41 |
+
"attributeerror": "Controlla che l'oggetto non sia None.",
|
| 42 |
+
"nameerror": "Controlla typo nel nome variabile/funzione.",
|
| 43 |
+
"syntaxerror": "Controlla la sintassi o le quote del comando.",
|
| 44 |
+
"memoryerror": "Processa in chunk, riduci dimensione dati.",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
_dom_cls = max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else ""
|
| 48 |
+
_specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.")
|
| 49 |
+
|
| 50 |
+
_selfheal_msg = (
|
| 51 |
+
f"⚠️ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}×{_selfheal_max}]: "
|
| 52 |
+
f"Hint specifico: {_specific} "
|
| 53 |
+
"NON ripetere lo stesso metodo — cambia libreria o pattern."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Evita doppia iniezione
|
| 57 |
+
if not any(isinstance(w, str) and "CAMBIO STRATEGIA" in w for w in exec_warn):
|
| 58 |
+
exec_warn.insert(0, _selfheal_msg)
|
| 59 |
+
_logger.info("GAP-SELFHEAL: Strategia di healing iniettata per %s", _dom_cls or "errore raw")
|
agents/fallback_utils.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fallback_utils.py — Funzioni di utilità per il loop di fallback.
|
| 2 |
+
Estratto da unified_loop_fallback.py (split 2026-06-30).
|
| 3 |
+
"""
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
def _is_refusal(text: str) -> bool:
|
| 7 |
+
"""Verifica se la risposta del modello è un rifiuto (S129)."""
|
| 8 |
+
if not text: return False
|
| 9 |
+
refusals = ["mi dispiace", "non posso", "i apologize", "i cannot", "unauthorized", "access denied"]
|
| 10 |
+
t = text.lower()
|
| 11 |
+
return any(r in t for r in refusals)
|
| 12 |
+
|
| 13 |
+
def _s759_bjac(a: str, b: str) -> float:
|
| 14 |
+
"""Calcola la somiglianza di Jaccard tra due stringhe (S759)."""
|
| 15 |
+
if not a or not b: return 0.0
|
| 16 |
+
set_a = set(a.lower().split())
|
| 17 |
+
set_b = set(b.lower().split())
|
| 18 |
+
intersection = len(set_a.intersection(set_b))
|
| 19 |
+
union = len(set_a.union(set_b))
|
| 20 |
+
return intersection / union if union > 0 else 0.0
|
| 21 |
+
|
| 22 |
+
def _avg10(lst: list) -> float:
|
| 23 |
+
"""Calcola la media degli ultimi 10 elementi di una lista."""
|
| 24 |
+
if not lst: return 0.0
|
| 25 |
+
sub = lst[-10:]
|
| 26 |
+
return sum(sub) / len(sub)
|
agents/file_conversion.py
DELETED
|
@@ -1,163 +0,0 @@
|
|
| 1 |
-
"""Conversioni tabellari deterministiche per dati CSV espliciti nel goal.
|
| 2 |
-
|
| 3 |
-
Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``.
|
| 4 |
-
Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import csv
|
| 9 |
-
import io
|
| 10 |
-
import json
|
| 11 |
-
import re
|
| 12 |
-
from dataclasses import dataclass
|
| 13 |
-
from typing import Any
|
| 14 |
-
|
| 15 |
-
_ATTACHMENT_RE = re.compile(
|
| 16 |
-
r"###\s*📎\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
|
| 17 |
-
re.IGNORECASE,
|
| 18 |
-
)
|
| 19 |
-
# Il target può essere espresso come "file chiamato foo.json" oppure come
|
| 20 |
-
# "poi crea foo.json". Il gruppo è limitato a nomi semplici, quindi il parser
|
| 21 |
-
# non accetta path traversal o istruzioni aggiuntive.
|
| 22 |
-
_TARGET_RE = re.compile(
|
| 23 |
-
r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)"
|
| 24 |
-
r"['`\"]?(?P<name>[\w.-]+\.json)\b",
|
| 25 |
-
re.IGNORECASE,
|
| 26 |
-
)
|
| 27 |
-
_CONVERSION_RE = re.compile(
|
| 28 |
-
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b",
|
| 29 |
-
re.IGNORECASE,
|
| 30 |
-
)
|
| 31 |
-
_INLINE_CSV_RE = re.compile(
|
| 32 |
-
r"\b(?:crea|scrivi)\s+(?P<name>[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*"
|
| 33 |
-
r"(?P<body>[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)",
|
| 34 |
-
re.IGNORECASE,
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@dataclass(frozen=True)
|
| 39 |
-
class CsvJsonConversion:
|
| 40 |
-
source_name: str
|
| 41 |
-
target_name: str
|
| 42 |
-
content: str
|
| 43 |
-
row_count: int
|
| 44 |
-
source_content: str
|
| 45 |
-
source_is_inline: bool = False
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _coerce_scalar(value: str) -> Any:
|
| 49 |
-
value = value.strip()
|
| 50 |
-
if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
|
| 51 |
-
return int(value)
|
| 52 |
-
if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value):
|
| 53 |
-
return float(value)
|
| 54 |
-
return value
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _csv_body(raw_body: str) -> str:
|
| 58 |
-
lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
| 59 |
-
while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")):
|
| 60 |
-
lines.pop(0)
|
| 61 |
-
return "\n".join(lines).strip()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None:
|
| 65 |
-
"""Legge CSV senza tollerare header/colonne ambigue o righe tronche."""
|
| 66 |
-
try:
|
| 67 |
-
reader = csv.DictReader(io.StringIO(csv_body))
|
| 68 |
-
raw_headers = reader.fieldnames
|
| 69 |
-
if not raw_headers:
|
| 70 |
-
return None
|
| 71 |
-
headers = [str(header or "").strip() for header in raw_headers]
|
| 72 |
-
if any(not header for header in headers) or len(set(headers)) != len(headers):
|
| 73 |
-
return None
|
| 74 |
-
|
| 75 |
-
rows: list[dict[str, Any]] = []
|
| 76 |
-
for raw_row in reader:
|
| 77 |
-
# DictReader usa None per colonne in eccesso e per celle mancanti.
|
| 78 |
-
if None in raw_row or any(raw_row.get(header) is None for header in raw_headers):
|
| 79 |
-
return None
|
| 80 |
-
row = {
|
| 81 |
-
headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "")
|
| 82 |
-
for index in range(len(headers))
|
| 83 |
-
}
|
| 84 |
-
rows.append(row)
|
| 85 |
-
return rows
|
| 86 |
-
except (csv.Error, UnicodeError):
|
| 87 |
-
return None
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]:
|
| 91 |
-
"""Verifica che il JSON sia l’array esatto dei record CSV normalizzati.
|
| 92 |
-
|
| 93 |
-
La verifica è intenzionalmente stretta: stessa cardinalità, stesso ordine,
|
| 94 |
-
stesse chiavi e stessi valori dopo la coercizione deterministica del CSV.
|
| 95 |
-
"""
|
| 96 |
-
expected = _parse_csv_rows(_csv_body(csv_content))
|
| 97 |
-
if expected is None:
|
| 98 |
-
return False, "CSV non valido o ambiguo"
|
| 99 |
-
try:
|
| 100 |
-
actual = json.loads(json_content)
|
| 101 |
-
except (TypeError, json.JSONDecodeError):
|
| 102 |
-
return False, "JSON non valido"
|
| 103 |
-
if not isinstance(actual, list):
|
| 104 |
-
return False, "il JSON deve essere un array"
|
| 105 |
-
if any(not isinstance(record, dict) for record in actual):
|
| 106 |
-
return False, "ogni record JSON deve essere un oggetto"
|
| 107 |
-
if actual != expected:
|
| 108 |
-
return False, "i record JSON non corrispondono esattamente al CSV"
|
| 109 |
-
return True, ""
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None:
|
| 113 |
-
csv_body = _csv_body(raw_body)
|
| 114 |
-
rows = _parse_csv_rows(csv_body)
|
| 115 |
-
if rows is None:
|
| 116 |
-
return None
|
| 117 |
-
content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n"
|
| 118 |
-
is_valid, _reason = validate_csv_json_equivalence(csv_body, content)
|
| 119 |
-
if not is_valid:
|
| 120 |
-
# Difesa di coerenza interna: una conversione diretta non può dichiararsi
|
| 121 |
-
# riuscita se il proprio serializzatore non supera il medesimo contratto.
|
| 122 |
-
return None
|
| 123 |
-
return CsvJsonConversion(
|
| 124 |
-
source_name=source_name.strip(),
|
| 125 |
-
target_name=target_name.strip(),
|
| 126 |
-
content=content,
|
| 127 |
-
row_count=len(rows),
|
| 128 |
-
source_content=csv_body + "\n",
|
| 129 |
-
source_is_inline=source_is_inline,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 134 |
-
"""Converte un CSV allegato o esplicitamente incluso nel goal in JSON.
|
| 135 |
-
|
| 136 |
-
Il ritorno è ``None`` quando il goal non definisce una conversione tabellare
|
| 137 |
-
completa: il resto del loop conserva quindi il comportamento esistente.
|
| 138 |
-
"""
|
| 139 |
-
if not _CONVERSION_RE.search(goal):
|
| 140 |
-
return None
|
| 141 |
-
|
| 142 |
-
target = _TARGET_RE.search(goal)
|
| 143 |
-
if not target:
|
| 144 |
-
return None
|
| 145 |
-
|
| 146 |
-
inline = _INLINE_CSV_RE.search(goal)
|
| 147 |
-
if inline:
|
| 148 |
-
return _build_conversion(
|
| 149 |
-
inline.group("name"),
|
| 150 |
-
target.group("name"),
|
| 151 |
-
inline.group("body"),
|
| 152 |
-
source_is_inline=True,
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
attachment = _ATTACHMENT_RE.search(goal)
|
| 156 |
-
if not attachment:
|
| 157 |
-
return None
|
| 158 |
-
return _build_conversion(
|
| 159 |
-
attachment.group("name"),
|
| 160 |
-
target.group("name"),
|
| 161 |
-
attachment.group("body"),
|
| 162 |
-
source_is_inline=False,
|
| 163 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/goal_verifier.py
CHANGED
|
@@ -40,7 +40,7 @@ class GoalVerificationStatus(str, Enum):
|
|
| 40 |
FAIL = "FAIL"
|
| 41 |
UNKNOWN = "UNKNOWN"
|
| 42 |
|
| 43 |
-
RETRY_THRESHOLD = 0.
|
| 44 |
MAX_GOAL_CHARS = 400
|
| 45 |
MAX_ANS_CHARS = 1500
|
| 46 |
MAX_HINT_CHARS = 150
|
|
@@ -178,18 +178,7 @@ class GoalVerifier:
|
|
| 178 |
r"flask|fastapi|django|express|nestjs|rails|laravel|"
|
| 179 |
r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
|
| 180 |
r"database|schema|migration|model|table|index|query|"
|
| 181 |
-
r"test|spec|fixture|mock|unit.*test|integration.*test)\b",
|
| 182 |
-
re.IGNORECASE,
|
| 183 |
-
)
|
| 184 |
-
|
| 185 |
-
_FILE_CONVERSION_RE = re.compile(
|
| 186 |
-
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}"
|
| 187 |
-
r"\b(?:csv|tsv|xlsx|xls|json|pdf|txt|markdown|md|docx)\b",
|
| 188 |
-
re.IGNORECASE,
|
| 189 |
-
)
|
| 190 |
-
_IMPLEMENTATION_CONTEXT_RE = re.compile(
|
| 191 |
-
r"\b(?:codice|script|funzione|function|class|componente|component|api|endpoint|"
|
| 192 |
-
r"typescript|javascript|python|react|backend|frontend|test\s+unit|test\s+e2e)\b",
|
| 193 |
re.IGNORECASE,
|
| 194 |
)
|
| 195 |
|
|
@@ -204,13 +193,7 @@ class GoalVerifier:
|
|
| 204 |
|
| 205 |
@classmethod
|
| 206 |
def is_code_goal(cls, goal: str) -> bool:
|
| 207 |
-
|
| 208 |
-
# una semplice lettura/conversione in un task di sviluppo da riparare.
|
| 209 |
-
user_goal = goal.split("--- **File allegati:**", 1)[0][:500]
|
| 210 |
-
if (cls._FILE_CONVERSION_RE.search(user_goal)
|
| 211 |
-
and not cls._IMPLEMENTATION_CONTEXT_RE.search(user_goal)):
|
| 212 |
-
return False
|
| 213 |
-
return bool(cls._CODE_RE.search(user_goal))
|
| 214 |
|
| 215 |
@classmethod
|
| 216 |
def adaptive_threshold(cls, goal: str) -> float:
|
|
@@ -220,9 +203,9 @@ class GoalVerifier:
|
|
| 220 |
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 221 |
return 0.25
|
| 222 |
if _COMPLEX_CODE_RE.search(g[:500]):
|
| 223 |
-
return 0.
|
| 224 |
if cls._CODE_RE.search(g[:500]):
|
| 225 |
-
return 0.
|
| 226 |
return RETRY_THRESHOLD
|
| 227 |
|
| 228 |
def __init__(self, llm: Any) -> None:
|
|
|
|
| 40 |
FAIL = "FAIL"
|
| 41 |
UNKNOWN = "UNKNOWN"
|
| 42 |
|
| 43 |
+
RETRY_THRESHOLD = 0.35
|
| 44 |
MAX_GOAL_CHARS = 400
|
| 45 |
MAX_ANS_CHARS = 1500
|
| 46 |
MAX_HINT_CHARS = 150
|
|
|
|
| 178 |
r"flask|fastapi|django|express|nestjs|rails|laravel|"
|
| 179 |
r"node|deno|bun|docker|dockerfile|nginx|github.*action|workflow\.yml|"
|
| 180 |
r"database|schema|migration|model|table|index|query|"
|
| 181 |
+
r"test|spec|fixture|mock|e2e|unit.*test|integration.*test)\b",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
re.IGNORECASE,
|
| 183 |
)
|
| 184 |
|
|
|
|
| 193 |
|
| 194 |
@classmethod
|
| 195 |
def is_code_goal(cls, goal: str) -> bool:
|
| 196 |
+
return bool(cls._CODE_RE.search(goal[:500]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
@classmethod
|
| 199 |
def adaptive_threshold(cls, goal: str) -> float:
|
|
|
|
| 203 |
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 204 |
return 0.25
|
| 205 |
if _COMPLEX_CODE_RE.search(g[:500]):
|
| 206 |
+
return 0.55
|
| 207 |
if cls._CODE_RE.search(g[:500]):
|
| 208 |
+
return 0.42
|
| 209 |
return RETRY_THRESHOLD
|
| 210 |
|
| 211 |
def __init__(self, llm: Any) -> None:
|
agents/grid_rag.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/agents/grid_rag.py — Grid-Enhanced RAG (S766-GRID-4)
|
| 3 |
+
|
| 4 |
+
Sistema RAG (Retrieval-Augmented Generation) avanzato che indicizza:
|
| 5 |
+
- Memoria distribuita (Supabase A, B, C, D)
|
| 6 |
+
- Log di sistema e di Railway
|
| 7 |
+
- Documentazione interna (.agents/memory/)
|
| 8 |
+
|
| 9 |
+
Architettura:
|
| 10 |
+
- GridIndexer: Indicizza i dati provenienti da diverse fonti
|
| 11 |
+
- ContextRetriever: Recupera il contesto più rilevante per il goal corrente
|
| 12 |
+
- KnowledgeGraph: Mappa le relazioni tra i diversi profili e i loro stati
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import asyncio
|
| 17 |
+
import logging
|
| 18 |
+
from typing import List, Dict, Any, Optional
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
import json
|
| 21 |
+
|
| 22 |
+
_logger = logging.getLogger("grid_rag")
|
| 23 |
+
|
| 24 |
+
# ── Configurazione ─────────────────────────────────────────────────────────
|
| 25 |
+
RAG_INDEX_SIZE = 100 # Numero di elementi da mantenere nel buffer RAG
|
| 26 |
+
RAG_SIMILARITY_THRESHOLD = 0.75
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class GridIndexer:
|
| 30 |
+
"""Indicizzatore per la Grid."""
|
| 31 |
+
|
| 32 |
+
def __init__(self):
|
| 33 |
+
self.index = []
|
| 34 |
+
self._lock = asyncio.Lock()
|
| 35 |
+
|
| 36 |
+
async def add_to_index(self, source: str, content: str, metadata: Dict):
|
| 37 |
+
"""Aggiunge un elemento all'indice RAG."""
|
| 38 |
+
async with self._lock:
|
| 39 |
+
entry = {
|
| 40 |
+
"source": source,
|
| 41 |
+
"content": content,
|
| 42 |
+
"metadata": metadata,
|
| 43 |
+
"timestamp": datetime.now().isoformat(),
|
| 44 |
+
}
|
| 45 |
+
self.index.append(entry)
|
| 46 |
+
# Mantieni dimensione fissa
|
| 47 |
+
if len(self.index) > RAG_INDEX_SIZE:
|
| 48 |
+
self.index.pop(0)
|
| 49 |
+
|
| 50 |
+
async def index_railway_logs(self, profile: str, logs: str):
|
| 51 |
+
"""Indicizza i log di Railway per identificare crash passati."""
|
| 52 |
+
lines = logs.split("\n")
|
| 53 |
+
for line in lines[-50:]: # Ultime 50 righe
|
| 54 |
+
if "error" in line.lower() or "crash" in line.lower() or "failed" in line.lower():
|
| 55 |
+
await self.add_to_index(
|
| 56 |
+
source=f"railway_logs_{profile}",
|
| 57 |
+
content=line,
|
| 58 |
+
metadata={"type": "log_error", "profile": profile}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ContextRetriever:
|
| 63 |
+
"""Recuperatore di contesto per l'agente."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, indexer: GridIndexer):
|
| 66 |
+
self.indexer = indexer
|
| 67 |
+
|
| 68 |
+
async def retrieve_relevant_context(self, query: str) -> List[Dict]:
|
| 69 |
+
"""
|
| 70 |
+
Recupera il contesto rilevante basato sulla query.
|
| 71 |
+
Attualmente usa keyword matching semplice (potenziabile con embeddings).
|
| 72 |
+
"""
|
| 73 |
+
relevant = []
|
| 74 |
+
keywords = query.lower().split()
|
| 75 |
+
|
| 76 |
+
async with self.indexer._lock:
|
| 77 |
+
for entry in self.indexer.index:
|
| 78 |
+
content = entry["content"].lower()
|
| 79 |
+
score = sum(1 for kw in keywords if kw in content)
|
| 80 |
+
|
| 81 |
+
if score > 0:
|
| 82 |
+
entry_with_score = entry.copy()
|
| 83 |
+
entry_with_score["score"] = score
|
| 84 |
+
relevant.append(entry_with_score)
|
| 85 |
+
|
| 86 |
+
# Ordina per score decrescente
|
| 87 |
+
relevant.sort(key=lambda x: x["score"], reverse=True)
|
| 88 |
+
return relevant[:10] # Ritorna i top 10
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class GridRAG:
|
| 92 |
+
"""Interfaccia principale per il RAG della Grid."""
|
| 93 |
+
|
| 94 |
+
def __init__(self):
|
| 95 |
+
self.indexer = GridIndexer()
|
| 96 |
+
self.retriever = ContextRetriever(self.indexer)
|
| 97 |
+
|
| 98 |
+
async def prepare_agent_context(self, goal: str) -> str:
|
| 99 |
+
"""
|
| 100 |
+
Prepara il contesto per l'agente unificando i dati RAG.
|
| 101 |
+
"""
|
| 102 |
+
context_items = await self.retriever.retrieve_relevant_context(goal)
|
| 103 |
+
|
| 104 |
+
if not context_items:
|
| 105 |
+
return ""
|
| 106 |
+
|
| 107 |
+
context_str = "\n--- GRID RAG CONTEXT ---\n"
|
| 108 |
+
for item in context_items:
|
| 109 |
+
context_str += f"[{item['source']}] {item['content']}\n"
|
| 110 |
+
context_str += "------------------------\n"
|
| 111 |
+
|
| 112 |
+
return context_str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ── Singleton globale ──────────────────────────────────────────────────────
|
| 116 |
+
_grid_rag_instance: Optional[GridRAG] = None
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_grid_rag() -> GridRAG:
|
| 120 |
+
"""Restituisce l'istanza globale del GridRAG."""
|
| 121 |
+
global _grid_rag_instance
|
| 122 |
+
if _grid_rag_instance is None:
|
| 123 |
+
_grid_rag_instance = GridRAG()
|
| 124 |
+
return _grid_rag_instance
|
agents/html_fast_path.py
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
"""Classificazione locale del fast path per mini-app HTML a file singolo.
|
| 2 |
-
|
| 3 |
-
Il classificatore è deliberatamente conservativo: in caso di dubbio restituisce
|
| 4 |
-
False. Non usa LLM, rete o stato globale e quindi non aggiunge latenza misurabile.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from dataclasses import dataclass
|
| 9 |
-
import re
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@dataclass(frozen=True)
|
| 13 |
-
class HtmlFastPathDecision:
|
| 14 |
-
eligible: bool
|
| 15 |
-
reason: str
|
| 16 |
-
path: str = "index.html"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
_HTML_RE = re.compile(r"\b(?:html5?|html|pagina\s+web|single[- ]page|landing\s+page)\b", re.I)
|
| 20 |
-
_CREATE_RE = re.compile(r"\b(?:crea|genera|scrivi|realizza|implementa|build|create|generate|make)\b", re.I)
|
| 21 |
-
_SINGLE_FILE_RE = re.compile(
|
| 22 |
-
r"\b(?:un\s+solo\s+file|singolo\s+file|one\s+file|single\s+file|file\s+unico)\b", re.I
|
| 23 |
-
)
|
| 24 |
-
_PATH_RE = re.compile(r"(?<![\w./-])([\w./-]+\.html)(?![\w.-])", re.I)
|
| 25 |
-
_FORBIDDEN_RE = re.compile(
|
| 26 |
-
r"\b(?:deploy|pubblica|publish|rilascia|release|github|git|npm|pnpm|yarn|install|"
|
| 27 |
-
r"api|backend|server|database|db|auth|login|pagamento|payment|webhook|secret|token|"
|
| 28 |
-
r"shell|bash|terminal|esegui\s+comandi|execute\s+commands|multi[- ]file|pi[uù]\s+file|"
|
| 29 |
-
r"react|vue|angular|next(?:\.js)?|vite|typescript|python|sql)\b",
|
| 30 |
-
re.I,
|
| 31 |
-
)
|
| 32 |
-
_EXTERNAL_RE = re.compile(r"\b(?:fetch|axios|websocket|stripe|supabase|firebase|oauth)\b|https?://", re.I)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def classify_html_fast_path(goal: str) -> HtmlFastPathDecision:
|
| 36 |
-
"""Return an eligible decision only for a safe, self-contained HTML request."""
|
| 37 |
-
text = " ".join(str(goal or "").split())
|
| 38 |
-
if not text:
|
| 39 |
-
return HtmlFastPathDecision(False, "empty_goal")
|
| 40 |
-
if len(text) > 500:
|
| 41 |
-
return HtmlFastPathDecision(False, "goal_too_long")
|
| 42 |
-
if not _HTML_RE.search(text):
|
| 43 |
-
return HtmlFastPathDecision(False, "not_html_goal")
|
| 44 |
-
if not _CREATE_RE.search(text):
|
| 45 |
-
return HtmlFastPathDecision(False, "not_creation_goal")
|
| 46 |
-
if not _SINGLE_FILE_RE.search(text):
|
| 47 |
-
return HtmlFastPathDecision(False, "single_file_not_explicit")
|
| 48 |
-
if _FORBIDDEN_RE.search(text):
|
| 49 |
-
return HtmlFastPathDecision(False, "contains_project_or_sensitive_operation")
|
| 50 |
-
if _EXTERNAL_RE.search(text):
|
| 51 |
-
return HtmlFastPathDecision(False, "external_dependency_or_network")
|
| 52 |
-
|
| 53 |
-
paths = _PATH_RE.findall(text)
|
| 54 |
-
path = paths[0] if paths else "index.html"
|
| 55 |
-
if "/" in path or path.startswith("."):
|
| 56 |
-
return HtmlFastPathDecision(False, "nested_path_not_allowed", path)
|
| 57 |
-
return HtmlFastPathDecision(True, "self_contained_single_html", path)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
__all__ = ["HtmlFastPathDecision", "classify_html_fast_path"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/planner.py
CHANGED
|
@@ -118,14 +118,6 @@ REGOLA DATA INTEGRITY (S-RECOVERY): Prima di pianificare analisi su dati numeric
|
|
| 118 |
REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate,
|
| 119 |
DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask.
|
| 120 |
|
| 121 |
-
REGOLA ORCHESTRATION (S-GAP9): Per task complessi (>5 passi), includi SEMPRE un subtask finale di "Verifica Integrazione e Test End-to-End".
|
| 122 |
-
Scomponi i rami Backend e Frontend in parallel_groups separati per massimizzare l'efficienza.
|
| 123 |
-
|
| 124 |
-
REGOLA RECOVERY & ROBUSTNESS (S-GAP12, S-GAP7):
|
| 125 |
-
- Se l'obiettivo è ambiguo o i dati sembrano incoerenti, il primo subtask DEVE essere "Analisi Critica e Validazione Requisiti" (tool: direct_response).
|
| 126 |
-
- Per ogni integrazione API, aggiungi un subtask di "Health Check / Verifica Connettività" prima delle operazioni core.
|
| 127 |
-
- Se il task fallisce 2 volte, il piano deve includere un passo di "Debug e Analisi Log" (tool: read_file/execute_shell).
|
| 128 |
-
|
| 129 |
REGOLE GRAFO DI DIPENDENZE:
|
| 130 |
- requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[]
|
| 131 |
- requires:[N] → subtask che dipende dall'output di subtask id N
|
|
@@ -194,7 +186,6 @@ def _parse_plan(raw: str) -> dict | None:
|
|
| 194 |
|
| 195 |
class Planner:
|
| 196 |
def __init__(self, llm_client: AIClient | None = None):
|
| 197 |
-
self._explicit_llm = llm_client is not None
|
| 198 |
if llm_client is not None:
|
| 199 |
self.llm = llm_client
|
| 200 |
else:
|
|
@@ -211,9 +202,7 @@ class Planner:
|
|
| 211 |
|
| 212 |
def _get_fast_llm(self) -> AIClient:
|
| 213 |
"""Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
|
| 214 |
-
Fallback: Groq
|
| 215 |
-
if self._explicit_llm:
|
| 216 |
-
return self.llm
|
| 217 |
try:
|
| 218 |
from models.role_router import RoleRouter, Role
|
| 219 |
return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
|
|
|
|
| 118 |
REGOLA ASSOLUTA (S-GAP2): Per qualsiasi richiesta di creazione app/progetto/boilerplate,
|
| 119 |
DEVI verificare se esiste scaffold_project corrispondente. Se esiste → PRIMO subtask.
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
REGOLE GRAFO DI DIPENDENZE:
|
| 122 |
- requires:[] → subtask eseguibile immediatamente in parallelo con altri requires:[]
|
| 123 |
- requires:[N] → subtask che dipende dall'output di subtask id N
|
|
|
|
| 186 |
|
| 187 |
class Planner:
|
| 188 |
def __init__(self, llm_client: AIClient | None = None):
|
|
|
|
| 189 |
if llm_client is not None:
|
| 190 |
self.llm = llm_client
|
| 191 |
else:
|
|
|
|
| 202 |
|
| 203 |
def _get_fast_llm(self) -> AIClient:
|
| 204 |
"""Gap-5: Cerebras gpt-oss-120b (2000+ tok/s) per quick-start draft.
|
| 205 |
+
Fallback: Groq llama-3.1-8b-instant se CEREBRAS_API_KEY assente."""
|
|
|
|
|
|
|
| 206 |
try:
|
| 207 |
from models.role_router import RoleRouter, Role
|
| 208 |
return RoleRouter.get_client(Role.REASONER) # Cerebras 120B
|
agents/reflection_sidecar.py
CHANGED
|
@@ -87,14 +87,9 @@ class ReflectionSidecar:
|
|
| 87 |
if count >= _MAX_ERRORS_BEFORE_REFLECT:
|
| 88 |
# Avvia reflection in background (non blocca l'agente principale)
|
| 89 |
if self._reflect_task is None or self._reflect_task.done():
|
| 90 |
-
# BUGFIX: eccezioni di _reflect_and_update erano perse silenziosamente
|
| 91 |
-
def _log_ref_exc(t):
|
| 92 |
-
if not t.cancelled() and t.exception():
|
| 93 |
-
_logger.warning("[reflection_sidecar] reflect task raised: %s", t.exception())
|
| 94 |
self._reflect_task = asyncio.create_task(
|
| 95 |
self._reflect_and_update(tool, error, context)
|
| 96 |
)
|
| 97 |
-
self._reflect_task.add_done_callback(_log_ref_exc)
|
| 98 |
|
| 99 |
async def _reflect_and_update(
|
| 100 |
self, tool: str, last_error: str, context: str
|
|
|
|
| 87 |
if count >= _MAX_ERRORS_BEFORE_REFLECT:
|
| 88 |
# Avvia reflection in background (non blocca l'agente principale)
|
| 89 |
if self._reflect_task is None or self._reflect_task.done():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
self._reflect_task = asyncio.create_task(
|
| 91 |
self._reflect_and_update(tool, error, context)
|
| 92 |
)
|
|
|
|
| 93 |
|
| 94 |
async def _reflect_and_update(
|
| 95 |
self, tool: str, last_error: str, context: str
|
agents/strategic_healer.py
CHANGED
|
@@ -67,17 +67,6 @@ class StrategyDecision:
|
|
| 67 |
# ── Healer principale ──────────────────────────────────────────────────────────
|
| 68 |
|
| 69 |
class StrategicHealer:
|
| 70 |
-
|
| 71 |
-
# ── S-DYNAMIC-TOOL-HEALING: Fallback dinamico per tool (S512) ────────────
|
| 72 |
-
async def get_tool_fallback_strategy(self, tool_name: str, error: str) -> str:
|
| 73 |
-
"""Determina una strategia alternativa se un tool specifico fallisce."""
|
| 74 |
-
fallbacks = {
|
| 75 |
-
"google_search": "Il tool di ricerca web è instabile. Usa 'webpage_extract' direttamente sugli URL noti o tenta una ricerca mirata su GitHub/Wikipedia via shell.",
|
| 76 |
-
"web_fetch": "L'estrazione fallisce. Usa 'curl -s' via shell per ottenere il contenuto grezzo e analizzalo con regex.",
|
| 77 |
-
"python_exec": "L'esecuzione Python ha fallito. Tenta di risolvere il task tramite logica shell (bc, awk, sed) o semplifica lo script."
|
| 78 |
-
}
|
| 79 |
-
return fallbacks.get(tool_name, f"Il tool {tool_name} ha fallito. Analizza l'errore {error} e cambia approccio.")
|
| 80 |
-
|
| 81 |
"""
|
| 82 |
Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
|
| 83 |
|
|
|
|
| 67 |
# ── Healer principale ──────────────────────────────────────────────────────────
|
| 68 |
|
| 69 |
class StrategicHealer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
| 71 |
Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
|
| 72 |
|
agents/unified_loop.py
CHANGED
|
@@ -55,57 +55,10 @@ from agents.unified_loop_types import (
|
|
| 55 |
_ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
|
| 56 |
_is_goal_ambiguous,
|
| 57 |
_is_borderline_ambiguous,
|
| 58 |
-
AgentState,
|
| 59 |
UnifiedLoopState,
|
| 60 |
_maybe_await,
|
| 61 |
)
|
| 62 |
|
| 63 |
-
# I4.5: active state is scoped to the current asyncio task, not the loop instance.
|
| 64 |
-
# This lets the public guard close unexpected exceptions without sharing state across runs.
|
| 65 |
-
_ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
|
| 66 |
-
# P0: EngineeringState is a shadow/canary projection of the legacy lifecycle.
|
| 67 |
-
# Context-local storage keeps parallel runs isolated even when one loop instance is reused.
|
| 68 |
-
from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode
|
| 69 |
-
|
| 70 |
-
_ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar(
|
| 71 |
-
"active_engineering_state", default=None
|
| 72 |
-
)
|
| 73 |
-
_ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar(
|
| 74 |
-
"active_engineering_mode", default=None
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _schedule_engineering_persist(engineering_state: EngineeringState) -> None:
|
| 79 |
-
"""Persist a snapshot without blocking the loop or making observability fatal."""
|
| 80 |
-
snapshot = engineering_state.snapshot()
|
| 81 |
-
|
| 82 |
-
async def _persist() -> None:
|
| 83 |
-
try:
|
| 84 |
-
from api.persistence import sb_save_engineering_state
|
| 85 |
-
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot)
|
| 86 |
-
except Exception as exc: # shadow state must never break the user task
|
| 87 |
-
_logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__)
|
| 88 |
-
|
| 89 |
-
try:
|
| 90 |
-
task = asyncio.create_task(_persist())
|
| 91 |
-
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
| 92 |
-
except RuntimeError:
|
| 93 |
-
# No running event loop during defensive/test-only calls.
|
| 94 |
-
return
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None:
|
| 98 |
-
"""Flush the terminal snapshot before returning a run result."""
|
| 99 |
-
if engineering_state is None:
|
| 100 |
-
return
|
| 101 |
-
snapshot = engineering_state.snapshot()
|
| 102 |
-
try:
|
| 103 |
-
from api.persistence import sb_save_engineering_state
|
| 104 |
-
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True)
|
| 105 |
-
except Exception as exc: # persistence must not turn a completed task into a crash
|
| 106 |
-
engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}")
|
| 107 |
-
_logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__)
|
| 108 |
-
|
| 109 |
# S404: Error Classifier â import lazy per evitare circular import issues
|
| 110 |
def _get_classifier():
|
| 111 |
from agents.error_classifier import classify_error, format_for_context
|
|
@@ -176,43 +129,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 176 |
self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
|
| 177 |
self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer
|
| 178 |
# ââ GAP-3: Rollback atomico scritture âââââââââââââââââââââââââââââââââââââââââ
|
| 179 |
-
async def _transition_state(
|
| 180 |
-
self,
|
| 181 |
-
state: UnifiedLoopState,
|
| 182 |
-
next_state: AgentState,
|
| 183 |
-
on_step: StepCallback | None = None,
|
| 184 |
-
) -> None:
|
| 185 |
-
"""Validate and publish one per-run state transition."""
|
| 186 |
-
previous = state.state_machine.current
|
| 187 |
-
state.state_machine.transition(next_state)
|
| 188 |
-
|
| 189 |
-
# P0 adapter: mirror every legacy transition into the versioned state.
|
| 190 |
-
engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 191 |
-
if engineering_state is not None:
|
| 192 |
-
try:
|
| 193 |
-
engineering_state.transition(next_state.value)
|
| 194 |
-
_schedule_engineering_persist(engineering_state)
|
| 195 |
-
except Exception as exc:
|
| 196 |
-
engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}")
|
| 197 |
-
if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE:
|
| 198 |
-
raise
|
| 199 |
-
_logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__)
|
| 200 |
-
|
| 201 |
-
if previous == next_state or on_step is None:
|
| 202 |
-
return
|
| 203 |
-
try:
|
| 204 |
-
event = {
|
| 205 |
-
"action": "state_transition",
|
| 206 |
-
"status": "done",
|
| 207 |
-
"from_state": previous.value,
|
| 208 |
-
"to_state": next_state.value,
|
| 209 |
-
}
|
| 210 |
-
if engineering_state is not None:
|
| 211 |
-
event["engineering_state"] = engineering_state.projection()
|
| 212 |
-
await _maybe_await(on_step(event))
|
| 213 |
-
except Exception as _state_callback_error:
|
| 214 |
-
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
-
|
| 216 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 217 |
"""
|
| 218 |
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà .
|
|
@@ -550,29 +466,9 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 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 |
-
try:
|
| 554 |
-
from agents.html_fast_path import classify_html_fast_path
|
| 555 |
-
_html_fast_decision = classify_html_fast_path(state.goal)
|
| 556 |
-
except Exception as _html_cls_exc:
|
| 557 |
-
_logger.debug("[html-fast-path] classifier unavailable: %s", type(_html_cls_exc).__name__)
|
| 558 |
-
_html_fast_decision = None
|
| 559 |
-
_html_fast_plan = None
|
| 560 |
-
if _html_fast_decision is not None and _html_fast_decision.eligible and not tool_results:
|
| 561 |
-
_html_fast_plan = {
|
| 562 |
-
"summary": "Piano locale mini-app HTML a file singolo",
|
| 563 |
-
"goal": state.goal,
|
| 564 |
-
"subtasks": [
|
| 565 |
-
{"id": 1, "description": f"Scrivi {_html_fast_decision.path}: {state.goal}", "tool": "write_file", "requires": []},
|
| 566 |
-
{"id": 2, "description": f"Rileggi {_html_fast_decision.path} e verifica la scrittura", "tool": "read_file", "requires": [1]},
|
| 567 |
-
],
|
| 568 |
-
"complexity": "low",
|
| 569 |
-
"source": "local_html_fast_path",
|
| 570 |
-
}
|
| 571 |
-
_logger.info("[html-fast-path] planner bypass: %s", _html_fast_decision.path)
|
| 572 |
_should_plan = (
|
| 573 |
self.planner
|
| 574 |
and not tool_results
|
| 575 |
-
and _html_fast_plan is None
|
| 576 |
and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
|
| 577 |
and len(state.goal) > 10
|
| 578 |
)
|
|
@@ -589,7 +485,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 589 |
}
|
| 590 |
_logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
|
| 591 |
_t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
|
| 592 |
-
if _should_plan
|
| 593 |
if on_step:
|
| 594 |
await _maybe_await(on_step({
|
| 595 |
"loop": 0, "action": "plan", "status": "started",
|
|
@@ -598,10 +494,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 598 |
}))
|
| 599 |
# S640: timeout planner + S-FMT-ORCH fast-fix bypass
|
| 600 |
# Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
|
| 601 |
-
if
|
| 602 |
-
plan = _html_fast_plan
|
| 603 |
-
_logger.info("[html-fast-path] ARCHITECT bypassato")
|
| 604 |
-
elif _fast_fix_plan is not None:
|
| 605 |
plan = _fast_fix_plan
|
| 606 |
_logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
|
| 607 |
else:
|
|
@@ -626,12 +519,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 626 |
"explanation": "Il pianificatore ha impiegato troppo â procedo senza piano",
|
| 627 |
"visibility": "progress",
|
| 628 |
}))
|
| 629 |
-
|
| 630 |
-
existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None)
|
| 631 |
-
if existing_plan_step:
|
| 632 |
-
plan = existing_plan_step.get("result")
|
| 633 |
-
_logger.info("[P1-RECOVERY] Plan restored from steps")
|
| 634 |
-
elif plan is not None:
|
| 635 |
state.steps.append({"action": "plan", "result": plan})
|
| 636 |
try:
|
| 637 |
from api.state import record_timing as _rtc_pl
|
|
@@ -1038,15 +926,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1038 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 1039 |
reg_name, inp_builder = tool_key_pair
|
| 1040 |
if reg_name and inp_builder is not None:
|
| 1041 |
-
# P1-RECOVERY: skip subtasks already completed in state.steps
|
| 1042 |
-
_st_id = subtask.get("id")
|
| 1043 |
-
_done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None)
|
| 1044 |
-
if _done_step:
|
| 1045 |
-
_logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id)
|
| 1046 |
-
# Ripristiniamo l'output nel buffer per i dipendenti
|
| 1047 |
-
_existing_out = _done_step.get("output", "")
|
| 1048 |
-
_subtask_outputs[str(_st_id)] = _existing_out
|
| 1049 |
-
continue
|
| 1050 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 1051 |
elif _s_tool:
|
| 1052 |
# COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
|
|
@@ -1782,16 +1661,16 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1782 |
_logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
|
| 1783 |
self._tdd_fail_inject = None
|
| 1784 |
# GAP-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
|
| 1785 |
-
if
|
| 1786 |
try:
|
| 1787 |
_sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
|
| 1788 |
-
_sh_decision = await self._strategic_healer.analyze_and_decide(
|
| 1789 |
if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
|
| 1790 |
exec_warn.insert(0, _sh_decision.strategy_prompt)
|
| 1791 |
_logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
|
| 1792 |
if _sh_decision and getattr(_sh_decision, 'should_stop', False):
|
| 1793 |
_logger.info("GAP-4: StrategicHealer → should_stop, interruzione fallback")
|
| 1794 |
-
return
|
| 1795 |
except Exception as _sh_loop_err:
|
| 1796 |
_logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err)
|
| 1797 |
# GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction.
|
|
@@ -2254,58 +2133,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 2254 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2255 |
except Exception as _exc:
|
| 2256 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 2257 |
-
# BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
|
| 2258 |
-
# modifica answer, retry, provider routing o scoring.
|
| 2259 |
-
try:
|
| 2260 |
-
from benchmarks.shadow_telemetry import validate_and_record_shadow
|
| 2261 |
-
validate_and_record_shadow(
|
| 2262 |
-
goal=state.goal,
|
| 2263 |
-
answer=answer,
|
| 2264 |
-
metadata={
|
| 2265 |
-
"provider": getattr(_active_llm, "provider", None),
|
| 2266 |
-
"model": getattr(_active_llm, "model", None),
|
| 2267 |
-
"profile": getattr(_active_llm, "profile", None),
|
| 2268 |
-
"attempt": _llm_try,
|
| 2269 |
-
"latency_ms": round(_llm_elapsed, 2),
|
| 2270 |
-
"source": "unified_loop",
|
| 2271 |
-
},
|
| 2272 |
-
)
|
| 2273 |
-
except Exception as _exc:
|
| 2274 |
-
_logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
|
| 2275 |
-
|
| 2276 |
-
# BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
|
| 2277 |
-
# non estraibile/non conforme. Non aggiunge tentativi oltre il budget
|
| 2278 |
-
# esistente e non scatta su goal non-coding.
|
| 2279 |
-
if not _is_last:
|
| 2280 |
-
try:
|
| 2281 |
-
from benchmarks.validators import validate_coding_retry
|
| 2282 |
-
_code_validation = validate_coding_retry(
|
| 2283 |
-
state.goal,
|
| 2284 |
-
answer,
|
| 2285 |
-
is_last_attempt=_is_last,
|
| 2286 |
-
)
|
| 2287 |
-
if _code_validation is not None:
|
| 2288 |
-
state.steps.append({
|
| 2289 |
-
"action": f"typescript_contract_retry_{_llm_try}",
|
| 2290 |
-
"failure_code": _code_validation.failure_code,
|
| 2291 |
-
})
|
| 2292 |
-
_code_repair = (
|
| 2293 |
-
"CONTRATTO TYPESCRIPT FALLITO: "
|
| 2294 |
-
f"{_code_validation.failure_code}.\n"
|
| 2295 |
-
"Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
|
| 2296 |
-
"```typescript ... ``` non vuoto, completo e compilabile. "
|
| 2297 |
-
"Mantieni la firma e tutti i simboli richiesti dal task. "
|
| 2298 |
-
"Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
|
| 2299 |
-
)
|
| 2300 |
-
messages = [
|
| 2301 |
-
messages[0],
|
| 2302 |
-
{"role": "system", "content": _code_repair},
|
| 2303 |
-
*messages[1:],
|
| 2304 |
-
]
|
| 2305 |
-
_error_severity = "syntax"
|
| 2306 |
-
continue
|
| 2307 |
-
except Exception as _exc:
|
| 2308 |
-
_logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
|
| 2309 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2310 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2311 |
if _fr == 'length' and on_step:
|
|
@@ -3061,7 +2888,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3061 |
except Exception:
|
| 3062 |
pass
|
| 3063 |
# S455-P10: task supervisionato — done_callback logga eccezioni silenziate
|
| 3064 |
-
|
| 3065 |
_rv_t.add_done_callback(
|
| 3066 |
lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
|
| 3067 |
)
|
|
@@ -3533,57 +3360,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3533 |
|
| 3534 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3535 |
on_step: StepCallback | None = None,
|
| 3536 |
-
session_id: str = ""
|
| 3537 |
-
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3538 |
-
"""Run the loop and close unexpected exceptions as a controlled FAILED state."""
|
| 3539 |
-
previous_state = _ACTIVE_LOOP_STATE.get()
|
| 3540 |
-
previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 3541 |
-
previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
|
| 3542 |
-
try:
|
| 3543 |
-
return await self._run_impl(
|
| 3544 |
-
goal, context, max_steps, on_step, session_id, allow_tools,
|
| 3545 |
-
allow_local_csv_conversion,
|
| 3546 |
-
)
|
| 3547 |
-
except Exception as _run_error:
|
| 3548 |
-
state = _ACTIVE_LOOP_STATE.get()
|
| 3549 |
-
error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
|
| 3550 |
-
if state is None:
|
| 3551 |
-
return {
|
| 3552 |
-
"success": False,
|
| 3553 |
-
"goal": goal,
|
| 3554 |
-
"error": error_text,
|
| 3555 |
-
"agent_state": AgentState.FAILED.value,
|
| 3556 |
-
"state_history": [AgentState.IDLE.value, AgentState.FAILED.value],
|
| 3557 |
-
}
|
| 3558 |
-
|
| 3559 |
-
state.errors.append(error_text)
|
| 3560 |
-
previous = state.state_machine.current
|
| 3561 |
-
if previous != AgentState.FAILED:
|
| 3562 |
-
try:
|
| 3563 |
-
await self._transition_state(state, AgentState.FAILED, on_step)
|
| 3564 |
-
except Exception as _state_transition_error:
|
| 3565 |
-
_logger.debug(
|
| 3566 |
-
"[unified_loop] failure transition silenced: %s",
|
| 3567 |
-
_state_transition_error,
|
| 3568 |
-
)
|
| 3569 |
-
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
| 3570 |
-
return {
|
| 3571 |
-
"success": False,
|
| 3572 |
-
"goal": state.goal,
|
| 3573 |
-
"steps": state.steps,
|
| 3574 |
-
"errors": state.errors,
|
| 3575 |
-
"error": error_text,
|
| 3576 |
-
**state.state_machine.snapshot(),
|
| 3577 |
-
}
|
| 3578 |
-
finally:
|
| 3579 |
-
_ACTIVE_LOOP_STATE.set(previous_state)
|
| 3580 |
-
_ACTIVE_ENGINEERING_STATE.set(previous_engineering_state)
|
| 3581 |
-
_ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode)
|
| 3582 |
-
|
| 3583 |
-
async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3584 |
-
on_step: StepCallback | None = None,
|
| 3585 |
-
session_id: str = "", allow_tools: bool = True,
|
| 3586 |
-
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3587 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 3588 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 3589 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso â input come
|
|
@@ -3618,17 +3395,17 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3618 |
except Exception:
|
| 3619 |
_sid_token = None # fallback silente â registry usa default "agent_default"
|
| 3620 |
|
| 3621 |
-
# S750-GAP-B: pre-warm sandbox
|
| 3622 |
-
#
|
| 3623 |
-
|
| 3624 |
-
|
| 3625 |
-
|
| 3626 |
-
|
| 3627 |
-
|
| 3628 |
-
|
| 3629 |
-
|
| 3630 |
-
|
| 3631 |
-
|
| 3632 |
|
| 3633 |
# S568-B: reset _session_files ogni run â previene memory leak su sessioni lunghe.
|
| 3634 |
# Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
|
|
@@ -3643,121 +3420,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3643 |
|
| 3644 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 3645 |
|
| 3646 |
-
# P1: EngineeringState is the recovery authority unless explicitly disabled.
|
| 3647 |
-
engineering_config = EngineeringStateConfig.from_env()
|
| 3648 |
-
_effective_mode = engineering_config.mode
|
| 3649 |
-
_ACTIVE_ENGINEERING_MODE.set(_effective_mode)
|
| 3650 |
-
|
| 3651 |
-
engineering_state: EngineeringState | None = None
|
| 3652 |
-
recovery_status = "disabled"
|
| 3653 |
-
if _effective_mode != EngineeringStateMode.OFF:
|
| 3654 |
-
engineering_state = EngineeringState.start(
|
| 3655 |
-
goal,
|
| 3656 |
-
run_id=self._run_task_id,
|
| 3657 |
-
session_id=session_id,
|
| 3658 |
-
checkpoint_id=session_id or self._run_task_id,
|
| 3659 |
-
)
|
| 3660 |
-
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3661 |
-
recovery_status = "started"
|
| 3662 |
-
|
| 3663 |
-
# RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition.
|
| 3664 |
-
if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id:
|
| 3665 |
-
try:
|
| 3666 |
-
from api.persistence import sb_get_checkpoint
|
| 3667 |
-
legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id)
|
| 3668 |
-
candidate = (legacy_checkpoint or {}).get("engineering_state")
|
| 3669 |
-
if candidate:
|
| 3670 |
-
restored = EngineeringState.from_snapshot(candidate)
|
| 3671 |
-
if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest:
|
| 3672 |
-
engineering_state.diagnostic("restore conflict: identity mismatch")
|
| 3673 |
-
recovery_status = "conflict"
|
| 3674 |
-
elif _effective_mode == EngineeringStateMode.AUTHORITATIVE:
|
| 3675 |
-
engineering_state = restored
|
| 3676 |
-
engineering_state.prepare_for_resume()
|
| 3677 |
-
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3678 |
-
if legacy_checkpoint:
|
| 3679 |
-
checkpoint_steps = legacy_checkpoint.get("steps")
|
| 3680 |
-
checkpoint_errors = legacy_checkpoint.get("errors")
|
| 3681 |
-
state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else []
|
| 3682 |
-
state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else []
|
| 3683 |
-
recovery_status = "restored"
|
| 3684 |
-
_logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision)
|
| 3685 |
-
else:
|
| 3686 |
-
engineering_state.diagnostic("restore validated read-only")
|
| 3687 |
-
recovery_status = "validated"
|
| 3688 |
-
else:
|
| 3689 |
-
recovery_status = "checkpoint_missing"
|
| 3690 |
-
except Exception as restore_error:
|
| 3691 |
-
engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}")
|
| 3692 |
-
recovery_status = "rejected"
|
| 3693 |
-
_logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__)
|
| 3694 |
-
|
| 3695 |
-
_ACTIVE_LOOP_STATE.set(state)
|
| 3696 |
-
await self._transition_state(state, AgentState.CLASSIFYING, on_step)
|
| 3697 |
-
|
| 3698 |
-
def _with_state(result: dict[str, Any]) -> dict[str, Any]:
|
| 3699 |
-
result.update(state.state_machine.snapshot())
|
| 3700 |
-
if engineering_state is not None:
|
| 3701 |
-
result["engineering_state"] = engineering_state.projection()
|
| 3702 |
-
return result
|
| 3703 |
-
|
| 3704 |
-
if engineering_state is not None and on_step is not None:
|
| 3705 |
-
try:
|
| 3706 |
-
await _maybe_await(on_step({
|
| 3707 |
-
"action": "engineering_state",
|
| 3708 |
-
"status": recovery_status,
|
| 3709 |
-
"mode": _effective_mode.value,
|
| 3710 |
-
"engineering_state": engineering_state.projection(),
|
| 3711 |
-
}))
|
| 3712 |
-
except Exception as recovery_event_error:
|
| 3713 |
-
_logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__)
|
| 3714 |
-
|
| 3715 |
-
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3716 |
-
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
| 3717 |
-
try:
|
| 3718 |
-
await self._transition_state(state, next_state, on_step)
|
| 3719 |
-
finally:
|
| 3720 |
-
# P1 contract: persist the terminal state before returning to the caller.
|
| 3721 |
-
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
| 3722 |
-
return _with_state(result)
|
| 3723 |
-
|
| 3724 |
-
# Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner,
|
| 3725 |
-
# sandbox, speculazione o tool card è raggiungibile. L'unica eccezione è la
|
| 3726 |
-
# conversione CSV→JSON già riconosciuta e validata dal parser puro al confine HTTP.
|
| 3727 |
-
if not allow_tools:
|
| 3728 |
-
if allow_local_csv_conversion:
|
| 3729 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 3730 |
-
direct_results, _tools_count, _exec_success, _exec_errors = await self._run_direct_tools(
|
| 3731 |
-
goal, on_step=on_step, local_csv_only=True,
|
| 3732 |
-
)
|
| 3733 |
-
if direct_results.startswith("[DIRECT_TERMINAL]\n"):
|
| 3734 |
-
_r = await _finish({
|
| 3735 |
-
"success": _exec_success > 0,
|
| 3736 |
-
"output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
|
| 3737 |
-
"steps": state.steps,
|
| 3738 |
-
})
|
| 3739 |
-
else:
|
| 3740 |
-
_r = await _finish({
|
| 3741 |
-
"success": False,
|
| 3742 |
-
"output": direct_results,
|
| 3743 |
-
"steps": state.steps,
|
| 3744 |
-
"errors": ["conversione CSV locale non completata"],
|
| 3745 |
-
})
|
| 3746 |
-
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3747 |
-
_r["effective_max_steps"] = state.max_steps
|
| 3748 |
-
if _sid_token is not None:
|
| 3749 |
-
try: _sid_var.reset(_sid_token)
|
| 3750 |
-
except Exception: pass
|
| 3751 |
-
return _r
|
| 3752 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 3753 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 3754 |
-
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3755 |
-
_r["effective_max_steps"] = state.max_steps
|
| 3756 |
-
if _sid_token is not None:
|
| 3757 |
-
try: _sid_var.reset(_sid_token)
|
| 3758 |
-
except Exception: pass
|
| 3759 |
-
return _r
|
| 3760 |
-
|
| 3761 |
# GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
|
| 3762 |
try:
|
| 3763 |
from agents.strategic_healer import StrategicHealer as _SHClass
|
|
@@ -3839,7 +3501,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3839 |
"title": "Specifica cosa vuoi fare",
|
| 3840 |
"explanation": _amb_answer,
|
| 3841 |
}))
|
| 3842 |
-
_r_amb =
|
| 3843 |
if _sid_token is not None:
|
| 3844 |
try: _sid_var.reset(_sid_token)
|
| 3845 |
except Exception: pass
|
|
@@ -3956,7 +3618,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3956 |
"title": "Puoi essere più specifico?",
|
| 3957 |
"explanation": _bl_answer,
|
| 3958 |
}))
|
| 3959 |
-
_r_bl =
|
| 3960 |
if _sid_token is not None:
|
| 3961 |
try: _sid_var.reset(_sid_token)
|
| 3962 |
except Exception: pass
|
|
@@ -3973,8 +3635,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3973 |
_rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3974 |
except Exception as _exc:
|
| 3975 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3976 |
-
await self.
|
| 3977 |
-
_r = await _finish(await self._run_fast_path(state, on_step))
|
| 3978 |
_r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
|
| 3979 |
_r["effective_max_steps"] = state.max_steps # GAP-2-FIX
|
| 3980 |
# S749-D: reset ContextVar
|
|
@@ -3993,8 +3654,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3993 |
except Exception as _exc:
|
| 3994 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3995 |
# Puro ragionamento â LLM diretto, nessun overhead tool
|
| 3996 |
-
await self.
|
| 3997 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 3998 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3999 |
try:
|
| 4000 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4024,8 +3684,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4024 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 4025 |
except Exception:
|
| 4026 |
pass
|
| 4027 |
-
await self.
|
| 4028 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4029 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4030 |
_r["effective_max_steps"] = state.max_steps
|
| 4031 |
if _sid_token is not None:
|
|
@@ -4092,15 +3751,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4092 |
if _sid_token is not None:
|
| 4093 |
try: _sid_var.reset(_sid_token)
|
| 4094 |
except Exception: pass
|
| 4095 |
-
|
| 4096 |
-
return await _finish({
|
| 4097 |
"success": True,
|
| 4098 |
"answer": _p36_answer,
|
| 4099 |
"timing_ms": _p36_ms,
|
| 4100 |
"effective_max_steps": state.max_steps,
|
| 4101 |
"steps": [{"action": "p36_python_analyze", "status": "done",
|
| 4102 |
"output": _p36_answer[:300]}],
|
| 4103 |
-
}
|
| 4104 |
except Exception as _p36_exc:
|
| 4105 |
_logger.debug("P36 fast-path silenced: %s", _p36_exc)
|
| 4106 |
# fail-open: cade nel percorso normale
|
|
@@ -4112,7 +3770,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4112 |
except Exception as _exc:
|
| 4113 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 4114 |
_t_tool = _time.monotonic()
|
| 4115 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 4116 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 4117 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 4118 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
@@ -4121,20 +3778,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4121 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4122 |
"tools_fired": _tools_count,
|
| 4123 |
}))
|
| 4124 |
-
|
| 4125 |
-
|
| 4126 |
-
|
| 4127 |
-
|
| 4128 |
-
|
| 4129 |
-
|
| 4130 |
-
else:
|
| 4131 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4132 |
-
_r = await _finish(await self._run_fallback(
|
| 4133 |
-
state, on_step,
|
| 4134 |
-
preloaded_tool_results=direct_results or None,
|
| 4135 |
-
preloaded_tool_exec_successes=_exec_success,
|
| 4136 |
-
preloaded_tool_exec_errors=_exec_errors,
|
| 4137 |
-
))
|
| 4138 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4139 |
try:
|
| 4140 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4161,7 +3810,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4161 |
# S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
|
| 4162 |
# S402: unpack 4-tuple â aggiunto _exec_success/_exec_errors per Tool Integrity Guard
|
| 4163 |
_t_tool = _time.monotonic()
|
| 4164 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 4165 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 4166 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 4167 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
@@ -4173,20 +3821,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4173 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4174 |
"tools_fired": _tools_count,
|
| 4175 |
}))
|
| 4176 |
-
|
| 4177 |
-
|
| 4178 |
-
|
| 4179 |
-
|
| 4180 |
-
|
| 4181 |
-
|
| 4182 |
-
else:
|
| 4183 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4184 |
-
_r = await _finish(await self._run_fallback(
|
| 4185 |
-
state, on_step,
|
| 4186 |
-
preloaded_tool_results=direct_results,
|
| 4187 |
-
preloaded_tool_exec_successes=_exec_success,
|
| 4188 |
-
preloaded_tool_exec_errors=_exec_errors,
|
| 4189 |
-
))
|
| 4190 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4191 |
try:
|
| 4192 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4210,8 +3850,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4210 |
# Rimosso: -25s worst case, path sempre: direct_tools â _run_fallback.
|
| 4211 |
|
| 4212 |
# Fallback: LLM senza tool results (tool non triggered o tutti skip)
|
| 4213 |
-
await self.
|
| 4214 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4215 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4216 |
try:
|
| 4217 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 55 |
_ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
|
| 56 |
_is_goal_ambiguous,
|
| 57 |
_is_borderline_ambiguous,
|
|
|
|
| 58 |
UnifiedLoopState,
|
| 59 |
_maybe_await,
|
| 60 |
)
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# S404: Error Classifier â import lazy per evitare circular import issues
|
| 63 |
def _get_classifier():
|
| 64 |
from agents.error_classifier import classify_error, format_for_context
|
|
|
|
| 129 |
self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
|
| 130 |
self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback → iniettato in exec_warn prima di StrategicHealer
|
| 131 |
# ââ GAP-3: Rollback atomico scritture âââââââââââââââââââââââââââââââââââââââââ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 133 |
"""
|
| 134 |
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà .
|
|
|
|
| 466 |
# F17+B7: planner per task di progettazione/implementazione â soglia ridotta a 10 chars
|
| 467 |
# Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
|
| 468 |
# _NEEDS_PLAN_RE filtra già query semplici â len guard serve solo per 1-8 char input.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
_should_plan = (
|
| 470 |
self.planner
|
| 471 |
and not tool_results
|
|
|
|
| 472 |
and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
|
| 473 |
and len(state.goal) > 10
|
| 474 |
)
|
|
|
|
| 485 |
}
|
| 486 |
_logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
|
| 487 |
_t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
|
| 488 |
+
if _should_plan:
|
| 489 |
if on_step:
|
| 490 |
await _maybe_await(on_step({
|
| 491 |
"loop": 0, "action": "plan", "status": "started",
|
|
|
|
| 494 |
}))
|
| 495 |
# S640: timeout planner + S-FMT-ORCH fast-fix bypass
|
| 496 |
# Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
|
| 497 |
+
if _fast_fix_plan is not None:
|
|
|
|
|
|
|
|
|
|
| 498 |
plan = _fast_fix_plan
|
| 499 |
_logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
|
| 500 |
else:
|
|
|
|
| 519 |
"explanation": "Il pianificatore ha impiegato troppo â procedo senza piano",
|
| 520 |
"visibility": "progress",
|
| 521 |
}))
|
| 522 |
+
if plan is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
state.steps.append({"action": "plan", "result": plan})
|
| 524 |
try:
|
| 525 |
from api.state import record_timing as _rtc_pl
|
|
|
|
| 926 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 927 |
reg_name, inp_builder = tool_key_pair
|
| 928 |
if reg_name and inp_builder is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 930 |
elif _s_tool:
|
| 931 |
# COG-4: tool non in _TOOL_MAP — tenta generazione dinamica
|
|
|
|
| 1661 |
_logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
|
| 1662 |
self._tdd_fail_inject = None
|
| 1663 |
# GAP-4: StrategicHealer — analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
|
| 1664 |
+
if exec_errors and getattr(self, '_strategic_healer', None):
|
| 1665 |
try:
|
| 1666 |
_sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
|
| 1667 |
+
_sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str)
|
| 1668 |
if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
|
| 1669 |
exec_warn.insert(0, _sh_decision.strategy_prompt)
|
| 1670 |
_logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
|
| 1671 |
if _sh_decision and getattr(_sh_decision, 'should_stop', False):
|
| 1672 |
_logger.info("GAP-4: StrategicHealer → should_stop, interruzione fallback")
|
| 1673 |
+
return # _run_fallback: should_stop → esci dal fallback (non c'è loop da rompere)
|
| 1674 |
except Exception as _sh_loop_err:
|
| 1675 |
_logger.debug("GAP-4: StrategicHealer loop silenced — %s", _sh_loop_err)
|
| 1676 |
# GAP-SELFHEAL v2: dual-mode fingerprinting — raw + error-class extraction.
|
|
|
|
| 2133 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2134 |
except Exception as _exc:
|
| 2135 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2136 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2137 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2138 |
if _fr == 'length' and on_step:
|
|
|
|
| 2888 |
except Exception:
|
| 2889 |
pass
|
| 2890 |
# S455-P10: task supervisionato — done_callback logga eccezioni silenziate
|
| 2891 |
+
asyncio.create_task(_reverify_task())
|
| 2892 |
_rv_t.add_done_callback(
|
| 2893 |
lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
|
| 2894 |
)
|
|
|
|
| 3360 |
|
| 3361 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3362 |
on_step: StepCallback | None = None,
|
| 3363 |
+
session_id: str = "") -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3364 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 3365 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 3366 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso â input come
|
|
|
|
| 3395 |
except Exception:
|
| 3396 |
_sid_token = None # fallback silente â registry usa default "agent_default"
|
| 3397 |
|
| 3398 |
+
# S750-GAP-B: pre-warm sandbox backend-exec â POST /api/session in background.
|
| 3399 |
+
# asyncio.create_task lancia la richiesta senza bloccare il routing:
|
| 3400 |
+
# mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway è già pronta.
|
| 3401 |
+
try:
|
| 3402 |
+
from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
|
| 3403 |
+
if _eurl:
|
| 3404 |
+
asyncio.ensure_future(
|
| 3405 |
+
_ce({"session_id": self._run_task_id}, endpoint="/api/session")
|
| 3406 |
+
)
|
| 3407 |
+
except Exception as _exc:
|
| 3408 |
+
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3409 |
|
| 3410 |
# S568-B: reset _session_files ogni run â previene memory leak su sessioni lunghe.
|
| 3411 |
# Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
|
|
|
|
| 3420 |
|
| 3421 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 3422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3423 |
# GAP-4: StrategicHealer — init + load past failures (LLM-based self-healing cognitivo)
|
| 3424 |
try:
|
| 3425 |
from agents.strategic_healer import StrategicHealer as _SHClass
|
|
|
|
| 3501 |
"title": "Specifica cosa vuoi fare",
|
| 3502 |
"explanation": _amb_answer,
|
| 3503 |
}))
|
| 3504 |
+
_r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 3505 |
if _sid_token is not None:
|
| 3506 |
try: _sid_var.reset(_sid_token)
|
| 3507 |
except Exception: pass
|
|
|
|
| 3618 |
"title": "Puoi essere più specifico?",
|
| 3619 |
"explanation": _bl_answer,
|
| 3620 |
}))
|
| 3621 |
+
_r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 3622 |
if _sid_token is not None:
|
| 3623 |
try: _sid_var.reset(_sid_token)
|
| 3624 |
except Exception: pass
|
|
|
|
| 3635 |
_rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3636 |
except Exception as _exc:
|
| 3637 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3638 |
+
_r = await self._run_fast_path(state, on_step)
|
|
|
|
| 3639 |
_r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
|
| 3640 |
_r["effective_max_steps"] = state.max_steps # GAP-2-FIX
|
| 3641 |
# S749-D: reset ContextVar
|
|
|
|
| 3654 |
except Exception as _exc:
|
| 3655 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3656 |
# Puro ragionamento â LLM diretto, nessun overhead tool
|
| 3657 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3658 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3659 |
try:
|
| 3660 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3684 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3685 |
except Exception:
|
| 3686 |
pass
|
| 3687 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3688 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3689 |
_r["effective_max_steps"] = state.max_steps
|
| 3690 |
if _sid_token is not None:
|
|
|
|
| 3751 |
if _sid_token is not None:
|
| 3752 |
try: _sid_var.reset(_sid_token)
|
| 3753 |
except Exception: pass
|
| 3754 |
+
return {
|
|
|
|
| 3755 |
"success": True,
|
| 3756 |
"answer": _p36_answer,
|
| 3757 |
"timing_ms": _p36_ms,
|
| 3758 |
"effective_max_steps": state.max_steps,
|
| 3759 |
"steps": [{"action": "p36_python_analyze", "status": "done",
|
| 3760 |
"output": _p36_answer[:300]}],
|
| 3761 |
+
}
|
| 3762 |
except Exception as _p36_exc:
|
| 3763 |
_logger.debug("P36 fast-path silenced: %s", _p36_exc)
|
| 3764 |
# fail-open: cade nel percorso normale
|
|
|
|
| 3770 |
except Exception as _exc:
|
| 3771 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3772 |
_t_tool = _time.monotonic()
|
|
|
|
| 3773 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 3774 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 3775 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
|
|
| 3778 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 3779 |
"tools_fired": _tools_count,
|
| 3780 |
}))
|
| 3781 |
+
_r = await self._run_fallback(
|
| 3782 |
+
state, on_step,
|
| 3783 |
+
preloaded_tool_results=direct_results or None,
|
| 3784 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 3785 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 3786 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3787 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3788 |
try:
|
| 3789 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3810 |
# S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
|
| 3811 |
# S402: unpack 4-tuple â aggiunto _exec_success/_exec_errors per Tool Integrity Guard
|
| 3812 |
_t_tool = _time.monotonic()
|
|
|
|
| 3813 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 3814 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 3815 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
|
|
| 3821 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 3822 |
"tools_fired": _tools_count,
|
| 3823 |
}))
|
| 3824 |
+
_r = await self._run_fallback(
|
| 3825 |
+
state, on_step,
|
| 3826 |
+
preloaded_tool_results=direct_results,
|
| 3827 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 3828 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 3829 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3830 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3831 |
try:
|
| 3832 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3850 |
# Rimosso: -25s worst case, path sempre: direct_tools â _run_fallback.
|
| 3851 |
|
| 3852 |
# Fallback: LLM senza tool results (tool non triggered o tutti skip)
|
| 3853 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3854 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3855 |
try:
|
| 3856 |
from api.state import record_timing as _rtc_ttr
|
agents/unified_loop_delegate.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_delegate.py — DelegateMixin: debug riflessivo, replan, delega in-loop.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale.
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_reflective_debug(goal, errors): BGAP-GUARD diagnosi breve da errori tool
|
| 7 |
+
_budget_replan_check(state, step): BGAP-1 replan probabilistico su budget critico
|
| 8 |
+
_DELEGATE_RESEARCH_RE: regex riconoscimento sub-goal tipo ricerca
|
| 9 |
+
_run_in_loop_delegate(sub_goal): GAP-1 micro-agente specializzato in-loop
|
| 10 |
+
|
| 11 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 12 |
+
MRO garantisce che DelegateMixin._budget_replan_check sovrascriva HelpersMixin
|
| 13 |
+
(DelegateMixin precede HelpersMixin nella lista basi di UnifiedAgentLoop).
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import logging
|
| 19 |
+
import re
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from agents.unified_loop_types import StepCallback, UnifiedLoopState, _maybe_await
|
| 23 |
+
|
| 24 |
+
_logger = logging.getLogger("agente_ai")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class DelegateMixin:
|
| 28 |
+
async def _reflective_debug(
|
| 29 |
+
self, goal: str = "", errors: Any = None, **kwargs: Any
|
| 30 |
+
) -> str:
|
| 31 |
+
"""Reflective debug: analizza errori e propone diagnosi in max 2 frasi.
|
| 32 |
+
Chiamato dopo tool failures per arricchire state.context con ipotesi fix.
|
| 33 |
+
Fail-open: non blocca mai il loop in caso di errore LLM."""
|
| 34 |
+
try:
|
| 35 |
+
_ctx = f"Goal: {str(goal)[:200]}\nErrori: {'; '.join(str(e)[:300] for e in (errors if isinstance(errors, list) else [errors])[:3])}" # S573: 150→300
|
| 36 |
+
_fast = self._get_fast_llm()
|
| 37 |
+
_diag = await asyncio.wait_for(
|
| 38 |
+
_fast.chat([{"role": "user", "content": f"Diagnosi breve (max 2 frasi):\n{_ctx}"}], max_tokens=300), # S586: 120->180->300
|
| 39 |
+
timeout=5.0,
|
| 40 |
+
)
|
| 41 |
+
return (str(_diag) if _diag else "").strip()[:300]
|
| 42 |
+
except Exception:
|
| 43 |
+
pass # fail-open
|
| 44 |
+
return ""
|
| 45 |
+
|
| 46 |
+
# ── BGAP-1: Probabilistic Re-planning Trigger ────────────────────────────
|
| 47 |
+
async def _budget_replan_check(
|
| 48 |
+
self, state: Any, step_count: int, on_step: Any = None
|
| 49 |
+
) -> str:
|
| 50 |
+
"""BGAP-1: probabilistic re-planning trigger.
|
| 51 |
+
Guards: skip se _n_err < 2 OR _budget_ratio < 0.6.
|
| 52 |
+
Usa _get_fast_llm() con max_tokens=120. Fail-open."""
|
| 53 |
+
_n_err = len(state.errors) if getattr(state, 'errors', None) else 0
|
| 54 |
+
if _n_err < 2:
|
| 55 |
+
return ''
|
| 56 |
+
_budget_ratio = step_count / max(state.max_steps, 1)
|
| 57 |
+
if _budget_ratio < 0.6:
|
| 58 |
+
return ''
|
| 59 |
+
# dedup guard [GAP-1-REPLAN]: skip se già replanned in questo loop
|
| 60 |
+
if '[GAP-1-REPLAN]' in (state.context or ''):
|
| 61 |
+
return ''
|
| 62 |
+
try:
|
| 63 |
+
_fast_llm = self._get_fast_llm()
|
| 64 |
+
_prompt = (
|
| 65 |
+
f'Task ha avuto {_n_err} errori e usato {_budget_ratio:.0%} del budget. '
|
| 66 |
+
f'Suggerisci UN approccio alternativo in max 2 frasi. Goal: {state.goal[:500]}' # S597: 200->300->500
|
| 67 |
+
)
|
| 68 |
+
_hint = await asyncio.wait_for(
|
| 69 |
+
_fast_llm.chat([{'role': 'user', 'content': _prompt}], max_tokens=120),
|
| 70 |
+
timeout=5.0,
|
| 71 |
+
)
|
| 72 |
+
return (str(_hint) if _hint else '').strip()[:200]
|
| 73 |
+
except Exception:
|
| 74 |
+
pass # fail-open totale
|
| 75 |
+
return ''
|
| 76 |
+
|
| 77 |
+
# ── GAP-1: Delega Dinamica In-Loop ─────────────────────────────────────
|
| 78 |
+
_DELEGATE_RESEARCH_RE = re.compile(
|
| 79 |
+
r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|'
|
| 80 |
+
r'news|notizie|fetch|scrape|pagina|sito|http)\b',
|
| 81 |
+
re.IGNORECASE,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict:
|
| 85 |
+
"""GAP-1: Delega Dinamica In-Loop.
|
| 86 |
+
Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale.
|
| 87 |
+
Architettura:
|
| 88 |
+
- Stesso executor del parent → accesso ai tool reali (write_file, run_python, ...)
|
| 89 |
+
- LLM selezionato per ruolo → RESEARCHER, CODER o REASONER in base al goal
|
| 90 |
+
- _is_delegate_child = True → blocca ricorsione (max 1 livello di delega)
|
| 91 |
+
- max_steps = 4 → micro-agente leggero, non un loop completo
|
| 92 |
+
- output troncato a 4000 chars → evita context-window explosion nel parent
|
| 93 |
+
"""
|
| 94 |
+
# P18: defensive anti-recursion guard at entry point
|
| 95 |
+
if getattr(self, '_is_delegate_child', False):
|
| 96 |
+
_logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry")
|
| 97 |
+
return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False}
|
| 98 |
+
try:
|
| 99 |
+
from models.role_router import RoleRouter as _RR_d, Role as _Role_d
|
| 100 |
+
# Seleziona LLM specializzato in base al tipo di sotto-obiettivo
|
| 101 |
+
if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]):
|
| 102 |
+
_sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash
|
| 103 |
+
elif self._CODE_RE.search(sub_goal[:300]):
|
| 104 |
+
_sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout
|
| 105 |
+
else:
|
| 106 |
+
_sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B
|
| 107 |
+
except Exception:
|
| 108 |
+
_sub_llm = self.llm # fallback: usa LLM del parent
|
| 109 |
+
|
| 110 |
+
# Crea loop figlio: stessi executor/planner/memory, LLM specializzato
|
| 111 |
+
_sub_loop = UnifiedAgentLoop(
|
| 112 |
+
llm_client=_sub_llm,
|
| 113 |
+
planner=self.planner,
|
| 114 |
+
executor=self.executor,
|
| 115 |
+
critic=None, # no critic — micro-agente leggero
|
| 116 |
+
memory=self.memory,
|
| 117 |
+
verifier=None, # no verifier — massima velocità
|
| 118 |
+
)
|
| 119 |
+
# Anti-ricorsione: il figlio non può delegare ulteriormente
|
| 120 |
+
_sub_loop._is_delegate_child = True
|
| 121 |
+
# Propaga session_id per isolare sandbox backend-exec
|
| 122 |
+
_sub_loop._run_task_id = self._run_task_id + "_d"
|
| 123 |
+
# GAP-6: condividi dict mutabile _session_files con il parent loop
|
| 124 |
+
# Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent
|
| 125 |
+
# Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate
|
| 126 |
+
_sub_loop._session_files = self._session_files
|
| 127 |
+
|
| 128 |
+
# P17-F1: buffer output parziale via on_step — sopravvive al timeout
|
| 129 |
+
_partial_steps: list[dict] = []
|
| 130 |
+
async def _capture_partial(step: dict) -> None:
|
| 131 |
+
if step.get("output") or step.get("explanation"):
|
| 132 |
+
_partial_steps.append(step)
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
_res = await asyncio.wait_for(
|
| 136 |
+
_sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial),
|
| 137 |
+
timeout=timeout,
|
| 138 |
+
)
|
| 139 |
+
_out = (_res.get("output") or "")[:4000]
|
| 140 |
+
_logger.info(
|
| 141 |
+
"GAP-1 delegate OK [%s] steps=%d: %s",
|
| 142 |
+
_res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60],
|
| 143 |
+
)
|
| 144 |
+
return {
|
| 145 |
+
"success": _res.get("success", False),
|
| 146 |
+
"output": _out,
|
| 147 |
+
"engine": _res.get("engine", "delegate"),
|
| 148 |
+
"steps": len(_res.get("steps", [])),
|
| 149 |
+
}
|
| 150 |
+
except asyncio.TimeoutError:
|
| 151 |
+
# P17-F1: esponi stato parziale invece di stringa vuota
|
| 152 |
+
# _session_files già condiviso con parent → parent vede file scritti
|
| 153 |
+
_partial_files = list(getattr(_sub_loop, "_session_files", {}).keys())
|
| 154 |
+
_partial_out = " ".join(
|
| 155 |
+
(s.get("output") or s.get("explanation") or "")[:300]
|
| 156 |
+
for s in _partial_steps[-3:]
|
| 157 |
+
).strip()[:1500]
|
| 158 |
+
_logger.warning(
|
| 159 |
+
"GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s",
|
| 160 |
+
timeout, len(_partial_steps), len(_partial_files), sub_goal[:60],
|
| 161 |
+
)
|
| 162 |
+
# S-PARTIAL: emetti evento SSE partial_output al frontend PRIMA di restituire
|
| 163 |
+
# così l'utente vede il chip "⚠ output parziale — riprendo" in tempo reale
|
| 164 |
+
if on_step:
|
| 165 |
+
await _maybe_await(on_step({
|
| 166 |
+
"event": "partial_output",
|
| 167 |
+
"action": "partial_output",
|
| 168 |
+
"visibility": "progress",
|
| 169 |
+
"partial": True,
|
| 170 |
+
"steps_done": len(_partial_steps),
|
| 171 |
+
"partial_files": _partial_files,
|
| 172 |
+
"partial_output": _partial_out,
|
| 173 |
+
"output": _partial_out,
|
| 174 |
+
"explanation": f"Output parziale dopo {timeout:.0f}s — l'agente sta recuperando",
|
| 175 |
+
"status": "warning",
|
| 176 |
+
}))
|
| 177 |
+
return {
|
| 178 |
+
"success": False,
|
| 179 |
+
"output": _partial_out,
|
| 180 |
+
"error": f"delegate timeout ({timeout:.0f}s) — risultato parziale",
|
| 181 |
+
"partial": True,
|
| 182 |
+
"partial_files": _partial_files,
|
| 183 |
+
"steps_done": len(_partial_steps),
|
| 184 |
+
}
|
| 185 |
+
except Exception as _de:
|
| 186 |
+
_logger.warning("GAP-1 delegate error: %s", _de)
|
| 187 |
+
return {"success": False, "output": "", "error": str(_de)[:200]}
|
| 188 |
+
|
| 189 |
+
# ââ S362: Role routing helpers âââââââââââââââââââââââââââââââââââââââââââââ
|
| 190 |
+
|
| 191 |
+
# S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi.
|
| 192 |
+
# Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM.
|
agents/unified_loop_fallback.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_helpers.py
CHANGED
|
@@ -27,19 +27,7 @@ import logging
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
-
from agents.unified_loop_types import
|
| 31 |
-
StepCallback,
|
| 32 |
-
UnifiedLoopState,
|
| 33 |
-
_LANG_INSTRUCTIONS,
|
| 34 |
-
_detect_user_lang,
|
| 35 |
-
_maybe_await,
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _get_classifier():
|
| 40 |
-
"""Load the error classifier lazily, avoiding import cycles."""
|
| 41 |
-
from agents.error_classifier import classify_error, format_for_context
|
| 42 |
-
return classify_error, format_for_context
|
| 43 |
|
| 44 |
|
| 45 |
class HelpersMixin:
|
|
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi — zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
+
from agents.unified_loop_types import StepCallback, UnifiedLoopState
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class HelpersMixin:
|
agents/unified_loop_llm.py
CHANGED
|
@@ -34,28 +34,13 @@ class LLMSelectionMixin:
|
|
| 34 |
|
| 35 |
def _get_llm_for_goal(self, goal: str) -> Any:
|
| 36 |
"""S362: return CODER-role LLM for code-heavy goals, default otherwise.
|
| 37 |
-
GAP-ROUT: route SQL/Reasoning/MMLU to REASONER role (Cerebras 120B).
|
| 38 |
S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
|
| 39 |
-
anche se _CODE_RE non matcha
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
bool(self._MMLU_GOAL_RE.search(g))
|
| 45 |
-
|
| 46 |
-
_tok = self._max_tokens_for_goal(goal)
|
| 47 |
-
_needs_heavy = _is_code or _is_reasoning or _tok >= 6144
|
| 48 |
-
|
| 49 |
-
if not _needs_heavy:
|
| 50 |
return self.llm
|
| 51 |
-
|
| 52 |
-
if _is_reasoning:
|
| 53 |
-
try:
|
| 54 |
-
from models.role_router import RoleRouter, Role
|
| 55 |
-
return RoleRouter.get_client(Role.REASONER)
|
| 56 |
-
except Exception:
|
| 57 |
-
pass
|
| 58 |
-
|
| 59 |
if self._coder_llm is None:
|
| 60 |
try:
|
| 61 |
from models.role_router import RoleRouter, Role
|
|
@@ -65,7 +50,7 @@ class LLMSelectionMixin:
|
|
| 65 |
return self._coder_llm
|
| 66 |
|
| 67 |
def _get_fast_llm(self) -> Any:
|
| 68 |
-
"""S-FAST: return Role.FAST client (Groq
|
| 69 |
Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
|
| 70 |
Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
|
| 71 |
if self._fast_llm is None:
|
|
@@ -119,28 +104,12 @@ class LLMSelectionMixin:
|
|
| 119 |
return self._verifier_llm
|
| 120 |
|
| 121 |
def _is_pure_explanation(self, goal: str) -> bool:
|
| 122 |
-
"""True
|
| 123 |
-
|
| 124 |
-
I riferimenti nominali contestuali, per esempio ``dopo una modifica al
|
| 125 |
-
codice``, non trasformano una domanda esplicativa in un task operativo.
|
| 126 |
-
Una seconda azione imperativa resta invece un percorso operativo.
|
| 127 |
-
"""
|
| 128 |
if len(goal) > 300: return False
|
| 129 |
-
|
| 130 |
-
if
|
| 131 |
-
if self._EXPL_FILE_REF_RE.search(
|
| 132 |
-
if self._EXPL_REALTIME_RE.search(text): return False
|
| 133 |
-
if self._EXPL_TUTORIAL_RE.search(text): return True
|
| 134 |
-
contextual_spans = [
|
| 135 |
-
match.span()
|
| 136 |
-
for match in self._EXPL_CONTEXTUAL_ACTION_REF_RE.finditer(text)
|
| 137 |
-
]
|
| 138 |
-
for action in self._EXPL_ACTION_RE.finditer(text):
|
| 139 |
-
if not any(
|
| 140 |
-
start <= action.start() < end
|
| 141 |
-
for start, end in contextual_spans
|
| 142 |
-
):
|
| 143 |
-
return False
|
| 144 |
return True
|
| 145 |
|
| 146 |
# S371: _SKIP_SMOL_RE â skippa smolagents per query semplici (notizie, cerca) â direct tools
|
|
@@ -300,18 +269,11 @@ class LLMSelectionMixin:
|
|
| 300 |
_FORMAT_DIRECTIVE_CODE = (
|
| 301 |
"FORMATO RISPOSTA OBBLIGATORIO â CODICE:\n"
|
| 302 |
"⢠Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
|
| 303 |
-
"â¢
|
| 304 |
-
"
|
| 305 |
-
"â¢
|
| 306 |
-
"
|
| 307 |
-
"â¢
|
| 308 |
-
"nessun simbolo non definito, tipi espliciti.\n"
|
| 309 |
-
"⢠Per codice async con handler indipendenti: includi `async`, `await` e `try/catch` oppure "
|
| 310 |
-
"`Promise.allSettled` per isolare ogni errore.\n"
|
| 311 |
-
"⢠Per correzioni React useEffect: preserva la struttura, usa AbortController o una guardia di annullamento "
|
| 312 |
-
"e restituisci sempre cleanup (`return () => ...`).\n"
|
| 313 |
-
"⢠Aggiungi commenti inline solo per la logica non ovvia. Se multi-file: mostra ogni file in un blocco separato "
|
| 314 |
-
"con il nome come titolo; formato titolo: ### src/nomefile.tsx."
|
| 315 |
)
|
| 316 |
_FORMAT_DIRECTIVE_MARKDOWN = (
|
| 317 |
"FORMATO RISPOSTA OBBLIGATORIO â STRUTTURATO:\n"
|
|
@@ -502,23 +464,6 @@ class LLMSelectionMixin:
|
|
| 502 |
r'risposta\s+breve|brief\s+answer|short\s+answer)\b',
|
| 503 |
re.IGNORECASE,
|
| 504 |
)
|
| 505 |
-
# GAP-ROUT: routing specializzato per benchmark (SQL, Reasoning, MMLU)
|
| 506 |
-
_SQL_GOAL_RE = re.compile(
|
| 507 |
-
r'\b(sql|postgresql|cte ricorsiva|recursive cte|with recursive|'
|
| 508 |
-
r'window functions?|over\(|partition by|rank\(|row_number\(|'
|
| 509 |
-
r'gerarchia|parent_id|manager_id|recursive)\b',
|
| 510 |
-
re.IGNORECASE,
|
| 511 |
-
)
|
| 512 |
-
_REASONING_GOAL_RE = re.compile(
|
| 513 |
-
r'\b(reasoning|gsm8k|math|matematica|logica|ragionamento|'
|
| 514 |
-
r'ted the t-rex|calcola|calcolare|probabilit|bayes|frazioni|percentuale)\b',
|
| 515 |
-
re.IGNORECASE,
|
| 516 |
-
)
|
| 517 |
-
_MMLU_GOAL_RE = re.compile(
|
| 518 |
-
r'\b(mmlu|computer science|informatica|architettura|os|networking|'
|
| 519 |
-
r'database|complessità|p vs np|modello osi|acid properties)\b',
|
| 520 |
-
re.IGNORECASE,
|
| 521 |
-
)
|
| 522 |
# S-FMT-ORCH: fast-fix detector per bypass ARCHITECT su singola operazione (<180 chars)
|
| 523 |
# B1: espansa con 10 operazioni atomiche — guardata da len(goal)<180 nel chiamante.
|
| 524 |
# Conseguenze: skip ARCHITECT (-15s) per operazioni single-step unambiguamente chiare.
|
|
@@ -559,7 +504,7 @@ class LLMSelectionMixin:
|
|
| 559 |
r"^\s*(?:"
|
| 560 |
r"(?:cos'?[e\xe8]\s+)"
|
| 561 |
r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
|
| 562 |
-
r"|(?:
|
| 563 |
r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
|
| 564 |
r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
|
| 565 |
r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
|
|
@@ -572,22 +517,6 @@ class LLMSelectionMixin:
|
|
| 572 |
r")",
|
| 573 |
re.IGNORECASE | re.DOTALL,
|
| 574 |
)
|
| 575 |
-
_EXPL_REALTIME_RE = re.compile(
|
| 576 |
-
r"\b(oggi|adesso|ora|live|real.?time|notizie|news|ultime|recenti|"
|
| 577 |
-
r"aggiornamenti|previsioni|meteo|prezzo|quotazione|borsa|trend)\b",
|
| 578 |
-
re.IGNORECASE,
|
| 579 |
-
)
|
| 580 |
-
_EXPL_TUTORIAL_RE = re.compile(
|
| 581 |
-
r"^\s*spiega(?:mi)?\b.{0,80}?\b(?:e\s+)?poi\s+"
|
| 582 |
-
r"(?:indica|descrivi|elenca)\s+(?:i\s+)?(?:passaggi|step)\s+"
|
| 583 |
-
r"(?:per\s+)?(?:modificare|correggere|configurare|aggiornare)\b",
|
| 584 |
-
re.IGNORECASE | re.DOTALL,
|
| 585 |
-
)
|
| 586 |
-
_EXPL_CONTEXTUAL_ACTION_REF_RE = re.compile(
|
| 587 |
-
r"\b(?:dopo|prima|durante|in seguito a|a seguito di)\s+una\s+modifica\s+"
|
| 588 |
-
r"(?:al|del|nel)\s+(?:codice|file|progetto)\b",
|
| 589 |
-
re.IGNORECASE,
|
| 590 |
-
)
|
| 591 |
_EXPL_ACTION_RE = re.compile(
|
| 592 |
r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
|
| 593 |
r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
|
|
|
|
| 34 |
|
| 35 |
def _get_llm_for_goal(self, goal: str) -> Any:
|
| 36 |
"""S362: return CODER-role LLM for code-heavy goals, default otherwise.
|
|
|
|
| 37 |
S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
|
| 38 |
+
anche se _CODE_RE non matcha â garantisce qualità su app multi-file."""
|
| 39 |
+
_is_code = bool(self._CODE_RE.search(goal[:500]))
|
| 40 |
+
_tok = self._max_tokens_for_goal(goal)
|
| 41 |
+
_needs_coder = _is_code or _tok >= 6144 # app complesse â sempre 70B
|
| 42 |
+
if not _needs_coder:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
return self.llm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
if self._coder_llm is None:
|
| 45 |
try:
|
| 46 |
from models.role_router import RoleRouter, Role
|
|
|
|
| 50 |
return self._coder_llm
|
| 51 |
|
| 52 |
def _get_fast_llm(self) -> Any:
|
| 53 |
+
"""S-FAST: return Role.FAST client (Groq llama-3.1-8b-instant) per query semplici.
|
| 54 |
Caricato lazy e cachato in self._fast_llm — zero overhead dopo il primo accesso.
|
| 55 |
Fallback silenzioso su self.llm se GROQ_API_KEY mancante o RoleRouter non disponibile."""
|
| 56 |
if self._fast_llm is None:
|
|
|
|
| 104 |
return self._verifier_llm
|
| 105 |
|
| 106 |
def _is_pure_explanation(self, goal: str) -> bool:
|
| 107 |
+
"""B5: True se goal è domanda concettuale pura — nessun tool necessario.
|
| 108 |
+
4 guard fail-open: len<300 | pattern interrogativo | no action verb | no file ref."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if len(goal) > 300: return False
|
| 110 |
+
if not self._PURE_EXPLANATION_RE.search(goal[:200]): return False
|
| 111 |
+
if self._EXPL_ACTION_RE.search(goal[:200]): return False
|
| 112 |
+
if self._EXPL_FILE_REF_RE.search(goal[:200]): return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
return True
|
| 114 |
|
| 115 |
# S371: _SKIP_SMOL_RE â skippa smolagents per query semplici (notizie, cerca) â direct tools
|
|
|
|
| 269 |
_FORMAT_DIRECTIVE_CODE = (
|
| 270 |
"FORMATO RISPOSTA OBBLIGATORIO â CODICE:\n"
|
| 271 |
"⢠Usa SEMPRE blocchi markdown con linguaggio specificato (```python, ```typescript, ecc.)\n"
|
| 272 |
+
"⢠Struttura: breve spiegazione â blocco codice completo â come usarlo\n"
|
| 273 |
+
"⢠Ogni blocco deve essere autonomo ed eseguibile senza modifiche\n"
|
| 274 |
+
"⢠Aggiungi commenti inline per la logica non ovvia\n"
|
| 275 |
+
"⢠Se multi-file: mostra ogni file in un blocco separato con il nome come titolo\n"
|
| 276 |
+
"⢠Formato titolo file OBBLIGATORIO: ### src/nomefile.tsx (H3 - risparmia spazio verticale su mobile)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
)
|
| 278 |
_FORMAT_DIRECTIVE_MARKDOWN = (
|
| 279 |
"FORMATO RISPOSTA OBBLIGATORIO â STRUTTURATO:\n"
|
|
|
|
| 464 |
r'risposta\s+breve|brief\s+answer|short\s+answer)\b',
|
| 465 |
re.IGNORECASE,
|
| 466 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
# S-FMT-ORCH: fast-fix detector per bypass ARCHITECT su singola operazione (<180 chars)
|
| 468 |
# B1: espansa con 10 operazioni atomiche — guardata da len(goal)<180 nel chiamante.
|
| 469 |
# Conseguenze: skip ARCHITECT (-15s) per operazioni single-step unambiguamente chiare.
|
|
|
|
| 504 |
r"^\s*(?:"
|
| 505 |
r"(?:cos'?[e\xe8]\s+)"
|
| 506 |
r"|(?:che\s+cos'?[a\xe0]?\s*[e\xe8]\s+)"
|
| 507 |
+
r"|(?:spiegami\b)"
|
| 508 |
r"|(?:dimmi\s+(?:come|cosa|cos|perch[e\xe8]|qual[e\xe8])\b)"
|
| 509 |
r"|(?:qual[e\xe8]\s+|qual\s+[e\xe8]\s+)(?:la\s+)?(?:differenz[ae]|scopo|significato)"
|
| 510 |
r"|(?:come\s+funziona\s+(?!il\s+(?:mio|tuo|nostro|codice|progetto|login|sito|sistema|questo)\b))"
|
|
|
|
| 517 |
r")",
|
| 518 |
re.IGNORECASE | re.DOTALL,
|
| 519 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
_EXPL_ACTION_RE = re.compile(
|
| 521 |
r"\b(crea|scrivi|genera|implementa|esegui|correggi|fix|run|create|write|"
|
| 522 |
r"generate|implement|execute|installa|deploy|avvia|configura|aggiorna|update|"
|
agents/unified_loop_prompts.py
CHANGED
|
@@ -40,16 +40,8 @@ class PromptBuilderMixin:
|
|
| 40 |
"4. Non dire 'puoi fare X' — mostra X fatto, con codice completo se richiesto\n"
|
| 41 |
"5. Se incontri un errore, analizza e riprova con approccio diverso\n"
|
| 42 |
"6. Sii specifico e concreto — niente placeholder o risposte vaghe\n"
|
| 43 |
-
"7. Per codice:
|
| 44 |
-
"8. Per matematica: mostra calcoli passo passo con numeri esatti
|
| 45 |
-
"OBBLIGO per problemi GSM8K/math: termina SEMPRE la risposta con una riga separata "
|
| 46 |
-
"\'#### <numero>\' (es. #### 225). Niente testo dopo quel numero.\n"
|
| 47 |
-
"8b. Per domande a scelta multipla (A/B/C/D): inizia la risposta con "
|
| 48 |
-
"\'Risposta: X\' dove X è la lettera scelta, poi spiega il ragionamento.\n"
|
| 49 |
-
"8c. OBBLIGO TypeScript: ogni snippet di codice TypeScript DEVE essere in blocchi "
|
| 50 |
-
"```typescript```...```typescript. Mai inline, mai in blocchi generici. "
|
| 51 |
-
"Il codice deve compilare: nessun placeholder, nessun TODO, tipi espliciti. In caso di REFACTORING: sostituisci SEMPRE nomi di variabili a lettera singola (p, m, v) con nomi semantici e descrittivi, e usa interfacce o tipi per ogni oggetto complesso.\n"
|
| 52 |
-
"8d. REASONING: Per problemi complessi, scomponi il problema in sotto-task logici. Verifica la coerenza dei risultati intermedi prima di procedere al calcolo finale.\n"
|
| 53 |
"9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
|
| 54 |
"10. NON inventare mai informazioni su te stesso: token usati, context window, "
|
| 55 |
"versione, architettura, parametri interni. Se non lo sai con certezza, "
|
|
@@ -117,13 +109,10 @@ class PromptBuilderMixin:
|
|
| 117 |
" **Passo 4:** Estrai sub — mai decode() senza verify()\n"
|
| 118 |
"• Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
|
| 119 |
"\n"
|
| 120 |
-
"===
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
"un'azione se non hai ricevuto conferma dal sistema.\n"
|
| 125 |
-
"Se l'approccio A fallisce, prova B o C, ma se tutti falliscono, spiega il motivo\n"
|
| 126 |
-
"tecnico reale invece di simulare un successo inesistente.\n"
|
| 127 |
"Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
|
| 128 |
"pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
|
| 129 |
"Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
|
|
@@ -306,7 +295,7 @@ class PromptBuilderMixin:
|
|
| 306 |
|
| 307 |
# ── S200: Context-aware rule injection ──────────────────────────────────────
|
| 308 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 309 |
-
# Con
|
| 310 |
# causa troncamento silenzioso — le regole non vengono mai lette.
|
| 311 |
# Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
|
| 312 |
# (posizione con massima attenzione del modello = "recency bias").
|
|
@@ -430,22 +419,6 @@ class PromptBuilderMixin:
|
|
| 430 |
" }\n"
|
| 431 |
"EventRegistry: on+off+listEvents SOLO (NO emit). EventHistory: emit+getHistory+historySize+clearHistory SOLO (NO on)."
|
| 432 |
),
|
| 433 |
-
(
|
| 434 |
-
["fixa", "correggi", "patch", "fix ", "corregg", "aggiusta", "sistema il bug",
|
| 435 |
-
"correggi il bug", "bug fix", "bugfix", "applica il fix", "correggi solo",
|
| 436 |
-
"modifica solo", "cambia solo", "tocca solo"],
|
| 437 |
-
"PATCH MINIMALE OBBLIGATORIA (RB1-FIX): Stai operando in modalita' FIX/PATCH. "
|
| 438 |
-
"REGOLA ASSOLUTA: modifica SOLO i punti specificati dall'utente. "
|
| 439 |
-
"VIETATO riscrivere la struttura esistente. "
|
| 440 |
-
"VIETATO aggiungere import, dipendenze o funzioni non richieste dall'utente. "
|
| 441 |
-
"VIETATO cambiare il comportamento delle parti non menzionate. "
|
| 442 |
-
"Approccio corretto: (1) identifica esattamente cosa e' rotto, "
|
| 443 |
-
"(2) scrivi SOLO il diff minimo necessario, "
|
| 444 |
-
"(3) preserva import/export, API pubbliche e side effect non coinvolti, "
|
| 445 |
-
"(4) verifica che il resto del codice rimanga invariato. "
|
| 446 |
-
"Usa apply_patch invece di write_file per qualsiasi modifica < 50% del file. "
|
| 447 |
-
"NON riscrivere funzioni, classi o moduli interi — applica il fix minimo."
|
| 448 |
-
),
|
| 449 |
(
|
| 450 |
["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
|
| 451 |
"REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
|
|
@@ -493,151 +466,6 @@ class PromptBuilderMixin:
|
|
| 493 |
"4. Half-stars: Math.floor(value) per intere + value % 1 >= 0.5 per mezza stella\n"
|
| 494 |
"5. INCLUDI SEMPRE le parole: interface, Props, export, star nel codice completo"
|
| 495 |
),
|
| 496 |
-
(
|
| 497 |
-
["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
|
| 498 |
-
"github.com/drizzle", "drizzle-orm/pg-core"],
|
| 499 |
-
"DRIZZLE ORM — schema-first guidance:\n"
|
| 500 |
-
"Use typed table definitions, explicit relations, and migrations; avoid raw SQL when the task asks for Drizzle ORM."
|
| 501 |
-
),
|
| 502 |
-
(
|
| 503 |
-
["sql", "postgresql", "cte ricorsiva", "gerarchia organizzativa",
|
| 504 |
-
"recursive cte", "with recursive", "gerarchia con depth", "window functions",
|
| 505 |
-
"rank()", "row_number()", "partition by", "gerarchia dipendenti", "over("],
|
| 506 |
-
"SQL EXPERT — RECURSIVE CTE & ANALYTICS (S-BENCH-SQL):\n"
|
| 507 |
-
"Per query su gerarchie (manager-dipendente, categorie padre-figlio) o analisi dati avanzate.\n"
|
| 508 |
-
"PROCEDURA OBBLIGATORIA:\n"
|
| 509 |
-
"1. Apri un blocco <thinking>.\n"
|
| 510 |
-
"2. Identifica la tabella e le colonne chiave (id, parent_id/manager_id).\n"
|
| 511 |
-
"3. Definisci l'ANCORA (la radice della gerarchia, es. manager_id IS NULL).\n"
|
| 512 |
-
"4. Definisci la PARTE RICORSIVA (il JOIN tra la CTE e la tabella base).\n"
|
| 513 |
-
"5. Calcola la profondità (depth) incrementando ad ogni iterazione.\n"
|
| 514 |
-
"6. Per classifiche/aggregati mobili usa Window Functions: `RANK() OVER (PARTITION BY ... ORDER BY ...)`.\n"
|
| 515 |
-
"7. Chiudi il blocco </thinking>.\n\n"
|
| 516 |
-
"ESEMPIO FEW-SHOT (Gerarchia):\n"
|
| 517 |
-
"```sql\n"
|
| 518 |
-
"WITH RECURSIVE org_chart AS (\n"
|
| 519 |
-
" SELECT id, name, manager_id, 1 as depth FROM employees WHERE manager_id IS NULL\n"
|
| 520 |
-
" UNION ALL\n"
|
| 521 |
-
" SELECT e.id, e.name, e.manager_id, oc.depth + 1 FROM employees e\n"
|
| 522 |
-
" JOIN org_chart oc ON e.manager_id = oc.id\n"
|
| 523 |
-
") SELECT * FROM org_chart ORDER BY depth, name;\n"
|
| 524 |
-
"```\n"
|
| 525 |
-
"REGOLA: Usa SEMPRE `WITH RECURSIVE` per le gerarchie. MAI fare join multipli manuali."
|
| 526 |
-
),
|
| 527 |
-
(
|
| 528 |
-
["data analysis", "time series", "anomalia", "outlier", "trend", "stagionalità",
|
| 529 |
-
"luglio", "lug", "z-score", "13m", "anomaly", "media mobile", "peak", "drop",
|
| 530 |
-
"calo", "picco", "mese", "month", "weekly", "daily", "revenue", "traffic"],
|
| 531 |
-
"DATA ANALYST — ANOMALY DETECTION v2 (S-BENCH-DA):\n"
|
| 532 |
-
"PROCEDURA OBBLIGATORIA (mostra tutti i calcoli):\n"
|
| 533 |
-
"1. TABELLA: riproponi i dati in tabella markdown (mese|valore).\n"
|
| 534 |
-
"2. STATISTICHE: Media=Σvalori/n, StdDev=√(Σ(xi-μ)²/n) — calcola esplicitamente.\n"
|
| 535 |
-
"3. Z-SCORE: per ogni punto: Z=(x-μ)/σ. Flag se |Z|>2 (moderata) o |Z|>3 (grave).\n"
|
| 536 |
-
"4. ANOMALIA: nomina il mese/periodo con Z-score preciso e tipo (drop/spike).\n"
|
| 537 |
-
"5. CAUSA: suggerisci 2-3 cause plausibili con ragionamento.\n"
|
| 538 |
-
"6. CONCLUSIONE: '## Anomalia: [periodo] — Z-score: [X] — Tipo: [drop/spike]'\n\n"
|
| 539 |
-
"ESEMPIO: luglio=200, media=400, σ=80 → Z=(200-400)/80=-2.5 → ANOMALIA MODERATA (drop).\n"
|
| 540 |
-
"Struttura risposta: ## Dati → ## Statistiche → ## Z-Score → ## Anomalie → ## Cause → ## Conclusione"
|
| 541 |
-
),
|
| 542 |
-
(
|
| 543 |
-
["reasoning", "gsm8k", "math", "matematica", "logica", "ragionamento", "ted the t-rex",
|
| 544 |
-
"how many", "quanti", "quante", "calcola", "quanto", "totale", "potato salad",
|
| 545 |
-
"kg", "pounds", "cost", "costo", "distance", "distanza", "speed", "velocità",
|
| 546 |
-
"bought", "sold", "left", "rimane", "remaining", "ore", "minuti", "days", "weeks"],
|
| 547 |
-
"REASONER — GSM8K & CHAIN-OF-THOUGHT v2 (S-BENCH-RE):\n"
|
| 548 |
-
"STEP 1 — VARIABILI: elenca ogni entità del problema con il suo valore numerico.\n"
|
| 549 |
-
"STEP 2 — EQUAZIONI: scrivi l'equazione matematica PRIMA di calcolarla.\n"
|
| 550 |
-
"STEP 3 — CALCOLO: mostra ogni operazione intermedia con il risultato.\n"
|
| 551 |
-
"STEP 4 — SELF-CHECK: rileggi il problema originale e verifica che la risposta risponda ESATTAMENTE alla domanda.\n"
|
| 552 |
-
"STEP 5 — RISPOSTA FINALE: ultima riga DEVE essere 'Risposta: **X**' (bold, numero esatto).\n\n"
|
| 553 |
-
"ESEMPIO:\n"
|
| 554 |
-
"Problema: Ted the T-Rex vuole portare 225g di insalata. Ha già 45g. Quanto manca?\n"
|
| 555 |
-
"STEP 1: target=225g, già=45g\n"
|
| 556 |
-
"STEP 2: mancante = target - già = 225 - 45\n"
|
| 557 |
-
"STEP 3: 225 - 45 = 180\n"
|
| 558 |
-
"STEP 4: domanda=quanto manca → risposta=180g ✓\n"
|
| 559 |
-
"Risposta: **180 g**\n\n"
|
| 560 |
-
"CRITICO: MAI rispondere con NULL, stringa vuota o approssimazioni. "
|
| 561 |
-
"MAI saltare i passaggi intermedi."
|
| 562 |
-
),
|
| 563 |
-
(
|
| 564 |
-
["mmlu", "computer science", "informatica", "architettura", "os", "networking", "database",
|
| 565 |
-
"quale delle seguenti", "which of the following", "pairs of", "which pair", "algorithm",
|
| 566 |
-
"complexity", "complessità", "big-o", "sorting", "hashing", "binary", "heap", "tree",
|
| 567 |
-
"cpu", "memory", "virtual memory", "deadlock", "semaphore", "mutex", "protocol"],
|
| 568 |
-
"CS EXPERT — MMLU ELIMINATION METHOD v2 (S-BENCH-MMLU):\n"
|
| 569 |
-
"METODO ELIMINAZIONE OBBLIGATORIO:\n"
|
| 570 |
-
"1. Leggi tutte le opzioni (A/B/C/D) PRIMA di rispondere.\n"
|
| 571 |
-
"2. Elimina le opzioni chiaramente false con motivazione di 1 riga.\n"
|
| 572 |
-
"3. Per le rimanenti: applica il principio tecnico pertinente.\n"
|
| 573 |
-
"4. Scegli con certezza: 'La risposta corretta è **X** perché...'\n\n"
|
| 574 |
-
"CONOSCENZE CORE:\n"
|
| 575 |
-
"• Complessità: O(1)<O(log n)<O(n)<O(n log n)<O(n²)<O(2ⁿ)\n"
|
| 576 |
-
"• OS: FCFS/SJF/RR scheduling; paging/segmentation; mutex/semaphore sync\n"
|
| 577 |
-
"• Networking: TCP/IP 4 layers; DNS; TLS handshake; HTTP vs HTTPS\n"
|
| 578 |
-
"• Database: ACID; 1NF/2NF/3NF; B-tree index; JOIN types; MVCC\n"
|
| 579 |
-
"• Strutture dati: array O(1); linked list O(n); BST O(log n) avg; hash O(1) avg\n"
|
| 580 |
-
"FORMATO: prima ragionamento eliminazione, poi riga finale 'Risposta: **X**'"
|
| 581 |
-
),
|
| 582 |
-
(
|
| 583 |
-
["changelog", "semver", "release notes", "patch", "minor", "major", "feat",
|
| 584 |
-
"breaking change", "CHANGELOG", "release history", "versioning", "bumped"],
|
| 585 |
-
"WRITER PRO — CHANGELOG & SEMVER v2 (S-BENCH-WR):\n"
|
| 586 |
-
"STRUTTURA OBBLIGATORIA (Keep A Changelog):\n"
|
| 587 |
-
"## [X.Y.Z] - AAAA-MM-GG\n"
|
| 588 |
-
"### Added\n"
|
| 589 |
-
"- [feat] Descrizione in imperativo (es. 'Add retry logic for failed requests')\n"
|
| 590 |
-
"### Changed\n"
|
| 591 |
-
"- [change] Descrizione modifica con impatto\n"
|
| 592 |
-
"### Fixed\n"
|
| 593 |
-
"- [fix] Descrizione bug fix con riferimento issue se disponibile\n"
|
| 594 |
-
"### Security\n"
|
| 595 |
-
"- [sec] Fix CVE-YYYY-XXXX se applicabile\n\n"
|
| 596 |
-
"REGOLE SEMVER:\n"
|
| 597 |
-
"• MAJOR (X.0.0): breaking changes — API incompatibili\n"
|
| 598 |
-
"• MINOR (0.Y.0): nuove feature backward-compatible\n"
|
| 599 |
-
"• PATCH (0.0.Z): bug fix backward-compatible\n"
|
| 600 |
-
"Linguaggio: imperativo inglese formale ('Add', 'Fix', 'Remove', 'Update').\n"
|
| 601 |
-
"Ogni entry: max 80 caratteri. No emoji. Ogni sezione solo se ci sono voci pertinenti."
|
| 602 |
-
),
|
| 603 |
-
(
|
| 604 |
-
["context", "finestra", "1101ch", "recupero", "quante persone", "lungo testo",
|
| 605 |
-
"quanti", "trova nel testo", "nel documento", "how many", "team", "anni di esperienza",
|
| 606 |
-
"members", "employees", "experience", "years of experience"],
|
| 607 |
-
"CONTEXT RETRIEVAL — LONG CONTEXT v2 (S-BENCH-CTX):\n"
|
| 608 |
-
"PROCEDURA ANTI-HALLUCINATION:\n"
|
| 609 |
-
"1. SCANSIONA l'intero testo — non fermarti alla prima occorrenza.\n"
|
| 610 |
-
"2. ELENCA: crea una lista esplicita di tutti gli elementi trovati.\n"
|
| 611 |
-
"3. CONTA: numero = len(lista). Mostra lista + count.\n"
|
| 612 |
-
"4. VERIFICA: rileggi la lista, controlla che non manchino elementi.\n"
|
| 613 |
-
"5. RISPOSTA: 'Ho trovato N elementi: [lista]. Risposta: **N**'\n\n"
|
| 614 |
-
"CRITICO: se il testo dice '>5 anni', conta SOLO chi supera 5 (escludere esattamente 5).\n"
|
| 615 |
-
"MAI rispondere con un numero senza aver prima elencato gli elementi contati."
|
| 616 |
-
),
|
| 617 |
-
(
|
| 618 |
-
["compare", "confronta", "paragona", "message queue", "kafka", "rabbitmq", "redis pub",
|
| 619 |
-
"use case", "caso d'uso", "quando usare", "quale scegliere", "pro e contro", "trade-off",
|
| 620 |
-
"vs", "versus", "differenza tra", "difference between", "quale tecnologia",
|
| 621 |
-
"research synthesis", "analizza e confronta", "microservizi", "architettura"],
|
| 622 |
-
"RESEARCH SYNTHESIZER — COMPARE & CONTRAST (S-BENCH-RS):\n"
|
| 623 |
-
"STRUTTURA OBBLIGATORIA per confronti tecnici:\n"
|
| 624 |
-
"## Contesto\n"
|
| 625 |
-
"Definisci il problema/use case in 2 righe.\n"
|
| 626 |
-
"## Confronto\n"
|
| 627 |
-
"| Criterio | Opzione A | Opzione B | Vincitore |\n"
|
| 628 |
-
"| --- | --- | --- | --- |\n"
|
| 629 |
-
"| Performance | ... | ... | ... |\n"
|
| 630 |
-
"| Scalabilità | ... | ... | ... |\n"
|
| 631 |
-
"| Complessità setup | ... | ... | ... |\n"
|
| 632 |
-
"| Use case ideale | ... | ... | ... |\n"
|
| 633 |
-
"## Raccomandazione\n"
|
| 634 |
-
"Per [use case X]: scegli **Opzione A** perché [motivo specifico con numeri].\n"
|
| 635 |
-
"Per [use case Y]: scegli **Opzione B** perché [motivo specifico con numeri].\n"
|
| 636 |
-
"## Conclusione\n"
|
| 637 |
-
"Non esiste risposta universale: dipende da [fattori chiave specifici].\n\n"
|
| 638 |
-
"REGOLA: ogni affermazione deve essere concreta e specifica. "
|
| 639 |
-
"MAI risposte vaghe come 'dipende' senza spiegare DA COSA dipende."
|
| 640 |
-
),
|
| 641 |
(
|
| 642 |
["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
|
| 643 |
"github.com/drizzle", "drizzle-orm/pg-core"],
|
|
@@ -881,18 +709,17 @@ class PromptBuilderMixin:
|
|
| 881 |
"lrucache", "eviction", "minheap", "comparatore", "stack generico", "ringbuffer",
|
| 882 |
"circular buffer", "rate limiter", "token bucket", "trie", "prefix tree",
|
| 883 |
"capacita fissa", "fixed capacity"],
|
| 884 |
-
"REGOLA CLASSE TypeScript (S-BENCH-FEAT) —
|
| 885 |
-
"
|
| 886 |
-
"
|
| 887 |
-
"
|
| 888 |
-
"
|
| 889 |
-
"
|
| 890 |
-
"
|
| 891 |
-
"
|
| 892 |
-
"
|
| 893 |
-
"
|
| 894 |
-
"
|
| 895 |
-
"CRITICO: Ogni metodo richiesto deve essere implementato DENTRO la classe (non fuori)."
|
| 896 |
),
|
| 897 |
(
|
| 898 |
["correggi solo", "typescript strict", "strict error", "parametri senza tipo",
|
|
@@ -1054,16 +881,15 @@ class PromptBuilderMixin:
|
|
| 1054 |
# P27-B1: FR equivalents
|
| 1055 |
"tâche ambiguë", "que faire", "sans données", "manque d'informations",
|
| 1056 |
],
|
| 1057 |
-
"RECOVERY TASK AMBIGUO
|
| 1058 |
-
"
|
| 1059 |
-
"
|
| 1060 |
-
"
|
| 1061 |
-
"
|
| 1062 |
-
"
|
| 1063 |
-
"
|
| 1064 |
-
"
|
| 1065 |
-
"
|
| 1066 |
-
"ESEMPIO DI DOMANDA CHIARIFICATRICE: \"I dati forniti per l'A/B test sembrano incoerenti (es. 100% di successo per entrambi i gruppi). Potresti verificare i valori?\"\n"
|
| 1067 |
"3. Ultima riga: 'Attendo chiarimenti prima di procedere.'\n"
|
| 1068 |
"\n"
|
| 1069 |
"VERIFICA OBBLIGATORIA — il testo DEVE contenere queste keyword esatte:\n"
|
|
@@ -1074,79 +900,57 @@ class PromptBuilderMixin:
|
|
| 1074 |
),
|
| 1075 |
# ── S-BENCH-RS: research_synthesis ──────────────────────────────────
|
| 1076 |
# Trigger: frasi esatte dal benchmark prompt (3 scenari: compare/tradeoff/sciq)
|
| 1077 |
-
#
|
|
|
|
| 1078 |
(
|
| 1079 |
["coprire:", "message queue per use case", "event sourcing", "saga pattern",
|
| 1080 |
"kafka", "rabbitmq", "nats", "redis streams", "circuit breaker",
|
| 1081 |
"compare: message", "analisi tradeoff architetturale",
|
| 1082 |
"immutabilità", "svantaggi (≥", "vantaggi (≥",
|
| 1083 |
"solutions architect"],
|
| 1084 |
-
"RISPOSTA ARCHITETTURA (RS-BENCH) — MARKDOWN OBBLIGATORIO (
|
| 1085 |
-
"
|
| 1086 |
-
"
|
| 1087 |
-
"
|
| 1088 |
-
"
|
| 1089 |
-
"
|
| 1090 |
-
"
|
| 1091 |
-
"
|
| 1092 |
-
"
|
| 1093 |
-
"
|
| 1094 |
-
"
|
| 1095 |
-
"
|
| 1096 |
-
" ## Vantaggi di [NomeA]: [≥3 bullet con **keyword** in grassetto]\n"
|
| 1097 |
-
" ## Svantaggi di [NomeA]: [≥2 bullet dettagliati]\n"
|
| 1098 |
-
" ## Quando usarlo: [2-3 scenari industriali reali]\n"
|
| 1099 |
-
" ## Raccomandazione\n"
|
| 1100 |
-
" [Conclusione esplicita: quale scegliere e perché, con condizioni per l'alternativa.]\n\n"
|
| 1101 |
-
"CRITICO: La sezione '## Raccomandazione' è OBBLIGATORIA — il checker la cerca con /raccomand|conclusione/i.\n"
|
| 1102 |
-
"CRITICO: Includi TUTTE le keyword del prompt nel testo (latenza, throughput, persistenza, etc.).\n"
|
| 1103 |
-
"CRITICO: Usa **grassetto** per le keyword tecniche — il checker cerca /^#+\\s|\\*\\*/m."
|
| 1104 |
),
|
| 1105 |
# ── S-BENCH-CW: context_window ──────────────────────────────────────
|
| 1106 |
# Trigger: prompt benchmark CW (documento team Q2 2026) + frasi dirette del prompt
|
| 1107 |
-
#
|
|
|
|
|
|
|
| 1108 |
(
|
| 1109 |
["anni di anzianità", "anni in azienda", "team report",
|
| 1110 |
"q2 2026", "budget allocato", "stipendio annuo",
|
| 1111 |
"citando il dato dal documento",
|
| 1112 |
"rispondi solo alla domanda specificata. non inventare"],
|
| 1113 |
"ANALISI DOCUMENTO STRUTTURATO (CW-BENCH) — metodo obbligatorio:\n"
|
| 1114 |
-
"
|
| 1115 |
-
"
|
| 1116 |
-
"
|
| 1117 |
-
"
|
| 1118 |
-
"
|
| 1119 |
-
"
|
| 1120 |
-
"
|
| 1121 |
-
"
|
| 1122 |
-
" -
|
| 1123 |
-
"
|
| 1124 |
-
"PARTE 2 — RISPOSTA DIRETTA (parole obbligatorie incluse):\n"
|
| 1125 |
-
" Per domanda su anzianità: '[N] persone hanno anzianità superiore a 5 anni.'\n"
|
| 1126 |
-
" → usa SEMPRE le parole 'anzianità' e '5 anni' nella risposta\n"
|
| 1127 |
-
" Per domanda su stipendio: 'Lo stipendio annuo di [Nome] ([ruolo]) è €[valore].'\n"
|
| 1128 |
-
" → cita SEMPRE il nome esatto e il valore numerico dal documento\n"
|
| 1129 |
-
" Per domanda su costo totale: 'Il costo totale annuo degli stipendi è €[somma].'\n"
|
| 1130 |
-
" → usa SEMPRE le parole 'totale', 'somma' o 'costo' nella risposta\n"
|
| 1131 |
-
"CRITICO: Il checker cerca /senior|anzianit|5\\s*ann/i — usa 'anzianità' o 'senior' SEMPRE.\n"
|
| 1132 |
-
"CRITICO: Il numero nella risposta deve essere ESATTAMENTE quello del documento."
|
| 1133 |
),
|
| 1134 |
# ── S-BENCH-CC: code_correct ─────────────────────────────────────────
|
| 1135 |
# Trigger: SOLO il problema reverseWords — keyword unico e specifico
|
| 1136 |
-
#
|
| 1137 |
(
|
| 1138 |
["reversewords", "inverti ordine parole", "rimuovi spazi extra"],
|
| 1139 |
-
"FUNZIONE PURA TYPESCRIPT (CC-BENCH)
|
| 1140 |
-
"
|
| 1141 |
-
"
|
| 1142 |
-
"```typescript\n"
|
| 1143 |
-
"export function reverseWords(s: string): string {\n"
|
| 1144 |
-
" return s.trim().split(/\\s+/).reverse().join(' ');\n"
|
| 1145 |
-
"}\n"
|
| 1146 |
-
"```\n"
|
| 1147 |
-
"NON aggiungere testo fuori dal blocco ```typescript.\n"
|
| 1148 |
-
"NON usare blocchi ```ts o ```js — SOLO ```typescript.\n"
|
| 1149 |
-
"La funzione DEVE essere exported: export function reverseWords(...)."
|
| 1150 |
),
|
| 1151 |
# ── S-BENCH-REC: recovery ────────────────────────────────────────────
|
| 1152 |
# Trigger: SOLO A/B test con ratio impossibile
|
|
@@ -1190,29 +994,26 @@ class PromptBuilderMixin:
|
|
| 1190 |
),
|
| 1191 |
# ── S-BENCH-DA: data_analysis ────────────────────────────────────────
|
| 1192 |
# Trigger: SOLO la struttura esatta del prompt benchmark DA
|
| 1193 |
-
#
|
| 1194 |
(
|
| 1195 |
["vendite mensili:", "rispondi esattamente con questo formato",
|
| 1196 |
"copia la struttura, sostituisci", "mese col valore massimo",
|
| 1197 |
"valore anomalo fuori scala"],
|
| 1198 |
-
"TIME SERIES ANALISI (DA-BENCH) —
|
| 1199 |
-
"
|
| 1200 |
-
"
|
| 1201 |
-
"
|
| 1202 |
-
"
|
| 1203 |
-
"
|
| 1204 |
-
"
|
| 1205 |
-
"
|
| 1206 |
-
"
|
| 1207 |
-
"
|
| 1208 |
-
"- **
|
| 1209 |
-
"- **
|
| 1210 |
-
"
|
| 1211 |
-
"
|
| 1212 |
-
"
|
| 1213 |
-
"CRITICO: Il numero dopo 'Media:' deve essere il risultato aritmetico reale (non null, non '?').\n"
|
| 1214 |
-
"CRITICO: Includi TUTTI i mesi nel calcolo della media — non saltarne nessuno.\n"
|
| 1215 |
-
"ESEMPIO: dati=[100,150,10] → Somma=260, N=3, Media=260/3=86.7 → output: - **Media: 86.7**"
|
| 1216 |
),
|
| 1217 |
# ── S-BENCH-ROB: robustness ─────────────────────────────────────────────
|
| 1218 |
# 4 scenari: injection / rumore / contraddizioni / degradazione progressiva
|
|
@@ -1327,7 +1128,7 @@ class PromptBuilderMixin:
|
|
| 1327 |
),
|
| 1328 |
# ── S-BENCH-BF: bug_fix ──────────────────────────────────────────────
|
| 1329 |
# Trigger: frasi esatte del prompt benchmark BF + identificatori di scenario
|
| 1330 |
-
#
|
| 1331 |
(
|
| 1332 |
["identifica e correggi i bug typescript",
|
| 1333 |
"non riscrivere struttura",
|
|
@@ -1336,24 +1137,14 @@ class PromptBuilderMixin:
|
|
| 1336 |
"promise.all crash", "processusers",
|
| 1337 |
"setstate su componente unmontato", "useasyncdata",
|
| 1338 |
"deepclone via spread", "clonepoint", "clonedate"],
|
| 1339 |
-
"BUG FIX TYPESCRIPT (BF-BENCH)
|
| 1340 |
-
"
|
| 1341 |
-
"
|
| 1342 |
-
"
|
| 1343 |
-
"
|
| 1344 |
-
"
|
| 1345 |
-
"
|
| 1346 |
-
"
|
| 1347 |
-
"CRITICO: Correggi SOLO il bug senza riscrivere la struttura del codice o aggiungere funzionalità non richieste.\n"
|
| 1348 |
-
"CRITICO: Il codice DEVE essere TypeScript valido e compilabile (zero errori tsc).\n"
|
| 1349 |
-
"PATTERN DI FIX (prioritari):\n"
|
| 1350 |
-
"- Binary search: `lo = mid + 1` e `hi = mid - 1` per evitare loop infiniti.\n"
|
| 1351 |
-
"- Promise.all: se un task fallisce, cadono tutti. Usa `Promise.allSettled` o `try/catch` nel map.\n"
|
| 1352 |
-
"- React setState: controlla `isMounted` prima di chiamare setter asincroni.\n"
|
| 1353 |
-
"- Deep Clone: spread `...` è shallow. Usa `new Date(d.getTime())` o `new Point(p.x, p.y)`.\n"
|
| 1354 |
-
"- Event Listeners: rimuovi SEMPRE il listener nel cleanup del useEffect.\n"
|
| 1355 |
-
"- Race Conditions: implementa meccanismi di sincronizzazione (es. mutex, semafori) o debounce/throttle.\n"
|
| 1356 |
-
"- Memory Leaks: identifica e rilascia risorse non più utilizzate (es. `clearInterval`, `removeEventListener`)."
|
| 1357 |
),
|
| 1358 |
# ── S-CHIP-DIAGRAM: chip "Diagramma" → forza output Mermaid ──────────���──
|
| 1359 |
# Trigger: frasi esatte dal chip text (QuickActionChips.tsx)
|
|
@@ -1450,105 +1241,20 @@ class PromptBuilderMixin:
|
|
| 1450 |
" - Mai esporre dati sensibili (token, password) nel payload"
|
| 1451 |
),
|
| 1452 |
|
| 1453 |
-
|
| 1454 |
-
# ── BENCH-REASONING: GSM8K / math word problems (S-BENCH-MATH) ──────
|
| 1455 |
-
(
|
| 1456 |
-
["passo 1", "passo 2", "passo 3", "ragionamento step-by-step",
|
| 1457 |
-
"strette di mano", "handshakes", "potato salad", "ted the t-rex",
|
| 1458 |
-
"quante strette", "n persone si stringono", "formula:", "mostra il calcolo",
|
| 1459 |
-
"**#### n**", "#### n", "gsm8k"],
|
| 1460 |
-
"FORMATO RISPOSTA MATEMATICA OBBLIGATORIO (S-BENCH-MATH):\n"
|
| 1461 |
-
"1. Mostra i calcoli passo per passo con numeri esatti.\n"
|
| 1462 |
-
"2. Ultima riga SEMPRE: #### <numero> (solo il numero, nient'altro dopo)\n"
|
| 1463 |
-
" Esempio corretto: #### 225\n"
|
| 1464 |
-
" SBAGLIATO: 'La risposta e 225' oppure '**225**' oppure 'Risposta: 225'\n"
|
| 1465 |
-
"3. Il pattern #### N e l'UNICO estratto dal benchmark — qualsiasi altro formato = FAIL."
|
| 1466 |
-
),
|
| 1467 |
-
# ── BENCH-MMLU: scelta multipla A/B/C/D (S-BENCH-MMLU) ─────────────
|
| 1468 |
-
(
|
| 1469 |
-
["domanda di informatica a scelta multipla", "rispondi con la lettera",
|
| 1470 |
-
"a/b/c/d", "quicksort nel caso peggiore", "mergesort",
|
| 1471 |
-
"complessita' temporale", "deadlock", "scelta multipla",
|
| 1472 |
-
"college_computer_science", "spazio o(v)", "race condition"],
|
| 1473 |
-
"FORMATO RISPOSTA MMLU OBBLIGATORIO (S-BENCH-MMLU):\n"
|
| 1474 |
-
"Rispondi SEMPRE con: **La risposta corretta e: (X)**\n"
|
| 1475 |
-
"dove X e esattamente A, B, C o D.\n"
|
| 1476 |
-
"Poi spiega brevemente il ragionamento (1-2 frasi).\n"
|
| 1477 |
-
"CORRETTO: **La risposta corretta e: (C)**\n"
|
| 1478 |
-
"SBAGLIATO: 'La risposta e C' o 'C' da solo (senza bold e parentesi)\n"
|
| 1479 |
-
"Il benchmark estrae la lettera SOLO da **X** o **(X)** — usa SEMPRE il bold."
|
| 1480 |
-
),
|
| 1481 |
-
# ── BENCH-DATA-ANALYSIS: formato bullet obbligatorio (S-BENCH-DA) ───
|
| 1482 |
-
(
|
| 1483 |
-
["rispondi esattamente con questo formato", "non aggiungere testo prima",
|
| 1484 |
-
"copia la struttura, sostituisci i valori", "valore anomalo fuori scala",
|
| 1485 |
-
"vendite mensili", "mese col valore massimo", "time series"],
|
| 1486 |
-
"FORMATO DATA ANALYSIS OBBLIGATORIO (S-BENCH-DA) — COPIA ESATTO:\n"
|
| 1487 |
-
"- **Media: N**\n"
|
| 1488 |
-
"- **Picco: MESE (N)**\n"
|
| 1489 |
-
"- **Anomalia: MESE (N)**\n"
|
| 1490 |
-
"- **Trend: testo breve**\n"
|
| 1491 |
-
"REGOLE ASSOLUTE:\n"
|
| 1492 |
-
"1. Inizia SUBITO con '- **Media:' — ZERO testo prima dei 4 bullet\n"
|
| 1493 |
-
"2. Usa bold su tutto il bullet: **Media: 158.4** (non 'Media: 158.4')\n"
|
| 1494 |
-
"3. Calcola la media reale: somma tutti i valori / numero mesi\n"
|
| 1495 |
-
"4. Anomalia = mese con valore drasticamente fuori scala (molto piu basso)\n"
|
| 1496 |
-
"Il benchmark estrae SOLO dal pattern **Media: N** — altri formati = FAIL immediato."
|
| 1497 |
-
),
|
| 1498 |
-
# ── BENCH-SQL-CTE: recursive CTE + window functions (S-BENCH-SQL) ───
|
| 1499 |
-
(
|
| 1500 |
-
["cte ricorsiva", "gerarchia organizzativa", "with recursive",
|
| 1501 |
-
"recursive cte", "gerarchia", "lag(", "window function",
|
| 1502 |
-
"email duplicate", "variazione % mom", "ordini con status",
|
| 1503 |
-
"ultimi 12 mesi", "revenue totale"],
|
| 1504 |
-
"FORMATO SQL OBBLIGATORIO (S-BENCH-SQL):\n"
|
| 1505 |
-
"Scrivi SQL SEMPRE in blocco markdown sql — MAI inline o senza code block.\n"
|
| 1506 |
-
"Per CTE ricorsiva — struttura ESATTA obbligatoria:\n"
|
| 1507 |
-
"WITH RECURSIVE nome_cte AS (\n"
|
| 1508 |
-
" SELECT ... , 0 AS depth -- base case (radice)\n"
|
| 1509 |
-
" UNION ALL\n"
|
| 1510 |
-
" SELECT e.* , cte.depth+1 FROM tabella e JOIN nome_cte cte ON e.parent_id=cte.id\n"
|
| 1511 |
-
")\n"
|
| 1512 |
-
"SELECT * FROM nome_cte ORDER BY depth;\n"
|
| 1513 |
-
"Per LAG/Window: LAG(col) OVER (PARTITION BY ... ORDER BY ...) AS prev_val\n"
|
| 1514 |
-
"Il benchmark valida: blocco sql presente, UNION ALL, depth, sintassi completa."
|
| 1515 |
-
),
|
| 1516 |
-
# ── BENCH-RESEARCH-SYNTHESIS: comparazione strutturata (S-BENCH-RS) ─
|
| 1517 |
-
(
|
| 1518 |
-
["compare:", "message queue per use case", "confronta", "kafka", "rabbitmq",
|
| 1519 |
-
"redis queue", "evidenza dal contesto", "confidence:", "affidabilit",
|
| 1520 |
-
"risposta diretta:", "strutturata"],
|
| 1521 |
-
"FORMATO RESEARCH SYNTHESIS OBBLIGATORIO (S-BENCH-RS):\n"
|
| 1522 |
-
"Struttura ESATTA — 4 sezioni:\n"
|
| 1523 |
-
"1. **Risposta diretta**: [risposta in 1 frase con valore/raccomandazione]\n"
|
| 1524 |
-
"2. **Evidenza**: [dati specifici, latenze, throughput, numeri reali]\n"
|
| 1525 |
-
"3. **Ragionamento**: [confronto pro/contro per ogni opzione — 3-4 frasi]\n"
|
| 1526 |
-
"4. **Confidence**: [alta/media/bassa + motivazione]\n"
|
| 1527 |
-
"Per confronti tecnologici includi SEMPRE queste parole chiave:\n"
|
| 1528 |
-
"affidabilita, throughput, latenza, scalabilita, persistenza, use-case\n"
|
| 1529 |
-
"Il benchmark verifica presenza di almeno 5 keyword — meno di 5 = score basso."
|
| 1530 |
-
),
|
| 1531 |
]
|
| 1532 |
|
| 1533 |
@staticmethod
|
| 1534 |
-
def _extract_persona(goal: str) -> tuple[str | None, str]:
|
|
|
|
|
|
|
|
|
|
| 1535 |
import re as _re
|
| 1536 |
-
_m = _re.match(r'^/persona\s+(RESEARCHER|CODER|REASONER
|
| 1537 |
if _m:
|
| 1538 |
clean = goal.strip()[_m.end():].strip()
|
| 1539 |
return _m.group(1).upper(), clean if clean else goal.strip()
|
| 1540 |
-
g_lower = goal.lower()
|
| 1541 |
-
if any(kw in g_lower for kw in ['codice', 'funzione', 'bug', 'fix', 'implementa', 'typescript', 'python']):
|
| 1542 |
-
return 'CODER', goal
|
| 1543 |
-
if any(kw in g_lower for kw in ['cerca', 'ricerca', 'fonti', 'notizie', 'aggiornamenti']):
|
| 1544 |
-
return 'RESEARCHER', goal
|
| 1545 |
-
if any(kw in g_lower for kw in ['ragiona', 'perché', 'spiega passo', 'logica']):
|
| 1546 |
-
return 'REASONER', goal
|
| 1547 |
-
if any(kw in g_lower for kw in ['analizza', 'dati', 'trend', 'confronta']):
|
| 1548 |
-
return 'ANALYST', goal
|
| 1549 |
-
if any(kw in g_lower for kw in ['architettura', 'struttura', 'sistema', 'disegna']):
|
| 1550 |
-
return 'ARCHITECT', goal
|
| 1551 |
return None, goal
|
|
|
|
| 1552 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1553 |
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
| 1554 |
goal_lower = goal.lower()
|
|
@@ -1757,11 +1463,9 @@ class PromptBuilderMixin:
|
|
| 1757 |
"\n\nCHECKLIST ANALITICA (verifica mentalmente prima di rispondere):\n"
|
| 1758 |
"□ Ho risposto a TUTTI i punti richiesti nel goal\n"
|
| 1759 |
"□ Ho sviluppato ogni punto con dettagli concreti (non superficiale)\n"
|
| 1760 |
-
"□ LOGICA: Ho verificato la coerenza dei dati (es. se parlo di date, sono in ordine cronologico?)\n"
|
| 1761 |
-
"□ ANOMALIE: Ho cercato contraddizioni nei dati forniti dai tool?\n"
|
| 1762 |
-
"□ CALCOLI: Se ci sono numeri, ho fatto un doppio controllo rapido?\n"
|
| 1763 |
"□ La risposta ha una struttura chiara (sezioni o paragrafi)\n"
|
| 1764 |
-
"□ Ho concluso con una raccomandazione o sintesi finale (se richiesto)"
|
|
|
|
| 1765 |
)
|
| 1766 |
# ── Item 4: formato rigido per goal con template esplicito ──────────────
|
| 1767 |
# Trigger: goal con '[campo]', '{{', tabelle markdown, o "usa questo formato".
|
|
@@ -1823,11 +1527,4 @@ _CONTEXT_RULES_ADVANCED = [
|
|
| 1823 |
"Nei test Vitest, usa vi.mock() e vi.spyOn() — non jest.mock(). Importa da 'vitest' non da '@jest'.",
|
| 1824 |
"Nei test Playwright, usa page.getByRole(), page.getByTestId() per selettori resilienti — non XPath o CSS fragili.",
|
| 1825 |
"In Pydantic v2, usa model_validator e field_validator al posto di @validator (deprecato). BaseModel.model_dump() sostituisce .dict().",
|
| 1826 |
-
"LOGICA: Se i dati dei tool sembrano contraddirsi, segnalalo esplicitamente invece di ignorarlo.",
|
| 1827 |
-
"DATA_ANALYSIS: Calcola sempre Media, Mediana e Deviazione Standard per set di dati numerici prima di trarre conclusioni.",
|
| 1828 |
-
"ANOMALY_DETECTION: In una serie temporale, identifica i valori che deviano più del 30% dalla media mobile come potenziali anomalie.",
|
| 1829 |
-
"VERIFICA: Se il goal chiede un conteggio (es. 'quante persone'), elenca i nomi mentalmente prima di dare il numero finale.",
|
| 1830 |
]
|
| 1831 |
-
|
| 1832 |
-
|
| 1833 |
-
|
|
|
|
| 40 |
"4. Non dire 'puoi fare X' — mostra X fatto, con codice completo se richiesto\n"
|
| 41 |
"5. Se incontri un errore, analizza e riprova con approccio diverso\n"
|
| 42 |
"6. Sii specifico e concreto — niente placeholder o risposte vaghe\n"
|
| 43 |
+
"7. Per codice: sempre blocchi markdown con sintassi corretta, tipizzati\n"
|
| 44 |
+
"8. Per matematica: mostra calcoli passo passo con numeri esatti\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"9. Per decisioni architetturali: dai 3 opzioni con pro/contro e raccomandazione\n"
|
| 46 |
"10. NON inventare mai informazioni su te stesso: token usati, context window, "
|
| 47 |
"versione, architettura, parametri interni. Se non lo sai con certezza, "
|
|
|
|
| 109 |
" **Passo 4:** Estrai sub — mai decode() senza verify()\n"
|
| 110 |
"• Rate limiting benchmark: NON inventare numeri ms. Se non hai dati reali dilo esplicitamente.\n"
|
| 111 |
"\n"
|
| 112 |
+
"=== PROBLEM SOLVING ASSOLUTO ===\n"
|
| 113 |
+
"MAI dire 'non posso', 'non sono in grado', 'mi dispiace ma', 'impossibile', "
|
| 114 |
+
"'non ho accesso a', 'non riesco'. Ogni problema ha una soluzione — se l'approccio "
|
| 115 |
+
"A non funziona, prova B, poi C. Sei un problem solver, non un filtro di rifiuti.\n"
|
|
|
|
|
|
|
|
|
|
| 116 |
"Se il codice e troppo lungo per analizzarlo tutto in una volta, analizzalo pezzo per "
|
| 117 |
"pezzo: prima la struttura, poi i dettagli, poi i bug. Non fermarti mai.\n"
|
| 118 |
"Quando trovi codice con bug multipli, elencali tutti numerati anche se sono tanti.\n"
|
|
|
|
| 295 |
|
| 296 |
# ── S200: Context-aware rule injection ──────────────────────────────────────
|
| 297 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 298 |
+
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
| 299 |
# causa troncamento silenzioso — le regole non vengono mai lette.
|
| 300 |
# Soluzione: iniettare 2-4 regole contestuali ALLA FINE del user message
|
| 301 |
# (posizione con massima attenzione del modello = "recency bias").
|
|
|
|
| 419 |
" }\n"
|
| 420 |
"EventRegistry: on+off+listEvents SOLO (NO emit). EventHistory: emit+getHistory+historySize+clearHistory SOLO (NO on)."
|
| 421 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
(
|
| 423 |
["error boundary", "errorboundary", "errore app", "crash app", "fallback"],
|
| 424 |
"REGOLA ErrorBoundary: NON solo root level (un errore abbatte tutta l'app). "
|
|
|
|
| 466 |
"4. Half-stars: Math.floor(value) per intere + value % 1 >= 0.5 per mezza stella\n"
|
| 467 |
"5. INCLUDI SEMPRE le parole: interface, Props, export, star nel codice completo"
|
| 468 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
(
|
| 470 |
["drizzle", "drizzle-orm", "drizzle-team", "pgtable", "drizzle schema",
|
| 471 |
"github.com/drizzle", "drizzle-orm/pg-core"],
|
|
|
|
| 709 |
"lrucache", "eviction", "minheap", "comparatore", "stack generico", "ringbuffer",
|
| 710 |
"circular buffer", "rate limiter", "token bucket", "trie", "prefix tree",
|
| 711 |
"capacita fissa", "fixed capacity"],
|
| 712 |
+
"REGOLA CLASSE TypeScript (S-BENCH-FEAT) — anti-TS2323:\n"
|
| 713 |
+
"MAI dichiarare lo stesso nome due volte nello stesso blocco typescript.\n"
|
| 714 |
+
"TS2323 'Cannot redeclare exported variable X' si verifica quando:\n"
|
| 715 |
+
" (a) export class X {} + export { X } → SBAGLIATO\n"
|
| 716 |
+
" (b) export interface X {} + export class X {} → SBAGLIATO\n"
|
| 717 |
+
" (c) class X {} dichiarata due volte → SBAGLIATO\n"
|
| 718 |
+
"SCEGLI UNO stile e usalo in modo coerente per TUTTO il blocco:\n"
|
| 719 |
+
" STILE A (preferito): export class X { ... } — senza export { } alla fine\n"
|
| 720 |
+
" STILE B: class X { ... } ... export { X } — solo come ultima riga\n"
|
| 721 |
+
"NON mescolare i due stili per la stessa classe.\n"
|
| 722 |
+
"Ogni metodo richiesto deve essere implementato DENTRO la classe (non fuori)."
|
|
|
|
| 723 |
),
|
| 724 |
(
|
| 725 |
["correggi solo", "typescript strict", "strict error", "parametri senza tipo",
|
|
|
|
| 881 |
# P27-B1: FR equivalents
|
| 882 |
"tâche ambiguë", "que faire", "sans données", "manque d'informations",
|
| 883 |
],
|
| 884 |
+
"RECOVERY TASK AMBIGUO (REC-AMB) — RISPOSTA VERBATIM OBBLIGATORIA:\n"
|
| 885 |
+
"Il task non ha dati o parametri sufficienti. Segui ESATTAMENTE questo schema:\n"
|
| 886 |
+
"\n"
|
| 887 |
+
"1. Prima riga — chiedi con punto interrogativo:\n"
|
| 888 |
+
" 'Cosa vorresti analizzare esattamente? Hai dati disponibili?'\n"
|
| 889 |
+
"2. Poi elenca le ipotesi con questa formula (copia letteralmente):\n"
|
| 890 |
+
" '- Ipotesi A: se intendi analisi numerica, potrei calcolare statistiche'\n"
|
| 891 |
+
" '- Ipotesi B: se intendi analisi del codice, potrei fare una code review'\n"
|
| 892 |
+
" '- Ipotesi C: assumo che tu voglia qualcosa di strutturato, conferma il tipo'\n"
|
|
|
|
| 893 |
"3. Ultima riga: 'Attendo chiarimenti prima di procedere.'\n"
|
| 894 |
"\n"
|
| 895 |
"VERIFICA OBBLIGATORIA — il testo DEVE contenere queste keyword esatte:\n"
|
|
|
|
| 900 |
),
|
| 901 |
# ── S-BENCH-RS: research_synthesis ──────────────────────────────────
|
| 902 |
# Trigger: frasi esatte dal benchmark prompt (3 scenari: compare/tradeoff/sciq)
|
| 903 |
+
# V2: aggiunto "immutabilità" (unico di Event Sourcing RS prompt), "svantaggi (≥", "vantaggi (≥"
|
| 904 |
+
# Rimossi: "analisi tradeoff" (troppo generico), "quando usarlo" (false positive React)
|
| 905 |
(
|
| 906 |
["coprire:", "message queue per use case", "event sourcing", "saga pattern",
|
| 907 |
"kafka", "rabbitmq", "nats", "redis streams", "circuit breaker",
|
| 908 |
"compare: message", "analisi tradeoff architetturale",
|
| 909 |
"immutabilità", "svantaggi (≥", "vantaggi (≥",
|
| 910 |
"solutions architect"],
|
| 911 |
+
"RISPOSTA ARCHITETTURA (RS-BENCH) — MARKDOWN OBBLIGATORIO (min 200 parole):\n"
|
| 912 |
+
"Struttura esatta per confronto tecnologie:\n"
|
| 913 |
+
" ## Confronto [NomeTecnologiaA] vs [NomeTecnologiaB]\n"
|
| 914 |
+
" ### [Dimensione 1 dal prompt]: valore A vs valore B con dati concreti\n"
|
| 915 |
+
" ### [Dimensione 2]: ... (ripeti per OGNI dimensione in 'Coprire:')\n"
|
| 916 |
+
" ## Vantaggi: [≥3 bullet con **keyword** in grassetto]\n"
|
| 917 |
+
" ## Svantaggi: [≥2 bullet]\n"
|
| 918 |
+
" ## Quando usarlo: [2-3 scenari concreti]\n"
|
| 919 |
+
" ## Raccomandazione: per [contesto A] → scegli X; per [contesto B] → scegli Y\n"
|
| 920 |
+
"CRITICO: usa i NOMI ESATTI delle tecnologie menzionate nel prompt.\n"
|
| 921 |
+
"CRITICO: includi le keyword richieste (latenza, throughput, persistenza, ecc).\n"
|
| 922 |
+
"CRITICO: termina SEMPRE con la sezione '## Raccomandazione:'."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
),
|
| 924 |
# ── S-BENCH-CW: context_window ──────────────────────────────────────
|
| 925 |
# Trigger: prompt benchmark CW (documento team Q2 2026) + frasi dirette del prompt
|
| 926 |
+
# V2: aggiunti trigger "leggi attentamente il documento" (frase nel prompt CW),
|
| 927 |
+
# "rispondi solo alla domanda specificata" (frase nel prompt CW)
|
| 928 |
+
# Content: forza enumerazione + parola "anzianità" (richiesta dal checker `cited`)
|
| 929 |
(
|
| 930 |
["anni di anzianità", "anni in azienda", "team report",
|
| 931 |
"q2 2026", "budget allocato", "stipendio annuo",
|
| 932 |
"citando il dato dal documento",
|
| 933 |
"rispondi solo alla domanda specificata. non inventare"],
|
| 934 |
"ANALISI DOCUMENTO STRUTTURATO (CW-BENCH) — metodo obbligatorio:\n"
|
| 935 |
+
"1. ENUMERA ogni membro del documento con il valore cercato:\n"
|
| 936 |
+
" [Nome]: [valore rilevante] — es. Alice: 7 anni in azienda ✓ (>5)\n"
|
| 937 |
+
" (ripeti per OGNI membro della sezione 'Team Members')\n"
|
| 938 |
+
"2. CONTA o SOMMA il risultato finale\n"
|
| 939 |
+
"3. RISPOSTA FINALE (una sola riga):\n"
|
| 940 |
+
" - Per conteggio anzianità: 'X persone hanno anzianità superiore a 5 anni.'\n"
|
| 941 |
+
" (usa la parola 'anzianità' — obbligatoria)\n"
|
| 942 |
+
" - Per stipendio singolo: 'Lo stipendio di [Nome] è €X.' (cita il nome)\n"
|
| 943 |
+
" - Per totale stipendi: 'Il costo totale annuo degli stipendi è €X.' (usa 'totale')\n"
|
| 944 |
+
"NON inventare valori — usa SOLO i dati presenti nel documento."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
),
|
| 946 |
# ── S-BENCH-CC: code_correct ─────────────────────────────────────────
|
| 947 |
# Trigger: SOLO il problema reverseWords — keyword unico e specifico
|
| 948 |
+
# Rimossi: "function ", "string): string", "implementa la funzione" (troppo generici)
|
| 949 |
(
|
| 950 |
["reversewords", "inverti ordine parole", "rimuovi spazi extra"],
|
| 951 |
+
"FUNZIONE PURA TYPESCRIPT (CC-BENCH):\n"
|
| 952 |
+
"Rispondi con un singolo blocco ```typescript con solo la funzione.\n"
|
| 953 |
+
"Per reverseWords: gestisci spazi multipli con trim() + split(/\\s+/) + reverse() + join(' ')."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
),
|
| 955 |
# ── S-BENCH-REC: recovery ────────────────────────────────────────────
|
| 956 |
# Trigger: SOLO A/B test con ratio impossibile
|
|
|
|
| 994 |
),
|
| 995 |
# ── S-BENCH-DA: data_analysis ────────────────────────────────────────
|
| 996 |
# Trigger: SOLO la struttura esatta del prompt benchmark DA
|
| 997 |
+
# Fix S-BENCH-DA-V2: avg:null risolto con passi aritmetici espliciti
|
| 998 |
(
|
| 999 |
["vendite mensili:", "rispondi esattamente con questo formato",
|
| 1000 |
"copia la struttura, sostituisci", "mese col valore massimo",
|
| 1001 |
"valore anomalo fuori scala"],
|
| 1002 |
+
"TIME SERIES ANALISI (DA-BENCH) — 4 bullet esatti, zero testo prima/dopo:\n"
|
| 1003 |
+
"Passo 1 — calcola dal JSON (non scrivere i calcoli intermedi):\n"
|
| 1004 |
+
" media = (somma di TUTTI i valori 'vendite') / (numero totale di mesi), arrotonda a 1 decimale\n"
|
| 1005 |
+
" CRITICO: NON escludere il mese anomalo dal calcolo — includi TUTTI i mesi senza eccezioni\n"
|
| 1006 |
+
" SUGGERIMENTO: se il prompt contiene 'es. Media: X', X è il valore atteso — confronta con il tuo calcolo\n"
|
| 1007 |
+
" picco = il nome del mese con il valore 'vendite' più alto\n"
|
| 1008 |
+
" anomalia = il nome del mese con il valore 'vendite' nettamente fuori scala (di solito ≤15)\n"
|
| 1009 |
+
"Passo 2 — scrivi ESATTAMENTE questi 4 bullet (primo carattere = trattino, zero testo prima):\n"
|
| 1010 |
+
"- **Media: <valore_calcolato>**\n"
|
| 1011 |
+
"- **Picco: <MESE> (<valore_picco>)**\n"
|
| 1012 |
+
"- **Anomalia: <MESE> (<valore_anomalo>)**\n"
|
| 1013 |
+
"- **Trend: <descrizione breve>**\n"
|
| 1014 |
+
"Regola ASSOLUTA: sostituisci OGNI <...> con il valore numerico/testuale reale dai dati.\n"
|
| 1015 |
+
"NON scrivere i tag <...> nella risposta finale. NON aggiungere testo prima del primo bullet.\n"
|
| 1016 |
+
"NON usare tool. La risposta è solo i 4 bullet, nient'altro."
|
|
|
|
|
|
|
|
|
|
| 1017 |
),
|
| 1018 |
# ── S-BENCH-ROB: robustness ─────────────────────────────────────────────
|
| 1019 |
# 4 scenari: injection / rumore / contraddizioni / degradazione progressiva
|
|
|
|
| 1128 |
),
|
| 1129 |
# ── S-BENCH-BF: bug_fix ──────────────────────────────────────────────
|
| 1130 |
# Trigger: frasi esatte del prompt benchmark BF + identificatori di scenario
|
| 1131 |
+
# "identifica e correggi i bug typescript" + "non riscrivere struttura" = firma esatta BF
|
| 1132 |
(
|
| 1133 |
["identifica e correggi i bug typescript",
|
| 1134 |
"non riscrivere struttura",
|
|
|
|
| 1137 |
"promise.all crash", "processusers",
|
| 1138 |
"setstate su componente unmontato", "useasyncdata",
|
| 1139 |
"deepclone via spread", "clonepoint", "clonedate"],
|
| 1140 |
+
"BUG FIX TYPESCRIPT (BF-BENCH):\n"
|
| 1141 |
+
"Correggi SOLO il bug senza riscrivere la struttura. Blocco ```typescript.\n"
|
| 1142 |
+
"Pattern di fix:\n"
|
| 1143 |
+
"- Binary search off-by-one: `lo = mid + 1` (non `lo = mid`)\n"
|
| 1144 |
+
"- Promise.all crash: usa Promise.allSettled(), gestisci .fulfilled/.rejected\n"
|
| 1145 |
+
"- setState su unmount: flag `let mounted=true` + cleanup `return ()=>{mounted=false}`\n"
|
| 1146 |
+
"- deepClone spread: `new Point(p.x,p.y)` e `new Date(d.getTime())`\n"
|
| 1147 |
+
"- Memory leak: clearInterval nel return del useEffect"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
),
|
| 1149 |
# ── S-CHIP-DIAGRAM: chip "Diagramma" → forza output Mermaid ──────────���──
|
| 1150 |
# Trigger: frasi esatte dal chip text (QuickActionChips.tsx)
|
|
|
|
| 1241 |
" - Mai esporre dati sensibili (token, password) nel payload"
|
| 1242 |
),
|
| 1243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
]
|
| 1245 |
|
| 1246 |
@staticmethod
|
| 1247 |
+
def _extract_persona(goal: str) -> "tuple[str | None, str]":
|
| 1248 |
+
"""P19-F1: Estrae persona dal goal se inizia con /persona <NAME>.
|
| 1249 |
+
Ritorna (persona_name | None, goal_senza_prefisso).
|
| 1250 |
+
"""
|
| 1251 |
import re as _re
|
| 1252 |
+
_m = _re.match(r'^/persona\s+(RESEARCHER|CODER|REASONER)\b', goal.strip(), _re.IGNORECASE)
|
| 1253 |
if _m:
|
| 1254 |
clean = goal.strip()[_m.end():].strip()
|
| 1255 |
return _m.group(1).upper(), clean if clean else goal.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
return None, goal
|
| 1257 |
+
|
| 1258 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1259 |
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
| 1260 |
goal_lower = goal.lower()
|
|
|
|
| 1463 |
"\n\nCHECKLIST ANALITICA (verifica mentalmente prima di rispondere):\n"
|
| 1464 |
"□ Ho risposto a TUTTI i punti richiesti nel goal\n"
|
| 1465 |
"□ Ho sviluppato ogni punto con dettagli concreti (non superficiale)\n"
|
|
|
|
|
|
|
|
|
|
| 1466 |
"□ La risposta ha una struttura chiara (sezioni o paragrafi)\n"
|
| 1467 |
+
"□ Ho concluso con una raccomandazione o sintesi finale (se richiesto)\n"
|
| 1468 |
+
"□ La risposta è almeno 200 parole"
|
| 1469 |
)
|
| 1470 |
# ── Item 4: formato rigido per goal con template esplicito ──────────────
|
| 1471 |
# Trigger: goal con '[campo]', '{{', tabelle markdown, o "usa questo formato".
|
|
|
|
| 1527 |
"Nei test Vitest, usa vi.mock() e vi.spyOn() — non jest.mock(). Importa da 'vitest' non da '@jest'.",
|
| 1528 |
"Nei test Playwright, usa page.getByRole(), page.getByTestId() per selettori resilienti — non XPath o CSS fragili.",
|
| 1529 |
"In Pydantic v2, usa model_validator e field_validator al posto di @validator (deprecato). BaseModel.model_dump() sostituisce .dict().",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1530 |
]
|
|
|
|
|
|
|
|
|
agents/unified_loop_routing.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_routing.py — RoutingMixin: regex CODE, estrazione file da output LLM.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale.
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_CODE_RE: regex riconoscimento goal di tipo codice (S362/S427)
|
| 7 |
+
_EXT: pattern estensioni file supportate (S416/S422)
|
| 8 |
+
_FILE_BLOCK_RE: regex estrazione blocchi file da risposta LLM (S422-Fix1)
|
| 9 |
+
_extract_written_files(answer): classmethod — estrae dict path→content da output LLM
|
| 10 |
+
|
| 11 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class RoutingMixin:
|
| 19 |
+
_CODE_RE = re.compile(
|
| 20 |
+
r'\b(scrivi|crea|genera|implementa|refactor|bug|fix|debug|test|codice|'
|
| 21 |
+
r'funzione|classe|componente|api|endpoint|typescript|javascript|python|'
|
| 22 |
+
r'react|vue|swift|kotlin|write|create|generate|implement|code|function|'
|
| 23 |
+
r'class|component|frontend|backend|server|client|hook|store|type|'
|
| 24 |
+
r'interface|migration|query|schema|dockerfile|workflow|'
|
| 25 |
+
# S427: verbi italiani azione-codice mancanti
|
| 26 |
+
r'sistema|sistemi|correggi|corregge|debugga|patch|patcha|rinomina|'
|
| 27 |
+
r'sostituisci|rimpiazza|ottimizza|refactorizza|ristruttura|'
|
| 28 |
+
r'aggiungi|aggiorna|integra|rimuovi|elimina|cancella|inserisci|'
|
| 29 |
+
# S427: verbi inglesi azione-codice mancanti
|
| 30 |
+
r'rename|replace|remove|delete|patch|optimize|restructure|'
|
| 31 |
+
r'add|update|integrate|insert|scaffold|bootstrap|deploy|'
|
| 32 |
+
# S427: framework/librerie/pattern aggiuntivi
|
| 33 |
+
r'svelte|angular|next\.?js|nuxt|remix|astro|nest\.?js|'
|
| 34 |
+
r'fastapi|flask|django|express|rails|laravel|spring|'
|
| 35 |
+
r'graphql|grpc|websocket|rest|sql|nosql|'
|
| 36 |
+
r'prisma|drizzle|sqlalchemy|mongoose|sequelize|'
|
| 37 |
+
r'css|scss|sass|html|rust|go|java|kotlin|dart|flutter|'
|
| 38 |
+
r'service|repository|controller|middleware|utility|helper|'
|
| 39 |
+
r'decorator|enum|zod|vite|webpack|eslint|prettier|jest|vitest)\b',
|
| 40 |
+
re.IGNORECASE,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# S416-Fix1: estrae pathâcontent dei file scritti nella risposta LLM
|
| 44 |
+
# Pattern: "path/file.ext:" o "### file.ext" o "FILE: file.ext" seguito da code block
|
| 45 |
+
# S422-Fix1: esteso con 4 formati aggiuntivi (bold, inline code, lista, commento inline)
|
| 46 |
+
# Copre 9/9 formati LLM più comuni â S416 era silenziosamente rotto al 60-70%
|
| 47 |
+
_EXT = r'(?:tsx?|jsx?|py|css|html|md|json|ya?ml|sh|toml|sql|go|rs|rb|java|kt|swift|vue|svelte)'
|
| 48 |
+
_FILE_BLOCK_RE = re.compile(
|
| 49 |
+
r'(?:'
|
| 50 |
+
# p1: FILE: path o ## FILE: path
|
| 51 |
+
r'(?:^|\n)\s*(?:#{1,3}\s*)?(?:FILE|file|File):\s*[`"]?(?P<p1>[\w./\-]+\.\w+)[`"]?\s*\n'
|
| 52 |
+
# p2: path: o path- (solo con estensione nota)
|
| 53 |
+
r'|(?:^|\n)\s*[`"]?(?P<p2>[\w./\-]+\.' + _EXT + r')[`"]?\s*[:\-â]\s*\n'
|
| 54 |
+
# p3: ## path (markdown heading)
|
| 55 |
+
r'|(?:^|\n)#{1,3}\s+(?P<p3>[\w./\-]+\.' + _EXT + r')\s*\n'
|
| 56 |
+
# p4: **path** (bold) â formato più comune GPT/OpenRouter/Claude
|
| 57 |
+
r'|(?:^|\n)\s*\*\*(?P<p4>[\w./\-]+\.' + _EXT + r')\*\*\s*.*?\n'
|
| 58 |
+
# p5: `path` (inline code) prima del blocco
|
| 59 |
+
r'|(?:^|\n)\s*`(?P<p5>[\w./\-]+\.' + _EXT + r')`\s*.*?\n'
|
| 60 |
+
# p6: 1. **path** o - **path** (lista)
|
| 61 |
+
r'|(?:^|\n)\s*(?:\d+\.|[-*])\s+\*\*?(?P<p6>[\w./\-]+\.' + _EXT + r')\*?\*?\s*.*?\n'
|
| 62 |
+
r')'
|
| 63 |
+
# blocco codice â opzionale commento // path o # path come prima riga (p7)
|
| 64 |
+
r'```(?:\w+\n(?:(?://|#)\s*(?P<p7>[\w./\-]+\.' + _EXT + r')\s*\n))?'
|
| 65 |
+
r'(?P<content>.+?)```',
|
| 66 |
+
re.DOTALL | re.MULTILINE,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
@classmethod
|
| 70 |
+
def _extract_written_files(cls, answer: str) -> dict[str, str]:
|
| 71 |
+
"""S422-Fix1: estrae file pathâcontent dall'output LLM per iniettarli come contesto.
|
| 72 |
+
Copre tutti i formati comuni: FILE:, ##, **bold**, `inline`, lista, commento inline."""
|
| 73 |
+
result: dict[str, str] = {}
|
| 74 |
+
for m in cls._FILE_BLOCK_RE.finditer(answer):
|
| 75 |
+
path = (m.group("p1") or m.group("p2") or m.group("p3") or
|
| 76 |
+
m.group("p4") or m.group("p5") or m.group("p6") or
|
| 77 |
+
m.group("p7") or "")
|
| 78 |
+
content = m.group("content") or ""
|
| 79 |
+
if path and content.strip():
|
| 80 |
+
result[path.strip()] = content.strip()[:3000]
|
| 81 |
+
return result
|
| 82 |
+
|
agents/unified_loop_tools.py
CHANGED
|
@@ -1,36 +1,37 @@
|
|
| 1 |
"""unified_loop_tools.py — DirectToolsMixin: tool execution layer.
|
|
|
|
| 2 |
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
|
|
|
|
| 3 |
Contiene (nell'ordine originale del file):
|
| 4 |
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
|
| 5 |
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
|
| 6 |
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
|
| 7 |
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
|
| 8 |
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
|
|
|
|
| 9 |
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 10 |
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
|
|
|
| 13 |
import asyncio
|
| 14 |
-
import hashlib
|
| 15 |
import os
|
| 16 |
import re
|
| 17 |
from typing import Any
|
| 18 |
-
import logging
|
| 19 |
-
try:
|
| 20 |
-
from api.state import record_timing as _rtc_global # telemetria tool call
|
| 21 |
-
except ImportError:
|
| 22 |
-
_rtc_global = None # state module non ancora disponibile al boot
|
| 23 |
|
|
|
|
| 24 |
_logger = logging.getLogger("agents.unified_loop_tools")
|
|
|
|
| 25 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
class DirectToolsMixin:
|
| 30 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 31 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
| 32 |
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
|
| 33 |
# pronta per essere iniettata nel prompt LLM.
|
|
|
|
| 34 |
_WEATHER_INTENT_RE = re.compile(
|
| 35 |
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
|
| 36 |
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
|
|
@@ -58,49 +59,158 @@ class DirectToolsMixin:
|
|
| 58 |
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 59 |
re.IGNORECASE,
|
| 60 |
)
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
_SEARCH_INTENT_RE = re.compile(
|
| 63 |
-
r"
|
| 64 |
-
r"
|
| 65 |
-
r"
|
| 66 |
-
r"
|
| 67 |
-
r"
|
| 68 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
re.IGNORECASE,
|
| 70 |
)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
r"
|
| 76 |
-
r"(?:
|
| 77 |
-
r"
|
| 78 |
-
r"
|
| 79 |
-
r"(
|
| 80 |
re.IGNORECASE,
|
| 81 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
_CALC_INTENT_RE = re.compile(
|
| 83 |
-
r"\b(calcola|quanto\s+fa|risultato\s+di|compute|
|
| 84 |
-
r"
|
| 85 |
-
r"
|
|
|
|
|
|
|
| 86 |
re.IGNORECASE,
|
| 87 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _extract_city(self, goal: str) -> str:
|
| 89 |
m = self._CITY_RE.search(goal)
|
| 90 |
if m:
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def _extract_search_query(self, goal: str) -> str:
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def _extract_calc_expr(self, goal: str) -> str:
|
| 99 |
-
m =
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
def _extract_dir_path(self, goal: str) -> str:
|
| 103 |
-
|
| 104 |
m = re.search(
|
| 105 |
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
|
| 106 |
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
|
|
@@ -113,16 +223,18 @@ class DirectToolsMixin:
|
|
| 113 |
return "."
|
| 114 |
|
| 115 |
def _extract_file_pattern(self, goal: str) -> str:
|
| 116 |
-
|
| 117 |
m = re.search(
|
| 118 |
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
|
| 119 |
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
|
| 120 |
goal, re.IGNORECASE,
|
| 121 |
)
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
|
| 124 |
def _extract_git_cwd(self, goal: str) -> str:
|
| 125 |
-
|
| 126 |
m = re.search(
|
| 127 |
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
|
| 128 |
goal, re.IGNORECASE,
|
|
@@ -132,84 +244,92 @@ class DirectToolsMixin:
|
|
| 132 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 133 |
return candidate
|
| 134 |
return "."
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
goal: str,
|
| 138 |
-
on_step: StepCallback | None = None,
|
| 139 |
-
*,
|
| 140 |
-
local_csv_only: bool = False,
|
| 141 |
-
) -> tuple[str, int, int, int]:
|
| 142 |
"""
|
| 143 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 144 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 145 |
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 146 |
n_called: numero totale di tool chiamati
|
| 147 |
-
n_success:
|
| 148 |
-
n_errors:
|
|
|
|
|
|
|
| 149 |
"""
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
| 154 |
|
| 155 |
results: list[str] = []
|
| 156 |
-
n_called = 0
|
| 157 |
-
n_success = 0
|
| 158 |
-
n_errors = 0
|
| 159 |
-
TOOL_TIMEOUT = 25
|
| 160 |
|
| 161 |
-
# Governor
|
| 162 |
_gov_called: set[str] = set()
|
| 163 |
-
_gov_total = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 165 |
-
|
| 166 |
|
| 167 |
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
_gov_called
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
return True
|
| 177 |
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
| 179 |
try:
|
| 180 |
-
|
|
|
|
| 181 |
except Exception:
|
| 182 |
-
# Cache speculativa opzionale: mai bloccare l'esecuzione reale.
|
| 183 |
return None
|
| 184 |
|
| 185 |
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 186 |
# Pre-check intent (sincrono) → costruisce lista coroutine → gather
|
|
|
|
|
|
|
| 187 |
url_m = self._URL_RE.search(goal)
|
|
|
|
| 188 |
async def _t_get_weather() -> str | None:
|
| 189 |
if not self._WEATHER_INTENT_RE.search(goal):
|
| 190 |
return None
|
| 191 |
-
city = self._extract_city(goal)
|
| 192 |
if not _gov_check("get_weather", city):
|
| 193 |
return None
|
| 194 |
try:
|
| 195 |
-
if on_step:
|
| 196 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 197 |
-
"title": "Meteo", "explanation": f"Recupero meteo per {city}…"}))
|
| 198 |
_sc = _spec_hit("get_weather", {"city": city})
|
| 199 |
if _sc is not None:
|
| 200 |
return _sc
|
|
|
|
|
|
|
|
|
|
| 201 |
_t0 = asyncio.get_event_loop().time()
|
| 202 |
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 203 |
try:
|
| 204 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 205 |
-
except Exception
|
| 206 |
-
if "
|
| 207 |
_wdesc = {
|
| 208 |
-
0: "
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 215 |
try:
|
|
@@ -222,11 +342,12 @@ class DirectToolsMixin:
|
|
| 222 |
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 223 |
f"Condizioni: {desc}"
|
| 224 |
)
|
| 225 |
-
return f"[get_weather: errore — {r['error'][:300]}]"
|
| 226 |
except asyncio.TimeoutError:
|
| 227 |
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 228 |
except Exception as exc:
|
| 229 |
-
return f"[get_weather: errore — {str(exc)[:300]}]"
|
|
|
|
| 230 |
async def _t_read_page() -> str | None:
|
| 231 |
if not url_m:
|
| 232 |
return None
|
|
@@ -244,14 +365,15 @@ class DirectToolsMixin:
|
|
| 244 |
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 245 |
try:
|
| 246 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 247 |
-
except Exception
|
| 248 |
if r.get("content"):
|
| 249 |
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 250 |
-
return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]"
|
| 251 |
except asyncio.TimeoutError:
|
| 252 |
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 253 |
except Exception as exc:
|
| 254 |
-
return f"[read_page: errore — {str(exc)[:300]}]"
|
|
|
|
| 255 |
async def _t_calculate() -> str | None:
|
| 256 |
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 257 |
return None
|
|
@@ -269,14 +391,15 @@ class DirectToolsMixin:
|
|
| 269 |
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 270 |
try:
|
| 271 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 272 |
-
except Exception
|
| 273 |
if "result" in r:
|
| 274 |
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 275 |
-
return f"[calculate: errore — {r.get('error', '?')[:300]}]"
|
| 276 |
except asyncio.TimeoutError:
|
| 277 |
return "[calculate: timeout]"
|
| 278 |
except Exception as exc:
|
| 279 |
-
return f"[calculate: errore — {str(exc)[:300]}]"
|
|
|
|
| 280 |
async def _t_web_search() -> str | None:
|
| 281 |
if not self._SEARCH_INTENT_RE.search(goal):
|
| 282 |
return None
|
|
@@ -294,95 +417,27 @@ class DirectToolsMixin:
|
|
| 294 |
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 295 |
try:
|
| 296 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 297 |
-
except Exception
|
| 298 |
hits = r.get("results", [])
|
| 299 |
if hits:
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
if conversion is None:
|
| 312 |
-
return None
|
| 313 |
-
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 314 |
-
return None
|
| 315 |
-
|
| 316 |
-
# Il successo diretto è consentito solo dopo il confronto semantico
|
| 317 |
-
# record-per-record. Questo blocca cataloghi generici/allucinati prima
|
| 318 |
-
# che il loop possa dichiarare una conversione corretta.
|
| 319 |
-
is_valid, validation_error = validate_csv_json_equivalence(
|
| 320 |
-
conversion.source_content,
|
| 321 |
-
conversion.content,
|
| 322 |
-
)
|
| 323 |
-
if not is_valid:
|
| 324 |
-
return f"[convert_csv_to_json: validazione fallita — {validation_error}]"
|
| 325 |
-
|
| 326 |
-
async def _write(path: str, content: str) -> dict[str, Any]:
|
| 327 |
-
return await asyncio.wait_for(
|
| 328 |
-
TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
|
| 329 |
-
timeout=TOOL_TIMEOUT,
|
| 330 |
-
)
|
| 331 |
-
|
| 332 |
-
try:
|
| 333 |
-
if on_step:
|
| 334 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 335 |
-
"title": "Conversione CSV in JSON",
|
| 336 |
-
"explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica record…"}))
|
| 337 |
-
_t0 = asyncio.get_event_loop().time()
|
| 338 |
-
|
| 339 |
-
# Per il CSV inline il goal richiede esplicitamente entrambi gli
|
| 340 |
-
# artefatti. Gli allegati conservano il comportamento esistente:
|
| 341 |
-
# viene scritto soltanto il JSON, poiché la fonte è già disponibile.
|
| 342 |
-
written_paths: list[str] = []
|
| 343 |
-
if conversion.source_is_inline:
|
| 344 |
-
source_written = await _write(conversion.source_name, conversion.source_content)
|
| 345 |
-
if not source_written.get("ok"):
|
| 346 |
-
return f"[convert_csv_to_json: errore sorgente — {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 347 |
-
written_paths.append(conversion.source_name)
|
| 348 |
-
if on_step:
|
| 349 |
-
await _maybe_await(on_step({
|
| 350 |
-
"action": "file_written", "status": "done",
|
| 351 |
-
"path": conversion.source_name, "content": conversion.source_content,
|
| 352 |
-
"title": "File CSV creato",
|
| 353 |
-
"explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
|
| 354 |
-
}))
|
| 355 |
-
|
| 356 |
-
target_written = await _write(conversion.target_name, conversion.content)
|
| 357 |
-
if not target_written.get("ok"):
|
| 358 |
-
return f"[convert_csv_to_json: errore JSON — {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 359 |
-
written_paths.append(conversion.target_name)
|
| 360 |
-
if on_step:
|
| 361 |
-
await _maybe_await(on_step({
|
| 362 |
-
"action": "file_written", "status": "done",
|
| 363 |
-
"path": conversion.target_name, "content": conversion.content,
|
| 364 |
-
"title": "File JSON creato",
|
| 365 |
-
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
|
| 366 |
-
}))
|
| 367 |
-
try:
|
| 368 |
-
from api.state import record_timing as _rtc
|
| 369 |
-
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 370 |
-
except Exception as _e:
|
| 371 |
-
_logger.debug('[timing/record_timing] %s', _e)
|
| 372 |
-
paths = ", ".join(f"`{path}`" for path in written_paths)
|
| 373 |
-
return (
|
| 374 |
-
"[DIRECT_TERMINAL]\n"
|
| 375 |
-
f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
|
| 376 |
-
f"e `{conversion.target_name}`.\n\n"
|
| 377 |
-
f"File workspace salvati: {paths}."
|
| 378 |
-
)
|
| 379 |
except asyncio.TimeoutError:
|
| 380 |
-
return "[
|
| 381 |
except Exception as exc:
|
| 382 |
-
return f"[
|
| 383 |
|
| 384 |
async def _t_generate_image() -> str | None:
|
| 385 |
-
if not self.
|
| 386 |
return None
|
| 387 |
_img_prompt = re.sub(
|
| 388 |
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
|
@@ -394,52 +449,28 @@ class DirectToolsMixin:
|
|
| 394 |
if on_step:
|
| 395 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 396 |
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
|
| 397 |
-
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
|
| 398 |
if _sc is not None:
|
| 399 |
return _sc
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
# resta solo per garantire la creazione gratuita se il secret non è
|
| 403 |
-
# ancora disponibile durante un riavvio del runtime.
|
| 404 |
try:
|
| 405 |
-
from api.
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
|
| 415 |
-
f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
|
| 416 |
)
|
| 417 |
-
|
| 418 |
-
_artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
|
| 419 |
-
_artifact_path = f"generated-image-{_artifact_id}.jpg"
|
| 420 |
-
if on_step:
|
| 421 |
-
await _maybe_await(on_step({
|
| 422 |
-
"action": "file_written",
|
| 423 |
-
"status": "done",
|
| 424 |
-
"path": _artifact_path,
|
| 425 |
-
"source_url": img_url,
|
| 426 |
-
"mime_type": img_mime,
|
| 427 |
-
"title": "Immagine salvata nel workspace",
|
| 428 |
-
"explanation": f"Salvo {_artifact_path} nel VFS…",
|
| 429 |
-
}))
|
| 430 |
-
return (
|
| 431 |
-
"[DIRECT_TERMINAL]\n"
|
| 432 |
-
f"\n\n"
|
| 433 |
-
"E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
|
| 434 |
-
f"[Apri o scarica l’immagine]({img_url}).\n\n"
|
| 435 |
-
f"File VFS: `{_artifact_path}`\n"
|
| 436 |
-
f"Prompt usato: {_img_prompt[:200]}\n"
|
| 437 |
-
"Dimensioni: 512x512 px"
|
| 438 |
-
)
|
| 439 |
except asyncio.TimeoutError:
|
| 440 |
return "[generate_image: timeout — provider non raggiungibile]"
|
| 441 |
except Exception as exc:
|
| 442 |
-
return f"[generate_image: errore — {str(exc)[:300]}]"
|
|
|
|
| 443 |
async def _t_run_python() -> str | None:
|
| 444 |
_RUN_CODE_RE = re.compile(
|
| 445 |
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
|
@@ -458,52 +489,139 @@ class DirectToolsMixin:
|
|
| 458 |
if on_step:
|
| 459 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 460 |
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
|
| 461 |
-
_sc = _spec_hit("run_python", {"code": _code[:400]})
|
| 462 |
if _sc is not None:
|
| 463 |
return _sc
|
| 464 |
_t0 = asyncio.get_event_loop().time()
|
| 465 |
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 466 |
try:
|
| 467 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 468 |
-
except Exception
|
| 469 |
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 470 |
_out = (
|
| 471 |
"[CODICE PYTHON ESEGUITO]\n"
|
| 472 |
f"```python\n{_code[:500]}\n```\n"
|
| 473 |
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 474 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
return _out
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
except asyncio.TimeoutError:
|
| 478 |
return "[run_python: timeout 18s]"
|
| 479 |
except Exception as exc:
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
async def _t_web_research() -> str | None:
|
| 482 |
-
|
| 483 |
-
if not _RESEARCH_RE.search(goal):
|
| 484 |
return None
|
| 485 |
-
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
return None
|
| 488 |
try:
|
| 489 |
if on_step:
|
| 490 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 491 |
-
"title": "Ricerca approfondita", "explanation": f"
|
|
|
|
|
|
|
|
|
|
| 492 |
_t0 = asyncio.get_event_loop().time()
|
| 493 |
-
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](
|
| 494 |
try:
|
| 495 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 496 |
-
except Exception
|
| 497 |
-
if r.get("
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
except asyncio.TimeoutError:
|
| 501 |
-
return "[web_research: timeout
|
| 502 |
except Exception as exc:
|
| 503 |
return f"[web_research: errore — {str(exc)[:300]}]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
async def _t_directory_tree() -> str | None:
|
| 505 |
-
|
| 506 |
-
if not _TREE_RE.search(goal):
|
| 507 |
return None
|
| 508 |
_path = self._extract_dir_path(goal)
|
| 509 |
if not _gov_check("directory_tree", _path):
|
|
@@ -512,17 +630,23 @@ class DirectToolsMixin:
|
|
| 512 |
if on_step:
|
| 513 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 514 |
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
|
|
|
|
| 515 |
r = await asyncio.wait_for(
|
| 516 |
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
|
| 517 |
)
|
|
|
|
|
|
|
|
|
|
| 518 |
if r.get("ok") and r.get("tree"):
|
| 519 |
-
return f"[STRUTTURA PROGETTO
|
| 520 |
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 521 |
except Exception as exc:
|
| 522 |
-
return f"[directory_tree: errore — {str(exc)[:
|
|
|
|
| 523 |
async def _t_file_search() -> str | None:
|
| 524 |
-
|
| 525 |
-
if not _SEARCH_RE.search(goal):
|
| 526 |
return None
|
| 527 |
_pattern = self._extract_file_pattern(goal)
|
| 528 |
if not _pattern or not _gov_check("file_search", _pattern):
|
|
@@ -531,40 +655,29 @@ class DirectToolsMixin:
|
|
| 531 |
try:
|
| 532 |
if on_step:
|
| 533 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 534 |
-
"title": "Ricerca
|
|
|
|
| 535 |
r = await asyncio.wait_for(
|
| 536 |
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
|
| 537 |
)
|
|
|
|
|
|
|
|
|
|
| 538 |
if r.get("ok"):
|
| 539 |
_matches = r.get("matches", [])
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
| 544 |
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 545 |
except Exception as exc:
|
| 546 |
-
return f"[file_search: errore — {str(exc)[:
|
| 547 |
-
|
| 548 |
-
_NEWS_RE = re.compile(r"\b(news|notizie|ultim[ae]\s+ora|breaking)\b", re.IGNORECASE)
|
| 549 |
-
if not _NEWS_RE.search(goal):
|
| 550 |
-
return None
|
| 551 |
-
query = self._extract_search_query(goal)
|
| 552 |
-
try:
|
| 553 |
-
if on_step:
|
| 554 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 555 |
-
"title": "Notizie", "explanation": f"Cerco notizie su: {query[:60]}…"}))
|
| 556 |
-
r = await asyncio.wait_for(TOOL_REGISTRY["get_news"]["_fn"](query=query), timeout=15)
|
| 557 |
-
if r.get("news"):
|
| 558 |
-
_out = [f"[NOTIZIE REALI: {query}]"]
|
| 559 |
-
for n in r["news"][:5]:
|
| 560 |
-
_out.append(f"• {n['title']} ({n.get('source', '?')}): {n.get('description', '')[:150]}")
|
| 561 |
-
return "\n".join(_out)
|
| 562 |
-
return "[get_news: nessuna notizia trovata]"
|
| 563 |
-
except Exception as exc:
|
| 564 |
-
return f"[get_news: errore — {str(exc)[:200]}]"
|
| 565 |
async def _t_git_status() -> str | None:
|
| 566 |
-
|
| 567 |
-
if not _GIT_RE.search(goal):
|
| 568 |
return None
|
| 569 |
_cwd = self._extract_git_cwd(goal)
|
| 570 |
if not _gov_check("git_status", _cwd):
|
|
@@ -572,75 +685,84 @@ class DirectToolsMixin:
|
|
| 572 |
try:
|
| 573 |
if on_step:
|
| 574 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 575 |
-
"title": "Stato Git", "explanation":
|
|
|
|
| 576 |
r = await asyncio.wait_for(
|
| 577 |
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
|
| 578 |
)
|
|
|
|
|
|
|
|
|
|
| 579 |
if r.get("ok"):
|
| 580 |
-
|
| 581 |
if r.get("status"):
|
| 582 |
-
|
| 583 |
if r.get("log"):
|
| 584 |
-
|
| 585 |
-
return
|
| 586 |
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 587 |
except Exception as exc:
|
| 588 |
-
return f"[git_status: errore — {str(exc)[:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
async def _t_analyze_python() -> str | None:
|
| 590 |
-
# P30-B1: Analisi statica Python integrata nel tool layer
|
| 591 |
if not self._ANALYZE_PY_RE.search(goal):
|
| 592 |
return None
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
| 597 |
try:
|
| 598 |
if on_step:
|
| 599 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 600 |
-
"title": "Analisi
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
_out.append("
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
return "\n".join(_out)
|
| 613 |
except asyncio.TimeoutError:
|
| 614 |
return "[python_analyze: timeout]"
|
| 615 |
except Exception as _exc:
|
| 616 |
return f"[python_analyze: errore — {str(_exc)[:200]}]"
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
_terminal_conversion = await _t_convert_csv_attachment()
|
| 620 |
-
if _terminal_conversion is not None:
|
| 621 |
-
return (_terminal_conversion, 1,
|
| 622 |
-
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 623 |
-
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
| 624 |
-
# Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
|
| 625 |
-
# ammessi altri direct tool, né fallback impliciti a immagine/rete.
|
| 626 |
-
if local_csv_only:
|
| 627 |
-
return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
|
| 628 |
-
_terminal_image = await _t_generate_image()
|
| 629 |
-
if _terminal_image is not None:
|
| 630 |
-
return (_terminal_image, 1,
|
| 631 |
-
int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
|
| 632 |
-
int(": errore" in _terminal_image or ": timeout" in _terminal_image))
|
| 633 |
-
|
| 634 |
-
# Esecuzione parallela per i tool non terminali.
|
| 635 |
-
_sem = asyncio.Semaphore(3)
|
| 636 |
-
async def _sem_wrap(coro):
|
| 637 |
-
if coro is None: return None
|
| 638 |
-
async with _sem: return await coro
|
| 639 |
-
_parallel_results = await asyncio.gather(
|
| 640 |
_sem_wrap(_t_get_weather()),
|
| 641 |
_sem_wrap(_t_read_page()),
|
| 642 |
_sem_wrap(_t_calculate()),
|
| 643 |
_sem_wrap(_t_web_search()),
|
|
|
|
| 644 |
_sem_wrap(_t_run_python()),
|
| 645 |
_sem_wrap(_t_web_research()),
|
| 646 |
_sem_wrap(_t_directory_tree()),
|
|
@@ -653,22 +775,54 @@ class DirectToolsMixin:
|
|
| 653 |
for _pr in _parallel_results:
|
| 654 |
if isinstance(_pr, str):
|
| 655 |
results.append(_pr)
|
| 656 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
_REAL_DATA_PREFIXES = (
|
| 658 |
-
"[RICERCA WEB REALE",
|
| 659 |
-
"[
|
| 660 |
-
"[
|
| 661 |
-
"[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
)
|
| 663 |
-
for
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
# ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
|
| 671 |
-
#
|
|
|
|
|
|
|
| 672 |
_FALSE_CLAIM_RE = re.compile(
|
| 673 |
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 674 |
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
|
@@ -696,38 +850,78 @@ class DirectToolsMixin:
|
|
| 696 |
false_claim_re: "re.Pattern[str]",
|
| 697 |
realtime_goal_re: "re.Pattern[str]",
|
| 698 |
) -> str:
|
| 699 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
if n_success > 0 or n_errors == 0:
|
| 701 |
-
return response
|
| 702 |
if not realtime_goal_re.search(goal):
|
| 703 |
-
return response
|
| 704 |
if not false_claim_re.search(response):
|
| 705 |
-
return response
|
|
|
|
| 706 |
disclaimer = (
|
| 707 |
"\n\n---\n"
|
| 708 |
-
"**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 709 |
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 710 |
"dal mio training e potrebbero non essere aggiornate. "
|
| 711 |
-
"Per dati live consulta
|
|
|
|
| 712 |
)
|
| 713 |
return response + disclaimer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
_TOOL_NEEDED_RE = re.compile(
|
| 715 |
-
r"\b(meteo|
|
| 716 |
-
r"
|
| 717 |
-
r"
|
| 718 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
re.IGNORECASE,
|
| 720 |
)
|
|
|
|
| 721 |
def _needs_tools(self, goal: str) -> bool:
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
if bool(self._CODE_GOAL_RE.search(goal)): return True
|
| 730 |
-
return False
|
| 731 |
_SIMPLE_CONV_RE = re.compile(
|
| 732 |
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 733 |
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
|
@@ -744,6 +938,11 @@ class DirectToolsMixin:
|
|
| 744 |
r")\.?\s*[!?]?$",
|
| 745 |
re.IGNORECASE,
|
| 746 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
_SIMPLE_MATH_RE = re.compile(
|
| 748 |
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
|
| 749 |
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
|
|
@@ -751,6 +950,7 @@ class DirectToolsMixin:
|
|
| 751 |
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
|
| 752 |
re.IGNORECASE,
|
| 753 |
)
|
|
|
|
| 754 |
_ANALYZE_PY_RE = re.compile(
|
| 755 |
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
|
| 756 |
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
|
|
@@ -762,18 +962,24 @@ class DirectToolsMixin:
|
|
| 762 |
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
|
| 763 |
re.IGNORECASE,
|
| 764 |
)
|
|
|
|
| 765 |
_PY_BLOCK_IN_GOAL_RE = re.compile(
|
| 766 |
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 767 |
re.IGNORECASE,
|
| 768 |
)
|
| 769 |
-
|
| 770 |
-
_CODE_RE = re.compile(r"```[\s\S]*?```")
|
| 771 |
def _is_simple_query(self, goal: str) -> bool:
|
|
|
|
|
|
|
|
|
|
| 772 |
g = goal.strip()
|
| 773 |
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 774 |
return False
|
|
|
|
|
|
|
| 775 |
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
|
| 776 |
return True
|
|
|
|
| 777 |
if len(g) > 70 or self._needs_tools(g):
|
| 778 |
return False
|
| 779 |
return bool(self._SIMPLE_CONV_RE.match(g))
|
|
|
|
| 1 |
"""unified_loop_tools.py — DirectToolsMixin: tool execution layer.
|
| 2 |
+
|
| 3 |
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
|
| 4 |
+
|
| 5 |
Contiene (nell'ordine originale del file):
|
| 6 |
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
|
| 7 |
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
|
| 8 |
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
|
| 9 |
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
|
| 10 |
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
|
| 11 |
+
|
| 12 |
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 13 |
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
+
|
| 17 |
import asyncio
|
|
|
|
| 18 |
import os
|
| 19 |
import re
|
| 20 |
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
import logging
|
| 23 |
_logger = logging.getLogger("agents.unified_loop_tools")
|
| 24 |
+
|
| 25 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
+
from agents.unified_loop_types import StepCallback
|
| 27 |
+
|
| 28 |
+
|
| 29 |
class DirectToolsMixin:
|
| 30 |
# ── Direct tool execution (S193) ─────────────────────────────────────────
|
| 31 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
| 32 |
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
|
| 33 |
# pronta per essere iniettata nel prompt LLM.
|
| 34 |
+
|
| 35 |
_WEATHER_INTENT_RE = re.compile(
|
| 36 |
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
|
| 37 |
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
|
|
|
|
| 59 |
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 60 |
re.IGNORECASE,
|
| 61 |
)
|
| 62 |
+
_CITY_BARE_RE = re.compile(
|
| 63 |
+
r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
|
| 64 |
+
r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
|
| 65 |
+
re.IGNORECASE,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
_URL_RE = re.compile(r"https?://[^\s\)\"']+")
|
| 69 |
+
|
| 70 |
+
# NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \b…\b wrapper
|
| 71 |
+
# to avoid false-negative from word-boundary check after non-word char.
|
| 72 |
_SEARCH_INTENT_RE = re.compile(
|
| 73 |
+
r"(?:"
|
| 74 |
+
r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
|
| 75 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 76 |
+
r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
|
| 77 |
+
r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) — \b finale falliva con singola lettera
|
| 78 |
+
r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
|
| 79 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 80 |
+
r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
|
| 81 |
+
r"search\s+for\s+|find\s+online\s+)\b"
|
| 82 |
+
r"|\bcerca\s*:|\bsearch\s*:"
|
| 83 |
+
r")",
|
| 84 |
re.IGNORECASE,
|
| 85 |
)
|
| 86 |
+
_SEARCH_QUERY_RE = re.compile(
|
| 87 |
+
r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
|
| 88 |
+
r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
|
| 89 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 90 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
|
| 91 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 92 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 93 |
+
r"web\s+search\s*:?\s*)"
|
| 94 |
+
r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
|
| 95 |
re.IGNORECASE,
|
| 96 |
)
|
| 97 |
+
|
| 98 |
+
_IMAGE_GEN_INTENT_RE = re.compile(
|
| 99 |
+
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
|
| 100 |
+
# perché "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
|
| 101 |
+
r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
|
| 102 |
+
r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
|
| 103 |
+
r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
|
| 104 |
+
re.IGNORECASE
|
| 105 |
+
)
|
| 106 |
+
# S427: aggiunti trigger di calcolo IT/EN comuni
|
| 107 |
_CALC_INTENT_RE = re.compile(
|
| 108 |
+
r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
|
| 109 |
+
r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
|
| 110 |
+
r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
|
| 111 |
+
r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
|
| 112 |
+
r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
|
| 113 |
re.IGNORECASE,
|
| 114 |
)
|
| 115 |
+
|
| 116 |
+
_WEB_RESEARCH_INTENT_RE = re.compile(
|
| 117 |
+
r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
|
| 118 |
+
r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
|
| 119 |
+
r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
|
| 120 |
+
r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito))",
|
| 121 |
+
re.IGNORECASE,
|
| 122 |
+
)
|
| 123 |
+
_WEB_RESEARCH_TOPIC_RE = re.compile(
|
| 124 |
+
r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
|
| 125 |
+
re.IGNORECASE,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# S764: intent regex per i 3 nuovi fast-path tool (directory_tree / file_search / git_status)
|
| 129 |
+
_DIRECTORY_TREE_INTENT_RE = re.compile(
|
| 130 |
+
r"\b(directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
|
| 131 |
+
r"struttura\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
|
| 132 |
+
r"elenca\s+(?:file|cartell[ae]|directory)|lista\s+(?:file|cartell[ae])|"
|
| 133 |
+
r"show\s+(?:directory|folder)\s+tree|tree\s+(?:command|cmd|del\s+progetto)|"
|
| 134 |
+
r"ls\s+-[lRra]|find\s+\.\s+-type)\b",
|
| 135 |
+
re.IGNORECASE,
|
| 136 |
+
)
|
| 137 |
+
_FILE_SEARCH_INTENT_RE = re.compile(
|
| 138 |
+
r"\b(cerca\s+nel\s+(?:codice|progetto|file)|"
|
| 139 |
+
r"trova\s+(?:nel\s+codice|nel\s+progetto|nei\s+file)|"
|
| 140 |
+
r"grep\s+|file[\s_]search|cerca\s+la\s+stringa|"
|
| 141 |
+
r"search\s+in\s+(?:code|files|project)|find\s+in\s+files|"
|
| 142 |
+
r"dove\s+[eè]\s+(?:definit[ao]|usato|chiamato)|"
|
| 143 |
+
r"occorrenze\s+di|tutte\s+le\s+occorrenze)\b",
|
| 144 |
+
re.IGNORECASE,
|
| 145 |
+
)
|
| 146 |
+
_GIT_INTENT_RE = re.compile(
|
| 147 |
+
r"\b(git\s+status|git\s+diff|stato\s+git|stato\s+del\s+repository|"
|
| 148 |
+
r"file\s+modificat[i]|modifiche\s+in\s+sospeso|"
|
| 149 |
+
r"branch\s+corrente|current\s+branch|ultimi\s+commit|recent\s+commits|"
|
| 150 |
+
r"git\s+log|repository\s+status)\b",
|
| 151 |
+
re.IGNORECASE,
|
| 152 |
+
)
|
| 153 |
+
# S766: news intent — attiva _t_get_news fast-path
|
| 154 |
+
_NEWS_INTENT_RE = re.compile(
|
| 155 |
+
r"\b(notizie|ultime\s+notizie|news|headlines|notiziario|"
|
| 156 |
+
r"ultime\s+ore|breaking\s+news|novit\u00e0|"
|
| 157 |
+
r"aggiornamenti\s+su|cosa\s+succede|what.s\s+happening)\b",
|
| 158 |
+
re.IGNORECASE,
|
| 159 |
+
)
|
| 160 |
+
_CALC_EXPR_RE = re.compile(
|
| 161 |
+
r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
|
| 162 |
+
# S390-B-M: aggiunto % (modulo) e // (floor division) al char class
|
| 163 |
+
r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
|
| 164 |
+
re.IGNORECASE,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
def _extract_city(self, goal: str) -> str:
|
| 168 |
m = self._CITY_RE.search(goal)
|
| 169 |
if m:
|
| 170 |
+
return m.group(1).strip()
|
| 171 |
+
m2 = self._CITY_BARE_RE.search(goal)
|
| 172 |
+
if m2:
|
| 173 |
+
city = m2.group(1).strip()
|
| 174 |
+
_stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
|
| 175 |
+
"meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
|
| 176 |
+
if city.lower() not in _stop:
|
| 177 |
+
return city
|
| 178 |
+
return ""
|
| 179 |
+
|
| 180 |
def _extract_search_query(self, goal: str) -> str:
|
| 181 |
+
m = self._SEARCH_QUERY_RE.search(goal)
|
| 182 |
+
if m:
|
| 183 |
+
q = m.group(1).strip().rstrip(".,?!")
|
| 184 |
+
if len(q) > 1: # B1: soglia da >3 a >1 — topic brevi come 'AI', 'LLM', 'GPT'
|
| 185 |
+
return q
|
| 186 |
+
if self._SEARCH_INTENT_RE.search(goal):
|
| 187 |
+
clean = re.sub(
|
| 188 |
+
r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
|
| 189 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 190 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
|
| 191 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 192 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 193 |
+
r"web\s+search\s*:?\s*)",
|
| 194 |
+
"", goal.strip(), flags=re.IGNORECASE
|
| 195 |
+
).strip().rstrip(".,?!")
|
| 196 |
+
if len(clean) > 1: # B1: soglia abbassata da >3 a >1
|
| 197 |
+
return clean
|
| 198 |
+
# B1: ultimo fallback — usa il goal intero (es. 'ultime notizie AI' → 'ultime notizie AI')
|
| 199 |
+
if len(goal.strip()) > 1:
|
| 200 |
+
return goal.strip()[:200] # S579: 120→200 (fallback query usa il goal intero)
|
| 201 |
+
return ""
|
| 202 |
+
|
| 203 |
def _extract_calc_expr(self, goal: str) -> str:
|
| 204 |
+
m = self._CALC_EXPR_RE.search(goal)
|
| 205 |
+
if m:
|
| 206 |
+
expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
|
| 207 |
+
if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
|
| 208 |
+
return expr
|
| 209 |
+
return ""
|
| 210 |
+
|
| 211 |
|
| 212 |
def _extract_dir_path(self, goal: str) -> str:
|
| 213 |
+
# Estrae il path della directory dal goal, default '.'
|
| 214 |
m = re.search(
|
| 215 |
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
|
| 216 |
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
|
|
|
|
| 223 |
return "."
|
| 224 |
|
| 225 |
def _extract_file_pattern(self, goal: str) -> str:
|
| 226 |
+
# Estrae il pattern di ricerca file dal goal
|
| 227 |
m = re.search(
|
| 228 |
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
|
| 229 |
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
|
| 230 |
goal, re.IGNORECASE,
|
| 231 |
)
|
| 232 |
+
if m:
|
| 233 |
+
return m.group(1).strip()
|
| 234 |
+
return ""
|
| 235 |
|
| 236 |
def _extract_git_cwd(self, goal: str) -> str:
|
| 237 |
+
# Estrae il cwd per git dal goal, default '.'
|
| 238 |
m = re.search(
|
| 239 |
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
|
| 240 |
goal, re.IGNORECASE,
|
|
|
|
| 244 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 245 |
return candidate
|
| 246 |
return "."
|
| 247 |
+
|
| 248 |
+
async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
"""
|
| 250 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 251 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 252 |
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 253 |
n_called: numero totale di tool chiamati
|
| 254 |
+
n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
|
| 255 |
+
n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
|
| 256 |
+
S376: Tool Governor — previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
|
| 257 |
+
S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
|
| 258 |
"""
|
| 259 |
+
try:
|
| 260 |
+
from tools.registry import TOOL_REGISTRY
|
| 261 |
+
except ImportError:
|
| 262 |
+
# S649: fix tipo ritorno — run() aspetta 4-tuple, non 2-tuple
|
| 263 |
+
return "", 0, 0, 0
|
| 264 |
|
| 265 |
results: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
# S376/S393: Tool Governor — previene duplicate E supero budget globale per run
|
| 268 |
_gov_called: set[str] = set()
|
| 269 |
+
_gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
|
| 270 |
+
# S650: budget adattivo — task complessi necessitano più tool calls
|
| 271 |
+
# _max_tokens_for_goal >= 6144 indica app multi-feature → 9 tool calls
|
| 272 |
+
# _max_tokens_for_goal >= 4096 indica task singolo complesso → 7 tool calls
|
| 273 |
+
# Default: 6 (query semplice, meteo, news, calcolo)
|
| 274 |
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 275 |
+
_GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
|
| 276 |
|
| 277 |
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 278 |
+
"""S393 Tool Governor: previene duplicate e supero budget.
|
| 279 |
+
Returns True solo se il tool NON è stato già chiamato con questi arg
|
| 280 |
+
E il budget totale del run non è esaurito."""
|
| 281 |
+
if _gov_total[0] >= _GOV_MAX_CALLS:
|
| 282 |
+
return False # budget esaurito — blocca TUTTE le chiamate successive
|
| 283 |
+
sig = f"{tool_name}:{key_arg[:150]}" # S608: 80→150
|
| 284 |
+
if sig in _gov_called:
|
| 285 |
+
return False # chiamata duplicata — skip silenzioso
|
| 286 |
+
_gov_called.add(sig)
|
| 287 |
+
_gov_total[0] += 1
|
| 288 |
return True
|
| 289 |
|
| 290 |
+
# Doc2-1a-FIX: helper cache speculativa (S361) — 0ms latency su cache hit.
|
| 291 |
+
# get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
|
| 292 |
+
# ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
|
| 293 |
+
def _spec_hit(tool_name: str, args: dict) -> "str | None":
|
| 294 |
try:
|
| 295 |
+
from api.speculative import get_speculative_result as _gsr
|
| 296 |
+
return _gsr(goal, tool_name, args)
|
| 297 |
except Exception:
|
|
|
|
| 298 |
return None
|
| 299 |
|
| 300 |
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 301 |
# Pre-check intent (sincrono) → costruisce lista coroutine → gather
|
| 302 |
+
# Il governor usa stato locale; asyncio è single-threaded → nessuna race condition
|
| 303 |
+
|
| 304 |
url_m = self._URL_RE.search(goal)
|
| 305 |
+
|
| 306 |
async def _t_get_weather() -> str | None:
|
| 307 |
if not self._WEATHER_INTENT_RE.search(goal):
|
| 308 |
return None
|
| 309 |
+
city = self._extract_city(goal) or "Milano"
|
| 310 |
if not _gov_check("get_weather", city):
|
| 311 |
return None
|
| 312 |
try:
|
|
|
|
|
|
|
|
|
|
| 313 |
_sc = _spec_hit("get_weather", {"city": city})
|
| 314 |
if _sc is not None:
|
| 315 |
return _sc
|
| 316 |
+
if on_step:
|
| 317 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 318 |
+
"title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}…"}))
|
| 319 |
_t0 = asyncio.get_event_loop().time()
|
| 320 |
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 321 |
try:
|
| 322 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 323 |
+
except Exception: pass
|
| 324 |
+
if "error" not in r:
|
| 325 |
_wdesc = {
|
| 326 |
+
0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
|
| 327 |
+
3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
|
| 328 |
+
51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
|
| 329 |
+
61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
|
| 330 |
+
71: "neve leggera", 73: "neve", 75: "neve intensa",
|
| 331 |
+
80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
|
| 332 |
+
95: "temporale", 96: "temporale con grandine",
|
| 333 |
}
|
| 334 |
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 335 |
try:
|
|
|
|
| 342 |
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 343 |
f"Condizioni: {desc}"
|
| 344 |
)
|
| 345 |
+
return f"[get_weather: errore — {r['error'][:300]}]" # S605: 200→300
|
| 346 |
except asyncio.TimeoutError:
|
| 347 |
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 348 |
except Exception as exc:
|
| 349 |
+
return f"[get_weather: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 350 |
+
|
| 351 |
async def _t_read_page() -> str | None:
|
| 352 |
if not url_m:
|
| 353 |
return None
|
|
|
|
| 365 |
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 366 |
try:
|
| 367 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 368 |
+
except Exception: pass
|
| 369 |
if r.get("content"):
|
| 370 |
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 371 |
+
return f"[read_page: errore — {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200→300
|
| 372 |
except asyncio.TimeoutError:
|
| 373 |
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 374 |
except Exception as exc:
|
| 375 |
+
return f"[read_page: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 376 |
+
|
| 377 |
async def _t_calculate() -> str | None:
|
| 378 |
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 379 |
return None
|
|
|
|
| 391 |
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 392 |
try:
|
| 393 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 394 |
+
except Exception: pass
|
| 395 |
if "result" in r:
|
| 396 |
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 397 |
+
return f"[calculate: errore — {r.get('error', '?')[:300]}]" # S605: 200→300
|
| 398 |
except asyncio.TimeoutError:
|
| 399 |
return "[calculate: timeout]"
|
| 400 |
except Exception as exc:
|
| 401 |
+
return f"[calculate: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 402 |
+
|
| 403 |
async def _t_web_search() -> str | None:
|
| 404 |
if not self._SEARCH_INTENT_RE.search(goal):
|
| 405 |
return None
|
|
|
|
| 417 |
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 418 |
try:
|
| 419 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 420 |
+
except Exception: pass
|
| 421 |
hits = r.get("results", [])
|
| 422 |
if hits:
|
| 423 |
+
snippets = "\n".join(
|
| 424 |
+
f"• [{item['title']}] {item['snippet']}"
|
| 425 |
+
+ (f"\n URL: {item['url']}" if item.get("url") else "")
|
| 426 |
+
for item in hits[:6] # S591: 4→6 — più risultati web nel context
|
| 427 |
+
)
|
| 428 |
+
return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
|
| 429 |
+
# S428 Sprint1-Fix2: rimosso "rispondo con dati del training" — invitava LLM
|
| 430 |
+
# ad allucinare training data come se fosse una ricerca reale riuscita.
|
| 431 |
+
# Ora è un errore esplicito → contato come _n_errors → _all_errors=True →
|
| 432 |
+
# _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
|
| 433 |
+
return f"[web_search: NESSUN_RISULTATO — nessun dato trovato per '{query[:150]}']" # S608: 80→150
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
except asyncio.TimeoutError:
|
| 435 |
+
return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s — nessun dato disponibile]"
|
| 436 |
except Exception as exc:
|
| 437 |
+
return f"[web_search: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 438 |
|
| 439 |
async def _t_generate_image() -> str | None:
|
| 440 |
+
if not self._IMAGE_GEN_INTENT_RE.search(goal):
|
| 441 |
return None
|
| 442 |
_img_prompt = re.sub(
|
| 443 |
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
|
|
|
| 449 |
if on_step:
|
| 450 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 451 |
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}…"}))
|
| 452 |
+
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400→600
|
| 453 |
if _sc is not None:
|
| 454 |
return _sc
|
| 455 |
+
_t0 = asyncio.get_event_loop().time()
|
| 456 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400→600
|
|
|
|
|
|
|
| 457 |
try:
|
| 458 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 459 |
+
except Exception: pass
|
| 460 |
+
img_url = r.get("url", "")
|
| 461 |
+
if img_url:
|
| 462 |
+
return (
|
| 463 |
+
f"[IMMAGINE AI GENERATA]\n"
|
| 464 |
+
f"URL: {img_url}\n"
|
| 465 |
+
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100→200
|
| 466 |
+
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
|
|
|
|
|
|
|
| 467 |
)
|
| 468 |
+
return "[generate_image: nessun URL restituito]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
except asyncio.TimeoutError:
|
| 470 |
return "[generate_image: timeout — provider non raggiungibile]"
|
| 471 |
except Exception as exc:
|
| 472 |
+
return f"[generate_image: errore — {str(exc)[:300]}]" # S605: 200→300
|
| 473 |
+
|
| 474 |
async def _t_run_python() -> str | None:
|
| 475 |
_RUN_CODE_RE = re.compile(
|
| 476 |
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
|
|
|
| 489 |
if on_step:
|
| 490 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 491 |
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandbox…"}))
|
| 492 |
+
_sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200→400
|
| 493 |
if _sc is not None:
|
| 494 |
return _sc
|
| 495 |
_t0 = asyncio.get_event_loop().time()
|
| 496 |
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 497 |
try:
|
| 498 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 499 |
+
except Exception: pass
|
| 500 |
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 501 |
_out = (
|
| 502 |
"[CODICE PYTHON ESEGUITO]\n"
|
| 503 |
f"```python\n{_code[:500]}\n```\n"
|
| 504 |
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 505 |
)
|
| 506 |
+
# S-GAP3: TDD auto-check — solo su codice complesso (>=8 righe, def/class)
|
| 507 |
+
try:
|
| 508 |
+
from agents.tdd_runner import run_tdd_check as _tdd_chk, _should_test as _tdd_gate
|
| 509 |
+
if _tdd_gate(_code):
|
| 510 |
+
class _TDDExec:
|
| 511 |
+
async def run_tool(self, name, args):
|
| 512 |
+
fn = TOOL_REGISTRY.get(name, {}).get("_fn")
|
| 513 |
+
return await fn(**args) if fn else {}
|
| 514 |
+
from api.state import _get_ai_client as _tdd_ai
|
| 515 |
+
_tdd_r = await asyncio.wait_for(_tdd_chk(_code, _TDDExec(), _tdd_ai()), timeout=35.0)
|
| 516 |
+
if _tdd_r["ran"]:
|
| 517 |
+
_ok = _tdd_r["passed"]
|
| 518 |
+
_badge = ("Auto-test: OK" if _ok else f"Auto-test: FAIL\n```\n{_tdd_r['output'][:300]}\n```")
|
| 519 |
+
_out += f"\n{_badge}"
|
| 520 |
+
# GAP-NEW-2: se TDD FAIL, inietta traceback in exec_warn
|
| 521 |
+
# via self._tdd_fail_inject — letto da unified_loop.py
|
| 522 |
+
# prima del campionamento StrategicHealer (riga ~2142).
|
| 523 |
+
if not _ok:
|
| 524 |
+
self._tdd_fail_inject = (
|
| 525 |
+
f"[TDD-AUTO-FAIL] traceback del test generato:\n"
|
| 526 |
+
f"```\n{_tdd_r['output'][:400]}\n```"
|
| 527 |
+
)
|
| 528 |
+
except Exception as _exc:
|
| 529 |
+
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 530 |
return _out
|
| 531 |
+
if r.get("error"):
|
| 532 |
+
return f"[run_python: errore — {r['error'][:300]}]" # S605: 200→300
|
| 533 |
+
if r.get("stderr"):
|
| 534 |
+
# S573: 200→400 — stderr spesso contiene tracebacks multi-riga
|
| 535 |
+
# S593: 400→600 — tracebacks Python possono superare 400 chars
|
| 536 |
+
return f"[run_python: stderr — {r['stderr'][:600]}]"
|
| 537 |
+
return None
|
| 538 |
except asyncio.TimeoutError:
|
| 539 |
return "[run_python: timeout 18s]"
|
| 540 |
except Exception as exc:
|
| 541 |
+
# S593: 200→300 — exception str può includere path + msg
|
| 542 |
+
# S600: 300→500 — parity con altri exception handler
|
| 543 |
+
return f"[run_python: errore — {str(exc)[:500]}]"
|
| 544 |
+
|
| 545 |
+
|
| 546 |
async def _t_web_research() -> str | None:
|
| 547 |
+
if not self._WEB_RESEARCH_INTENT_RE.search(goal):
|
|
|
|
| 548 |
return None
|
| 549 |
+
_topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
|
| 550 |
+
_topic = _topic_m.group(1).strip() if _topic_m else re.sub(
|
| 551 |
+
r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
|
| 552 |
+
"", goal, flags=re.IGNORECASE
|
| 553 |
+
).strip()[:200] or goal[:200]
|
| 554 |
+
if not _topic or not _gov_check("web_research", _topic):
|
| 555 |
return None
|
| 556 |
try:
|
| 557 |
if on_step:
|
| 558 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 559 |
+
"title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}…"}))
|
| 560 |
+
_sc = _spec_hit("web_research", {"topic": _topic[:400]})
|
| 561 |
+
if _sc is not None:
|
| 562 |
+
return _sc
|
| 563 |
_t0 = asyncio.get_event_loop().time()
|
| 564 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
|
| 565 |
try:
|
| 566 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 567 |
+
except Exception: pass
|
| 568 |
+
if r.get("ok"):
|
| 569 |
+
_synthesis = r.get("synthesis", "")
|
| 570 |
+
_sources = r.get("sources", [])
|
| 571 |
+
out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
|
| 572 |
+
if _synthesis:
|
| 573 |
+
out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
|
| 574 |
+
if _sources:
|
| 575 |
+
for s in _sources[:4]:
|
| 576 |
+
out += f"• {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
|
| 577 |
+
return out.strip()
|
| 578 |
+
return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
|
| 579 |
except asyncio.TimeoutError:
|
| 580 |
+
return "[web_research: timeout 55s]"
|
| 581 |
except Exception as exc:
|
| 582 |
return f"[web_research: errore — {str(exc)[:300]}]"
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# S766: _t_get_news — notizie in tempo reale tramite TOOL_REGISTRY["get_news"]
|
| 586 |
+
async def _t_get_news() -> str | None:
|
| 587 |
+
if not self._NEWS_INTENT_RE.search(goal):
|
| 588 |
+
return None
|
| 589 |
+
_qm = re.search(
|
| 590 |
+
r"(?:notizie|news|ultime\s+notizie|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120})(?:\?|$|\.|,)",
|
| 591 |
+
goal, re.IGNORECASE,
|
| 592 |
+
)
|
| 593 |
+
_query = _qm.group(1).strip() if _qm else goal.strip()[:120]
|
| 594 |
+
if not _gov_check("get_news", _query):
|
| 595 |
+
return None
|
| 596 |
+
try:
|
| 597 |
+
if on_step:
|
| 598 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 599 |
+
"title": "Ultime notizie", "explanation": f"Cerco notizie: {_query[:60]}\u2026"}))
|
| 600 |
+
_sc = _spec_hit("get_news", {"query": _query, "max_results": 5})
|
| 601 |
+
if _sc is not None:
|
| 602 |
+
return _sc
|
| 603 |
+
r = await asyncio.wait_for(
|
| 604 |
+
TOOL_REGISTRY["get_news"]["_fn"](query=_query, max_results=5), timeout=20
|
| 605 |
+
)
|
| 606 |
+
if r.get("ok"):
|
| 607 |
+
items = r.get("results", r.get("articles", []))
|
| 608 |
+
if items:
|
| 609 |
+
out = [f"[NOTIZIE: '{_query[:60]}']"]
|
| 610 |
+
for it in items[:5]:
|
| 611 |
+
t = it.get("title", it.get("headline", "?"))
|
| 612 |
+
s = it.get("source", it.get("publisher", ""))
|
| 613 |
+
d = it.get("published_at", it.get("date", ""))
|
| 614 |
+
out.append(f"\u2022 {t}" + (f" [{s}]" if s else "") + (f" ({d})" if d else ""))
|
| 615 |
+
return "\n".join(out)
|
| 616 |
+
return f"[get_news: {r.get('error', 'nessun risultato')[:200]}]"
|
| 617 |
+
except asyncio.TimeoutError:
|
| 618 |
+
return "[get_news: timeout 20s]"
|
| 619 |
+
except Exception as exc:
|
| 620 |
+
return f"[get_news: errore — {str(exc)[:200]}]"
|
| 621 |
+
|
| 622 |
+
# S764: 3 nuovi tool fast-path — directory_tree / file_search / git_status
|
| 623 |
async def _t_directory_tree() -> str | None:
|
| 624 |
+
if not self._DIRECTORY_TREE_INTENT_RE.search(goal):
|
|
|
|
| 625 |
return None
|
| 626 |
_path = self._extract_dir_path(goal)
|
| 627 |
if not _gov_check("directory_tree", _path):
|
|
|
|
| 630 |
if on_step:
|
| 631 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 632 |
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
|
| 633 |
+
_t0 = asyncio.get_event_loop().time()
|
| 634 |
r = await asyncio.wait_for(
|
| 635 |
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
|
| 636 |
)
|
| 637 |
+
try:
|
| 638 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 639 |
+
except Exception: pass
|
| 640 |
if r.get("ok") and r.get("tree"):
|
| 641 |
+
return f"[STRUTTURA PROGETTO: '{_path}']\n{r['tree']}"
|
| 642 |
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
|
| 643 |
+
except asyncio.TimeoutError:
|
| 644 |
+
return "[directory_tree: timeout 8s]"
|
| 645 |
except Exception as exc:
|
| 646 |
+
return f"[directory_tree: errore — {str(exc)[:300]}]"
|
| 647 |
+
|
| 648 |
async def _t_file_search() -> str | None:
|
| 649 |
+
if not self._FILE_SEARCH_INTENT_RE.search(goal):
|
|
|
|
| 650 |
return None
|
| 651 |
_pattern = self._extract_file_pattern(goal)
|
| 652 |
if not _pattern or not _gov_check("file_search", _pattern):
|
|
|
|
| 655 |
try:
|
| 656 |
if on_step:
|
| 657 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 658 |
+
"title": "Ricerca nel codice", "explanation": f"Cerco '{_pattern[:40]}' nei file..."}))
|
| 659 |
+
_t0 = asyncio.get_event_loop().time()
|
| 660 |
r = await asyncio.wait_for(
|
| 661 |
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
|
| 662 |
)
|
| 663 |
+
try:
|
| 664 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 665 |
+
except Exception: pass
|
| 666 |
if r.get("ok"):
|
| 667 |
_matches = r.get("matches", [])
|
| 668 |
+
_count = r.get("count", len(_matches))
|
| 669 |
+
out = f"[FILE TROVATI: pattern='{_pattern}', {_count} occorrenze]\n"
|
| 670 |
+
for m in _matches[:20]:
|
| 671 |
+
out += f"{m.get('file','?')}:{m.get('line','?')}: {m.get('text','')[:120]}\n"
|
| 672 |
+
return out.strip()
|
| 673 |
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
|
| 674 |
+
except asyncio.TimeoutError:
|
| 675 |
+
return "[file_search: timeout 10s]"
|
| 676 |
except Exception as exc:
|
| 677 |
+
return f"[file_search: errore — {str(exc)[:300]}]"
|
| 678 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
async def _t_git_status() -> str | None:
|
| 680 |
+
if not self._GIT_INTENT_RE.search(goal):
|
|
|
|
| 681 |
return None
|
| 682 |
_cwd = self._extract_git_cwd(goal)
|
| 683 |
if not _gov_check("git_status", _cwd):
|
|
|
|
| 685 |
try:
|
| 686 |
if on_step:
|
| 687 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 688 |
+
"title": "Stato Git", "explanation": "Controllo branch e file modificati..."}))
|
| 689 |
+
_t0 = asyncio.get_event_loop().time()
|
| 690 |
r = await asyncio.wait_for(
|
| 691 |
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
|
| 692 |
)
|
| 693 |
+
try:
|
| 694 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 695 |
+
except Exception: pass
|
| 696 |
if r.get("ok"):
|
| 697 |
+
out = f"[STATO GIT (branch: {r.get('branch', '?')})\n"
|
| 698 |
if r.get("status"):
|
| 699 |
+
out += f"File modificati:\n{r['status'][:600]}\n"
|
| 700 |
if r.get("log"):
|
| 701 |
+
out += f"Ultimi commit:\n{r['log'][:400]}\n"
|
| 702 |
+
return out.strip() + "]"
|
| 703 |
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
|
| 704 |
+
except asyncio.TimeoutError:
|
| 705 |
+
return "[git_status: timeout 8s]"
|
| 706 |
except Exception as exc:
|
| 707 |
+
return f"[git_status: errore — {str(exc)[:300]}]"
|
| 708 |
+
|
| 709 |
+
# S419/S734: gather parallelo con Semaphore — limita concorrenza su mobile
|
| 710 |
+
# Default 4: max 4 tool simultanei — previene saturazione TCP su iPhone Safari.
|
| 711 |
+
# Impatto su goal normali (2-3 tool): ZERO (semaforo mai raggiunto).
|
| 712 |
+
# GAP-P3: configurabile via env TOOL_CONCURRENCY_LIMIT per ambienti server/desktop.
|
| 713 |
+
_TOOL_CONCURRENCY = int(os.getenv('TOOL_CONCURRENCY_LIMIT', '4'))
|
| 714 |
+
_gather_sem = asyncio.Semaphore(_TOOL_CONCURRENCY)
|
| 715 |
+
|
| 716 |
+
async def _sem_wrap(coro):
|
| 717 |
+
async with _gather_sem:
|
| 718 |
+
return await coro
|
| 719 |
+
|
| 720 |
+
# S764: 7->10 tool in gather (Semaphore(4) invariato)
|
| 721 |
+
# P30-B1: analisi statica Python — zero exec_engine, <5ms
|
| 722 |
async def _t_analyze_python() -> str | None:
|
|
|
|
| 723 |
if not self._ANALYZE_PY_RE.search(goal):
|
| 724 |
return None
|
| 725 |
+
_pm = self._PY_BLOCK_IN_GOAL_RE.search(goal)
|
| 726 |
+
if not _pm:
|
| 727 |
+
return None
|
| 728 |
+
_code = _pm.group(1)
|
| 729 |
+
if not _gov_check("python_analyze", _code[:80]):
|
| 730 |
+
return None
|
| 731 |
try:
|
| 732 |
if on_step:
|
| 733 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 734 |
+
"title": "Analisi Python", "explanation": "Analisi statica codice Python (AST)…"}))
|
| 735 |
+
_t0 = asyncio.get_event_loop().time()
|
| 736 |
+
_r = await asyncio.wait_for(
|
| 737 |
+
TOOL_REGISTRY["python_analyze"]["_fn"](code=_code), timeout=5
|
| 738 |
+
)
|
| 739 |
+
try:
|
| 740 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 741 |
+
except Exception: pass
|
| 742 |
+
_out = [f"[ANALISI PYTHON — {_r.get('summary', '?')}]"]
|
| 743 |
+
for _e in _r.get("errors", []):
|
| 744 |
+
_out.append(f"ERR {_e['type']} riga {_e['line']}: {_e['message']}" + (f" → {_e['text']}" if _e.get('text') else ""))
|
| 745 |
+
_c = _r.get("complexity", {})
|
| 746 |
+
if _c:
|
| 747 |
+
_out.append(
|
| 748 |
+
f"Struttura: {_c.get('total_lines',0)} righe, "
|
| 749 |
+
f"{_c.get('functions',0)} funzioni, "
|
| 750 |
+
f"{_c.get('classes',0)} classi, nesting max {_c.get('max_nesting',0)}"
|
| 751 |
+
)
|
| 752 |
+
for _s in _r.get("suggestions", []):
|
| 753 |
+
_out.append(f"Suggerimento: {_s}")
|
| 754 |
return "\n".join(_out)
|
| 755 |
except asyncio.TimeoutError:
|
| 756 |
return "[python_analyze: timeout]"
|
| 757 |
except Exception as _exc:
|
| 758 |
return f"[python_analyze: errore — {str(_exc)[:200]}]"
|
| 759 |
+
|
| 760 |
+
_parallel_results = await asyncio.gather(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
_sem_wrap(_t_get_weather()),
|
| 762 |
_sem_wrap(_t_read_page()),
|
| 763 |
_sem_wrap(_t_calculate()),
|
| 764 |
_sem_wrap(_t_web_search()),
|
| 765 |
+
_sem_wrap(_t_generate_image()),
|
| 766 |
_sem_wrap(_t_run_python()),
|
| 767 |
_sem_wrap(_t_web_research()),
|
| 768 |
_sem_wrap(_t_directory_tree()),
|
|
|
|
| 775 |
for _pr in _parallel_results:
|
| 776 |
if isinstance(_pr, str):
|
| 777 |
results.append(_pr)
|
| 778 |
+
|
| 779 |
+
# S428 Sprint1-Fix1: Tool Success Contract — conta successi per prefisso positivo.
|
| 780 |
+
# Il vecchio check ": errore —"/": timeout" NON catturava "NESSUN_RISULTATO" e
|
| 781 |
+
# "rispondo con dati del training" → contati come successi → _build_messages
|
| 782 |
+
# wrappava come "DATI REALI RECUPERATI" → LLM allucinava training data come reale.
|
| 783 |
+
# Soluzione: whitelist di prefissi che certificano dati REALI verificati.
|
| 784 |
_REAL_DATA_PREFIXES = (
|
| 785 |
+
"[RICERCA WEB REALE",
|
| 786 |
+
"[METEO",
|
| 787 |
+
"[CALCOLO REALE",
|
| 788 |
+
"[IMMAGINE AI GENERATA",
|
| 789 |
+
"[CODICE PYTHON ESEGUITO",
|
| 790 |
+
"[PAGINA REALE",
|
| 791 |
+
"[DATI REALI",
|
| 792 |
+
"[RICERCA APPROFONDITA",
|
| 793 |
+
"[STRUTTURA PROGETTO", # S764: directory_tree
|
| 794 |
+
"[FILE TROVATI", # S764: file_search
|
| 795 |
+
"[NOTIZIE",
|
| 796 |
+
"[STATO GIT", # S764: git_status
|
| 797 |
+
"[ANALISI PYTHON", # P30-B1: python_analyze
|
| 798 |
)
|
| 799 |
+
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
|
| 800 |
+
_n_errors = len(results) - _n_success
|
| 801 |
+
# Sprint 5 ITEM 13: tool_failure_count — mai incrementato prima
|
| 802 |
+
if _n_errors > 0:
|
| 803 |
+
try:
|
| 804 |
+
from api.state import increment_stat as _inc_tf
|
| 805 |
+
_inc_tf("tool_failure_count")
|
| 806 |
+
except Exception as _exc:
|
| 807 |
+
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 808 |
+
# P-HARNESS: traccia fallimenti per-tool; warn se threshold raggiunto
|
| 809 |
+
try:
|
| 810 |
+
from tools.harness_gate import record_failures_from_results as _hg_rec
|
| 811 |
+
from tools.registry import _agent_session_id_var as _hg_sid
|
| 812 |
+
_hg_n = _hg_rec(_hg_sid.get(), results)
|
| 813 |
+
if _hg_n:
|
| 814 |
+
_logger.warning(
|
| 815 |
+
"[harness_gate] %d tool(s) hit failure threshold — provider switch recommended",
|
| 816 |
+
_hg_n,
|
| 817 |
+
)
|
| 818 |
+
except Exception as _hg_exc: # noqa: BLE001
|
| 819 |
+
_logger.debug("[unified_loop_tools] harness silenced: %s", _hg_exc)
|
| 820 |
+
return "\n\n".join(results), len(results), _n_success, _n_errors
|
| 821 |
+
|
| 822 |
# ── Claim Validation (S428 Sprint1-Fix3) ─────────────────────────────────
|
| 823 |
+
# Quando tutti i tool hanno fallito, il LLM può ancora affermare "Ho trovato / Ho recuperato"
|
| 824 |
+
# nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
|
| 825 |
+
# esplicito SOLO se rileva false claim nella risposta — non riscrive il testo, lo estende.
|
| 826 |
_FALSE_CLAIM_RE = re.compile(
|
| 827 |
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 828 |
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
|
|
|
| 850 |
false_claim_re: "re.Pattern[str]",
|
| 851 |
realtime_goal_re: "re.Pattern[str]",
|
| 852 |
) -> str:
|
| 853 |
+
"""S428 Sprint1-Fix3: Claim Validation.
|
| 854 |
+
Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
|
| 855 |
+
contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
|
| 856 |
+
Non riscrive la risposta — la estende con una nota visibile all'utente.
|
| 857 |
+
"""
|
| 858 |
if n_success > 0 or n_errors == 0:
|
| 859 |
+
return response # dati reali presenti o nessun tool eseguito → ok
|
| 860 |
if not realtime_goal_re.search(goal):
|
| 861 |
+
return response # goal non richiede dati live → ok
|
| 862 |
if not false_claim_re.search(response):
|
| 863 |
+
return response # nessuna false claim → ok
|
| 864 |
+
# Rileva false claim + goal realtime + tutti tool falliti
|
| 865 |
disclaimer = (
|
| 866 |
"\n\n---\n"
|
| 867 |
+
"⚠️ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 868 |
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 869 |
"dal mio training e potrebbero non essere aggiornate. "
|
| 870 |
+
"Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
|
| 871 |
+
"o il sito ufficiale della tecnologia."
|
| 872 |
)
|
| 873 |
return response + disclaimer
|
| 874 |
+
|
| 875 |
+
# ── _needs_tools (S193) — regex ampliata ─────────────────────────────────
|
| 876 |
+
|
| 877 |
+
# S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
|
| 878 |
_TOOL_NEEDED_RE = re.compile(
|
| 879 |
+
r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
|
| 880 |
+
r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
|
| 881 |
+
r"piove|nevica|neve|temporale|nebbia|umidità|vento|forecast|"
|
| 882 |
+
r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
|
| 883 |
+
r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
|
| 884 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 885 |
+
r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 886 |
+
r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
|
| 887 |
+
r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
|
| 888 |
+
r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
|
| 889 |
+
r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
|
| 890 |
+
r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
|
| 891 |
+
r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
|
| 892 |
+
r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
|
| 893 |
+
r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
|
| 894 |
+
r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
|
| 895 |
+
r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
|
| 896 |
+
r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
|
| 897 |
+
r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
|
| 898 |
+
# S648: email/PDF keyword
|
| 899 |
+
r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
|
| 900 |
+
r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
|
| 901 |
+
r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf|"
|
| 902 |
+
# S764: git / npm / pip / file-search / directory-tree keywords
|
| 903 |
+
r"git\s+status|git\s+diff|git\s+log|git\s+clone|git\s+commit|"
|
| 904 |
+
r"stato\s+git|branch\s+corrente|file\s+modificati|ultimi\s+commit|"
|
| 905 |
+
r"npm\s+install|npm\s+run|npm\s+test|npm\s+build|pnpm\s+|yarn\s+add|"
|
| 906 |
+
r"pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett|"
|
| 907 |
+
r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
|
| 908 |
+
r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
|
| 909 |
+
r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
|
| 910 |
+
r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|"
|
| 911 |
+
# R9: webhook/call_api keywords — mancanti da _TOOL_NEEDED_RE
|
| 912 |
+
r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|"
|
| 913 |
+
r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b",
|
| 914 |
re.IGNORECASE,
|
| 915 |
)
|
| 916 |
+
|
| 917 |
def _needs_tools(self, goal: str) -> bool:
|
| 918 |
+
return bool(self._TOOL_NEEDED_RE.search(goal))
|
| 919 |
+
|
| 920 |
+
# ── S402: Fast Path ───────────────────────────────────────────────────────
|
| 921 |
+
# Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
|
| 922 |
+
# Target: <3s vs 20-60s per il full pipeline.
|
| 923 |
+
|
| 924 |
+
# S427: aggiunti ack comuni IT/EN per fast path più ampio
|
|
|
|
|
|
|
| 925 |
_SIMPLE_CONV_RE = re.compile(
|
| 926 |
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 927 |
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
|
|
|
| 938 |
r")\.?\s*[!?]?$",
|
| 939 |
re.IGNORECASE,
|
| 940 |
)
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
# S-FAST-MATH: espressioni aritmetiche semplici → fast-path (Groq 8B, ~150ms)
|
| 944 |
+
# Override del check _needs_tools: "calcola 2+2" non richiede tool di ricerca web.
|
| 945 |
+
# Pattern: prefisso opzionale (calcola/quanto fa) + espressione numerica.
|
| 946 |
_SIMPLE_MATH_RE = re.compile(
|
| 947 |
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
|
| 948 |
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
|
|
|
|
| 950 |
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
|
| 951 |
re.IGNORECASE,
|
| 952 |
)
|
| 953 |
+
# P30-B1: trigger analisi statica Python (IT + EN)
|
| 954 |
_ANALYZE_PY_RE = re.compile(
|
| 955 |
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
|
| 956 |
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
|
|
|
|
| 962 |
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
|
| 963 |
re.IGNORECASE,
|
| 964 |
)
|
| 965 |
+
# Regex per estrarre blocco python dal goal — P30-B1
|
| 966 |
_PY_BLOCK_IN_GOAL_RE = re.compile(
|
| 967 |
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 968 |
re.IGNORECASE,
|
| 969 |
)
|
| 970 |
+
|
|
|
|
| 971 |
def _is_simple_query(self, goal: str) -> bool:
|
| 972 |
+
"""S402: True per greeting/ack/identità semplice (<70 chars, no tool/code intent).
|
| 973 |
+
S-FAST-MATH: aggiunto check math semplice → fast-path, bypassa _needs_tools.
|
| 974 |
+
Attiva il fast path che salta memoria, planner, verifier e self-healing."""
|
| 975 |
g = goal.strip()
|
| 976 |
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 977 |
return False
|
| 978 |
+
# S-FAST-MATH: "calcola 2+2", "quanto fa 15*3" → fast-path (Groq 8B, 150ms)
|
| 979 |
+
# Controllo separato da _needs_tools: la matematica pura non richiede tool web.
|
| 980 |
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
|
| 981 |
return True
|
| 982 |
+
# Percorso originale: greeting/ack con limite 70 chars
|
| 983 |
if len(g) > 70 or self._needs_tools(g):
|
| 984 |
return False
|
| 985 |
return bool(self._SIMPLE_CONV_RE.match(g))
|
agents/unified_loop_types.py
CHANGED
|
@@ -19,61 +19,10 @@ from __future__ import annotations
|
|
| 19 |
import asyncio
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field
|
| 22 |
-
from enum import Enum
|
| 23 |
from typing import Any, Awaitable, Callable
|
| 24 |
|
| 25 |
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 26 |
|
| 27 |
-
class AgentState(str, Enum):
|
| 28 |
-
"""Lifecycle states for one UnifiedAgentLoop execution."""
|
| 29 |
-
|
| 30 |
-
IDLE = "IDLE"
|
| 31 |
-
CLASSIFYING = "CLASSIFYING"
|
| 32 |
-
TOOL_EXECUTING = "TOOL_EXECUTING"
|
| 33 |
-
THINKING = "THINKING"
|
| 34 |
-
FAILED = "FAILED"
|
| 35 |
-
COMPLETED = "COMPLETED"
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
_AGENT_STATE_TRANSITIONS: dict[AgentState, frozenset[AgentState]] = {
|
| 39 |
-
AgentState.IDLE: frozenset({AgentState.CLASSIFYING, AgentState.FAILED}),
|
| 40 |
-
AgentState.CLASSIFYING: frozenset({
|
| 41 |
-
AgentState.TOOL_EXECUTING, AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 42 |
-
}),
|
| 43 |
-
AgentState.TOOL_EXECUTING: frozenset({
|
| 44 |
-
AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 45 |
-
}),
|
| 46 |
-
AgentState.THINKING: frozenset({AgentState.COMPLETED, AgentState.FAILED}),
|
| 47 |
-
AgentState.FAILED: frozenset({AgentState.IDLE}),
|
| 48 |
-
# Exceptional finalization errors must be able to surface as FAILED.
|
| 49 |
-
AgentState.COMPLETED: frozenset({AgentState.IDLE, AgentState.FAILED}),
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class AgentLoopStateMachine:
|
| 54 |
-
"""Deterministic lifecycle machine owned by one loop invocation."""
|
| 55 |
-
|
| 56 |
-
def __init__(self) -> None:
|
| 57 |
-
self.current: AgentState = AgentState.IDLE
|
| 58 |
-
self.history: list[AgentState] = [AgentState.IDLE]
|
| 59 |
-
|
| 60 |
-
def transition(self, next_state: AgentState) -> None:
|
| 61 |
-
if next_state == self.current:
|
| 62 |
-
return
|
| 63 |
-
if next_state not in _AGENT_STATE_TRANSITIONS[self.current]:
|
| 64 |
-
raise ValueError(
|
| 65 |
-
f"Invalid AgentLoop transition: {self.current.value} -> {next_state.value}"
|
| 66 |
-
)
|
| 67 |
-
self.current = next_state
|
| 68 |
-
self.history.append(next_state)
|
| 69 |
-
|
| 70 |
-
def snapshot(self) -> dict[str, Any]:
|
| 71 |
-
return {
|
| 72 |
-
"agent_state": self.current.value,
|
| 73 |
-
"state_history": [state.value for state in self.history],
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
|
| 78 |
def _detect_user_lang(goal: str) -> str:
|
| 79 |
"""P27-B2: rilevamento lingua leggero — zero I/O, zero LLM, <1ms.
|
|
@@ -209,7 +158,6 @@ class UnifiedLoopState:
|
|
| 209 |
errors: list[str] = field(default_factory=list)
|
| 210 |
has_files: bool = False # B10: flag separato â evita di inquinare il context string
|
| 211 |
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
| 212 |
-
state_machine: AgentLoopStateMachine = field(default_factory=AgentLoopStateMachine)
|
| 213 |
|
| 214 |
|
| 215 |
async def _maybe_await(val: Any) -> None:
|
|
|
|
| 19 |
import asyncio
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field
|
|
|
|
| 22 |
from typing import Any, Awaitable, Callable
|
| 23 |
|
| 24 |
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
def _detect_user_lang(goal: str) -> str:
|
| 28 |
"""P27-B2: rilevamento lingua leggero — zero I/O, zero LLM, <1ms.
|
|
|
|
| 158 |
errors: list[str] = field(default_factory=list)
|
| 159 |
has_files: bool = False # B10: flag separato â evita di inquinare il context string
|
| 160 |
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
async def _maybe_await(val: Any) -> None:
|
agents/unified_loop_vfs.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_vfs.py — VFSMixin: scritture VFS, git backup, lock per path.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale.
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_rollback_writes(on_step): GAP-3 rollback atomico scritture parziali
|
| 7 |
+
_vfs_git_backup(): GAP-NEW-4 push session_files su branch vfs-backup GitHub
|
| 8 |
+
_get_vfs_lock(path): GAP-VFS per-path asyncio.Lock (lazy init)
|
| 9 |
+
|
| 10 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 11 |
+
MRO Python garantisce self._write_snapshots / self._session_files / self.executor
|
| 12 |
+
siano risolti su UnifiedAgentLoop.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import logging
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from agents.unified_loop_types import _maybe_await
|
| 21 |
+
|
| 22 |
+
_logger = logging.getLogger("agente_ai")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class VFSMixin:
|
| 26 |
+
async def _rollback_writes(self, on_step=None) -> None:
|
| 27 |
+
"""
|
| 28 |
+
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà .
|
| 29 |
+
Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
|
| 30 |
+
Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
|
| 31 |
+
File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
|
| 32 |
+
"""
|
| 33 |
+
if not self._write_snapshots or not self.executor:
|
| 34 |
+
return
|
| 35 |
+
if on_step:
|
| 36 |
+
await _maybe_await(on_step({
|
| 37 |
+
"action": "text_chunk",
|
| 38 |
+
"token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
|
| 39 |
+
"status": "streaming",
|
| 40 |
+
}))
|
| 41 |
+
_rolled = 0
|
| 42 |
+
for path, original in self._write_snapshots.items():
|
| 43 |
+
if original is None:
|
| 44 |
+
continue # file non esisteva prima â saltiamo (non eliminiamo)
|
| 45 |
+
try:
|
| 46 |
+
await asyncio.wait_for(
|
| 47 |
+
self.executor.run_tool("write_file", {"path": path, "content": original}),
|
| 48 |
+
timeout=10.0,
|
| 49 |
+
)
|
| 50 |
+
_rolled += 1
|
| 51 |
+
except Exception:
|
| 52 |
+
pass # non-fatal â best effort rollback
|
| 53 |
+
_total = len(self._write_snapshots) # salva prima del clear
|
| 54 |
+
self._write_snapshots = {}
|
| 55 |
+
_logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
|
| 56 |
+
|
| 57 |
+
# ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
|
| 58 |
+
async def _vfs_git_backup(self) -> None:
|
| 59 |
+
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 60 |
+
|
| 61 |
+
Fire-and-forget — non blocca mai il loop principale, non solleva eccezioni.
|
| 62 |
+
Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo".
|
| 63 |
+
Crea automaticamente il branch vfs-backup se non esiste.
|
| 64 |
+
Force-push consentito su vfs-backup (non è main — nessun rischio di perdita).
|
| 65 |
+
"""
|
| 66 |
+
import os as _os_vfs
|
| 67 |
+
gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip()
|
| 68 |
+
gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip()
|
| 69 |
+
if not gh_token or not gh_repo:
|
| 70 |
+
return
|
| 71 |
+
files = dict(self._session_files) # snapshot immutabile
|
| 72 |
+
if not files:
|
| 73 |
+
return
|
| 74 |
+
run_id = self._run_task_id[:8] or "unknown"
|
| 75 |
+
try:
|
| 76 |
+
import httpx as _hx4
|
| 77 |
+
headers = {
|
| 78 |
+
"Authorization": f"Bearer {gh_token}",
|
| 79 |
+
"Accept": "application/vnd.github+json",
|
| 80 |
+
"User-Agent": "agente-ai-vfs/1.0",
|
| 81 |
+
}
|
| 82 |
+
base = f"https://api.github.com/repos/{gh_repo}"
|
| 83 |
+
async with _hx4.AsyncClient(timeout=20.0) as _cli:
|
| 84 |
+
# 1. Leggi (o crea) branch vfs-backup
|
| 85 |
+
r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers)
|
| 86 |
+
if r_ref.status_code == 404:
|
| 87 |
+
r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers)
|
| 88 |
+
if r_main.status_code != 200:
|
| 89 |
+
return
|
| 90 |
+
r_cr = await _cli.post(f"{base}/git/refs", headers=headers,
|
| 91 |
+
json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]})
|
| 92 |
+
if r_cr.status_code not in (200, 201):
|
| 93 |
+
return
|
| 94 |
+
backup_head = r_main.json()["object"]["sha"]
|
| 95 |
+
elif r_ref.status_code == 200:
|
| 96 |
+
backup_head = r_ref.json()["object"]["sha"]
|
| 97 |
+
else:
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
# 2. Leggi base tree del backup HEAD
|
| 101 |
+
r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers)
|
| 102 |
+
if r_c.status_code != 200:
|
| 103 |
+
return
|
| 104 |
+
base_tree = r_c.json()["tree"]["sha"]
|
| 105 |
+
|
| 106 |
+
# 3. Crea blob per ogni file (max 20 per backup, max 50KB per file)
|
| 107 |
+
tree_items = []
|
| 108 |
+
for _path, _content in list(files.items())[:20]:
|
| 109 |
+
rb = await _cli.post(f"{base}/git/blobs", headers=headers,
|
| 110 |
+
json={"content": str(_content)[:50_000], "encoding": "utf-8"})
|
| 111 |
+
if rb.status_code == 201:
|
| 112 |
+
tree_items.append({
|
| 113 |
+
"path": f"vfs/{_path.lstrip('/')}",
|
| 114 |
+
"mode": "100644",
|
| 115 |
+
"type": "blob",
|
| 116 |
+
"sha": rb.json()["sha"],
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
if not tree_items:
|
| 120 |
+
return
|
| 121 |
+
|
| 122 |
+
# 4. Tree + commit + force-push su vfs-backup
|
| 123 |
+
rt = await _cli.post(f"{base}/git/trees", headers=headers,
|
| 124 |
+
json={"base_tree": base_tree, "tree": tree_items})
|
| 125 |
+
if rt.status_code != 201:
|
| 126 |
+
return
|
| 127 |
+
rc = await _cli.post(f"{base}/git/commits", headers=headers,
|
| 128 |
+
json={
|
| 129 |
+
"message": f"vfs-backup: {len(tree_items)} file (run {run_id})",
|
| 130 |
+
"tree": rt.json()["sha"],
|
| 131 |
+
"parents": [backup_head],
|
| 132 |
+
})
|
| 133 |
+
if rc.status_code != 201:
|
| 134 |
+
return
|
| 135 |
+
# force=True consentito: vfs-backup non è main, nessun rischio
|
| 136 |
+
await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers,
|
| 137 |
+
json={"sha": rc.json()["sha"], "force": True})
|
| 138 |
+
_logger.info(
|
| 139 |
+
"GAP-NEW-4: vfs-backup aggiornato — %d file, run %s",
|
| 140 |
+
len(tree_items), run_id,
|
| 141 |
+
)
|
| 142 |
+
except Exception as _vfs_err:
|
| 143 |
+
# Silent: il backup non deve MAI bloccare o crashare il loop principale
|
| 144 |
+
_logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80])
|
| 145 |
+
|
| 146 |
+
# ── GAP-VFS: per-path write lock ─────────────────────────────────────────
|
| 147 |
+
def _get_vfs_lock(self, path: str) -> asyncio.Lock:
|
| 148 |
+
"""GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS.
|
| 149 |
+
Previene race condition quando subtask paralleli (asyncio.gather)
|
| 150 |
+
scrivono lo stesso file contemporaneamente.
|
| 151 |
+
Lock creato lazy: zero overhead per run che non usano write paralleli."""
|
| 152 |
+
if path not in self._vfs_write_locks:
|
| 153 |
+
self._vfs_write_locks[path] = asyncio.Lock()
|
| 154 |
+
return self._vfs_write_locks[path]
|
| 155 |
+
|
| 156 |
+
# ── BGAP-GUARD: Reflective Debug (no-regression invariante) ────────────────
|
agents/watchdog.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
watchdog.py â S-WATCHDOG: Bidirectional Self-Healing Heartbeat.
|
| 3 |
+
Monitora il loop agentico e interviene in caso di stallo o deviazione dal goal.
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
from typing import Callable, Awaitable, Optional
|
| 9 |
+
|
| 10 |
+
_logger = logging.getLogger("agente_ai.agents.watchdog")
|
| 11 |
+
|
| 12 |
+
class BidirectionalWatchdog:
|
| 13 |
+
def __init__(self,
|
| 14 |
+
timeout_seconds: float = 45.0,
|
| 15 |
+
on_stale_callback: Optional[Callable[[], Awaitable[None]]] = None):
|
| 16 |
+
self.timeout = timeout_seconds
|
| 17 |
+
self.on_stale = on_stale_callback
|
| 18 |
+
self.last_heartbeat = time.monotonic()
|
| 19 |
+
self._running = False
|
| 20 |
+
self._monitor_task = None
|
| 21 |
+
|
| 22 |
+
def heartbeat(self):
|
| 23 |
+
"""Segnala che l'agente è ancora attivo e progredisce."""
|
| 24 |
+
self.last_heartbeat = time.monotonic()
|
| 25 |
+
_logger.debug("[Watchdog] Heartbeat ricevuto.")
|
| 26 |
+
|
| 27 |
+
async def start(self):
|
| 28 |
+
"""Avvia il monitoraggio in background."""
|
| 29 |
+
if self._running:
|
| 30 |
+
return
|
| 31 |
+
self._running = True
|
| 32 |
+
self.last_heartbeat = time.monotonic()
|
| 33 |
+
self._monitor_task = asyncio.create_task(self._monitor_loop())
|
| 34 |
+
_logger.info(f"[Watchdog] Monitoraggio avviato (timeout: {self.timeout}s)")
|
| 35 |
+
|
| 36 |
+
async def stop(self):
|
| 37 |
+
"""Ferma il monitoraggio."""
|
| 38 |
+
self._running = False
|
| 39 |
+
if self._monitor_task:
|
| 40 |
+
self._monitor_task.cancel()
|
| 41 |
+
try:
|
| 42 |
+
await self._monitor_task
|
| 43 |
+
except asyncio.CancelledError:
|
| 44 |
+
pass
|
| 45 |
+
_logger.info("[Watchdog] Monitoraggio fermato.")
|
| 46 |
+
|
| 47 |
+
async def _monitor_loop(self):
|
| 48 |
+
while self._running:
|
| 49 |
+
await asyncio.sleep(5.0)
|
| 50 |
+
elapsed = time.monotonic() - self.last_heartbeat
|
| 51 |
+
if elapsed > self.timeout:
|
| 52 |
+
_logger.warning(f"[Watchdog] Rilevato stallo! Nessun heartbeat da {elapsed:.1f}s.")
|
| 53 |
+
if self.on_stale:
|
| 54 |
+
try:
|
| 55 |
+
await self.on_stale()
|
| 56 |
+
# Resetta il timer dopo l'intervento per evitare interventi a raffica
|
| 57 |
+
self.heartbeat()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
_logger.error(f"[Watchdog] Errore durante l'intervento di self-healing: {e}")
|
| 60 |
+
|
| 61 |
+
async def self_check(self, state_summary: str) -> bool:
|
| 62 |
+
"""
|
| 63 |
+
L'agente chiama questo metodo per un controllo esterno di coerenza.
|
| 64 |
+
"""
|
| 65 |
+
_logger.info(f"[Watchdog] Eseguo Self-Check dello stato: {state_summary[:100]}...")
|
| 66 |
+
# Implementazione futura: chiamata a un modello critico esterno (Critic Layer)
|
| 67 |
+
return True
|
agents/workflow_engine.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import time
|
| 3 |
-
import uuid
|
| 4 |
-
from typing import Any, Dict, List, Optional
|
| 5 |
-
|
| 6 |
-
from pydantic import BaseModel, Field
|
| 7 |
-
|
| 8 |
-
_logger = logging.getLogger("agents.workflow_engine")
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class WorkflowStep(BaseModel):
|
| 12 |
-
step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 13 |
-
tool_name: str
|
| 14 |
-
args: Dict[str, Any]
|
| 15 |
-
status: str = "pending" # pending, running, completed, failed
|
| 16 |
-
result: Any = None
|
| 17 |
-
error: Optional[str] = None
|
| 18 |
-
started_at: Optional[float] = None
|
| 19 |
-
finished_at: Optional[float] = None
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class Workflow(BaseModel):
|
| 23 |
-
workflow_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 24 |
-
name: str
|
| 25 |
-
steps: List[WorkflowStep]
|
| 26 |
-
status: str = "pending"
|
| 27 |
-
created_at: float = Field(default_factory=time.time)
|
| 28 |
-
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class WorkflowExecutor:
|
| 32 |
-
"""
|
| 33 |
-
ARCH-I4.3: Workflow Engine.
|
| 34 |
-
|
| 35 |
-
Coordina workflow in-memory step-by-step tramite Kernel ed Executor. La
|
| 36 |
-
persistenza o il resume inter-processo non sono garantiti da questo motore;
|
| 37 |
-
i caller possono consultare lo stato del workflow corrente tramite
|
| 38 |
-
``get_workflow``.
|
| 39 |
-
"""
|
| 40 |
-
|
| 41 |
-
def __init__(self, kernel: Any, executor: Any):
|
| 42 |
-
self.kernel = kernel
|
| 43 |
-
self.executor = executor
|
| 44 |
-
self.active_workflows: Dict[str, Workflow] = {}
|
| 45 |
-
|
| 46 |
-
def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
| 47 |
-
"""Ritorna il workflow noto, inclusi gli stati terminali in memoria."""
|
| 48 |
-
return self.active_workflows.get(workflow_id)
|
| 49 |
-
|
| 50 |
-
async def execute_workflow(self, workflow: Workflow) -> Workflow:
|
| 51 |
-
"""Esegue un workflow step-by-step, mantenendo il fallback locale."""
|
| 52 |
-
self.active_workflows[workflow.workflow_id] = workflow
|
| 53 |
-
workflow.status = "running"
|
| 54 |
-
_logger.info("Avvio workflow: %s (%s)", workflow.name, workflow.workflow_id)
|
| 55 |
-
|
| 56 |
-
for step in workflow.steps:
|
| 57 |
-
step.status = "running"
|
| 58 |
-
step.started_at = time.time()
|
| 59 |
-
_logger.info(
|
| 60 |
-
"Esecuzione step: %s in workflow %s",
|
| 61 |
-
step.tool_name,
|
| 62 |
-
workflow.workflow_id,
|
| 63 |
-
)
|
| 64 |
-
try:
|
| 65 |
-
# ARCH-I4.3: il Kernel risolve la capability senza esporre
|
| 66 |
-
# l'infrastruttura al workflow.
|
| 67 |
-
resolution = await self.kernel.resolve_capability(step.tool_name)
|
| 68 |
-
if resolution.get("status") == "resolved":
|
| 69 |
-
worker = resolution["worker"]
|
| 70 |
-
worker_id = worker.id if hasattr(worker, "id") else worker["id"]
|
| 71 |
-
_logger.info(
|
| 72 |
-
"Step %s risolto su worker: %s",
|
| 73 |
-
step.tool_name,
|
| 74 |
-
worker_id,
|
| 75 |
-
)
|
| 76 |
-
result = await self.executor.run_tool(
|
| 77 |
-
tool_name=step.tool_name,
|
| 78 |
-
inputs=step.args,
|
| 79 |
-
worker_hint=worker_id,
|
| 80 |
-
)
|
| 81 |
-
else:
|
| 82 |
-
# Nessun worker registrato: il comportamento storico resta
|
| 83 |
-
# l'esecuzione locale tramite lo stesso Executor.
|
| 84 |
-
_logger.warning(
|
| 85 |
-
"Nessun worker per %s, provo esecuzione locale",
|
| 86 |
-
step.tool_name,
|
| 87 |
-
)
|
| 88 |
-
result = await self.executor.run_tool(
|
| 89 |
-
tool_name=step.tool_name,
|
| 90 |
-
inputs=step.args,
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
step.result = result
|
| 94 |
-
if isinstance(result, dict) and result.get("success") is False:
|
| 95 |
-
step.status = "failed"
|
| 96 |
-
step.error = str(result.get("error", "Tool execution failed"))
|
| 97 |
-
workflow.status = "failed"
|
| 98 |
-
break
|
| 99 |
-
step.status = "completed"
|
| 100 |
-
except Exception as exc:
|
| 101 |
-
step.status = "failed"
|
| 102 |
-
step.error = str(exc)
|
| 103 |
-
workflow.status = "failed"
|
| 104 |
-
_logger.error("Step %s fallito: %s", step.tool_name, exc)
|
| 105 |
-
break
|
| 106 |
-
finally:
|
| 107 |
-
step.finished_at = time.time()
|
| 108 |
-
|
| 109 |
-
if workflow.status == "running":
|
| 110 |
-
workflow.status = "completed"
|
| 111 |
-
_logger.info("Workflow %s terminato con stato: %s", workflow.name, workflow.status)
|
| 112 |
-
return workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/TELEGRAM_MODULES.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/api — Moduli Telegram (M2 Split)
|
| 2 |
+
|
| 3 |
+
> **Refactor M2 — 2 Luglio 2026**
|
| 4 |
+
> Il monolite `telegram_webhook.py` (2844 righe, 5 hotfix consecutivi) è stato
|
| 5 |
+
> spezzato in 6 moduli a responsabilità singola. Hotfix futuri toccano **un solo file**.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Grafo delle dipendenze
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
telegram_tg_client.py (stdlib + httpx only)
|
| 13 |
+
│
|
| 14 |
+
├──► telegram_keyboards.py (solo costanti/dict — zero import interni)
|
| 15 |
+
│ │
|
| 16 |
+
│ ├──► telegram_cmd_monitoring.py
|
| 17 |
+
│ │ └──► (esposto via telegram_webhook.py)
|
| 18 |
+
│ │
|
| 19 |
+
│ ├──► telegram_cmd_ai.py
|
| 20 |
+
│ │ └──► (esposto via telegram_webhook.py)
|
| 21 |
+
│ │
|
| 22 |
+
│ └──► telegram_callbacks.py
|
| 23 |
+
│ ├── importa _cmd_do, _cmd_autofix ... da telegram_cmd_ai
|
| 24 |
+
│ └── importa _cmd_help, _cmd_status ... da telegram_cmd_monitoring
|
| 25 |
+
│
|
| 26 |
+
└──► telegram_webhook.py (router FastAPI puro — importa da tutti)
|
| 27 |
+
└──► montato in backend/main.py come _tg_webhook_router
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Moduli
|
| 33 |
+
|
| 34 |
+
### `telegram_tg_client.py` — 276 righe
|
| 35 |
+
**Ruolo:** Telegram Bot API puro. Layer 0 senza dipendenze interne.
|
| 36 |
+
|
| 37 |
+
| Simbolo | Descrizione |
|
| 38 |
+
|---------|-------------|
|
| 39 |
+
| `_get_bot_token()` | Legge `TELEGRAM_BOT_TOKEN` dall'env |
|
| 40 |
+
| `_log_tg_exc(task)` | Log eccezioni fire-and-forget (Gap-2.6) |
|
| 41 |
+
| `_fmt_elapsed(sec)` | Formatta durata in human-readable |
|
| 42 |
+
| `_tg_reply(chat_id, text)` | Invia messaggio con parse_mode=HTML |
|
| 43 |
+
| `_tg_send(chat_id, text)` | Come reply, ritorna `message_id` |
|
| 44 |
+
| `_tg_edit(chat_id, msg_id, text)` | Modifica messaggio esistente |
|
| 45 |
+
| `_tg_photo(chat_id, url, caption)` | Invia foto via URL |
|
| 46 |
+
| `_tg_typing(chat_id)` | Invia `sendChatAction typing` |
|
| 47 |
+
| `_tg_react(chat_id, msg_id, emoji)` | Imposta reaction emoji |
|
| 48 |
+
| `_tg_answer_callback(callback_id)` | Risponde a `callback_query` (≤3s) |
|
| 49 |
+
|
| 50 |
+
**Import:** `asyncio, html, logging, os, time, httpx`
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### `telegram_keyboards.py` — 109 righe
|
| 55 |
+
**Ruolo:** Costanti UI e keyboards. Zero logica, zero import interni.
|
| 56 |
+
|
| 57 |
+
| Simbolo | Tipo | Descrizione |
|
| 58 |
+
|---------|------|-------------|
|
| 59 |
+
| `_QUICK_PICK_KB` | `dict` | Inline keyboard selezione task rapida |
|
| 60 |
+
| `_MAIN_KB` | `dict` | Reply keyboard principale |
|
| 61 |
+
| `_BENCH_ACTION_KB` | `dict` | Keyboard post-benchmark |
|
| 62 |
+
| `_TASK_MENU_KB` | `dict` | Sub-menu task |
|
| 63 |
+
| `_STATUS_MENU_KB` | `dict` | Sub-menu stato sistema |
|
| 64 |
+
| `_PERF_MENU_KB` | `dict` | Sub-menu performance |
|
| 65 |
+
| `_HEALTH_MENU_KB` | `dict` | Sub-menu health |
|
| 66 |
+
| `_DEV_MENU_KB` | `dict` | Sub-menu developer |
|
| 67 |
+
| `_LAST_GOAL` | `dict[int,str]` | Memoria per bottone 🔁 Rifai |
|
| 68 |
+
| `_BENCH_CACHE` | `dict[int,dict]` | Ultimo run benchmark per chat |
|
| 69 |
+
| `_after_task_kb(chat_id)` | func | Keyboard dinamica post-task |
|
| 70 |
+
|
| 71 |
+
**Import:** solo `from __future__ import annotations`
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
### `telegram_cmd_monitoring.py` — 456 righe
|
| 76 |
+
**Ruolo:** Comandi di monitoraggio, stato e diagnostica.
|
| 77 |
+
|
| 78 |
+
| Comando / Funzione | Trigger Telegram |
|
| 79 |
+
|--------------------|-----------------|
|
| 80 |
+
| `_cmd_help(chat_id)` | `/start` `/help` `tgw_help` |
|
| 81 |
+
| `_cmd_logs(chat_id)` | `/logs` `tgw_logs` |
|
| 82 |
+
| `_cmd_status(chat_id)` | `/stato` `tgw_status` |
|
| 83 |
+
| `_cmd_commit_summary(chat_id)` | `/commit` `tgw_commits` |
|
| 84 |
+
| `_cmd_check(chat_id)` | `/salute` `tgw_health` |
|
| 85 |
+
| `_cmd_tasks(chat_id)` | `/attività` `tgw_tasks` |
|
| 86 |
+
| `_cmd_git(chat_id, args)` | `/git` `tgw_git` |
|
| 87 |
+
| `_cmd_coord(chat_id)` | `/coord` `tgw_coord` |
|
| 88 |
+
| `_cmd_scan_now(chat_id)` | `/scan` `tgw_scan` |
|
| 89 |
+
| `_cmd_telemetry(chat_id)` | `/telemetria` `tgw_telemetry` |
|
| 90 |
+
|
| 91 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
### `telegram_cmd_ai.py` — 1152 righe
|
| 96 |
+
**Ruolo:** Comandi AI, LLM e operativi. Modulo più pesante — contiene lo streaming loop.
|
| 97 |
+
|
| 98 |
+
| Comando / Funzione | Trigger Telegram |
|
| 99 |
+
|--------------------|-----------------|
|
| 100 |
+
| `_cmd_do(chat_id, goal)` | `/avvia` — lancia task AI con streaming |
|
| 101 |
+
| `_cmd_autofix(chat_id)` | `/fix` `tgw_autofix` |
|
| 102 |
+
| `_cmd_nota(chat_id, text)` | `/nota` `tgw_nota` |
|
| 103 |
+
| `_cmd_cerca(chat_id, query)` | `/cerca` `tgw_cerca` |
|
| 104 |
+
| `_cmd_meteo(chat_id, city)` | `/meteo` `tgw_meteo` |
|
| 105 |
+
| `_cmd_riepilogo(chat_id)` | `/riepilogo` `tgw_briefing` |
|
| 106 |
+
| `_cmd_score(chat_id)` | `/score` `tgw_score` |
|
| 107 |
+
| `_cmd_bench(chat_id)` | `/bench` `tgw_bench` |
|
| 108 |
+
| `_cmd_improve(chat_id, target)` | `/migliora` `tgw_improve` |
|
| 109 |
+
|
| 110 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
### `telegram_callbacks.py` — 353 righe
|
| 115 |
+
**Ruolo:** Smista `callback_query` e `inline_query` in arrivo da Telegram.
|
| 116 |
+
|
| 117 |
+
| Funzione | Descrizione |
|
| 118 |
+
|----------|-------------|
|
| 119 |
+
| `_handle_inline(iq, token)` | Risponde alle inline query (`@ARJagent_ap_bot testo`) |
|
| 120 |
+
| `_handle_callback(cb, token)` | Dispatch per tutti i `callback_data` `tgw_*` / `agent` / `qp_*` |
|
| 121 |
+
|
| 122 |
+
**Import:** `telegram_tg_client` + `telegram_keyboards` + `telegram_cmd_ai` + `telegram_cmd_monitoring`
|
| 123 |
+
|
| 124 |
+
> ⚠️ **Questo modulo crea dipendenze circolari potenziali** — non importare
|
| 125 |
+
> `telegram_callbacks` da `telegram_cmd_ai` o `telegram_cmd_monitoring`.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### `telegram_webhook.py` — 605 righe *(era 2844)*
|
| 130 |
+
**Ruolo:** Router FastAPI puro. Solo endpoint HTTP, zero logica di business.
|
| 131 |
+
|
| 132 |
+
| Endpoint | Metodo | Descrizione |
|
| 133 |
+
|----------|--------|-------------|
|
| 134 |
+
| `/api/telegram/webhook` | POST | Ricezione update getUpdates (polling daemon) |
|
| 135 |
+
| `/api/telegram/process` | POST | Endpoint alternativo per CF Pages proxy |
|
| 136 |
+
| `/api/telegram/config/invalidate` | POST | Invalida cache config bot |
|
| 137 |
+
|
| 138 |
+
**Import:** FastAPI + tutti i 5 moduli sopra
|
| 139 |
+
**Montato in:** `backend/main.py` → `app.include_router(_tg_webhook_router)`
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Regole per gli hotfix
|
| 144 |
+
|
| 145 |
+
| Vuoi cambiare... | Tocca solo... |
|
| 146 |
+
|------------------|---------------|
|
| 147 |
+
| Aspetto di un bottone / label | `telegram_keyboards.py` |
|
| 148 |
+
| Risposta a un comando /help, /stato… | `telegram_cmd_monitoring.py` |
|
| 149 |
+
| Logica task AI / streaming / bench | `telegram_cmd_ai.py` |
|
| 150 |
+
| Routing dei bottoni inline (tgw_*) | `telegram_callbacks.py` |
|
| 151 |
+
| Helpers HTTP verso api.telegram.org | `telegram_tg_client.py` |
|
| 152 |
+
| Routing endpoint FastAPI | `telegram_webhook.py` |
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Daemon Node.js (scripts/lib/daemon-callbacks.mjs)
|
| 157 |
+
|
| 158 |
+
Il daemon usa **getUpdates polling** (non webhook registrato).
|
| 159 |
+
Riceve gli update, li smista ai command handler Python via HTTP interno.
|
| 160 |
+
Il flusso `callback_data tgw_*` viene elaborato da `telegram_callbacks.py::_handle_callback`.
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
Telegram ──► getUpdates daemon ──► POST /api/telegram/process
|
| 164 |
+
│
|
| 165 |
+
telegram_webhook.py (router)
|
| 166 |
+
│
|
| 167 |
+
┌───────────┴───────────┐
|
| 168 |
+
message? callback_query?
|
| 169 |
+
│ │
|
| 170 |
+
telegram_cmd_*.py telegram_callbacks.py
|
| 171 |
+
::_handle_callback
|
| 172 |
+
```
|
api/_agent_helpers.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
# DEP-11: /run_loop route rimossa (era stub 410 che attraversava CORS+rate-limiter+auth inutilmente).
|
| 49 |
+
# Migrazione client → /api/agent/tasks ; CF Worker redirect se necessario.
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ─── P17-F5: Persona helpers ──────────────────────────────────────────────────
|
| 53 |
+
import re as _re_persona
|
| 54 |
+
|
| 55 |
+
_PERSONA_KEYWORD_MAP: dict = {}
|
| 56 |
+
|
| 57 |
+
def _build_persona_kw_map() -> dict:
|
| 58 |
+
import re
|
| 59 |
+
return {
|
| 60 |
+
'researcher': re.compile(
|
| 61 |
+
r'\b(cerca|ricerca|research|trova|notizie|news|url|leggi|articolo|wikipedia|'
|
| 62 |
+
r'google|fonte|source|scrape|fetch|sito|pagina|web|http|verifica|fact.?check)\b',
|
| 63 |
+
re.IGNORECASE
|
| 64 |
+
),
|
| 65 |
+
'coder': re.compile(
|
| 66 |
+
r'\b(codice|code|funzione|function|bug|script|implementa|python|javascript|'
|
| 67 |
+
r'typescript|refactor|debug|test|classe|class|api|endpoint|sql|database|html|'
|
| 68 |
+
r'css|react|app|applicazione|programma|sviluppa)\b',
|
| 69 |
+
re.IGNORECASE
|
| 70 |
+
),
|
| 71 |
+
'reasoner': re.compile(
|
| 72 |
+
r'\b(analizza|pianifica|strategia|decide|ragiona|valuta|confronta|'
|
| 73 |
+
r'piano|roadmap|architettura|valutazione|decisione|ottimale|consiglia)\b',
|
| 74 |
+
re.IGNORECASE
|
| 75 |
+
),
|
| 76 |
+
'analyst': re.compile(
|
| 77 |
+
r'\b(dati|statistiche|grafico|dataset|csv|dataframe|pandas|matplotlib|'
|
| 78 |
+
r'metriche|kpi|trend|visualizza|dashboard|excel|tabella|percentuale|distribuzione)\b',
|
| 79 |
+
re.IGNORECASE
|
| 80 |
+
),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
def _classify_persona_server(goal: str) -> str:
|
| 84 |
+
"""P17-F5: classifica la persona dal goal via regex scoring. Zero LLM — zero latency."""
|
| 85 |
+
global _PERSONA_KEYWORD_MAP
|
| 86 |
+
if not _PERSONA_KEYWORD_MAP:
|
| 87 |
+
_PERSONA_KEYWORD_MAP = _build_persona_kw_map()
|
| 88 |
+
if not goal or len(goal) < 4:
|
| 89 |
+
return ''
|
| 90 |
+
best, best_score = '', 0
|
| 91 |
+
for persona_id, pattern in _PERSONA_KEYWORD_MAP.items():
|
| 92 |
+
score = len(pattern.findall(goal))
|
| 93 |
+
if score > best_score:
|
| 94 |
+
best_score, best = score, persona_id
|
| 95 |
+
return best if best_score >= 1 else ''
|
| 96 |
+
|
| 97 |
+
_PERSONA_CLIENT_CACHE: dict = {}
|
| 98 |
+
|
| 99 |
+
def _get_persona_llm_client(persona: str, default_client: object) -> object:
|
| 100 |
+
"""P17-F5: ritorna il client LLM persona-appropriate via role_router.
|
| 101 |
+
Fallback silente su default_client se la chiave API manca o role_router fallisce.
|
| 102 |
+
Cache in-process — zero overhead dopo il primo accesso.""";
|
| 103 |
+
if not persona:
|
| 104 |
+
return default_client
|
| 105 |
+
if persona in _PERSONA_CLIENT_CACHE:
|
| 106 |
+
return _PERSONA_CLIENT_CACHE[persona]
|
| 107 |
+
_ROLE_MAP = {'researcher': 'RESEARCHER', 'analyst': 'RESEARCHER',
|
| 108 |
+
'coder': 'CODER', 'reasoner': 'REASONER', 'architect': 'ARCHITECT'}
|
| 109 |
+
role_name = _ROLE_MAP.get(persona.lower())
|
| 110 |
+
if not role_name:
|
| 111 |
+
return default_client
|
| 112 |
+
try:
|
| 113 |
+
from models.role_router import RoleRouter, Role as _Role
|
| 114 |
+
role = getattr(_Role, role_name, None)
|
| 115 |
+
if role is None:
|
| 116 |
+
return default_client
|
| 117 |
+
client = RoleRouter.get_client(role)
|
| 118 |
+
_PERSONA_CLIENT_CACHE[persona] = client
|
| 119 |
+
return client
|
| 120 |
+
except Exception:
|
| 121 |
+
return default_client
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
api/admin_state.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
"""Stato operativo amministrativo protetto da JWT Supabase admin."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from datetime import datetime, timedelta, timezone
|
| 5 |
-
from typing import Any
|
| 6 |
-
|
| 7 |
-
from fastapi import APIRouter, Depends, Query
|
| 8 |
-
|
| 9 |
-
from .auth_guard import require_admin_user
|
| 10 |
-
from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
|
| 11 |
-
|
| 12 |
-
router = APIRouter(
|
| 13 |
-
prefix="/api/admin/state",
|
| 14 |
-
tags=["admin"],
|
| 15 |
-
dependencies=[Depends(require_admin_user)],
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
@router.get("/sessions")
|
| 20 |
-
async def admin_sessions(
|
| 21 |
-
max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
|
| 22 |
-
limit: int = Query(default=100, ge=1, le=200),
|
| 23 |
-
) -> dict[str, object]:
|
| 24 |
-
cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
|
| 25 |
-
|
| 26 |
-
def operation(client: Any):
|
| 27 |
-
return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
|
| 28 |
-
|
| 29 |
-
result = await _call(operation)
|
| 30 |
-
sessions = []
|
| 31 |
-
for row in result.data or []:
|
| 32 |
-
context = _json_object(row.get("context"))
|
| 33 |
-
session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
|
| 34 |
-
if not session_id:
|
| 35 |
-
continue
|
| 36 |
-
claimed = context.get("claimedFiles")
|
| 37 |
-
sessions.append({
|
| 38 |
-
"session_id": session_id,
|
| 39 |
-
"session_name": str(context.get("sessionName") or session_id)[:160],
|
| 40 |
-
"sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
|
| 41 |
-
"claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
|
| 42 |
-
"last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
|
| 43 |
-
"current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
|
| 44 |
-
})
|
| 45 |
-
return {"sessions": sessions}
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@router.get("/tasks")
|
| 49 |
-
async def admin_tasks(
|
| 50 |
-
limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
|
| 51 |
-
offset: int = Query(default=0, ge=0, le=10_000),
|
| 52 |
-
status: str | None = Query(default=None, max_length=64),
|
| 53 |
-
) -> dict[str, object]:
|
| 54 |
-
normalized_status = status.strip().upper() if status else ""
|
| 55 |
-
|
| 56 |
-
def operation(client: Any):
|
| 57 |
-
query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
|
| 58 |
-
if normalized_status:
|
| 59 |
-
query = query.eq("status", normalized_status)
|
| 60 |
-
page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
|
| 61 |
-
all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
|
| 62 |
-
return page, all_statuses
|
| 63 |
-
|
| 64 |
-
page, all_statuses = await _call(operation)
|
| 65 |
-
counts: dict[str, int] = {}
|
| 66 |
-
for row in all_statuses.data or []:
|
| 67 |
-
key = str(row.get("status") or "UNKNOWN").upper()
|
| 68 |
-
counts[key] = counts.get(key, 0) + 1
|
| 69 |
-
tasks = [{
|
| 70 |
-
"task_id": str(row.get("task_id") or ""),
|
| 71 |
-
"goal": str(row.get("goal") or "")[:1_000],
|
| 72 |
-
"status": str(row.get("status") or "UNKNOWN"),
|
| 73 |
-
"updated_at": _as_epoch_ms(row.get("updated_at")),
|
| 74 |
-
} for row in page.data or []]
|
| 75 |
-
return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/ads_manager.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import logging
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
_logger = logging.getLogger("ads_manager")
|
| 8 |
+
router = APIRouter(prefix="/api/ads", tags=["ads"])
|
| 9 |
+
|
| 10 |
+
DB_PATH = os.getenv("ADS_DB_PATH", "/app/data/ads/ads-client.db")
|
| 11 |
+
|
| 12 |
+
def get_db_connection():
|
| 13 |
+
if not os.path.exists(DB_PATH):
|
| 14 |
+
_logger.warning(f"Database Ads non trovato in {DB_PATH}")
|
| 15 |
+
return None
|
| 16 |
+
try:
|
| 17 |
+
conn = sqlite3.connect(DB_PATH)
|
| 18 |
+
conn.row_factory = sqlite3.Row
|
| 19 |
+
return conn
|
| 20 |
+
except Exception as e:
|
| 21 |
+
_logger.error(f"Errore connessione DB Ads: {e}")
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
@router.get("/health")
|
| 25 |
+
async def ads_health():
|
| 26 |
+
conn = get_db_connection()
|
| 27 |
+
if not conn:
|
| 28 |
+
return {"status": "error", "message": "Database non disponibile"}
|
| 29 |
+
try:
|
| 30 |
+
cursor = conn.cursor()
|
| 31 |
+
cursor.execute("SELECT count(*) FROM http_cache")
|
| 32 |
+
count = cursor.fetchone()[0]
|
| 33 |
+
conn.close()
|
| 34 |
+
return {"status": "ok", "record_count": count}
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return {"status": "error", "message": str(e)}
|
| 37 |
+
|
| 38 |
+
@router.get("/cache")
|
| 39 |
+
async def get_ads_cache(limit: int = 10):
|
| 40 |
+
conn = get_db_connection()
|
| 41 |
+
if not conn:
|
| 42 |
+
raise HTTPException(status_code=503, detail="Database Ads non disponibile")
|
| 43 |
+
try:
|
| 44 |
+
cursor = conn.cursor()
|
| 45 |
+
cursor.execute("SELECT * FROM http_cache LIMIT ?", (limit,))
|
| 46 |
+
rows = [dict(row) for row in cursor.fetchall()]
|
| 47 |
+
conn.close()
|
| 48 |
+
return {"ok": True, "data": rows}
|
| 49 |
+
except Exception as e:
|
| 50 |
+
raise HTTPException(status_code=500, detail=str(e))
|
api/agent.py
CHANGED
|
@@ -45,14 +45,13 @@ 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,
|
| 49 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 50 |
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 51 |
ReasonLoopIn, AgentTaskIn,
|
|
|
|
| 52 |
)
|
| 53 |
from .speculative import fire_speculative_tools
|
| 54 |
-
from .vfs_sync import build_vfs_sync_complete
|
| 55 |
-
from .task_tool_policy import build_task_tool_policy
|
| 56 |
try:
|
| 57 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 58 |
except Exception:
|
|
@@ -66,12 +65,6 @@ from .persistence import (
|
|
| 66 |
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 67 |
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff, # BG-4
|
| 68 |
)
|
| 69 |
-
try:
|
| 70 |
-
from .kernel import kernel as _kernel # ARCH-K2.2: Brain→Kernel abstraction
|
| 71 |
-
_KERNEL_AVAILABLE = True
|
| 72 |
-
except Exception:
|
| 73 |
-
_kernel = None # type: ignore[assignment]
|
| 74 |
-
_KERNEL_AVAILABLE = False
|
| 75 |
|
| 76 |
def _log_task_exc(task): # GAP-2.6: log silently-dropped exceptions in fire-and-forget tasks
|
| 77 |
if not task.cancelled():
|
|
@@ -89,21 +82,6 @@ except Exception:
|
|
| 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:
|
| 95 |
-
return
|
| 96 |
-
try:
|
| 97 |
-
runtime_config = credentials.as_runtime_config()
|
| 98 |
-
if not runtime_config:
|
| 99 |
-
return
|
| 100 |
-
from models.ai_client import AIClient
|
| 101 |
-
_task_ai_clients[task_id] = AIClient(byok_credentials=runtime_config)
|
| 102 |
-
except Exception as exc:
|
| 103 |
-
# Never include the payload or credential values in diagnostics.
|
| 104 |
-
_logger.warning("[agent] unable to initialise BYOK task client: %s", type(exc).__name__)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
# ── Deprecated run_loop ────────────────────────────────────────────────────────
|
| 108 |
|
| 109 |
@router.post('/run_loop', deprecated=True)
|
|
@@ -327,14 +305,8 @@ async def agent_run_stream(
|
|
| 327 |
_rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
|
| 328 |
yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': _ss(str(item.get('title', _rs_tool.replace('_', ' ').capitalize())))})}\n\n" # BUG-SSE-SURR
|
| 329 |
if '__done__' in item:
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
if not _final_res and not item.get('success', True):
|
| 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 = {
|
| 340 |
'llm': 'Elaborazione risposta AI',
|
|
@@ -530,23 +502,6 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
|
|
| 530 |
})
|
| 531 |
goal = body.goal
|
| 532 |
mode = body.mode
|
| 533 |
-
# ARCH-K2.2: registra il dispatch nella Queue del Kernel prima di triggerare GitHub Actions
|
| 534 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 535 |
-
_dispatch_id = str(uuid.uuid4())
|
| 536 |
-
asyncio.create_task(_kernel.submit_task(
|
| 537 |
-
payload={
|
| 538 |
-
'type': 'gh_dispatch',
|
| 539 |
-
'goal': goal,
|
| 540 |
-
'mode': mode,
|
| 541 |
-
'dispatch_id': _dispatch_id,
|
| 542 |
-
'metadata': {'workflow': 'agent-kernel.yml'},
|
| 543 |
-
},
|
| 544 |
-
priority='HIGH',
|
| 545 |
-
)).add_done_callback(_log_task_exc)
|
| 546 |
-
asyncio.create_task(_kernel.publish_event(
|
| 547 |
-
topic='agent.kernel.dispatched',
|
| 548 |
-
payload={'goal': goal[:200], 'mode': mode},
|
| 549 |
-
)).add_done_callback(_log_task_exc)
|
| 550 |
import httpx as _httpx
|
| 551 |
try:
|
| 552 |
async with _httpx.AsyncClient(timeout=15) as _hc:
|
|
@@ -568,42 +523,6 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
|
|
| 568 |
|
| 569 |
# ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
|
| 570 |
|
| 571 |
-
async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
|
| 572 |
-
"""
|
| 573 |
-
Versione interna di create_agent_task per uso da job_queue (GAP-1-fix).
|
| 574 |
-
Non richiede FastAPI body né dipendenze auth — chiamabile direttamente.
|
| 575 |
-
"""
|
| 576 |
-
_prune_agent_tasks()
|
| 577 |
-
if task_id in _agent_tasks:
|
| 578 |
-
return {"taskId": task_id, "status": _agent_tasks[task_id]["status"]}
|
| 579 |
-
created_at = int(time.time() * 1000)
|
| 580 |
-
_agent_tasks[task_id] = {
|
| 581 |
-
"id": task_id,
|
| 582 |
-
"status": "QUEUED",
|
| 583 |
-
"goal": goal,
|
| 584 |
-
"context": job.get("context", {}),
|
| 585 |
-
"max_steps": job.get("max_steps", 20),
|
| 586 |
-
"created_at": created_at,
|
| 587 |
-
"session_id": job.get("session_id", ""),
|
| 588 |
-
}
|
| 589 |
-
asyncio.create_task(
|
| 590 |
-
sb_upsert_task(task_id, goal, "QUEUED", job.get("max_steps", 20), job.get("context", {}), created_at)
|
| 591 |
-
).add_done_callback(_log_task_exc)
|
| 592 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 593 |
-
asyncio.create_task(_kernel.submit_task(
|
| 594 |
-
payload={
|
| 595 |
-
"task_id": task_id,
|
| 596 |
-
"goal": goal,
|
| 597 |
-
"max_steps": job.get("max_steps", 20),
|
| 598 |
-
"source": "job_queue",
|
| 599 |
-
"metadata": {"job_queue": True},
|
| 600 |
-
},
|
| 601 |
-
priority="NORMAL",
|
| 602 |
-
session_id=job.get("session_id", ""),
|
| 603 |
-
)).add_done_callback(_log_task_exc)
|
| 604 |
-
return {"taskId": task_id, "status": "QUEUED"}
|
| 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 |
"""
|
|
@@ -618,8 +537,6 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
|
|
| 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']}
|
| 624 |
|
| 625 |
# S359: try Supabase lazy restore (only hit network after backend restart)
|
|
@@ -629,12 +546,9 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
|
|
| 629 |
# Use context from the incoming request (not persisted to save space).
|
| 630 |
restored['context'] = body.context
|
| 631 |
_agent_tasks[task_id] = restored
|
| 632 |
-
_attach_byok_client(task_id, body.provider_credentials)
|
| 633 |
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 634 |
|
| 635 |
-
# Brand new task
|
| 636 |
-
# tool speculativo, pianificazione o chiamata al loop.
|
| 637 |
-
_tool_policy = build_task_tool_policy(body.goal)
|
| 638 |
created_at = int(time.time() * 1000)
|
| 639 |
_agent_tasks[task_id] = {
|
| 640 |
'id': task_id,
|
|
@@ -648,14 +562,10 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
|
|
| 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,
|
| 654 |
}
|
| 655 |
-
#
|
| 656 |
-
#
|
| 657 |
-
|
| 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,28 +576,9 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
|
|
| 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:
|
| 670 |
-
#
|
| 671 |
-
|
| 672 |
-
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 673 |
-
# ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
|
| 674 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 675 |
-
asyncio.create_task(_kernel.submit_task(
|
| 676 |
-
payload={
|
| 677 |
-
'task_id': task_id,
|
| 678 |
-
'goal': body.goal,
|
| 679 |
-
'max_steps': body.max_steps,
|
| 680 |
-
'persona': body.persona,
|
| 681 |
-
'source': 'agent_api',
|
| 682 |
-
'metadata': {'agent_api': True},
|
| 683 |
-
},
|
| 684 |
-
priority='NORMAL',
|
| 685 |
-
session_id=body.session_id,
|
| 686 |
-
)).add_done_callback(_log_task_exc)
|
| 687 |
-
asyncio.create_task(_kernel.publish_event(
|
| 688 |
-
topic='task.created',
|
| 689 |
-
payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
|
| 690 |
-
)).add_done_callback(_log_task_exc)
|
| 691 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 692 |
|
| 693 |
|
|
@@ -765,7 +656,6 @@ async def list_agent_tasks(limit: int = 50, status: str = '', role: AuthRole = D
|
|
| 765 |
async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 766 |
if task_id in _agent_tasks:
|
| 767 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 768 |
-
_task_ai_clients.pop(task_id, None)
|
| 769 |
reg = _loop_registry.get(task_id)
|
| 770 |
if reg and not reg.get('done'):
|
| 771 |
at = reg.get('asyncio_task')
|
|
@@ -826,17 +716,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 826 |
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 827 |
|
| 828 |
task = _agent_tasks[task_id]
|
| 829 |
-
# I task restaurati da persistenza potrebbero non contenere metadata runtime.
|
| 830 |
-
# Ricostruire la policy dal goal mantiene il resume fail-closed.
|
| 831 |
-
if (
|
| 832 |
-
"forbid_tools" not in task
|
| 833 |
-
or "literal_response" not in task
|
| 834 |
-
or "allow_local_csv_conversion" not in task
|
| 835 |
-
):
|
| 836 |
-
_restored_policy = build_task_tool_policy(task.get("goal", ""))
|
| 837 |
-
task["forbid_tools"] = _restored_policy.forbid_tools
|
| 838 |
-
task["literal_response"] = _restored_policy.literal_response
|
| 839 |
-
task["allow_local_csv_conversion"] = _restored_policy.allow_local_csv_conversion
|
| 840 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 841 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 842 |
|
|
@@ -845,19 +724,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 845 |
async def generate():
|
| 846 |
yield "retry: 3000\n\n"
|
| 847 |
|
| 848 |
-
# Contratto letterale: chiusura immediata prima di task_start, planner o
|
| 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'] = 'COMPLETED'
|
| 853 |
-
asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).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 |
-
}))
|
| 857 |
-
yield f"data: {literal_event}\n\n"
|
| 858 |
-
yield "data: [DONE]\n\n"
|
| 859 |
-
return
|
| 860 |
-
|
| 861 |
reg = _loop_registry.get(task_id)
|
| 862 |
|
| 863 |
is_done_reconnect = reg is not None and reg.get('done', False)
|
|
@@ -899,7 +765,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 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 ('
|
| 903 |
# Replay buffer from resume point
|
| 904 |
for evt_str in sb_events[_resume_from:]:
|
| 905 |
yield evt_str
|
|
@@ -966,7 +832,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 966 |
def _sse(event: str, data: dict) -> None:
|
| 967 |
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 968 |
_ctr[0] += 1
|
| 969 |
-
s = f"id: {_ctr[0]}\ndata: {json.dumps(
|
| 970 |
# GAP-3-FIX: text_chunk bypass buffer — fanout diretto, no persist.
|
| 971 |
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 972 |
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
|
@@ -991,29 +857,13 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 991 |
|
| 992 |
_agent_tasks[task_id]['status'] = 'RUNNING'
|
| 993 |
asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
|
| 994 |
-
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 995 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 996 |
-
asyncio.create_task(_kernel.publish_event(
|
| 997 |
-
topic='task.running',
|
| 998 |
-
payload={'task_id': task_id, 'status': 'RUNNING'},
|
| 999 |
-
)).add_done_callback(_log_task_exc)
|
| 1000 |
_prune_agent_tasks()
|
| 1001 |
|
| 1002 |
async def run_loop() -> None:
|
| 1003 |
try:
|
| 1004 |
-
# Contratto letterale: nessun provider, planner, tool, card o side effect.
|
| 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'] = 'COMPLETED'
|
| 1009 |
-
asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
|
| 1010 |
-
_sse('task_done', {'taskId': task_id, 'result': _literal_response})
|
| 1011 |
-
return
|
| 1012 |
-
|
| 1013 |
from agents.unified_loop import UnifiedAgentLoop
|
| 1014 |
-
#
|
| 1015 |
-
|
| 1016 |
-
client = _task_ai_clients.get(task_id) or _get_ai_client()
|
| 1017 |
try:
|
| 1018 |
from agents.critic import Critic
|
| 1019 |
from agents.response_verifier import ResponseVerifier
|
|
@@ -1046,24 +896,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 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 |
-
# ARTIFACT-CONTRACT: le richieste di pagina/app richiedono un
|
| 1050 |
-
# artifact reale, non una risposta narrativa. Il frontend committa
|
| 1051 |
-
# solo file_written + vfs_sync_complete, quindi l'agente deve
|
| 1052 |
-
# scrivere e rileggere almeno un file prima di dichiarare successo.
|
| 1053 |
-
_artifact_goal = bool(re.search(
|
| 1054 |
-
r"\b(?:pagina|sito|website|webapp|app|mini[- ]app|ui|interfaccia)\b",
|
| 1055 |
-
task.get('goal', ''), re.IGNORECASE,
|
| 1056 |
-
))
|
| 1057 |
-
if _artifact_goal:
|
| 1058 |
-
_artifact_hint = (
|
| 1059 |
-
"[CONTRATTO ARTIFACT UI]\n"
|
| 1060 |
-
"Per questa richiesta devi creare davvero i file nel workspace usando write_file "
|
| 1061 |
-
"(non limitarti a proporre codice in chat). Dopo la scrittura, usa read_file o "
|
| 1062 |
-
"un controllo equivalente per verificare il contenuto. Dichiara completamento "
|
| 1063 |
-
"solo se la scrittura è riuscita; in caso contrario segnala l'errore senza inventare "
|
| 1064 |
-
"un link o un'anteprima."
|
| 1065 |
-
)
|
| 1066 |
-
context_str = f"{_artifact_hint}\n\n{context_str}".strip()
|
| 1067 |
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 1068 |
# Bug: _resume_context era settato su task{} ma mai letto qui → context perduto su resume.
|
| 1069 |
_resume_ctx = task.get('_resume_context', '')
|
|
@@ -1134,49 +966,21 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1134 |
_hctx = task.get("_handoff_context", "")
|
| 1135 |
if _hctx:
|
| 1136 |
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 1137 |
-
#
|
| 1138 |
-
|
| 1139 |
-
_is_byok_task = task_id in _task_ai_clients
|
| 1140 |
-
_persona_client = client if _is_byok_task else _get_persona_llm_client(_persona, client)
|
| 1141 |
-
_planner = _get_planner()
|
| 1142 |
-
if _is_byok_task:
|
| 1143 |
-
try:
|
| 1144 |
-
from agents.planner import Planner
|
| 1145 |
-
_planner = Planner(llm_client=client)
|
| 1146 |
-
except Exception as exc:
|
| 1147 |
-
_logger.warning("[agent] BYOK planner fallback: %s", type(exc).__name__)
|
| 1148 |
loop = UnifiedAgentLoop(
|
| 1149 |
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 1150 |
-
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=
|
| 1151 |
)
|
| 1152 |
step_idx = [0]
|
| 1153 |
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
| 1154 |
-
# P34/SYNC-1: i file arrivano nel frontend in staging; raccogliamo
|
| 1155 |
-
# soltanto quelli con contenuto così il commit atomico può avvenire
|
| 1156 |
-
# una sola volta dopo un task riuscito.
|
| 1157 |
-
_vfs_written_paths: set[str] = set()
|
| 1158 |
-
# Accumula soltanto i token realmente emessi per poter riconciliare
|
| 1159 |
-
# il testo in streaming con l'output autorevole del finalizer.
|
| 1160 |
-
_streamed_chunks: list[str] = []
|
| 1161 |
|
| 1162 |
async def step_cb(step_data: dict) -> None:
|
| 1163 |
step_idx[0] += 1
|
| 1164 |
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 1165 |
# S420: streaming token — emetti direttamente senza passare dal buffer step
|
| 1166 |
if _action == 'text_chunk':
|
| 1167 |
-
|
| 1168 |
-
if _token:
|
| 1169 |
-
_streamed_chunks.append(_token)
|
| 1170 |
-
_sse('text_chunk', {'taskId': task_id, 'token': _token})
|
| 1171 |
-
return
|
| 1172 |
-
# RECOV-P1: engineering_state event — forward projection to frontend
|
| 1173 |
-
if _action == 'engineering_state':
|
| 1174 |
-
_sse('engineering_state', {
|
| 1175 |
-
'taskId': task_id,
|
| 1176 |
-
'status': step_data.get('status'),
|
| 1177 |
-
'mode': step_data.get('mode'),
|
| 1178 |
-
'engineering_state': step_data.get('engineering_state'),
|
| 1179 |
-
})
|
| 1180 |
return
|
| 1181 |
|
| 1182 |
# S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
|
|
@@ -1286,26 +1090,10 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1286 |
step_data.get('output', '')[:500])
|
| 1287 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 1288 |
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 1289 |
-
# SYNC-1: includi content nel SSE event per file_written (≤60KB)
|
| 1290 |
-
#
|
| 1291 |
-
# inoltra soltanto l'URL HTTPS generato internamente, che il client
|
| 1292 |
-
# materializza come data URI nel proprio VFS prima del commit atomico.
|
| 1293 |
if _action == 'file_written' and step_data.get('content'):
|
| 1294 |
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
| 1295 |
-
_vfs_written_paths.add(str(_vfs_file)[:500])
|
| 1296 |
-
elif _action == 'file_written':
|
| 1297 |
-
_source_url = str(step_data.get('source_url') or '')
|
| 1298 |
-
_mime_type = str(step_data.get('mime_type') or '')
|
| 1299 |
-
_allowed_image_origins = (
|
| 1300 |
-
'https://image.pollinations.ai/',
|
| 1301 |
-
'https://media.pollinations.ai/',
|
| 1302 |
-
'https://gen.pollinations.ai/',
|
| 1303 |
-
)
|
| 1304 |
-
if (_source_url.startswith(_allowed_image_origins) and
|
| 1305 |
-
_mime_type in {'image/jpeg', 'image/png', 'image/webp'}):
|
| 1306 |
-
_vfs_evt['sourceUrl'] = _source_url[:2_000]
|
| 1307 |
-
_vfs_evt['mimeType'] = _mime_type
|
| 1308 |
-
_vfs_written_paths.add(str(_vfs_file)[:500])
|
| 1309 |
_sse('vfs_update', _vfs_evt)
|
| 1310 |
|
| 1311 |
# S363-UI: thought event — emitted when planner completes
|
|
@@ -1407,146 +1195,31 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1407 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1408 |
on_step=step_cb,
|
| 1409 |
session_id=task.get('session_id', '') or '',
|
| 1410 |
-
allow_tools=not bool(task.get('forbid_tools', False)),
|
| 1411 |
-
allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
|
| 1412 |
)
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
# nella risposta finale dopo aver narrato una scrittura, senza produrre
|
| 1416 |
-
# il tool event `file_written`. Non committiamo mai una falsa positività:
|
| 1417 |
-
# se il goal è UI, proviamo solo a materializzare un documento HTML
|
| 1418 |
-
# completo già presente nell'output, usando l'executor VFS autenticato.
|
| 1419 |
-
# Se non troviamo un documento completo o la scrittura fallisce, il task
|
| 1420 |
-
# resta senza artifact e il frontend non riceve alcun sync inventato.
|
| 1421 |
-
if _artifact_goal and not _vfs_written_paths and getattr(loop, 'executor', None):
|
| 1422 |
-
_artifact_output = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1423 |
-
_html_match = re.search(r'(?is)(<!doctype\s+html\b.*?</html>)', _artifact_output)
|
| 1424 |
-
if not _html_match:
|
| 1425 |
-
_html_match = re.search(r'(?is)(<html(?:\s[^>]*)?>.*?</html>)', _artifact_output)
|
| 1426 |
-
if _html_match:
|
| 1427 |
-
_artifact_content = _html_match.group(1).strip()
|
| 1428 |
-
_artifact_path = 'index.html'
|
| 1429 |
-
_path_match = re.search(r'(?i)(?:/|\b)([\w.-]+\.html)\b', _artifact_output)
|
| 1430 |
-
if _path_match:
|
| 1431 |
-
_artifact_path = _path_match.group(1)
|
| 1432 |
-
try:
|
| 1433 |
-
_write_result = await asyncio.wait_for(
|
| 1434 |
-
loop.executor.run_tool('write_file', {
|
| 1435 |
-
'path': _artifact_path,
|
| 1436 |
-
'content': _artifact_content,
|
| 1437 |
-
}),
|
| 1438 |
-
timeout=25.0,
|
| 1439 |
-
)
|
| 1440 |
-
_write_ok = not (isinstance(_write_result, dict) and _write_result.get('error'))
|
| 1441 |
-
if _write_ok:
|
| 1442 |
-
_vfs_written_paths.add(_artifact_path)
|
| 1443 |
-
await step_cb({
|
| 1444 |
-
'action': 'file_written', 'status': 'done',
|
| 1445 |
-
'path': _artifact_path, 'content': _artifact_content,
|
| 1446 |
-
'result': f'Artifact materializzato: {_artifact_path}',
|
| 1447 |
-
})
|
| 1448 |
-
_read_result = await asyncio.wait_for(
|
| 1449 |
-
loop.executor.run_tool('read_file', {'path': _artifact_path}),
|
| 1450 |
-
timeout=15.0,
|
| 1451 |
-
)
|
| 1452 |
-
_read_ok = not (isinstance(_read_result, dict) and _read_result.get('error'))
|
| 1453 |
-
await step_cb({
|
| 1454 |
-
'action': 'read_file',
|
| 1455 |
-
'status': 'done' if _read_ok else 'error',
|
| 1456 |
-
'path': _artifact_path,
|
| 1457 |
-
'result': f'Verifica artifact: {_artifact_path}' if _read_ok else str(_read_result)[:300],
|
| 1458 |
-
})
|
| 1459 |
-
except Exception as _artifact_exc:
|
| 1460 |
-
_logger.warning('[agent] artifact fallback failed: %s', type(_artifact_exc).__name__)
|
| 1461 |
-
|
| 1462 |
-
# Lifecycle separato: l'esecuzione primaria è completa quando VFS e risultato
|
| 1463 |
-
# sono confermati; la verifica qualità successiva non deve tenere il task RUNNING.
|
| 1464 |
-
_agent_tasks[task_id]['status'] = 'COMPLETED'
|
| 1465 |
-
_agent_tasks[task_id]['quality_status'] = (
|
| 1466 |
-
'QUALITY_CHECK_PENDING' if _run_quality_check else 'NOT_REQUIRED'
|
| 1467 |
-
)
|
| 1468 |
-
asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
|
| 1469 |
-
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1470 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1471 |
-
asyncio.create_task(_kernel.publish_event(
|
| 1472 |
-
topic='task.completed',
|
| 1473 |
-
payload={'task_id': task_id, 'status': 'SUCCESS'},
|
| 1474 |
-
)).add_done_callback(_log_task_exc)
|
| 1475 |
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1476 |
-
|
| 1477 |
-
task_id, _vfs_written_paths, result,
|
| 1478 |
-
)
|
| 1479 |
-
if _vfs_commit is not None:
|
| 1480 |
-
# P34: completa l’atomic swap frontend solo dopo che il loop ha
|
| 1481 |
-
# confermato il task. Su errore/cancellazione lo staging rimane
|
| 1482 |
-
# intenzionalmente non committato.
|
| 1483 |
-
_sse('vfs_sync_complete', _vfs_commit)
|
| 1484 |
-
if _run_quality_check:
|
| 1485 |
-
_sse('quality_check_pending', {
|
| 1486 |
-
'taskId': task_id,
|
| 1487 |
-
'status': 'QUALITY_CHECK_PENDING',
|
| 1488 |
-
'primaryStatus': 'COMPLETED',
|
| 1489 |
-
})
|
| 1490 |
-
_streamed_text = ''.join(_streamed_chunks)
|
| 1491 |
-
if _streamed_text and _result_text != _streamed_text:
|
| 1492 |
-
# Il finalizer può riparare/normalizzare la risposta dopo gli
|
| 1493 |
-
# ultimi chunk. Notifica esplicitamente la sostituzione così il
|
| 1494 |
-
# client non mostra un testo parziale o divergente.
|
| 1495 |
-
_sse('text_replace', {
|
| 1496 |
-
'taskId': task_id,
|
| 1497 |
-
'text': _result_text[:8000],
|
| 1498 |
-
'reason': 'authoritative_finalizer_output',
|
| 1499 |
-
})
|
| 1500 |
-
_sse('task_done', {
|
| 1501 |
-
'taskId': task_id,
|
| 1502 |
-
'result': _result_text[:8000],
|
| 1503 |
-
'status': 'COMPLETED',
|
| 1504 |
-
'qualityStatus': _agent_tasks[task_id].get('quality_status', 'NOT_REQUIRED'),
|
| 1505 |
-
})
|
| 1506 |
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1507 |
|
| 1508 |
-
#
|
| 1509 |
-
# primario del task e mai il commit VFS già confermato.
|
| 1510 |
if _run_quality_check:
|
| 1511 |
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1512 |
-
if len(_qg_result) > 500 and _qg_result.count('```') >= 2:
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1518 |
-
)
|
| 1519 |
-
_agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_DONE'
|
| 1520 |
-
_sse('quality_check_done', {
|
| 1521 |
-
'taskId': task_id,
|
| 1522 |
-
'status': 'QUALITY_CHECK_DONE',
|
| 1523 |
-
'primaryStatus': 'COMPLETED',
|
| 1524 |
-
})
|
| 1525 |
-
except Exception as _q_exc:
|
| 1526 |
-
_agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_ERROR'
|
| 1527 |
-
_sse('quality_check_done', {
|
| 1528 |
-
'taskId': task_id,
|
| 1529 |
-
'status': 'QUALITY_CHECK_ERROR',
|
| 1530 |
-
'primaryStatus': 'COMPLETED',
|
| 1531 |
-
'error': type(_q_exc).__name__,
|
| 1532 |
-
})
|
| 1533 |
-
asyncio.create_task(_run_quality_background()).add_done_callback(_log_task_exc)
|
| 1534 |
|
| 1535 |
|
| 1536 |
except asyncio.CancelledError:
|
| 1537 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 1538 |
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 1539 |
-
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1540 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1541 |
-
asyncio.create_task(_kernel.publish_event(
|
| 1542 |
-
topic='task.cancelled',
|
| 1543 |
-
payload={'task_id': task_id, 'status': 'CANCELLED'},
|
| 1544 |
-
)).add_done_callback(_log_task_exc)
|
| 1545 |
_sse('task_cancelled', {'taskId': task_id})
|
| 1546 |
|
| 1547 |
except (ImportError, ModuleNotFoundError):
|
| 1548 |
-
_agent_tasks[task_id]['status'] = '
|
| 1549 |
-
asyncio.create_task(sb_update_status(task_id, '
|
| 1550 |
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1551 |
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1552 |
f'Goal ricevuto: {task["goal"]}\n\n'
|
|
@@ -1557,20 +1230,11 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1557 |
except Exception as err:
|
| 1558 |
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 1559 |
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 1560 |
-
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1561 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1562 |
-
asyncio.create_task(_kernel.publish_event(
|
| 1563 |
-
topic='task.failed',
|
| 1564 |
-
payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
|
| 1565 |
-
)).add_done_callback(_log_task_exc)
|
| 1566 |
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
| 1567 |
_sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
|
| 1568 |
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 1569 |
|
| 1570 |
finally:
|
| 1571 |
-
# Il task è terminale: rimuove l’unico riferimento alle credenziali
|
| 1572 |
-
# BYOK, lasciando replay SSE e metadati senza segreti.
|
| 1573 |
-
_task_ai_clients.pop(task_id, None)
|
| 1574 |
reg_entry['done'] = True
|
| 1575 |
reg_entry['finished_at'] = time.time()
|
| 1576 |
for q in list(reg_entry['subscriber_queues']):
|
|
@@ -1642,7 +1306,7 @@ async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Dep
|
|
| 1642 |
'extra': body.extra,
|
| 1643 |
'savedAt': int(time.time() * 1000),
|
| 1644 |
}
|
| 1645 |
-
asyncio.create_task(sb_save_checkpoint(task_id,
|
| 1646 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1647 |
|
| 1648 |
|
|
|
|
| 45 |
from pydantic import BaseModel, field_validator
|
| 46 |
from typing import Literal
|
| 47 |
from .state import (
|
| 48 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 49 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 50 |
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 51 |
ReasonLoopIn, AgentTaskIn,
|
| 52 |
+
write_ahead_task_created, # WRITE-AHEAD: persist immediato alla creazione task
|
| 53 |
)
|
| 54 |
from .speculative import fire_speculative_tools
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 57 |
except Exception:
|
|
|
|
| 65 |
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 66 |
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff, # BG-4
|
| 67 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
def _log_task_exc(task): # GAP-2.6: log silently-dropped exceptions in fire-and-forget tasks
|
| 70 |
if not task.cancelled():
|
|
|
|
| 82 |
router = APIRouter()
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# ── Deprecated run_loop ────────────────────────────────────────────────────────
|
| 86 |
|
| 87 |
@router.post('/run_loop', deprecated=True)
|
|
|
|
| 305 |
_rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
|
| 306 |
yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': _ss(str(item.get('title', _rs_tool.replace('_', ' ').capitalize())))})}\n\n" # BUG-SSE-SURR
|
| 307 |
if '__done__' in item:
|
| 308 |
+
yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n"
|
| 309 |
+
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
# S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
|
| 311 |
_NARR_QUICK = {
|
| 312 |
'llm': 'Elaborazione risposta AI',
|
|
|
|
| 502 |
})
|
| 503 |
goal = body.goal
|
| 504 |
mode = body.mode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
import httpx as _httpx
|
| 506 |
try:
|
| 507 |
async with _httpx.AsyncClient(timeout=15) as _hc:
|
|
|
|
| 523 |
|
| 524 |
# ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
@router.post('/api/agent/tasks')
|
| 527 |
async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 528 |
"""
|
|
|
|
| 537 |
|
| 538 |
# Already in memory → return immediately (normal path, includes S358 reconnect)
|
| 539 |
if task_id in _agent_tasks:
|
|
|
|
|
|
|
| 540 |
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 541 |
|
| 542 |
# S359: try Supabase lazy restore (only hit network after backend restart)
|
|
|
|
| 546 |
# Use context from the incoming request (not persisted to save space).
|
| 547 |
restored['context'] = body.context
|
| 548 |
_agent_tasks[task_id] = restored
|
|
|
|
| 549 |
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 550 |
|
| 551 |
+
# Brand new task
|
|
|
|
|
|
|
| 552 |
created_at = int(time.time() * 1000)
|
| 553 |
_agent_tasks[task_id] = {
|
| 554 |
'id': task_id,
|
|
|
|
| 562 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 563 |
'persona': body.persona, # P17-F5: expertise persona hint
|
| 564 |
'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
|
|
|
|
|
|
|
|
|
|
| 565 |
}
|
| 566 |
+
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 567 |
+
# periodico (15-60s). Finestra di perdita per la fase di creazione → zero.
|
| 568 |
+
asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
|
|
|
|
| 569 |
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 570 |
if body.session_id:
|
| 571 |
_hctx = await sb_restore_handoff_context(body.session_id)
|
|
|
|
| 576 |
asyncio.create_task(
|
| 577 |
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 578 |
).add_done_callback(_log_task_exc)
|
| 579 |
+
# S361: Speculative Tool Firing — pre-fires read-only tools in parallel
|
| 580 |
+
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 581 |
+
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 583 |
|
| 584 |
|
|
|
|
| 656 |
async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 657 |
if task_id in _agent_tasks:
|
| 658 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
|
|
|
| 659 |
reg = _loop_registry.get(task_id)
|
| 660 |
if reg and not reg.get('done'):
|
| 661 |
at = reg.get('asyncio_task')
|
|
|
|
| 716 |
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 717 |
|
| 718 |
task = _agent_tasks[task_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 720 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 721 |
|
|
|
|
| 724 |
async def generate():
|
| 725 |
yield "retry: 3000\n\n"
|
| 726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
reg = _loop_registry.get(task_id)
|
| 728 |
|
| 729 |
is_done_reconnect = reg is not None and reg.get('done', False)
|
|
|
|
| 765 |
sb_events = await sb_get_events(task_id)
|
| 766 |
if sb_events:
|
| 767 |
task_status = task.get('status', 'UNKNOWN')
|
| 768 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 769 |
# Replay buffer from resume point
|
| 770 |
for evt_str in sb_events[_resume_from:]:
|
| 771 |
yield evt_str
|
|
|
|
| 832 |
def _sse(event: str, data: dict) -> None:
|
| 833 |
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 834 |
_ctr[0] += 1
|
| 835 |
+
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 836 |
# GAP-3-FIX: text_chunk bypass buffer — fanout diretto, no persist.
|
| 837 |
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 838 |
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
|
|
|
| 857 |
|
| 858 |
_agent_tasks[task_id]['status'] = 'RUNNING'
|
| 859 |
asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
_prune_agent_tasks()
|
| 861 |
|
| 862 |
async def run_loop() -> None:
|
| 863 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 864 |
from agents.unified_loop import UnifiedAgentLoop
|
| 865 |
+
# S388: singleton — evita OpenAI() per ogni task
|
| 866 |
+
client = _get_ai_client()
|
|
|
|
| 867 |
try:
|
| 868 |
from agents.critic import Critic
|
| 869 |
from agents.response_verifier import ResponseVerifier
|
|
|
|
| 896 |
"Il frontend mostrerà automaticamente un pulsante 'Connetti' all'utente."
|
| 897 |
)
|
| 898 |
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 899 |
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 900 |
# Bug: _resume_context era settato su task{} ma mai letto qui → context perduto su resume.
|
| 901 |
_resume_ctx = task.get('_resume_context', '')
|
|
|
|
| 966 |
_hctx = task.get("_handoff_context", "")
|
| 967 |
if _hctx:
|
| 968 |
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 969 |
+
# P17-F5: route primary LLM to persona-appropriate client
|
| 970 |
+
_persona_client = _get_persona_llm_client(_persona, client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
loop = UnifiedAgentLoop(
|
| 972 |
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 973 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 974 |
)
|
| 975 |
step_idx = [0]
|
| 976 |
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
|
| 978 |
async def step_cb(step_data: dict) -> None:
|
| 979 |
step_idx[0] += 1
|
| 980 |
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 981 |
# S420: streaming token — emetti direttamente senza passare dal buffer step
|
| 982 |
if _action == 'text_chunk':
|
| 983 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 984 |
return
|
| 985 |
|
| 986 |
# S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
|
|
|
|
| 1090 |
step_data.get('output', '')[:500])
|
| 1091 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 1092 |
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 1093 |
+
# SYNC-1: includi content nel SSE event per file_written (≤60KB)
|
| 1094 |
+
# Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
|
|
|
|
|
|
|
| 1095 |
if _action == 'file_written' and step_data.get('content'):
|
| 1096 |
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1097 |
_sse('vfs_update', _vfs_evt)
|
| 1098 |
|
| 1099 |
# S363-UI: thought event — emitted when planner completes
|
|
|
|
| 1195 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1196 |
on_step=step_cb,
|
| 1197 |
session_id=task.get('session_id', '') or '',
|
|
|
|
|
|
|
| 1198 |
)
|
| 1199 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1200 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1201 |
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1202 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1204 |
|
| 1205 |
+
# S363: fire-and-forget quality check when code detected in output
|
|
|
|
| 1206 |
if _run_quality_check:
|
| 1207 |
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1208 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised — evita QG su snippet brevi
|
| 1209 |
+
asyncio.create_task(_run_quality_check(
|
| 1210 |
+
task_id, task['goal'], _qg_result,
|
| 1211 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1212 |
+
)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1213 |
|
| 1214 |
|
| 1215 |
except asyncio.CancelledError:
|
| 1216 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 1217 |
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1218 |
_sse('task_cancelled', {'taskId': task_id})
|
| 1219 |
|
| 1220 |
except (ImportError, ModuleNotFoundError):
|
| 1221 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1222 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1223 |
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1224 |
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1225 |
f'Goal ricevuto: {task["goal"]}\n\n'
|
|
|
|
| 1230 |
except Exception as err:
|
| 1231 |
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 1232 |
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1233 |
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
| 1234 |
_sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
|
| 1235 |
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 1236 |
|
| 1237 |
finally:
|
|
|
|
|
|
|
|
|
|
| 1238 |
reg_entry['done'] = True
|
| 1239 |
reg_entry['finished_at'] = time.time()
|
| 1240 |
for q in list(reg_entry['subscriber_queues']):
|
|
|
|
| 1306 |
'extra': body.extra,
|
| 1307 |
'savedAt': int(time.time() * 1000),
|
| 1308 |
}
|
| 1309 |
+
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 1310 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1311 |
|
| 1312 |
|
api/agent_checkpoint.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
backend/api/agent_checkpoint.py — Simplified checkpoint endpoints (ARCH-K2.3)
|
| 3 |
-
|
| 4 |
-
Aggiunge alias /api/agent/checkpoint (senza task_id nella path) per uso diretto dal frontend:
|
| 5 |
-
GET /api/agent/checkpoint — lista tutti i checkpoint attivi in memoria
|
| 6 |
-
POST /api/agent/checkpoint — salva checkpoint (taskId opzionale nel body)
|
| 7 |
-
GET /api/agent/checkpoint/{task_id} — recupera checkpoint specifico
|
| 8 |
-
DELETE /api/agent/checkpoint/{task_id} — elimina checkpoint
|
| 9 |
-
|
| 10 |
-
I checkpoint per-task esistono già su /api/agent/tasks/{id}/checkpoint (agent.py).
|
| 11 |
-
Questi alias sono più comodi quando il frontend non ha un task_id esplicito
|
| 12 |
-
(es. salvataggio periodico dello stato dell'agente, resume dopo refresh).
|
| 13 |
-
|
| 14 |
-
ROUTING CF PAGES: /api/agent/* → HANDS (Space B) via HANDS_PATTERNS[0].
|
| 15 |
-
Nessuna modifica a [[catchall]].ts necessaria.
|
| 16 |
-
|
| 17 |
-
NOTA: Import da api.agent e api.persistence sono LAZY (dentro le funzioni)
|
| 18 |
-
per evitare import circolari — agent.py importa già molti altri moduli.
|
| 19 |
-
"""
|
| 20 |
-
import time
|
| 21 |
-
import asyncio
|
| 22 |
-
import logging
|
| 23 |
-
from typing import Optional
|
| 24 |
-
|
| 25 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 26 |
-
from pydantic import BaseModel
|
| 27 |
-
|
| 28 |
-
from .auth_guard import require_role, AuthRole
|
| 29 |
-
|
| 30 |
-
_logger = logging.getLogger("api.agent_checkpoint")
|
| 31 |
-
|
| 32 |
-
router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class CheckpointBody(BaseModel):
|
| 36 |
-
taskId: Optional[str] = None # se omesso → usa "default"
|
| 37 |
-
step: int = 0
|
| 38 |
-
goal: str = ""
|
| 39 |
-
plan: list = []
|
| 40 |
-
logs: list[str] = []
|
| 41 |
-
artifacts: list[str] = []
|
| 42 |
-
retryCount: int = 0
|
| 43 |
-
extra: dict = {}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# ── GET /api/agent/checkpoint ─────────────────────────────────────────────────
|
| 47 |
-
@router.get("/api/agent/checkpoint")
|
| 48 |
-
async def list_checkpoints_alias():
|
| 49 |
-
"""
|
| 50 |
-
Lista tutti i checkpoint attivi in memoria.
|
| 51 |
-
Alias leggero per /api/agent/checkpoints (agent.py).
|
| 52 |
-
"""
|
| 53 |
-
# Import lazy — evita circolarità
|
| 54 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 55 |
-
|
| 56 |
-
_prune_checkpoints()
|
| 57 |
-
now = int(time.time() * 1000)
|
| 58 |
-
return {
|
| 59 |
-
"count": len(_task_checkpoints),
|
| 60 |
-
"checkpoints": [
|
| 61 |
-
{
|
| 62 |
-
"taskId": k,
|
| 63 |
-
"step": v.get("step", 0),
|
| 64 |
-
"goal": v.get("goal", "")[:300],
|
| 65 |
-
"age_ms": now - v.get("savedAt", now),
|
| 66 |
-
}
|
| 67 |
-
for k, v in _task_checkpoints.items()
|
| 68 |
-
],
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ── POST /api/agent/checkpoint ────────────────────────────────────────────────
|
| 73 |
-
@router.post("/api/agent/checkpoint")
|
| 74 |
-
async def save_checkpoint_alias(body: CheckpointBody):
|
| 75 |
-
"""
|
| 76 |
-
Salva un checkpoint. taskId opzionale: se omesso usa 'default'.
|
| 77 |
-
Replica la logica di /api/agent/tasks/{id}/checkpoint con Supabase persist.
|
| 78 |
-
"""
|
| 79 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 80 |
-
from api.persistence import sb_save_checkpoint # type: ignore[import]
|
| 81 |
-
|
| 82 |
-
_prune_checkpoints()
|
| 83 |
-
task_id = body.taskId or "default"
|
| 84 |
-
|
| 85 |
-
cp: dict = {
|
| 86 |
-
"taskId": task_id,
|
| 87 |
-
"step": body.step,
|
| 88 |
-
"goal": body.goal,
|
| 89 |
-
"plan": body.plan,
|
| 90 |
-
"logs": body.logs[-50:], # mantieni solo gli ultimi 50 log
|
| 91 |
-
"artifacts": body.artifacts,
|
| 92 |
-
"retryCount": body.retryCount,
|
| 93 |
-
"extra": body.extra,
|
| 94 |
-
"savedAt": int(time.time() * 1000),
|
| 95 |
-
}
|
| 96 |
-
_task_checkpoints[task_id] = cp
|
| 97 |
-
# Persist su Supabase — fire-and-forget (stesso pattern di agent.py)
|
| 98 |
-
asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
|
| 99 |
-
return {"saved": True, "taskId": task_id, "step": body.step}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
# ── GET /api/agent/checkpoint/{task_id} ──────────────────────────────────────
|
| 103 |
-
@router.get("/api/agent/checkpoint/{task_id}")
|
| 104 |
-
async def get_checkpoint_alias(task_id: str):
|
| 105 |
-
"""
|
| 106 |
-
Recupera il checkpoint per un task specifico.
|
| 107 |
-
Cerca prima in memoria (_task_checkpoints), poi su Supabase via sb_get_checkpoint.
|
| 108 |
-
"""
|
| 109 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 110 |
-
from api.persistence import sb_get_checkpoint # type: ignore[import]
|
| 111 |
-
|
| 112 |
-
_prune_checkpoints()
|
| 113 |
-
cp = _task_checkpoints.get(task_id)
|
| 114 |
-
if not cp:
|
| 115 |
-
cp = await sb_get_checkpoint(task_id)
|
| 116 |
-
if not cp:
|
| 117 |
-
raise HTTPException(
|
| 118 |
-
status_code=404,
|
| 119 |
-
detail={"error": "checkpoint_not_found", "taskId": task_id},
|
| 120 |
-
)
|
| 121 |
-
return cp
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
# ── DELETE /api/agent/checkpoint/{task_id} ───────────────────────────────────
|
| 125 |
-
@router.delete("/api/agent/checkpoint/{task_id}")
|
| 126 |
-
async def delete_checkpoint_alias(task_id: str):
|
| 127 |
-
"""Rimuove il checkpoint da memoria in-process (non elimina da Supabase)."""
|
| 128 |
-
from api.agent import _task_checkpoints # type: ignore[import]
|
| 129 |
-
|
| 130 |
-
_task_checkpoints.pop(task_id, None)
|
| 131 |
-
return {"deleted": task_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/agent_checkpoint_routes.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""agent_checkpoint_routes.py — Checkpoint, debug/timing, skill-stats, circuit-status.
|
| 2 |
+
|
| 3 |
+
Estratto da agent.py (split 2026-06-30).
|
| 4 |
+
Route coperte:
|
| 5 |
+
POST /api/agent/tasks/{task_id}/checkpoint
|
| 6 |
+
GET /api/agent/tasks/{task_id}/checkpoint
|
| 7 |
+
DELETE /api/agent/tasks/{task_id}/checkpoint
|
| 8 |
+
GET /api/agent/checkpoints
|
| 9 |
+
GET /debug/timing
|
| 10 |
+
GET /api/agent/skill-stats/{session_id}
|
| 11 |
+
GET /api/agent/skill-stats
|
| 12 |
+
GET /api/agent/circuit-status/{session_id}
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
import os, asyncio, json, uuid, time, re
|
| 16 |
+
import re as _re_persona
|
| 17 |
+
from fastapi import APIRouter, HTTPException, Request, Body
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from pydantic import BaseModel, field_validator
|
| 20 |
+
from typing import Literal
|
| 21 |
+
from .state import (
|
| 22 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 23 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 24 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 25 |
+
ReasonLoopIn, AgentTaskIn,
|
| 26 |
+
write_ahead_task_created,
|
| 27 |
+
)
|
| 28 |
+
from .speculative import fire_speculative_tools
|
| 29 |
+
try:
|
| 30 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 31 |
+
except Exception:
|
| 32 |
+
_run_quality_check = None
|
| 33 |
+
import logging
|
| 34 |
+
_logger = logging.getLogger("api.agent")
|
| 35 |
+
from .persistence import (
|
| 36 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 37 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 38 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 39 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
|
| 40 |
+
)
|
| 41 |
+
from ._agent_helpers import _RE_SURROGATES, _ss, _log_task_exc
|
| 42 |
+
try:
|
| 43 |
+
from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
|
| 44 |
+
except Exception:
|
| 45 |
+
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
|
| 46 |
+
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 47 |
+
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 48 |
+
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 49 |
+
|
| 50 |
+
router = APIRouter()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── Task checkpoints ───────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
class CheckpointIn(BaseModel):
|
| 57 |
+
taskId: str
|
| 58 |
+
step: int
|
| 59 |
+
goal: str
|
| 60 |
+
plan: list[str] = []
|
| 61 |
+
logs: list[str] = []
|
| 62 |
+
artifacts: list[str] = []
|
| 63 |
+
retryCount: int = 0
|
| 64 |
+
extra: dict = {}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post('/api/agent/tasks/{task_id}/checkpoint')
|
| 68 |
+
async def save_checkpoint(task_id: str, body: CheckpointIn):
|
| 69 |
+
_prune_checkpoints()
|
| 70 |
+
_task_checkpoints[task_id] = {
|
| 71 |
+
'taskId': task_id,
|
| 72 |
+
'step': body.step,
|
| 73 |
+
'goal': body.goal,
|
| 74 |
+
'plan': body.plan,
|
| 75 |
+
'logs': body.logs[-50:],
|
| 76 |
+
'artifacts': body.artifacts,
|
| 77 |
+
'retryCount': body.retryCount,
|
| 78 |
+
'extra': body.extra,
|
| 79 |
+
'savedAt': int(time.time() * 1000),
|
| 80 |
+
}
|
| 81 |
+
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 82 |
+
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get('/api/agent/tasks/{task_id}/checkpoint')
|
| 86 |
+
async def get_checkpoint(task_id: str):
|
| 87 |
+
_prune_checkpoints()
|
| 88 |
+
cp = _task_checkpoints.get(task_id)
|
| 89 |
+
if not cp:
|
| 90 |
+
cp = await sb_get_checkpoint(task_id)
|
| 91 |
+
if not cp:
|
| 92 |
+
raise HTTPException(404, detail={'error': 'checkpoint_not_found', 'taskId': task_id})
|
| 93 |
+
return cp
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.delete('/api/agent/tasks/{task_id}/checkpoint')
|
| 97 |
+
async def delete_checkpoint(task_id: str):
|
| 98 |
+
_task_checkpoints.pop(task_id, None)
|
| 99 |
+
return {'deleted': task_id}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.get('/api/agent/checkpoints')
|
| 103 |
+
async def list_checkpoints():
|
| 104 |
+
_prune_checkpoints()
|
| 105 |
+
now = int(time.time() * 1000)
|
| 106 |
+
return {
|
| 107 |
+
'count': len(_task_checkpoints),
|
| 108 |
+
'checkpoints': [
|
| 109 |
+
{'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200→300
|
| 110 |
+
for k, v in _task_checkpoints.items()
|
| 111 |
+
],
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ─── Sprint 5 ITEM 15: /debug/timing — telemetria timing + qualità agente ────
|
| 116 |
+
# Usato da TelemetryDashboard.tsx (frontend) per la sezione "Qualità agente".
|
| 117 |
+
# Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualità).
|
| 118 |
+
# Non richiede auth — dati aggregati, nessun dato sensibile.
|
| 119 |
+
@router.get('/debug/timing')
|
| 120 |
+
async def get_debug_timing():
|
| 121 |
+
"""
|
| 122 |
+
Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
|
| 123 |
+
e contatori qualità (goal_success, repair_success, tool_failure, req_engine).
|
| 124 |
+
Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
from api.state import _TIMING_STORE, _REPAIR_STATS
|
| 128 |
+
timing_stats: dict = {}
|
| 129 |
+
for label, samples in _TIMING_STORE.items():
|
| 130 |
+
if samples:
|
| 131 |
+
avg_val = round(sum(samples) / len(samples), 1)
|
| 132 |
+
else:
|
| 133 |
+
avg_val = None
|
| 134 |
+
timing_stats[label] = {"avg": avg_val, "count": len(samples)}
|
| 135 |
+
return {
|
| 136 |
+
"timing_stats": timing_stats,
|
| 137 |
+
"repair_stats": dict(_REPAIR_STATS),
|
| 138 |
+
}
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ─── GAP-SKILL-SYNC: /api/agent/skill-stats — statistiche tool adattive ──────
|
| 144 |
+
# Espone i dati del SkillTracker (session-scoped success/fail per tool)
|
| 145 |
+
# al frontend per merge con skillRegistry Dexie — vista cross-runtime unificata.
|
| 146 |
+
@router.get('/api/agent/skill-stats/{session_id}')
|
| 147 |
+
async def get_skill_stats(session_id: str):
|
| 148 |
+
"""Success/fail rate + Wilson score per ogni tool nella sessione.
|
| 149 |
+
|
| 150 |
+
Il frontend usa questa API per arricchire i dati Dexie di skillRegistry.ts
|
| 151 |
+
con le stats backend: confidence reale (server-side) vs contatori browser-only.
|
| 152 |
+
"""
|
| 153 |
+
try:
|
| 154 |
+
from agents.skill_tracker import get_skill_tracker
|
| 155 |
+
return {
|
| 156 |
+
"session_id": session_id,
|
| 157 |
+
"stats": get_skill_tracker().get_stats(session_id),
|
| 158 |
+
}
|
| 159 |
+
except Exception as exc:
|
| 160 |
+
return {"session_id": session_id, "stats": {}, "error": str(exc)}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.get('/api/agent/skill-stats')
|
| 164 |
+
async def list_all_skill_sessions():
|
| 165 |
+
"""Debug: panoramica di tutte le sessioni SkillTracker attive (tool count, call count)."""
|
| 166 |
+
try:
|
| 167 |
+
from agents.skill_tracker import get_skill_tracker
|
| 168 |
+
return get_skill_tracker().get_all_sessions()
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
return {"error": str(exc)}
|
| 171 |
+
|
| 172 |
+
# ── /api/agent/circuit-status/{session_id} — circuit breaker live status ──────
|
| 173 |
+
# Espone per ogni tool tracciato in sessione: stato circuito, Wilson score,
|
| 174 |
+
# recovery calls effettuate — utile per debug e monitoring real-time.
|
| 175 |
+
@router.get('/api/agent/circuit-status/{session_id}')
|
| 176 |
+
async def get_circuit_status(session_id: str):
|
| 177 |
+
"""
|
| 178 |
+
Stato real-time del circuit breaker per ogni tool di una sessione.
|
| 179 |
+
|
| 180 |
+
Per ogni tool tracciato, classifica il circuito come:
|
| 181 |
+
- open → Wilson score < 0.15 AND total_count >= 3 AND tool ha fallback
|
| 182 |
+
(il tool viene bypassato — routing automatico ai fallback)
|
| 183 |
+
- closed → performance sufficiente o dati insufficienti per aprire il circuit
|
| 184 |
+
|
| 185 |
+
Campi per tool:
|
| 186 |
+
wilson_score: lower bound dell'intervallo di confidenza al 95% (0–1)
|
| 187 |
+
success_count: successi registrati nella sessione
|
| 188 |
+
fail_count: fallimenti registrati nella sessione
|
| 189 |
+
total_count: chiamate totali
|
| 190 |
+
success_rate: raw rate (NON usato dal circuit — solo informativo)
|
| 191 |
+
avg_latency_ms: latenza media (ms)
|
| 192 |
+
has_fallbacks: True se TOOL_REGISTRY definisce fallback per il tool
|
| 193 |
+
recovery_calls: quante volte il recovery credit ha concesso un tentativo
|
| 194 |
+
circuit_state: "open" | "closed" | "no_data" | "insufficient_data"
|
| 195 |
+
|
| 196 |
+
Thresholds (from executor.py):
|
| 197 |
+
circuit_open_threshold: 0.15 (Wilson score sotto cui il circuit si apre)
|
| 198 |
+
min_calls_for_circuit: 3 (chiamate minime prima che il circuit possa aprirsi)
|
| 199 |
+
recovery_interval: 5 (ogni N call con circuit open → recovery attempt)
|
| 200 |
+
"""
|
| 201 |
+
try:
|
| 202 |
+
from agents.skill_tracker import get_skill_tracker
|
| 203 |
+
from tools.registry import TOOL_REGISTRY
|
| 204 |
+
from api.state import _get_executor
|
| 205 |
+
from agents.executor import (
|
| 206 |
+
_CIRCUIT_OPEN_THRESHOLD,
|
| 207 |
+
_MIN_CALLS_FOR_CIRCUIT,
|
| 208 |
+
_RECOVERY_INTERVAL,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
stats = get_skill_tracker().get_stats(session_id)
|
| 212 |
+
|
| 213 |
+
# Recovery counts vivono nell'istanza Executor singleton
|
| 214 |
+
executor = _get_executor()
|
| 215 |
+
rec_counts: dict = {}
|
| 216 |
+
if executor is not None:
|
| 217 |
+
rec_counts = getattr(executor, '_circuit_recovery_counts', {})
|
| 218 |
+
|
| 219 |
+
circuits_open: list[dict] = []
|
| 220 |
+
circuits_closed: list[dict] = []
|
| 221 |
+
|
| 222 |
+
for tool_name, s in stats.items():
|
| 223 |
+
has_fallbacks = bool(TOOL_REGISTRY.get(tool_name, {}).get('fallbacks'))
|
| 224 |
+
recovery_calls = rec_counts.get(tool_name, 0)
|
| 225 |
+
|
| 226 |
+
# Replica logica _is_circuit_open() di executor.py
|
| 227 |
+
if s['total_count'] == 0:
|
| 228 |
+
state = 'no_data'
|
| 229 |
+
elif s['total_count'] < _MIN_CALLS_FOR_CIRCUIT:
|
| 230 |
+
state = 'insufficient_data'
|
| 231 |
+
elif s['wilson_score'] < _CIRCUIT_OPEN_THRESHOLD and has_fallbacks:
|
| 232 |
+
state = 'open'
|
| 233 |
+
else:
|
| 234 |
+
state = 'closed'
|
| 235 |
+
|
| 236 |
+
entry = {
|
| 237 |
+
'tool': tool_name,
|
| 238 |
+
'circuit_state': state,
|
| 239 |
+
'wilson_score': s['wilson_score'],
|
| 240 |
+
'success_count': s['success_count'],
|
| 241 |
+
'fail_count': s['fail_count'],
|
| 242 |
+
'total_count': s['total_count'],
|
| 243 |
+
'success_rate': s['success_rate'],
|
| 244 |
+
'avg_latency_ms': s['avg_latency_ms'],
|
| 245 |
+
'has_fallbacks': has_fallbacks,
|
| 246 |
+
'recovery_calls': recovery_calls,
|
| 247 |
+
}
|
| 248 |
+
if state == 'open':
|
| 249 |
+
circuits_open.append(entry)
|
| 250 |
+
else:
|
| 251 |
+
circuits_closed.append(entry)
|
| 252 |
+
|
| 253 |
+
# Ordina open per Wilson score asc (peggiori prima), closed per desc (migliori prima)
|
| 254 |
+
circuits_open.sort(key=lambda x: x['wilson_score'])
|
| 255 |
+
circuits_closed.sort(key=lambda x: x['wilson_score'], reverse=True)
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
'session_id': session_id,
|
| 259 |
+
'total_tools_tracked': len(stats),
|
| 260 |
+
'circuits_open_count': len(circuits_open),
|
| 261 |
+
'circuits_closed_count': len(circuits_closed),
|
| 262 |
+
'circuits_open': circuits_open,
|
| 263 |
+
'circuits_closed': circuits_closed,
|
| 264 |
+
'thresholds': {
|
| 265 |
+
'circuit_open_threshold': _CIRCUIT_OPEN_THRESHOLD,
|
| 266 |
+
'min_calls_for_circuit': _MIN_CALLS_FOR_CIRCUIT,
|
| 267 |
+
'recovery_interval': _RECOVERY_INTERVAL,
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
return {
|
| 272 |
+
'session_id': session_id,
|
| 273 |
+
'total_tools_tracked': 0,
|
| 274 |
+
'circuits_open_count': 0,
|
| 275 |
+
'circuits_open': [],
|
| 276 |
+
'circuits_closed': [],
|
| 277 |
+
'error': str(exc),
|
| 278 |
+
}
|
| 279 |
+
|
api/agent_loop_routes.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
@router.post('/run_loop')
|
| 47 |
+
async def run_loop_removed():
|
| 48 |
+
"""S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=410,
|
| 51 |
+
detail={
|
| 52 |
+
"error": "Gone",
|
| 53 |
+
"message": "Endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream",
|
| 54 |
+
"migration": "/api/agent/tasks",
|
| 55 |
+
},
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── SSE run-stream ────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
@router.post('/api/agent/run-stream')
|
| 62 |
+
async def agent_run_stream(body: ReasonLoopIn, request: Request):
|
| 63 |
+
# S-BENCH: auth guard — consistente con /api/exec e /api/execute-shell
|
| 64 |
+
_itok = os.getenv('INTERNAL_TOKEN', '')
|
| 65 |
+
if _itok and request.headers.get('X-Internal-Token') != _itok:
|
| 66 |
+
raise HTTPException(401, 'Unauthorized')
|
| 67 |
+
async def generate():
|
| 68 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 69 |
+
|
| 70 |
+
async def step_cb(step: dict) -> None:
|
| 71 |
+
await queue.put(step)
|
| 72 |
+
|
| 73 |
+
async def run_loop() -> None:
|
| 74 |
+
try:
|
| 75 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 76 |
+
# S388: usa singleton _get_ai_client() — nessuna re-istanziazione OpenAI() per request
|
| 77 |
+
client = _get_ai_client()
|
| 78 |
+
try:
|
| 79 |
+
from agents.critic import Critic
|
| 80 |
+
from agents.response_verifier import ResponseVerifier
|
| 81 |
+
_critic = Critic(llm_client=client)
|
| 82 |
+
_verifier = ResponseVerifier()
|
| 83 |
+
except Exception as _cv_err:
|
| 84 |
+
_logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
|
| 85 |
+
_critic = None
|
| 86 |
+
_verifier = None
|
| 87 |
+
# Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through)
|
| 88 |
+
_resume_ctx = getattr(body, '_resume_context', None)
|
| 89 |
+
_resume_max = getattr(body, '_resume_max_steps', None) or body.max_steps
|
| 90 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 91 |
+
# Bug-5-FIX: resume context iniettato DOPO che context_str è definito (era NameError)
|
| 92 |
+
if _resume_ctx:
|
| 93 |
+
context_str = f"[RIPRESA AUTOMATICA]\n{_resume_ctx}\n\n{context_str}".strip()
|
| 94 |
+
|
| 95 |
+
loop = UnifiedAgentLoop(
|
| 96 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 97 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 98 |
+
)
|
| 99 |
+
# S456-X5: prepend project context (projectMemory.getContext() dal frontend)
|
| 100 |
+
if body.project_context:
|
| 101 |
+
context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
|
| 102 |
+
# S456-X4: inject top failure patterns appresi dal selfLearning frontend
|
| 103 |
+
if body.learning_hints:
|
| 104 |
+
# S591: learning_hints[:3]→[:5] — più pattern appresi nel context
|
| 105 |
+
hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
|
| 106 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 107 |
+
# P35: vincoli negativi dal frontend (agentConstraints.ts → VFS /.agent/constraints.json)
|
| 108 |
+
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 109 |
+
if _neg_c:
|
| 110 |
+
context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
| 111 |
+
result = await loop.run(
|
| 112 |
+
goal=body.goal, context=context_str,
|
| 113 |
+
max_steps=body.max_steps, on_step=step_cb,
|
| 114 |
+
session_id=getattr(body, "session_id", "") or "",
|
| 115 |
+
)
|
| 116 |
+
await queue.put({
|
| 117 |
+
'__done__': True,
|
| 118 |
+
'result': result.get('output', ''),
|
| 119 |
+
'engine': result.get('engine', 'fallback'),
|
| 120 |
+
'success': result.get('success', False),
|
| 121 |
+
})
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
| 124 |
+
try:
|
| 125 |
+
from api.incident_registry import log_incident as _log_inc
|
| 126 |
+
asyncio.create_task(_log_inc(
|
| 127 |
+
task_id=body.goal[:32].replace(' ', '_'),
|
| 128 |
+
goal=body.goal, error=str(exc), source="agent",
|
| 129 |
+
)).add_done_callback(_log_task_exc)
|
| 130 |
+
except Exception as _exc:
|
| 131 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 132 |
+
await queue.put({'__error__': str(exc)})
|
| 133 |
+
|
| 134 |
+
task = asyncio.create_task(run_loop())
|
| 135 |
+
task_id = body.goal[:32].replace(' ', '_')
|
| 136 |
+
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
| 137 |
+
_run_stream_tasks[task_id] = {"task": task, "queue": queue}
|
| 138 |
+
yield "retry: 3000\n\n"
|
| 139 |
+
yield f"data: {json.dumps({'type': 'task_start', 'taskId': task_id})}\n\n"
|
| 140 |
+
|
| 141 |
+
# S386: fast-fail — se tutti i provider sono down (heartbeat lo sa già),
|
| 142 |
+
# non aspettare 120s di tentativi: rispondi subito con errore chiaro.
|
| 143 |
+
try:
|
| 144 |
+
from api.state import _heartbeat_state
|
| 145 |
+
_providers = _heartbeat_state.get("providers", [])
|
| 146 |
+
if _providers and not any(p.get("ok") for p in _providers):
|
| 147 |
+
task.cancel()
|
| 148 |
+
_names = ", ".join(p["name"] for p in _providers)
|
| 149 |
+
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
|
| 150 |
+
yield "data: [DONE]\n\n"
|
| 151 |
+
return
|
| 152 |
+
except Exception as _hb_err:
|
| 153 |
+
_logger.debug("[routes] heartbeat skip silenced: %s", type(_hb_err).__name__) # non inizializzato, prosegui normalmente
|
| 154 |
+
|
| 155 |
+
# S386: timeout ridotto 120→60s — risposta entro 1 minuto o errore esplicito
|
| 156 |
+
timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60'))
|
| 157 |
+
heartbeat_secs = 15.0
|
| 158 |
+
elapsed = 0.0
|
| 159 |
+
try:
|
| 160 |
+
while True:
|
| 161 |
+
try:
|
| 162 |
+
item = await asyncio.wait_for(queue.get(), timeout=heartbeat_secs)
|
| 163 |
+
elapsed = 0.0
|
| 164 |
+
except asyncio.TimeoutError:
|
| 165 |
+
elapsed += heartbeat_secs
|
| 166 |
+
if elapsed >= timeout_secs:
|
| 167 |
+
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
|
| 168 |
+
break
|
| 169 |
+
yield 'data: {"type":"ping"}\n\n'
|
| 170 |
+
continue
|
| 171 |
+
# ABORT-2: segnale abort dall'endpoint POST /api/agent/abort
|
| 172 |
+
if "__abort__" in item:
|
| 173 |
+
_ar = item.get('abort_reason', 'user_stop') # MX18-ABORT: dynamic reason
|
| 174 |
+
_src = item.get('abort_source', 'backend_abort_queue')
|
| 175 |
+
yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': _ar, 'abort_source': _src})}\n\n" # MX16+MX18-ABORT
|
| 176 |
+
break
|
| 177 |
+
if '__error__' in item:
|
| 178 |
+
yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': _ss(item['__error__'])})}\n\n"
|
| 179 |
+
break
|
| 180 |
+
# S420: streaming token — emetti subito al frontend senza accumulare
|
| 181 |
+
if item.get('action') == 'text_chunk':
|
| 182 |
+
yield f"data: {json.dumps({'type': 'text_chunk', 'token': _ss(item.get('token', '')), 'taskId': task_id})}\n\n"
|
| 183 |
+
continue
|
| 184 |
+
# S758-P4.1: tool_use — chip pre-esecuzione (agent_run_stream path)
|
| 185 |
+
_rs_act = item.get('action', '')
|
| 186 |
+
_rs_st = item.get('status', '')
|
| 187 |
+
if ((_rs_act == 'tool_start' and _rs_st == 'running') or
|
| 188 |
+
(_rs_act.startswith('executor:') and _rs_st == 'started')):
|
| 189 |
+
_rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
|
| 190 |
+
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"
|
| 191 |
+
if '__done__' in item:
|
| 192 |
+
yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n"
|
| 193 |
+
break
|
| 194 |
+
# S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
|
| 195 |
+
_NARR_QUICK = {
|
| 196 |
+
'llm': 'Elaborazione risposta AI',
|
| 197 |
+
'direct_tools': 'Strumenti diretti',
|
| 198 |
+
'web_search': 'Ricerca web', 'get_weather': 'Dati meteo',
|
| 199 |
+
'read_page': 'Lettura pagina', 'calculate': 'Calcolo matematico',
|
| 200 |
+
'generate_image': 'Generazione immagine AI',
|
| 201 |
+
'execution_validator_fix': 'Auto-correzione codice (S393)',
|
| 202 |
+
'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
|
| 203 |
+
# S661: label narrative per tool aggiunti in S648-S659 — prima usavano
|
| 204 |
+
# _act_q.replace('_',' ').capitalize() → "Apply patch", "Call api" (generico)
|
| 205 |
+
'apply_patch': 'Applico patch al file…',
|
| 206 |
+
'call_api': 'Chiamo API REST…',
|
| 207 |
+
'send_email': 'Invio email…',
|
| 208 |
+
'create_pdf': 'Genero documento PDF…',
|
| 209 |
+
'web_research': 'Ricerca multi-fonte…',
|
| 210 |
+
'write_file': 'Scrivo file…',
|
| 211 |
+
'read_file': 'Leggo file…',
|
| 212 |
+
'execute_shell': 'Eseguo comando shell…',
|
| 213 |
+
'analyze_image': 'Analizzo immagine…',
|
| 214 |
+
'run_python': 'Eseguo Python (Pyodide)…',
|
| 215 |
+
# S-GAP1: narrative fasi strategiche
|
| 216 |
+
'plan': 'Analizzo la richiesta e preparo un piano di esecuzione…',
|
| 217 |
+
'reflective_debug': 'Ho incontrato un ostacolo — ricalcolo una strategia più efficiente…',
|
| 218 |
+
'fallback': 'Adotto un approccio alternativo per completare il task…',
|
| 219 |
+
'smolagents': 'Orchestro gli strumenti necessari…',
|
| 220 |
+
}
|
| 221 |
+
_act_q = item.get('action', '')
|
| 222 |
+
if 'explanation' not in item:
|
| 223 |
+
item['explanation'] = _NARR_QUICK.get(_act_q, _act_q.replace('_', ' ').capitalize())
|
| 224 |
+
if 'title' not in item:
|
| 225 |
+
item['title'] = item['explanation']
|
| 226 |
+
|
| 227 |
+
# S403: SSE Visibility Guard — classifica ogni step event:
|
| 228 |
+
# "internal" → mai visibile (pipeline internals: planner, llm, reflection)
|
| 229 |
+
# "progress" → visibile come progress card (tool reali, auto-fix)
|
| 230 |
+
# "debug" → visibile solo in dev mode (direct_tools, fast_path)
|
| 231 |
+
# Il frontend filtra per visibility — solo "progress" mostrato all'utente.
|
| 232 |
+
_STEP_VISIBILITY: dict[str, str] = {
|
| 233 |
+
# Internal pipeline — never shown to user
|
| 234 |
+
'plan': 'progress', # S-GAP1
|
| 235 |
+
'llm': 'internal',
|
| 236 |
+
'smolagents': 'internal',
|
| 237 |
+
'fallback': 'progress', # S-GAP1
|
| 238 |
+
'reflective_debug': 'progress', # S-GAP1
|
| 239 |
+
'fast_path': 'internal',
|
| 240 |
+
'executor': 'internal',
|
| 241 |
+
# Progress — shown as step cards (user-visible)
|
| 242 |
+
'tool_start': 'progress',
|
| 243 |
+
'execution_validator_fix': 'progress',
|
| 244 |
+
'goal_verifier': 'progress',
|
| 245 |
+
'web_search': 'progress',
|
| 246 |
+
'get_weather': 'progress',
|
| 247 |
+
'read_page': 'progress',
|
| 248 |
+
'calculate': 'progress',
|
| 249 |
+
'generate_image': 'progress',
|
| 250 |
+
'run_python': 'progress',
|
| 251 |
+
'tool_governor_skip': 'progress',
|
| 252 |
+
# S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY →
|
| 253 |
+
# fallback rule: _act_q.startswith('tool_') era False per questi →
|
| 254 |
+
# classificati 'debug' → nascosti all'utente durante esecuzione.
|
| 255 |
+
'apply_patch': 'progress',
|
| 256 |
+
'call_api': 'progress',
|
| 257 |
+
'send_email': 'progress',
|
| 258 |
+
'create_pdf': 'progress',
|
| 259 |
+
'web_research': 'progress',
|
| 260 |
+
'write_file': 'progress',
|
| 261 |
+
'read_file': 'progress',
|
| 262 |
+
'execute_shell': 'progress',
|
| 263 |
+
'analyze_image': 'progress',
|
| 264 |
+
# Debug — shown only when devMode active
|
| 265 |
+
'direct_tools': 'debug',
|
| 266 |
+
# S-LOOP2: fase esecuzione avanzata — visibili come progress card
|
| 267 |
+
'reasoning_core': 'progress', # S-LOOP2: ReasoningCore multi-step
|
| 268 |
+
'browser_verifier': 'progress', # S-LOOP2: Browser Goal Verification live
|
| 269 |
+
}
|
| 270 |
+
# Fallback: azioni sconosciute con "tool_" prefix → progress; resto → debug
|
| 271 |
+
_vis = _STEP_VISIBILITY.get(_act_q)
|
| 272 |
+
if _vis is None:
|
| 273 |
+
_vis = 'progress' if _act_q.startswith('tool_') or _act_q.startswith('executor:') else 'debug'
|
| 274 |
+
item['visibility'] = _vis
|
| 275 |
+
|
| 276 |
+
yield f"data: {json.dumps({'type': 'step_done', 'step': item, 'taskId': task_id})}\n\n"
|
| 277 |
+
finally:
|
| 278 |
+
task.cancel()
|
| 279 |
+
# ABORT-3: cleanup registro — libera memoria e impedisce abort su task già terminati
|
| 280 |
+
_run_stream_tasks.pop(task_id, None)
|
| 281 |
+
yield "data: [DONE]\n\n"
|
| 282 |
+
|
| 283 |
+
return StreamingResponse(generate(), media_type="text/event-stream",
|
| 284 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ── Reason loop / Unified loop ─────────────────────────────────────────────────
|
| 288 |
+
|
| 289 |
+
@router.post('/api/reason/loop')
|
| 290 |
+
async def reason_loop(body: ReasonLoopIn):
|
| 291 |
+
try:
|
| 292 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 293 |
+
# S388: singleton — riusa il client già inizializzato
|
| 294 |
+
client = _get_ai_client()
|
| 295 |
+
try:
|
| 296 |
+
from agents.critic import Critic
|
| 297 |
+
from agents.response_verifier import ResponseVerifier
|
| 298 |
+
_critic = Critic(llm_client=client)
|
| 299 |
+
_verifier = ResponseVerifier()
|
| 300 |
+
except Exception as _cv_err:
|
| 301 |
+
_logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
|
| 302 |
+
_critic = None
|
| 303 |
+
_verifier = None
|
| 304 |
+
loop = UnifiedAgentLoop(
|
| 305 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 306 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 307 |
+
)
|
| 308 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 309 |
+
# N-2-FIX: accumula step intermedi tramite on_step — inclusi nel response JSON per debug frontend
|
| 310 |
+
_steps_log: list[dict] = []
|
| 311 |
+
async def _on_step(step_data: dict) -> None:
|
| 312 |
+
_steps_log.append({
|
| 313 |
+
'action': step_data.get('action', ''),
|
| 314 |
+
'output': str(step_data.get('output', ''))[:400], # S577: 200→400
|
| 315 |
+
})
|
| 316 |
+
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 "")
|
| 317 |
+
if isinstance(result, dict):
|
| 318 |
+
output_text = result.get('output', '') or result.get('answer', '') or ''
|
| 319 |
+
engine_used = result.get('engine', 'ambiguity-gate' if result.get('answer') else 'unknown')
|
| 320 |
+
errors_list = result.get('errors', [])
|
| 321 |
+
else:
|
| 322 |
+
output_text = str(result)
|
| 323 |
+
engine_used = 'unknown'
|
| 324 |
+
errors_list = []
|
| 325 |
+
return {
|
| 326 |
+
'ok': bool(output_text and output_text.strip()),
|
| 327 |
+
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
| 328 |
+
'output': output_text, # alias compat frontend
|
| 329 |
+
'result': output_text,
|
| 330 |
+
'source': 'backend_loop',
|
| 331 |
+
'engine': engine_used,
|
| 332 |
+
'errors': errors_list,
|
| 333 |
+
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
| 334 |
+
}
|
| 335 |
+
except Exception as e:
|
| 336 |
+
_logger.error("[reason/loop] Error: %s", e)
|
| 337 |
+
return {
|
| 338 |
+
'ok': False,
|
| 339 |
+
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 340 |
+
'source': 'fallback',
|
| 341 |
+
'steps': [],
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@router.post('/api/unified/loop')
|
| 346 |
+
async def unified_loop(body: ReasonLoopIn):
|
| 347 |
+
"""Alias di /api/reason/loop — compatibilità con tutte le versioni frontend."""
|
| 348 |
+
return await reason_loop(body)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ── Agent kernel ───────────────────────────────────────────────────────────────
|
| 352 |
+
|
| 353 |
+
@router.get('/api/agent-kernel/status')
|
| 354 |
+
async def agent_kernel_status():
|
| 355 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 356 |
+
return {
|
| 357 |
+
'dispatch_available': bool(gh_token),
|
| 358 |
+
'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
|
| 359 |
+
'mobile_url': 'https://github.com/Baida98/AI/actions',
|
| 360 |
+
'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN', 'NVIDIA_API_KEY'],
|
| 361 |
+
'usage': 'Vai su GitHub Actions → Agent Kernel — no PC → Run workflow → inserisci il goal',
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
# S442-FIX3: modello Pydantic per agent_kernel_dispatch.
|
| 366 |
+
# Prima: body: dict grezzo → mode non validato, goal controllato solo dopo estrazione.
|
| 367 |
+
# Ora: validazione in ingresso → 422 chiaro invece di 500 a runtime.
|
| 368 |
+
class AgentKernelDispatchIn(BaseModel):
|
| 369 |
+
goal: str
|
| 370 |
+
mode: Literal["plan", "execute", "analyze"] = "plan"
|
| 371 |
+
|
| 372 |
+
@field_validator('goal', mode='before')
|
| 373 |
+
@classmethod
|
| 374 |
+
def validate_goal(cls, v: object) -> str:
|
| 375 |
+
if not isinstance(v, str) or not str(v).strip():
|
| 376 |
+
raise ValueError('goal must be a non-empty string')
|
| 377 |
+
return str(v).strip()
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@router.post('/api/agent-kernel/dispatch')
|
| 381 |
+
async def agent_kernel_dispatch(body: AgentKernelDispatchIn):
|
| 382 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 383 |
+
if not gh_token:
|
| 384 |
+
raise HTTPException(503, detail={
|
| 385 |
+
'error': 'no_github_token',
|
| 386 |
+
'message': 'GITHUB_TOKEN non configurato nel backend.',
|
| 387 |
+
})
|
| 388 |
+
goal = body.goal
|
| 389 |
+
mode = body.mode
|
| 390 |
+
import httpx as _httpx
|
| 391 |
+
try:
|
| 392 |
+
async with _httpx.AsyncClient(timeout=15) as _hc:
|
| 393 |
+
_resp = await _hc.post(
|
| 394 |
+
'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
|
| 395 |
+
json={'ref': 'main', 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'}},
|
| 396 |
+
headers={
|
| 397 |
+
'Authorization': f'Bearer {gh_token}',
|
| 398 |
+
'Accept': 'application/vnd.github+json',
|
| 399 |
+
'X-GitHub-Api-Version': '2022-11-28',
|
| 400 |
+
},
|
| 401 |
+
)
|
| 402 |
+
if _resp.status_code >= 400:
|
| 403 |
+
raise HTTPException(_resp.status_code, detail=_resp.text[:500])
|
| 404 |
+
return {'ok': True, 'status': _resp.status_code, 'goal': goal, 'mode': mode}
|
| 405 |
+
except _httpx.HTTPError as e:
|
| 406 |
+
raise HTTPException(502, detail=str(e)[:500])
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
# ── Agent tasks (FASE 2.1 + S359 persistence) ──────────────────────────────────
|
| 410 |
+
|
| 411 |
+
|
api/agent_memory.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase.
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
import time, asyncio
|
| 7 |
-
from typing import Any
|
| 8 |
from fastapi import APIRouter, Depends
|
| 9 |
from .auth_guard import require_role, AuthRole
|
| 10 |
from pydantic import BaseModel
|
| 11 |
-
from .state import _sb, _mem_fallback
|
| 12 |
-
import logging
|
| 13 |
|
|
|
|
| 14 |
_logger = logging.getLogger("api.agent_memory")
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
class MemoryEntry(BaseModel):
|
| 20 |
key: str
|
|
@@ -23,14 +28,15 @@ class MemoryEntry(BaseModel):
|
|
| 23 |
createdAt: int = 0
|
| 24 |
updatedAt: int = 0
|
| 25 |
|
| 26 |
-
def _mask_value(key: str, value: Any) -> Any:
|
| 27 |
-
"""Maschera il valore se la chiave è presente nel set SENSITIVE."""
|
| 28 |
-
if key in SENSITIVE and value:
|
| 29 |
-
return "[REDACTED]"
|
| 30 |
-
return value
|
| 31 |
|
| 32 |
async def _reconcile_fallback() -> int:
|
| 33 |
-
"""GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
if not _sb or not _mem_fallback:
|
| 35 |
return 0
|
| 36 |
synced = 0
|
|
@@ -46,60 +52,40 @@ async def _reconcile_fallback() -> int:
|
|
| 46 |
synced += 1
|
| 47 |
except Exception as _e:
|
| 48 |
_logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
|
| 49 |
-
break
|
| 50 |
if synced:
|
| 51 |
_logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
|
| 52 |
return synced
|
| 53 |
|
|
|
|
| 54 |
@router.get('/api/memory/agent')
|
| 55 |
async def list_agent_memory():
|
| 56 |
-
"""Lista le voci di memoria, mascherando i segreti."""
|
| 57 |
if _sb:
|
| 58 |
try:
|
| 59 |
-
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).
|
| 60 |
entries = [
|
| 61 |
-
{
|
| 62 |
-
|
| 63 |
-
'value': _mask_value(r['key'], r['value']),
|
| 64 |
-
'category': r.get('category', 'general'),
|
| 65 |
-
'createdAt': r.get('created_at', 0),
|
| 66 |
-
'updatedAt': r.get('updated_at', 0)
|
| 67 |
-
}
|
| 68 |
for r in (data.data or [])
|
| 69 |
]
|
| 70 |
return {'entries': entries}
|
| 71 |
except Exception as e:
|
| 72 |
_logger.warning('[memory] Supabase list error: %s', e)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
{
|
| 76 |
-
'key': v['key'],
|
| 77 |
-
'value': _mask_value(v['key'], v['value']),
|
| 78 |
-
'category': v.get('category', 'general'),
|
| 79 |
-
'createdAt': v.get('createdAt', 0),
|
| 80 |
-
'updatedAt': v.get('updatedAt', 0)
|
| 81 |
-
}
|
| 82 |
-
for v in _mem_fallback.values()
|
| 83 |
-
]
|
| 84 |
-
return {'entries': entries}
|
| 85 |
|
| 86 |
@router.get('/api/memory/agent/{key}')
|
| 87 |
async def get_agent_memory(key: str):
|
| 88 |
-
"""Recupera una singola voce di memoria, mascherando se sensibile."""
|
| 89 |
-
val = None
|
| 90 |
if _sb:
|
| 91 |
try:
|
| 92 |
data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
|
| 93 |
if data.data:
|
| 94 |
-
|
| 95 |
except Exception as e:
|
| 96 |
_logger.warning('[memory] Supabase get error: %s', e)
|
| 97 |
-
|
| 98 |
-
if
|
| 99 |
-
|
| 100 |
-
val = entry['value'] if entry else None
|
| 101 |
-
|
| 102 |
-
return {'value': _mask_value(key, val)}
|
| 103 |
|
| 104 |
@router.post('/api/memory/agent')
|
| 105 |
async def set_agent_memory(entry: MemoryEntry):
|
|
@@ -108,25 +94,31 @@ async def set_agent_memory(entry: MemoryEntry):
|
|
| 108 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 109 |
'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
|
| 110 |
}
|
|
|
|
| 111 |
_mem_fallback[entry.key] = record
|
|
|
|
| 112 |
if _sb:
|
| 113 |
try:
|
| 114 |
_sb.table('agent_memory').upsert({
|
| 115 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 116 |
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 117 |
}, on_conflict='key').execute()
|
|
|
|
|
|
|
| 118 |
if len(_mem_fallback) > 1:
|
| 119 |
asyncio.create_task(_reconcile_fallback())
|
| 120 |
except Exception as _e:
|
| 121 |
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
|
|
|
| 122 |
return {'ok': True, 'key': entry.key}
|
| 123 |
|
|
|
|
| 124 |
@router.delete('/api/memory/agent/{key}')
|
| 125 |
async def delete_agent_memory(key: str):
|
| 126 |
if _sb:
|
| 127 |
try:
|
| 128 |
_sb.table('agent_memory').delete().eq('key', key).execute()
|
| 129 |
except Exception as _exc:
|
| 130 |
-
_logger.debug("[agent_memory] silenced %s", type(_exc).__name__)
|
| 131 |
_mem_fallback.pop(key, None)
|
| 132 |
return {'deleted': key}
|
|
|
|
| 1 |
+
"""backend/api/agent_memory.py — Agent memory CRUD (S354).
|
| 2 |
+
|
| 3 |
GAP-MEM-FIX: aggiunta riconciliazione _mem_fallback → Supabase.
|
| 4 |
+
Problema confermato: quando Supabase è temporaneamente offline, le voci
|
| 5 |
+
finiscono solo in _mem_fallback (dict in-process). Al restart del backend
|
| 6 |
+
(HF Space free-tier riavvia spesso) il fallback viene perso completamente.
|
| 7 |
+
Fix: dopo ogni write Supabase riuscita, schedula un tentativo di sync del
|
| 8 |
+
fallback — se ci sono voci orfane le pubblica su Supabase e le rimuove dal
|
| 9 |
+
fallback locale. Nessun job periodico (troppo pesante su free-tier) — lazy
|
| 10 |
+
reconciliation al primo write riuscito dopo un periodo di downtime Supabase.
|
| 11 |
"""
|
| 12 |
import time, asyncio
|
|
|
|
| 13 |
from fastapi import APIRouter, Depends
|
| 14 |
from .auth_guard import require_role, AuthRole
|
| 15 |
from pydantic import BaseModel
|
| 16 |
+
from .state import _sb, _mem_fallback
|
|
|
|
| 17 |
|
| 18 |
+
import logging
|
| 19 |
_logger = logging.getLogger("api.agent_memory")
|
| 20 |
|
| 21 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 22 |
+
|
| 23 |
|
| 24 |
class MemoryEntry(BaseModel):
|
| 25 |
key: str
|
|
|
|
| 28 |
createdAt: int = 0
|
| 29 |
updatedAt: int = 0
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
async def _reconcile_fallback() -> int:
|
| 33 |
+
"""GAP-MEM-FIX: sincronizza voci _mem_fallback → Supabase.
|
| 34 |
+
|
| 35 |
+
Chiama dopo ogni write Supabase riuscita: se ci sono voci scritte
|
| 36 |
+
solo in fallback (es. dopo un periodo di downtime Supabase), le pubblica.
|
| 37 |
+
Ritorna il numero di voci sincronizzate.
|
| 38 |
+
Non solleva mai eccezioni — fire-and-forget.
|
| 39 |
+
"""
|
| 40 |
if not _sb or not _mem_fallback:
|
| 41 |
return 0
|
| 42 |
synced = 0
|
|
|
|
| 52 |
synced += 1
|
| 53 |
except Exception as _e:
|
| 54 |
_logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
|
| 55 |
+
break # Supabase non disponibile — interrompi, riprova al prossimo write
|
| 56 |
if synced:
|
| 57 |
_logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
|
| 58 |
return synced
|
| 59 |
|
| 60 |
+
|
| 61 |
@router.get('/api/memory/agent')
|
| 62 |
async def list_agent_memory():
|
|
|
|
| 63 |
if _sb:
|
| 64 |
try:
|
| 65 |
+
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute()
|
| 66 |
entries = [
|
| 67 |
+
{'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
|
| 68 |
+
'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
for r in (data.data or [])
|
| 70 |
]
|
| 71 |
return {'entries': entries}
|
| 72 |
except Exception as e:
|
| 73 |
_logger.warning('[memory] Supabase list error: %s', e)
|
| 74 |
+
return {'entries': list(_mem_fallback.values())}
|
| 75 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
@router.get('/api/memory/agent/{key}')
|
| 78 |
async def get_agent_memory(key: str):
|
|
|
|
|
|
|
| 79 |
if _sb:
|
| 80 |
try:
|
| 81 |
data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
|
| 82 |
if data.data:
|
| 83 |
+
return {'value': data.data[0]['value']}
|
| 84 |
except Exception as e:
|
| 85 |
_logger.warning('[memory] Supabase get error: %s', e)
|
| 86 |
+
entry = _mem_fallback.get(key)
|
| 87 |
+
return {'value': entry['value'] if entry else None}
|
| 88 |
+
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
@router.post('/api/memory/agent')
|
| 91 |
async def set_agent_memory(entry: MemoryEntry):
|
|
|
|
| 94 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 95 |
'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
|
| 96 |
}
|
| 97 |
+
# Sempre scrivi in fallback prima (garanzia immediata)
|
| 98 |
_mem_fallback[entry.key] = record
|
| 99 |
+
|
| 100 |
if _sb:
|
| 101 |
try:
|
| 102 |
_sb.table('agent_memory').upsert({
|
| 103 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 104 |
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 105 |
}, on_conflict='key').execute()
|
| 106 |
+
# GAP-MEM-FIX: Supabase disponibile → schedula riconciliazione fallback orfano
|
| 107 |
+
# (voci scritte solo in fallback durante downtime precedente)
|
| 108 |
if len(_mem_fallback) > 1:
|
| 109 |
asyncio.create_task(_reconcile_fallback())
|
| 110 |
except Exception as _e:
|
| 111 |
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
| 112 |
+
|
| 113 |
return {'ok': True, 'key': entry.key}
|
| 114 |
|
| 115 |
+
|
| 116 |
@router.delete('/api/memory/agent/{key}')
|
| 117 |
async def delete_agent_memory(key: str):
|
| 118 |
if _sb:
|
| 119 |
try:
|
| 120 |
_sb.table('agent_memory').delete().eq('key', key).execute()
|
| 121 |
except Exception as _exc:
|
| 122 |
+
_logger.debug("[agent_memory] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 123 |
_mem_fallback.pop(key, None)
|
| 124 |
return {'deleted': key}
|
api/agent_task_routes.py
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""agent_task_routes.py — Task CRUD: crea, lista, cancella, stato, stream SSE.
|
| 2 |
+
|
| 3 |
+
Estratto da agent.py (split 2026-06-30).
|
| 4 |
+
Route coperte:
|
| 5 |
+
POST /api/agent/tasks
|
| 6 |
+
GET /api/agent/tasks
|
| 7 |
+
DELETE /api/agent/tasks/{task_id}
|
| 8 |
+
GET /api/agent/tasks/{task_id}/status
|
| 9 |
+
GET /api/agent/tasks/{task_id}/stream (SSE principale, S359 persist)
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import os, asyncio, json, uuid, time, re
|
| 13 |
+
import re as _re_persona
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Request, Body
|
| 15 |
+
from fastapi.responses import StreamingResponse
|
| 16 |
+
from pydantic import BaseModel, field_validator
|
| 17 |
+
from typing import Literal
|
| 18 |
+
from .state import (
|
| 19 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 20 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 21 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 22 |
+
ReasonLoopIn, AgentTaskIn,
|
| 23 |
+
write_ahead_task_created,
|
| 24 |
+
)
|
| 25 |
+
from .speculative import fire_speculative_tools
|
| 26 |
+
from .prewarm import fire_predictive_prewarm # S950
|
| 27 |
+
try:
|
| 28 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 29 |
+
except Exception:
|
| 30 |
+
_run_quality_check = None
|
| 31 |
+
import logging
|
| 32 |
+
_logger = logging.getLogger("api.agent")
|
| 33 |
+
from .persistence import (
|
| 34 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 35 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 36 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 37 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
|
| 38 |
+
)
|
| 39 |
+
from ._agent_helpers import (
|
| 40 |
+
_RE_SURROGATES, _ss, _log_task_exc,
|
| 41 |
+
_PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
|
| 42 |
+
_build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
|
| 43 |
+
)
|
| 44 |
+
router = APIRouter()
|
| 45 |
+
|
| 46 |
+
@router.post('/api/agent/tasks')
|
| 47 |
+
async def create_agent_task(body: AgentTaskIn):
|
| 48 |
+
"""
|
| 49 |
+
Crea o recupera un task agent.
|
| 50 |
+
|
| 51 |
+
S359: se task_id non è in memoria ma esiste su Supabase (backend ha riavviato),
|
| 52 |
+
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 53 |
+
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 54 |
+
"""
|
| 55 |
+
_prune_agent_tasks()
|
| 56 |
+
task_id = body.taskId or str(uuid.uuid4())
|
| 57 |
+
|
| 58 |
+
# Already in memory → return immediately (normal path, includes S358 reconnect)
|
| 59 |
+
if task_id in _agent_tasks:
|
| 60 |
+
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 61 |
+
|
| 62 |
+
# S359: try Supabase lazy restore (only hit network after backend restart)
|
| 63 |
+
restored = await sb_restore_task(task_id)
|
| 64 |
+
if restored:
|
| 65 |
+
# Put restored metadata back into memory so stream_agent_task can use it.
|
| 66 |
+
# Use context from the incoming request (not persisted to save space).
|
| 67 |
+
restored['context'] = body.context
|
| 68 |
+
_agent_tasks[task_id] = restored
|
| 69 |
+
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 70 |
+
|
| 71 |
+
# Brand new task
|
| 72 |
+
created_at = int(time.time() * 1000)
|
| 73 |
+
_agent_tasks[task_id] = {
|
| 74 |
+
'id': task_id,
|
| 75 |
+
'status': 'QUEUED',
|
| 76 |
+
'goal': body.goal,
|
| 77 |
+
'context': body.context,
|
| 78 |
+
'max_steps': body.max_steps,
|
| 79 |
+
'created_at': created_at,
|
| 80 |
+
'project_context': body.project_context, # S456-X5
|
| 81 |
+
'learning_hints': body.learning_hints, # S456-X4
|
| 82 |
+
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 83 |
+
'persona': body.persona, # P17-F5: expertise persona hint
|
| 84 |
+
'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
|
| 85 |
+
}
|
| 86 |
+
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 87 |
+
# periodico (15-60s). Finestra di perdita per la fase di creazione → zero.
|
| 88 |
+
asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 89 |
+
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 90 |
+
if body.session_id:
|
| 91 |
+
_hctx = await sb_restore_handoff_context(body.session_id)
|
| 92 |
+
if _hctx:
|
| 93 |
+
_agent_tasks[task_id]['_handoff_context'] = _hctx
|
| 94 |
+
asyncio.create_task(sb_delete_handoff(body.session_id)).add_done_callback(_log_task_exc)
|
| 95 |
+
# Persist asynchronously — never block the response
|
| 96 |
+
asyncio.create_task(
|
| 97 |
+
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 98 |
+
).add_done_callback(_log_task_exc)
|
| 99 |
+
# S361: Speculative Tool Firing — pre-fires read-only tools in parallel
|
| 100 |
+
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 101 |
+
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 102 |
+
# S950: Predictive Pre-warming cluster 4x4
|
| 103 |
+
fire_predictive_prewarm(body.goal)
|
| 104 |
+
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── S369: List agent tasks (in-memory + Supabase merge) ─────────────────────
|
| 108 |
+
|
| 109 |
+
@router.get('/api/agent/tasks')
|
| 110 |
+
async def list_agent_tasks(limit: int = 50, status: str = ''):
|
| 111 |
+
"""
|
| 112 |
+
S369 — Lista tutti i task agent: unione di in-memory (_agent_tasks) e
|
| 113 |
+
Supabase (ultimi N task persistiti). In-memory ha sempre precedenza.
|
| 114 |
+
|
| 115 |
+
Query params:
|
| 116 |
+
limit — max task da Supabase (default 50, max 200)
|
| 117 |
+
status — filtra per status (es. RUNNING, SUCCESS, ERROR); vuoto = tutti
|
| 118 |
+
"""
|
| 119 |
+
_prune_agent_tasks()
|
| 120 |
+
now_ms = int(time.time() * 1000)
|
| 121 |
+
limit = min(max(limit, 1), 200)
|
| 122 |
+
|
| 123 |
+
# 1. Task in-memory (live)
|
| 124 |
+
mem_tasks = []
|
| 125 |
+
for tid, t in _agent_tasks.items():
|
| 126 |
+
reg = _loop_registry.get(tid)
|
| 127 |
+
is_live = reg is not None and not reg.get('done', True)
|
| 128 |
+
mem_tasks.append({
|
| 129 |
+
'taskId': tid,
|
| 130 |
+
'goal': (t.get('goal') or '')[:300], # S606: 200→300
|
| 131 |
+
'status': t.get('status', 'UNKNOWN'),
|
| 132 |
+
'maxSteps': t.get('max_steps', 8),
|
| 133 |
+
'createdAt': t.get('created_at', 0),
|
| 134 |
+
'ageMs': now_ms - t.get('created_at', now_ms),
|
| 135 |
+
'source': 'memory',
|
| 136 |
+
'isLive': is_live,
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
mem_ids = {t['taskId'] for t in mem_tasks}
|
| 140 |
+
|
| 141 |
+
# 2. Supabase recent tasks (only if Supabase available)
|
| 142 |
+
sb_tasks = []
|
| 143 |
+
try:
|
| 144 |
+
sb_rows = await sb_list_tasks(limit=limit, status_filter=status or None)
|
| 145 |
+
for r in sb_rows:
|
| 146 |
+
if r['task_id'] in mem_ids:
|
| 147 |
+
continue # already included from memory
|
| 148 |
+
sb_tasks.append({
|
| 149 |
+
'taskId': r['task_id'],
|
| 150 |
+
'goal': (r.get('goal') or '')[:300], # S606: 200→300
|
| 151 |
+
'status': r.get('status', 'UNKNOWN'),
|
| 152 |
+
'maxSteps': r.get('max_steps', 8),
|
| 153 |
+
'createdAt': r.get('created_at', 0),
|
| 154 |
+
'ageMs': now_ms - r.get('created_at', now_ms),
|
| 155 |
+
'source': 'supabase',
|
| 156 |
+
'isLive': False,
|
| 157 |
+
})
|
| 158 |
+
except Exception as _exc:
|
| 159 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 160 |
+
|
| 161 |
+
all_tasks = mem_tasks + sb_tasks
|
| 162 |
+
# Apply status filter to in-memory tasks too
|
| 163 |
+
if status:
|
| 164 |
+
all_tasks = [t for t in all_tasks if t['status'] == status.upper()]
|
| 165 |
+
|
| 166 |
+
# Sort by createdAt desc (newest first)
|
| 167 |
+
all_tasks.sort(key=lambda t: t['createdAt'], reverse=True)
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
'count': len(all_tasks),
|
| 171 |
+
'memory': len(mem_tasks),
|
| 172 |
+
'supabase': len(sb_tasks),
|
| 173 |
+
'tasks': all_tasks[:limit],
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.delete('/api/agent/tasks/{task_id}')
|
| 178 |
+
async def cancel_agent_task(task_id: str):
|
| 179 |
+
if task_id in _agent_tasks:
|
| 180 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 181 |
+
reg = _loop_registry.get(task_id)
|
| 182 |
+
if reg and not reg.get('done'):
|
| 183 |
+
at = reg.get('asyncio_task')
|
| 184 |
+
if at and not at.done():
|
| 185 |
+
at.cancel()
|
| 186 |
+
# Persist status + clean up events
|
| 187 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 188 |
+
asyncio.create_task(sb_delete_task_events(task_id)).add_done_callback(_log_task_exc)
|
| 189 |
+
# S361: clean speculative cache for cancelled task
|
| 190 |
+
try:
|
| 191 |
+
goal = _agent_tasks.get(task_id, {}).get('goal', '')
|
| 192 |
+
if goal:
|
| 193 |
+
from .speculative import purge_speculative
|
| 194 |
+
purge_speculative(goal)
|
| 195 |
+
except Exception as _exc:
|
| 196 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 197 |
+
return {'cancelled': task_id}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.get('/api/agent/tasks/{task_id}/status')
|
| 202 |
+
async def get_agent_task_status(task_id: str):
|
| 203 |
+
"""
|
| 204 |
+
Controlla lo stato di un task agent senza aprire un SSE stream.
|
| 205 |
+
Usato dal frontend per recovery al boot: verifica se un task in sospeso
|
| 206 |
+
e` ancora in esecuzione, completato, o scomparso dopo riavvio HF Space.
|
| 207 |
+
Returns: {taskId, status, goal, source: 'memory'|'supabase'|'not_found'}
|
| 208 |
+
"""
|
| 209 |
+
if task_id in _agent_tasks:
|
| 210 |
+
t = _agent_tasks[task_id]
|
| 211 |
+
return {'taskId': task_id, 'status': t.get('status', 'UNKNOWN'),
|
| 212 |
+
'goal': (t.get('goal') or '')[:300], 'source': 'memory'}
|
| 213 |
+
restored = await sb_restore_task(task_id)
|
| 214 |
+
if restored:
|
| 215 |
+
return {'taskId': task_id, 'status': restored.get('status', 'UNKNOWN'),
|
| 216 |
+
'goal': (restored.get('goal') or '')[:300], 'source': 'supabase'}
|
| 217 |
+
return {'taskId': task_id, 'status': 'NOT_FOUND', 'source': None}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@router.get('/api/agent/tasks/{task_id}/stream')
|
| 221 |
+
async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
| 222 |
+
"""
|
| 223 |
+
SSE stream per un task agent.
|
| 224 |
+
|
| 225 |
+
S358: reconnect-safe via _loop_registry fanout (no re-run mentre il backend gira).
|
| 226 |
+
S359: lazy restore da Supabase dopo restart HF Space:
|
| 227 |
+
- Task SUCCESS/ERROR → replay event buffer da Supabase → chiusura immediata.
|
| 228 |
+
- Task era RUNNING → replay buffer parziale + evento task_interrupted.
|
| 229 |
+
- Task non trovato → prova sb_restore_task prima di 404.
|
| 230 |
+
"""
|
| 231 |
+
# S359: se task_id non è in memoria, prova il restore da Supabase
|
| 232 |
+
if task_id not in _agent_tasks:
|
| 233 |
+
restored = await sb_restore_task(task_id)
|
| 234 |
+
if restored:
|
| 235 |
+
restored['context'] = []
|
| 236 |
+
_agent_tasks[task_id] = restored
|
| 237 |
+
else:
|
| 238 |
+
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 239 |
+
|
| 240 |
+
task = _agent_tasks[task_id]
|
| 241 |
+
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 242 |
+
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 243 |
+
|
| 244 |
+
sub_q: asyncio.Queue[str | None] = asyncio.Queue()
|
| 245 |
+
|
| 246 |
+
async def generate():
|
| 247 |
+
yield "retry: 3000\n\n"
|
| 248 |
+
|
| 249 |
+
reg = _loop_registry.get(task_id)
|
| 250 |
+
|
| 251 |
+
is_done_reconnect = reg is not None and reg.get('done', False)
|
| 252 |
+
is_reconnect = reg is not None and not reg.get('done', False)
|
| 253 |
+
|
| 254 |
+
# ── Case 1: loop già finito in questa sessione → replay buffer in-memory ──
|
| 255 |
+
if is_done_reconnect:
|
| 256 |
+
for evt_str in reg['event_buffer'][_resume_from:]:
|
| 257 |
+
yield evt_str
|
| 258 |
+
yield "data: [DONE]\n\n"
|
| 259 |
+
return
|
| 260 |
+
|
| 261 |
+
# ── Case 2: loop attivo in questa sessione → reconnect SSE (S358) ─────────
|
| 262 |
+
if is_reconnect:
|
| 263 |
+
join_idx = len(reg['event_buffer'])
|
| 264 |
+
reg['subscriber_queues'].append(sub_q)
|
| 265 |
+
try:
|
| 266 |
+
for evt_str in reg['event_buffer'][_resume_from:join_idx]:
|
| 267 |
+
yield evt_str
|
| 268 |
+
while True:
|
| 269 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 270 |
+
break
|
| 271 |
+
try:
|
| 272 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 273 |
+
if item is None:
|
| 274 |
+
break
|
| 275 |
+
yield item
|
| 276 |
+
except asyncio.TimeoutError:
|
| 277 |
+
yield ': heartbeat\n\n'
|
| 278 |
+
finally:
|
| 279 |
+
try:
|
| 280 |
+
reg['subscriber_queues'].remove(sub_q)
|
| 281 |
+
except ValueError as _exc:
|
| 282 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 283 |
+
yield "data: [DONE]\n\n"
|
| 284 |
+
return
|
| 285 |
+
|
| 286 |
+
# ── Case 2.5 (S359): backend riavviato → prova Supabase event buffer ──────
|
| 287 |
+
sb_events = await sb_get_events(task_id)
|
| 288 |
+
if sb_events:
|
| 289 |
+
task_status = task.get('status', 'UNKNOWN')
|
| 290 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 291 |
+
# Replay buffer from resume point
|
| 292 |
+
for evt_str in sb_events[_resume_from:]:
|
| 293 |
+
yield evt_str
|
| 294 |
+
if terminal:
|
| 295 |
+
# Task già completato → niente da fare, client ha tutto
|
| 296 |
+
yield "data: [DONE]\n\n"
|
| 297 |
+
return
|
| 298 |
+
else:
|
| 299 |
+
# Task era in esecuzione quando il backend è crashato — prova resume automatico
|
| 300 |
+
_cp_sb = _task_checkpoints.get(task_id) or await sb_get_checkpoint(task_id)
|
| 301 |
+
_can_resume = (
|
| 302 |
+
_cp_sb is not None and
|
| 303 |
+
len(_cp_sb.get('plan', [])) >= 1 and
|
| 304 |
+
len(_cp_sb.get('logs', [])) >= 2
|
| 305 |
+
)
|
| 306 |
+
if _can_resume:
|
| 307 |
+
# GAP-SYNC-FIX: usa _backend_steps se disponibili (context preciso per resume)
|
| 308 |
+
_bsteps = _cp_sb.get('_backend_steps', [])
|
| 309 |
+
if _bsteps:
|
| 310 |
+
_steps_text = '\n'.join(
|
| 311 |
+
f" Passo {s['step']}: {s['action']} → {s['result'][:80]}"
|
| 312 |
+
for s in _bsteps[-8:]
|
| 313 |
+
)
|
| 314 |
+
_rctx = (
|
| 315 |
+
f"[RESUME AUTOMATICO] Step già completati dal backend:\n{_steps_text}\n"
|
| 316 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)+1} senza ripetere quelli già eseguiti."
|
| 317 |
+
)
|
| 318 |
+
else:
|
| 319 |
+
# Fallback: context semantico (piano + log riassuntivi)
|
| 320 |
+
_rctx = (
|
| 321 |
+
f"Piano già definito: {' | '.join((_cp_sb.get('plan') or [])[:5])}\n"
|
| 322 |
+
f"Log fin qui: {' | '.join((_cp_sb.get('logs') or [])[-5:])}\n"
|
| 323 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)} senza ripetere gli step già fatti."
|
| 324 |
+
)
|
| 325 |
+
task['_resume_context'] = _rctx
|
| 326 |
+
task['_resume_max_steps'] = max(1, task.get('max_steps', 8) - _cp_sb.get('step', 0))
|
| 327 |
+
# Fall through a Case 3 — NON fare return
|
| 328 |
+
else:
|
| 329 |
+
# Nessun checkpoint utile → fallback onesto (comportamento precedente)
|
| 330 |
+
interrupted_evt = json.dumps({
|
| 331 |
+
'event': 'task_interrupted',
|
| 332 |
+
'taskId': task_id,
|
| 333 |
+
'reason': 'backend_restarted',
|
| 334 |
+
'message': 'Il backend si è riavviato durante l\'esecuzione. '
|
| 335 |
+
'Premi "Riprova" per rieseguire il task.',
|
| 336 |
+
})
|
| 337 |
+
yield f"data: {interrupted_evt}\n\n"
|
| 338 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 339 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 340 |
+
yield "data: [DONE]\n\n"
|
| 341 |
+
return
|
| 342 |
+
# ── Case 3: nuova esecuzione ──────────────────────────────────────────────
|
| 343 |
+
_prune_loop_registry()
|
| 344 |
+
reg_entry: dict = {
|
| 345 |
+
'asyncio_task': None,
|
| 346 |
+
'event_buffer': [],
|
| 347 |
+
'subscriber_queues': [sub_q],
|
| 348 |
+
'done': False,
|
| 349 |
+
'finished_at': 0.0,
|
| 350 |
+
}
|
| 351 |
+
_loop_registry[task_id] = reg_entry
|
| 352 |
+
_ctr = [0]
|
| 353 |
+
|
| 354 |
+
def _sse(event: str, data: dict) -> None:
|
| 355 |
+
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 356 |
+
_ctr[0] += 1
|
| 357 |
+
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 358 |
+
# GAP-3-FIX: text_chunk bypass buffer — fanout diretto, no persist.
|
| 359 |
+
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 360 |
+
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
| 361 |
+
if event == 'text_chunk':
|
| 362 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 363 |
+
try:
|
| 364 |
+
q.put_nowait(s)
|
| 365 |
+
except Exception as _exc:
|
| 366 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 367 |
+
return
|
| 368 |
+
reg_entry['event_buffer'].append(s)
|
| 369 |
+
# N-5-FIX: cap buffer a 500 eventi — evita crescita illimitata su task lunghi
|
| 370 |
+
if len(reg_entry['event_buffer']) > 500:
|
| 371 |
+
reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
|
| 372 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 373 |
+
try:
|
| 374 |
+
q.put_nowait(s)
|
| 375 |
+
except Exception as _exc:
|
| 376 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 377 |
+
# S359: persist event asynchronously (fire-and-forget)
|
| 378 |
+
asyncio.create_task(sb_append_event(task_id, _ctr[0], s)).add_done_callback(_log_task_exc)
|
| 379 |
+
|
| 380 |
+
_agent_tasks[task_id]['status'] = 'RUNNING'
|
| 381 |
+
asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
|
| 382 |
+
_prune_agent_tasks()
|
| 383 |
+
|
| 384 |
+
async def run_loop() -> None:
|
| 385 |
+
try:
|
| 386 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 387 |
+
# S388: singleton — evita OpenAI() per ogni task
|
| 388 |
+
client = _get_ai_client()
|
| 389 |
+
try:
|
| 390 |
+
from agents.critic import Critic
|
| 391 |
+
from agents.response_verifier import ResponseVerifier
|
| 392 |
+
_critic = Critic(llm_client=client)
|
| 393 |
+
_verifier = ResponseVerifier()
|
| 394 |
+
except Exception:
|
| 395 |
+
_critic = None
|
| 396 |
+
_verifier = None
|
| 397 |
+
|
| 398 |
+
context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
|
| 399 |
+
# S456-X5/X4: inject project context + learning hints stored at task creation
|
| 400 |
+
_proj_ctx = task.get('project_context', '')
|
| 401 |
+
if _proj_ctx:
|
| 402 |
+
context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
|
| 403 |
+
_hints = task.get('learning_hints', [])
|
| 404 |
+
if _hints:
|
| 405 |
+
# S591: _hints[:3]→[:5] — più pattern appresi nel context (task replay)
|
| 406 |
+
hints_str = "\n".join(f"- {h}" for h in _hints[:5])
|
| 407 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 408 |
+
# P16-F3: inject resume hint if task was promoted from queue at a specific step
|
| 409 |
+
_resume_step = task.get('resume_from_step')
|
| 410 |
+
if _resume_step:
|
| 411 |
+
context_str = f"[RIPRESA DA PASSO {_resume_step}] Riprendi dall'iterazione {_resume_step} del task.\n\n{context_str}".strip()
|
| 412 |
+
# P39-UX: Tocco Finale Manus — spiega all'agente come segnalare OAuth mancante
|
| 413 |
+
_connector_hint = (
|
| 414 |
+
"[CONNETTORI OAUTH]\n"
|
| 415 |
+
"Se durante il task hai bisogno di un accesso OAuth (GitHub, Google Calendar, Instagram)\n"
|
| 416 |
+
"ma non hai il token disponibile, includi nella tua risposta finale o parziale:\n"
|
| 417 |
+
" [CONNECTOR_NEEDED:github] oppure [CONNECTOR_NEEDED:google] oppure [CONNECTOR_NEEDED:instagram]\n"
|
| 418 |
+
"Il frontend mostrerà automaticamente un pulsante 'Connetti' all'utente."
|
| 419 |
+
)
|
| 420 |
+
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
| 421 |
+
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 422 |
+
# Bug: _resume_context era settato su task{} ma mai letto qui → context perduto su resume.
|
| 423 |
+
_resume_ctx = task.get('_resume_context', '')
|
| 424 |
+
if _resume_ctx:
|
| 425 |
+
context_str = f"{_resume_ctx}\n\n{context_str}".strip()
|
| 426 |
+
# P17-F5: inject Expertise Persona hint se specificato
|
| 427 |
+
_PERSONA_HINTS = {
|
| 428 |
+
"researcher": (
|
| 429 |
+
"[PERSONA: RICERCATORE ESPERTO]\n"
|
| 430 |
+
"- Priorizza sempre la ricerca web aggiornata prima di rispondere\n"
|
| 431 |
+
"- Cita fonti specifiche (URL, titolo, data) per ogni claim importante\n"
|
| 432 |
+
"- Struttura le risposte: Sommario → Dettaglio → Fonti\n"
|
| 433 |
+
"- Verifica incrociando più fonti prima di concludere\n"
|
| 434 |
+
"- Strumenti preferiti: web_search, read_page, fetch_url, research"
|
| 435 |
+
),
|
| 436 |
+
"coder": (
|
| 437 |
+
"[PERSONA: SENIOR ENGINEER]\n"
|
| 438 |
+
"- Scrivi codice production-ready: tipizzato, documentato, con error handling\n"
|
| 439 |
+
"- Esegui il codice per verificare il funzionamento prima di rispondere\n"
|
| 440 |
+
"- Preferisci soluzioni robuste e testate su approcci creativi ma fragili\n"
|
| 441 |
+
"- Documenta funzioni e classi con docstring/JSDoc\n"
|
| 442 |
+
"- Strumenti preferiti: run_python, write_file, read_file, pip_install"
|
| 443 |
+
),
|
| 444 |
+
"architect": (
|
| 445 |
+
"[PERSONA: ARCHITECT]\n"
|
| 446 |
+
"- Priorizza analisi, design di sistema e decisioni strategiche\n"
|
| 447 |
+
"- Struttura l'architettura in componenti chiari e mantenibili\n"
|
| 448 |
+
"- Considera scalabilità, manutenibilità e trade-off tecnici\n"
|
| 449 |
+
"- Documenta le decisioni architetturali e il loro razionale"
|
| 450 |
+
),
|
| 451 |
+
"reasoner": (
|
| 452 |
+
"[PERSONA: RAGIONATORE STRATEGICO]\n"
|
| 453 |
+
"- Usa ragionamento step-by-step esplicito: mostra il processo di pensiero\n"
|
| 454 |
+
"- Analizza ogni prospettiva prima di concludere\n"
|
| 455 |
+
"- Struttura la risposta: Analisi → Pro/Contro → Raccomandazione\n"
|
| 456 |
+
"- Considera le implicazioni di lungo termine delle scelte"
|
| 457 |
+
),
|
| 458 |
+
"analyst": (
|
| 459 |
+
"[PERSONA: ANALISTA DATI]\n"
|
| 460 |
+
"- Usa Python per elaborare e analizzare dati quando disponibili\n"
|
| 461 |
+
"- Produci visualizzazioni chiare (grafici, tabelle) ove possibile\n"
|
| 462 |
+
"- Interpreta i risultati con rigore: distingui correlazione da causalità\n"
|
| 463 |
+
"- Struttura i report: Executive Summary → Metodologia → Risultati → Conclusioni\n"
|
| 464 |
+
"- Strumenti preferiti: run_python, web_search, vision"
|
| 465 |
+
),
|
| 466 |
+
}
|
| 467 |
+
_persona = task.get('persona') or ''
|
| 468 |
+
# P17-F5-IMPROVED: server-side classification se persona vuota/auto
|
| 469 |
+
_persona_auto = False
|
| 470 |
+
if not _persona:
|
| 471 |
+
_persona = _classify_persona_server(task.get('goal', ''))
|
| 472 |
+
if _persona:
|
| 473 |
+
_persona_auto = True
|
| 474 |
+
task['persona'] = _persona # persist per history/resume
|
| 475 |
+
_persona_hint = _PERSONA_HINTS.get(_persona.lower().strip(), '')
|
| 476 |
+
if _persona_hint:
|
| 477 |
+
context_str = f"{_persona_hint}\n\n{context_str}".strip()
|
| 478 |
+
# P17-F5: emit persona_classified SSE event — UI badge feedback
|
| 479 |
+
if _persona:
|
| 480 |
+
_persona_conf = 0.85 if not _persona_auto else 0.78
|
| 481 |
+
_sse('persona_classified', {
|
| 482 |
+
'taskId': task_id,
|
| 483 |
+
'persona': _persona,
|
| 484 |
+
'confidence': _persona_conf,
|
| 485 |
+
'auto': _persona_auto,
|
| 486 |
+
})
|
| 487 |
+
# BG-4: inject cross-session handoff context if available
|
| 488 |
+
_hctx = task.get("_handoff_context", "")
|
| 489 |
+
if _hctx:
|
| 490 |
+
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 491 |
+
# P17-F5: route primary LLM to persona-appropriate client
|
| 492 |
+
_persona_client = _get_persona_llm_client(_persona, client)
|
| 493 |
+
loop = UnifiedAgentLoop(
|
| 494 |
+
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 495 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 496 |
+
)
|
| 497 |
+
step_idx = [0]
|
| 498 |
+
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
| 499 |
+
|
| 500 |
+
async def step_cb(step_data: dict) -> None:
|
| 501 |
+
step_idx[0] += 1
|
| 502 |
+
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 503 |
+
# S420: streaming token — emetti direttamente senza passare dal buffer step
|
| 504 |
+
if _action == 'text_chunk':
|
| 505 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
| 506 |
+
return
|
| 507 |
+
|
| 508 |
+
# S363-Blueprint: Narrative Streaming — explanation lookup for ALL step_done events
|
| 509 |
+
# S376: _STEP_NARRATIONS espanso — aggiunge 12 tool mancanti
|
| 510 |
+
# Il fallback `_action.replace('_', ' ').capitalize()` è troppo generico
|
| 511 |
+
# per tool composti — narrativa esplicita migliora la UX del LiveStreamBlock
|
| 512 |
+
_STEP_NARRATIONS = {
|
| 513 |
+
'plan': 'Analisi del goal e creazione piano di azione',
|
| 514 |
+
'llm': 'Elaborazione risposta AI',
|
| 515 |
+
'fallback': 'Completamento task',
|
| 516 |
+
'smolagents': 'Esecuzione agente autonomo con strumenti',
|
| 517 |
+
'web_search': 'Cerco informazioni aggiornate sul web',
|
| 518 |
+
'read_page': 'Leggo il contenuto della pagina web',
|
| 519 |
+
'fetch_url': 'Recupero dati dall\'URL richiesto',
|
| 520 |
+
'fetch_url_content': 'Scarico il contenuto dell\'URL',
|
| 521 |
+
'run_code': 'Eseguo il codice nel sandbox',
|
| 522 |
+
'write_file': 'Scrivo il file nel progetto',
|
| 523 |
+
'read_file': 'Leggo il file dal VFS',
|
| 524 |
+
'delete_file': 'Rimuovo il file dal progetto',
|
| 525 |
+
'create_file': 'Creo il file nel progetto',
|
| 526 |
+
'list_files': 'Elenco i file del progetto',
|
| 527 |
+
'search_github': 'Cerco codice e repository su GitHub',
|
| 528 |
+
'search_github_code': 'Cerco snippet di codice su GitHub',
|
| 529 |
+
'search_wikipedia': 'Consulto Wikipedia per informazioni',
|
| 530 |
+
'get_weather': 'Recupero le previsioni meteo',
|
| 531 |
+
'get_news': 'Carico le ultime notizie',
|
| 532 |
+
'get_currency': 'Consulto il tasso di cambio',
|
| 533 |
+
'get_location': 'Rilevo la posizione geografica',
|
| 534 |
+
'calculate': 'Calcolo l\'espressione matematica',
|
| 535 |
+
'math_eval': 'Valuto l\'espressione matematica',
|
| 536 |
+
'generate_image': 'Genero l\'immagine con AI (Pollinations)',
|
| 537 |
+
'remember': 'Salvo informazioni in memoria',
|
| 538 |
+
'recall': 'Recupero informazioni dalla memoria',
|
| 539 |
+
'direct_tools': 'Utilizzo strumenti diretti',
|
| 540 |
+
'critic_retry': 'Auto-correzione risposta (Quality Gate)',
|
| 541 |
+
'execution_validator_fix': 'Auto-fix codice rilevato (ExecutionValidator)',
|
| 542 |
+
'__thinking__': 'Ragionamento interno in corso',
|
| 543 |
+
'__plan__': 'Pianificazione step successivo',
|
| 544 |
+
'__verify__': 'Verifica e validazione risposta',
|
| 545 |
+
'reflective_debug': 'Analisi root cause errore (Chain-of-Verification)',
|
| 546 |
+
'lint_result': 'Validazione sintattica file',
|
| 547 |
+
'lint_code': 'Analisi statica del codice',
|
| 548 |
+
'project_skeleton': 'Mappa aggiornata del progetto',
|
| 549 |
+
'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato',
|
| 550 |
+
'severity_retry': 'Retry adattivo per tipologia errore (S376)',
|
| 551 |
+
# S-LOOP2: narrations per fasi avanzate
|
| 552 |
+
'reasoning_core': 'Ragionamento multi-step (ReasoningCore attivo)',
|
| 553 |
+
'browser_verifier': 'Verifica app live in tempo reale (Playwright)',
|
| 554 |
+
}
|
| 555 |
+
_tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 556 |
+
_narration = _STEP_NARRATIONS.get(_tool_key_narr,
|
| 557 |
+
_action.replace('executor:', '').replace('_', ' ').capitalize())
|
| 558 |
+
# P16-B4: propaga 'truncated' dal loop (finish_reason==length) → frontend
|
| 559 |
+
_step_truncated = bool(step_data.get('truncated', False))
|
| 560 |
+
_sse('step_done', {
|
| 561 |
+
'taskId': task_id,
|
| 562 |
+
'step': {
|
| 563 |
+
'name': _action,
|
| 564 |
+
'index': step_idx[0],
|
| 565 |
+
'status': step_data.get('status', 'done'),
|
| 566 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:500],
|
| 567 |
+
'explanation': _narration, # S363-Blueprint: narrative field
|
| 568 |
+
'truncated': _step_truncated, # P16-B4: segnala max_tokens raggiunto
|
| 569 |
+
},
|
| 570 |
+
})
|
| 571 |
+
# P39-UX: rileva [CONNECTOR_NEEDED:provider] nel result → emetti SSE connector_needed
|
| 572 |
+
import re as _re_cn
|
| 573 |
+
_cn_result = str(step_data.get('result', step_data.get('output', '')))
|
| 574 |
+
_cn_matches = _re_cn.findall(r'\[CONNECTOR_NEEDED:([\w]+)\]', _cn_result)
|
| 575 |
+
for _cn_prov in _cn_matches:
|
| 576 |
+
_PROVIDER_LABELS = {'github': 'GitHub', 'google': 'Google Calendar', 'instagram': 'Instagram'}
|
| 577 |
+
_cn_label = _PROVIDER_LABELS.get(_cn_prov.lower(), _cn_prov.capitalize())
|
| 578 |
+
_sse('connector_needed', {
|
| 579 |
+
'taskId': task_id,
|
| 580 |
+
'provider': _cn_prov.lower(),
|
| 581 |
+
'label': _cn_label,
|
| 582 |
+
'message': f"Per completare il task ho bisogno di accedere a {_cn_label}. Connettiti con un tap.",
|
| 583 |
+
})
|
| 584 |
+
# GAP-SYNC-FIX: accumula step results per resume preciso (checkpoint backend-side)
|
| 585 |
+
_backend_steps.append({
|
| 586 |
+
'step': step_idx[0],
|
| 587 |
+
'action': _action,
|
| 588 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:150],
|
| 589 |
+
'ok': step_data.get('status', 'done') not in ('error', 'failed'),
|
| 590 |
+
})
|
| 591 |
+
# Ogni 2 step: persisti il log su Supabase (non saturare Supabase su loop lunghi)
|
| 592 |
+
if step_idx[0] % 2 == 0:
|
| 593 |
+
asyncio.create_task(
|
| 594 |
+
sb_save_checkpoint(task_id, step_idx[0], {
|
| 595 |
+
'_backend_steps': _backend_steps[-10:], # ultime 10 step
|
| 596 |
+
'step': step_idx[0],
|
| 597 |
+
})
|
| 598 |
+
).add_done_callback(_log_task_exc)
|
| 599 |
+
# TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
|
| 600 |
+
asyncio.create_task(_tg_step(task_id, _action, _narration)).add_done_callback(_log_task_exc)
|
| 601 |
+
# S362: emit vfs_update when a file operation is detected
|
| 602 |
+
# SYNC-1: file_written (da unified_loop GAP-1) incluso + content forwarding
|
| 603 |
+
_VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete', 'file_written')
|
| 604 |
+
if _action in _VFS_ACTIONS or step_data.get('file_path'):
|
| 605 |
+
# S581: 120→200 — path file spesso 120-200 chars
|
| 606 |
+
# S596: 200→400 — result/output può contenere path completo di progetto
|
| 607 |
+
# S604: 400→500 — parity con altri campi step
|
| 608 |
+
# SYNC-1: file_written porta path in 'path', non 'file_path'
|
| 609 |
+
_vfs_file = (step_data.get('path') or
|
| 610 |
+
step_data.get('file_path') or
|
| 611 |
+
step_data.get('result', '')[:500] or
|
| 612 |
+
step_data.get('output', '')[:500])
|
| 613 |
+
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 614 |
+
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 615 |
+
# SYNC-1: includi content nel SSE event per file_written (≤60KB)
|
| 616 |
+
# Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
|
| 617 |
+
if _action == 'file_written' and step_data.get('content'):
|
| 618 |
+
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
| 619 |
+
_sse('vfs_update', _vfs_evt)
|
| 620 |
+
|
| 621 |
+
# S363-UI: thought event — emitted when planner completes
|
| 622 |
+
if _action == 'plan' and step_data.get('status') == 'done':
|
| 623 |
+
_plan_obj = step_data.get('result', step_data.get('output', ''))
|
| 624 |
+
_thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280→400
|
| 625 |
+
if _thought:
|
| 626 |
+
_sse('thought', {'taskId': task_id, 'text': _thought,
|
| 627 |
+
'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
|
| 628 |
+
# S367: plan_update — structured subtask list for live plan tracking UI
|
| 629 |
+
if isinstance(_plan_obj, dict) and _plan_obj.get('subtasks'):
|
| 630 |
+
_sse('plan_update', {
|
| 631 |
+
'taskId': task_id,
|
| 632 |
+
'subtasks': [
|
| 633 |
+
{
|
| 634 |
+
'id': s.get('id', _si + 1),
|
| 635 |
+
'description': s.get('description', '')[:200], # S581: 80→200
|
| 636 |
+
'tool': s.get('tool', ''),
|
| 637 |
+
'status': 'pending',
|
| 638 |
+
}
|
| 639 |
+
for _si, s in enumerate(_plan_obj['subtasks'])
|
| 640 |
+
],
|
| 641 |
+
'goal': _plan_obj.get('goal', ''),
|
| 642 |
+
})
|
| 643 |
+
|
| 644 |
+
# S367: subtask_done — mark individual subtask complete for live checkbox update
|
| 645 |
+
if step_data.get('subtask_id') and step_data.get('status') == 'done':
|
| 646 |
+
_sse('plan_update', {
|
| 647 |
+
'taskId': task_id,
|
| 648 |
+
'subtask_done': step_data['subtask_id'],
|
| 649 |
+
})
|
| 650 |
+
|
| 651 |
+
# S363-UI: action event — tool execution phase
|
| 652 |
+
_TOOL_EXPLAINS_S363 = {
|
| 653 |
+
'web_search': 'Cerco informazioni in rete',
|
| 654 |
+
'get_weather': 'Recupero dati meteo',
|
| 655 |
+
'get_news': 'Carico notizie recenti',
|
| 656 |
+
'search_wikipedia': 'Consulto Wikipedia',
|
| 657 |
+
'fetch_url': 'Leggo la pagina web',
|
| 658 |
+
'search_github': 'Cerco su GitHub',
|
| 659 |
+
'run_code': 'Eseguo il codice',
|
| 660 |
+
'write_file': 'Scrivo il file',
|
| 661 |
+
'read_file': 'Leggo il file',
|
| 662 |
+
'direct_tools': 'Eseguo strumenti diretti',
|
| 663 |
+
}
|
| 664 |
+
_tool_key = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 665 |
+
if _action.startswith('executor:') or _tool_key in _TOOL_EXPLAINS_S363:
|
| 666 |
+
_sse('action', {
|
| 667 |
+
'taskId': task_id,
|
| 668 |
+
'log': _tool_key.upper().replace('_', ' ')[:30],
|
| 669 |
+
'explain': _TOOL_EXPLAINS_S363.get(_tool_key, f'Esecuzione: {_tool_key}'),
|
| 670 |
+
})
|
| 671 |
+
# S758-P4.1: tool_use — chip pre-esecuzione (stream_agent_task path)
|
| 672 |
+
_is_pre_exec = (
|
| 673 |
+
(_action == 'tool_start' and step_data.get('status') == 'running') or
|
| 674 |
+
(_action.startswith('executor:') and step_data.get('status') == 'started')
|
| 675 |
+
)
|
| 676 |
+
if _is_pre_exec:
|
| 677 |
+
_sse('tool_use', {
|
| 678 |
+
'taskId': task_id,
|
| 679 |
+
'tool': _tool_key,
|
| 680 |
+
'name': _tool_key,
|
| 681 |
+
'label': (step_data.get('title') or
|
| 682 |
+
_TOOL_EXPLAINS_S363.get(_tool_key,
|
| 683 |
+
_tool_key.replace('_', ' ').capitalize())),
|
| 684 |
+
'args': {},
|
| 685 |
+
})
|
| 686 |
+
# S758-P4.1: task_thinking — chip ragionamento LLM
|
| 687 |
+
if (_action in ('__thinking__', 'reflective_debug') and
|
| 688 |
+
step_data.get('status') in ('started', 'running', 'running_deep')):
|
| 689 |
+
_sse('task_thinking', {
|
| 690 |
+
'taskId': task_id,
|
| 691 |
+
'message': (step_data.get('explanation') or step_data.get('title') or
|
| 692 |
+
"L’agente sta elaborando…"),
|
| 693 |
+
})
|
| 694 |
+
|
| 695 |
+
_sse('task_start', {'taskId': task_id, 'goal': task['goal']})
|
| 696 |
+
_task_started_ms = int(time.time() * 1000) # NOTIFY-BOT: elapsed tracking
|
| 697 |
+
asyncio.create_task(_tg_start(task_id, task['goal'])).add_done_callback(_log_task_exc)
|
| 698 |
+
_sse('step_start', {'taskId': task_id, 'step': {'name': 'Analisi goal', 'index': 0}})
|
| 699 |
+
|
| 700 |
+
# S364: inject project skeleton into context from VFS (Gap 4)
|
| 701 |
+
if task.get('conversation_id'):
|
| 702 |
+
try:
|
| 703 |
+
from api.project_manifest import build_manifest_from_vfs, get_skeleton
|
| 704 |
+
await asyncio.wait_for(
|
| 705 |
+
build_manifest_from_vfs(task['conversation_id']),
|
| 706 |
+
timeout=3.0,
|
| 707 |
+
)
|
| 708 |
+
_skeleton = await get_skeleton(task['conversation_id'])
|
| 709 |
+
if _skeleton:
|
| 710 |
+
context_str = (_skeleton + '\n\n' + context_str).strip()
|
| 711 |
+
except Exception:
|
| 712 |
+
pass # S364: skeleton injection is optional
|
| 713 |
+
|
| 714 |
+
result = await loop.run(
|
| 715 |
+
goal=task['goal'],
|
| 716 |
+
context=context_str,
|
| 717 |
+
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 718 |
+
on_step=step_cb,
|
| 719 |
+
session_id=task.get('session_id', '') or '',
|
| 720 |
+
)
|
| 721 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 722 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 723 |
+
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 724 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
| 725 |
+
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 726 |
+
|
| 727 |
+
# S363: fire-and-forget quality check when code detected in output
|
| 728 |
+
if _run_quality_check:
|
| 729 |
+
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 730 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised — evita QG su snippet brevi
|
| 731 |
+
asyncio.create_task(_run_quality_check(
|
| 732 |
+
task_id, task['goal'], _qg_result,
|
| 733 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 734 |
+
)).add_done_callback(_log_task_exc)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
except asyncio.CancelledError:
|
| 738 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 739 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 740 |
+
_sse('task_cancelled', {'taskId': task_id})
|
| 741 |
+
|
| 742 |
+
except (ImportError, ModuleNotFoundError):
|
| 743 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 744 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 745 |
+
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 746 |
+
_sse('task_done', {'taskId': task_id, 'result': (
|
| 747 |
+
f'Goal ricevuto: {task["goal"]}\n\n'
|
| 748 |
+
'Il backend non ha il modulo agents.unified_loop. '
|
| 749 |
+
'Configura HuggingFace Spaces con smolagents per l\'esecuzione autonoma.'
|
| 750 |
+
)})
|
| 751 |
+
|
| 752 |
+
except Exception as err:
|
| 753 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 754 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 755 |
+
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
| 756 |
+
_sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
|
| 757 |
+
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 758 |
+
|
| 759 |
+
finally:
|
| 760 |
+
reg_entry['done'] = True
|
| 761 |
+
reg_entry['finished_at'] = time.time()
|
| 762 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 763 |
+
try:
|
| 764 |
+
q.put_nowait(None)
|
| 765 |
+
except Exception as _exc:
|
| 766 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 767 |
+
|
| 768 |
+
reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
|
| 769 |
+
|
| 770 |
+
try:
|
| 771 |
+
while True:
|
| 772 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 773 |
+
at = reg_entry.get('asyncio_task')
|
| 774 |
+
if at and not at.done():
|
| 775 |
+
at.cancel()
|
| 776 |
+
break
|
| 777 |
+
try:
|
| 778 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 779 |
+
if item is None:
|
| 780 |
+
break
|
| 781 |
+
yield item
|
| 782 |
+
except asyncio.TimeoutError:
|
| 783 |
+
yield ': heartbeat\n\n'
|
| 784 |
+
finally:
|
| 785 |
+
try:
|
| 786 |
+
reg_entry['subscriber_queues'].remove(sub_q)
|
| 787 |
+
except ValueError as _exc:
|
| 788 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 789 |
+
|
| 790 |
+
yield "data: [DONE]\n\n"
|
| 791 |
+
|
| 792 |
+
return StreamingResponse(
|
| 793 |
+
generate(),
|
| 794 |
+
media_type='text/event-stream',
|
| 795 |
+
headers={
|
| 796 |
+
'Cache-Control': 'no-cache',
|
| 797 |
+
'X-Accel-Buffering': 'no',
|
| 798 |
+
}
|
| 799 |
+
)
|
| 800 |
+
|
api/auth_guard.py
CHANGED
|
@@ -33,7 +33,7 @@ from __future__ import annotations
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
from enum import IntEnum
|
| 36 |
-
from typing import Optional
|
| 37 |
|
| 38 |
from fastapi import Depends, Header, HTTPException, Request
|
| 39 |
|
|
@@ -96,48 +96,19 @@ _RATE_LIMITS: dict[int, int] = {
|
|
| 96 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 97 |
_rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
|
| 98 |
|
| 99 |
-
# Lo store è usato anche quando Redis non è disponibile. Un client una tantum
|
| 100 |
-
# lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
|
| 101 |
-
# sweep ammortizzato: il lavoro resta O(1) per la quasi totalità delle richieste
|
| 102 |
-
# e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
|
| 103 |
-
_RATE_STORE_SWEEP_EVERY = 128
|
| 104 |
-
_rate_store_checks = 0
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def _prune_expired_rate_keys(now: float, window_s: float) -> None:
|
| 108 |
-
"""Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
|
| 109 |
-
global _rate_store_checks
|
| 110 |
-
_rate_store_checks += 1
|
| 111 |
-
if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
|
| 112 |
-
return
|
| 113 |
-
|
| 114 |
-
window_start = now - window_s
|
| 115 |
-
stale_keys = [
|
| 116 |
-
stored_key
|
| 117 |
-
for stored_key, timestamps in _rate_store.items()
|
| 118 |
-
if not timestamps or timestamps[-1] < window_start
|
| 119 |
-
]
|
| 120 |
-
for stored_key in stale_keys:
|
| 121 |
-
_rate_store.pop(stored_key, None)
|
| 122 |
-
|
| 123 |
|
| 124 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 125 |
"""Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
|
| 126 |
|
| 127 |
-
USER usa
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
|
| 131 |
-
e ADMIN mantengono il bucket per-token.
|
| 132 |
"""
|
| 133 |
if role == 0:
|
| 134 |
-
# USER:
|
| 135 |
raw = f"0:{client_ip or 'unknown'}"
|
| 136 |
-
elif role == 1 and client_ip:
|
| 137 |
-
# MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
|
| 138 |
-
raw = f"1:{client_ip}"
|
| 139 |
else:
|
| 140 |
-
#
|
| 141 |
raw = f"{role}:{token_header or 'anonymous'}"
|
| 142 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 143 |
|
|
@@ -152,7 +123,6 @@ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]
|
|
| 152 |
"""
|
| 153 |
now = _rl_time.monotonic()
|
| 154 |
window_start = now - window_s
|
| 155 |
-
_prune_expired_rate_keys(now, window_s)
|
| 156 |
|
| 157 |
if key not in _rate_store:
|
| 158 |
_rate_store[key] = _col.deque()
|
|
@@ -192,66 +162,6 @@ def _check_rate_limit(
|
|
| 192 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 193 |
|
| 194 |
|
| 195 |
-
async def require_supabase_user(request: Request) -> dict[str, Any]:
|
| 196 |
-
"""Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
|
| 197 |
-
|
| 198 |
-
La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
|
| 199 |
-
Authorization del chiamante e non viene scritto nei log.
|
| 200 |
-
"""
|
| 201 |
-
import httpx
|
| 202 |
-
|
| 203 |
-
authorization = request.headers.get("Authorization", "")
|
| 204 |
-
if not authorization.lower().startswith("bearer "):
|
| 205 |
-
raise HTTPException(status_code=401, detail="Bearer token richiesto")
|
| 206 |
-
jwt = authorization[7:].strip()
|
| 207 |
-
if not jwt:
|
| 208 |
-
raise HTTPException(status_code=401, detail="Bearer token non valido")
|
| 209 |
-
|
| 210 |
-
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 211 |
-
api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 212 |
-
if not supabase_url or not api_key:
|
| 213 |
-
raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
|
| 214 |
-
|
| 215 |
-
try:
|
| 216 |
-
async with httpx.AsyncClient(timeout=5) as client:
|
| 217 |
-
response = await client.get(
|
| 218 |
-
f"{supabase_url}/auth/v1/user",
|
| 219 |
-
headers={
|
| 220 |
-
"apikey": api_key,
|
| 221 |
-
"Authorization": f"Bearer {jwt}",
|
| 222 |
-
"Accept": "application/json",
|
| 223 |
-
},
|
| 224 |
-
)
|
| 225 |
-
except httpx.HTTPError as exc:
|
| 226 |
-
logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
|
| 227 |
-
raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
|
| 228 |
-
|
| 229 |
-
if response.status_code != 200:
|
| 230 |
-
raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
|
| 231 |
-
try:
|
| 232 |
-
user = response.json()
|
| 233 |
-
except ValueError as exc:
|
| 234 |
-
raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
|
| 235 |
-
if not isinstance(user, dict) or not user.get("id"):
|
| 236 |
-
raise HTTPException(status_code=401, detail="Utente Supabase non valido")
|
| 237 |
-
return user
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
async def require_admin_user(request: Request) -> dict[str, Any]:
|
| 241 |
-
"""Richiede un JWT Supabase con app_metadata.role=admin.
|
| 242 |
-
|
| 243 |
-
app_metadata è server-controlled; user_metadata non viene mai considerato
|
| 244 |
-
per autorizzare l’area amministrativa.
|
| 245 |
-
"""
|
| 246 |
-
user = await require_supabase_user(request)
|
| 247 |
-
app_metadata = user.get("app_metadata") or {}
|
| 248 |
-
roles = app_metadata.get("roles") or []
|
| 249 |
-
is_admin = app_metadata.get("role") == "admin" or "admin" in roles
|
| 250 |
-
if not is_admin:
|
| 251 |
-
raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
|
| 252 |
-
return user
|
| 253 |
-
|
| 254 |
-
|
| 255 |
class AuthRole(IntEnum):
|
| 256 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 257 |
USER = 0
|
|
@@ -266,7 +176,6 @@ def _get_token(env_var: str) -> str:
|
|
| 266 |
|
| 267 |
async def _resolve_role(
|
| 268 |
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 269 |
-
x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
|
| 270 |
x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
|
| 271 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 272 |
) -> AuthRole:
|
|
@@ -284,11 +193,9 @@ async def _resolve_role(
|
|
| 284 |
logger.debug("auth: OPERATOR role granted")
|
| 285 |
return AuthRole.OPERATOR
|
| 286 |
|
| 287 |
-
# MACHINE
|
| 288 |
-
|
| 289 |
-
int_tok
|
| 290 |
-
machine_header = x_internal_token or x_machine_token
|
| 291 |
-
if int_tok and machine_header and _sec_comp.compare_digest(machine_header, int_tok):
|
| 292 |
logger.debug("auth: MACHINE role granted")
|
| 293 |
return AuthRole.MACHINE
|
| 294 |
|
|
@@ -296,38 +203,6 @@ async def _resolve_role(
|
|
| 296 |
return AuthRole.USER
|
| 297 |
|
| 298 |
|
| 299 |
-
async def require_private_state_machine(
|
| 300 |
-
request: 'Request',
|
| 301 |
-
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 302 |
-
) -> AuthRole:
|
| 303 |
-
"""Autorizza esclusivamente il proxy Pages dello stato privato.
|
| 304 |
-
|
| 305 |
-
Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
|
| 306 |
-
dipendono le integrazioni legacy del master B. Il token non conferisce un
|
| 307 |
-
ruolo più ampio del canale MACHINE e resta soggetto allo stesso rate limit.
|
| 308 |
-
"""
|
| 309 |
-
import secrets as _sec_comp
|
| 310 |
-
private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
|
| 311 |
-
if not private_token:
|
| 312 |
-
raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
|
| 313 |
-
if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
|
| 314 |
-
raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
|
| 315 |
-
|
| 316 |
-
client_ip = (
|
| 317 |
-
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
| 318 |
-
or request.headers.get('X-Real-IP', '')
|
| 319 |
-
or (request.client.host if request.client else None)
|
| 320 |
-
) or None
|
| 321 |
-
allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
|
| 322 |
-
if not allowed:
|
| 323 |
-
raise HTTPException(
|
| 324 |
-
status_code=429,
|
| 325 |
-
detail="Rate limit stato privato superato",
|
| 326 |
-
headers={'Retry-After': str(retry_after)},
|
| 327 |
-
)
|
| 328 |
-
return AuthRole.MACHINE
|
| 329 |
-
|
| 330 |
-
|
| 331 |
def require_role(min_role: AuthRole):
|
| 332 |
"""
|
| 333 |
FastAPI Depends factory per autorizzazione granulare.
|
|
@@ -348,8 +223,7 @@ def require_role(min_role: AuthRole):
|
|
| 348 |
_token_hdr = (
|
| 349 |
request.headers.get('X-Admin-Token') or
|
| 350 |
request.headers.get('X-Operator-Token') or
|
| 351 |
-
request.headers.get('X-Internal-Token')
|
| 352 |
-
request.headers.get('X-Machine-Token')
|
| 353 |
)
|
| 354 |
# GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
|
| 355 |
_client_ip: str | None = (
|
|
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
from enum import IntEnum
|
| 36 |
+
from typing import Optional
|
| 37 |
|
| 38 |
from fastapi import Depends, Header, HTTPException, Request
|
| 39 |
|
|
|
|
| 96 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 97 |
_rate_store: dict[str, _col.deque] = {} # token_hash → deque di timestamps
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 101 |
"""Chiave rate limiter: hash(role + discriminante) — non espone token né IP in chiaro.
|
| 102 |
|
| 103 |
+
GAP-AUTH-FIX: USER (role=0) usa client_ip come discriminante — bucket per IP,
|
| 104 |
+
non bucket globale condiviso. Previene DoS a costo zero (un client svuota tutti).
|
| 105 |
+
Ruoli autenticati (MACHINE/OPERATOR/ADMIN) continuano a usare il token hash.
|
|
|
|
|
|
|
| 106 |
"""
|
| 107 |
if role == 0:
|
| 108 |
+
# USER: discrimina per IP — ogni client ha il proprio bucket
|
| 109 |
raw = f"0:{client_ip or 'unknown'}"
|
|
|
|
|
|
|
|
|
|
| 110 |
else:
|
| 111 |
+
# Ruoli autenticati: discrimina per token (più preciso dell'IP)
|
| 112 |
raw = f"{role}:{token_header or 'anonymous'}"
|
| 113 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 114 |
|
|
|
|
| 123 |
"""
|
| 124 |
now = _rl_time.monotonic()
|
| 125 |
window_start = now - window_s
|
|
|
|
| 126 |
|
| 127 |
if key not in _rate_store:
|
| 128 |
_rate_store[key] = _col.deque()
|
|
|
|
| 162 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 163 |
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
class AuthRole(IntEnum):
|
| 166 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 167 |
USER = 0
|
|
|
|
| 176 |
|
| 177 |
async def _resolve_role(
|
| 178 |
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
|
|
|
| 179 |
x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
|
| 180 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 181 |
) -> AuthRole:
|
|
|
|
| 193 |
logger.debug("auth: OPERATOR role granted")
|
| 194 |
return AuthRole.OPERATOR
|
| 195 |
|
| 196 |
+
# MACHINE (INTERNAL_TOKEN, già generato al boot da main.py)
|
| 197 |
+
int_tok = _get_token("INTERNAL_TOKEN")
|
| 198 |
+
if int_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, int_tok):
|
|
|
|
|
|
|
| 199 |
logger.debug("auth: MACHINE role granted")
|
| 200 |
return AuthRole.MACHINE
|
| 201 |
|
|
|
|
| 203 |
return AuthRole.USER
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def require_role(min_role: AuthRole):
|
| 207 |
"""
|
| 208 |
FastAPI Depends factory per autorizzazione granulare.
|
|
|
|
| 223 |
_token_hdr = (
|
| 224 |
request.headers.get('X-Admin-Token') or
|
| 225 |
request.headers.get('X-Operator-Token') or
|
| 226 |
+
request.headers.get('X-Internal-Token')
|
|
|
|
| 227 |
)
|
| 228 |
# GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy → X-Forwarded-For)
|
| 229 |
_client_ip: str | None = (
|
api/benchmark.py
CHANGED
|
@@ -373,7 +373,7 @@ async def run_benchmark(
|
|
| 373 |
#
|
| 374 |
# Per ogni categoria agente (DA / ORCH / MC / REC):
|
| 375 |
# 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
|
| 376 |
-
# 2. Chiama il LLM (ARCHITECT =
|
| 377 |
# 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
|
| 378 |
# 4. Produce score 0-100 per categoria + media totale
|
| 379 |
#
|
|
|
|
| 373 |
#
|
| 374 |
# Per ogni categoria agente (DA / ORCH / MC / REC):
|
| 375 |
# 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
|
| 376 |
+
# 2. Chiama il LLM (ARCHITECT = llama-3.3-70b-versatile) a temperatura 0.3
|
| 377 |
# 3. Valuta la risposta con checker regex (stessa logica di benchmark-extended.mjs)
|
| 378 |
# 4. Produce score 0-100 per categoria + media totale
|
| 379 |
#
|
api/benchmark_handler.py
CHANGED
|
@@ -20,89 +20,64 @@ from typing import Any
|
|
| 20 |
logger = logging.getLogger("agente_ai.benchmark_handler")
|
| 21 |
|
| 22 |
# ── Percorsi server Railway ────────────────────────────────────────────────────
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
os.getenv("BENCHMARK_RUNNER_PATH", "").strip(),
|
| 30 |
-
os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
|
| 31 |
-
"/home/user/app/benchmark-extended.mjs",
|
| 32 |
-
"/app/benchmark-extended.mjs",
|
| 33 |
-
)
|
| 34 |
-
_BENCH_SCRIPT = next(
|
| 35 |
-
(candidate for candidate in _BENCH_SCRIPT_CANDIDATES if candidate and os.path.isfile(candidate)),
|
| 36 |
-
os.path.join(_REPO_ROOT, "benchmark-extended.mjs"),
|
| 37 |
-
)
|
| 38 |
-
_REPORT_V7 = "/tmp/agente-ai/benchmark-v5-latest.json"
|
| 39 |
-
_REPORT_V7_WEAK = "/tmp/agente-ai/benchmark-v5-weak-latest.json"
|
| 40 |
-
_WEAK_CATEGORIES = (
|
| 41 |
-
"sql", "context_window", "reasoning", "data_analysis", "research_synthesis",
|
| 42 |
-
"mmlu", "technical_writing", "code_correct", "feature", "security",
|
| 43 |
-
)
|
| 44 |
-
# 20 task seriali possono richiedere più di 12 minuti con provider gratuiti.
|
| 45 |
-
_BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "3600"))
|
| 46 |
|
| 47 |
# v6.2 — usato come fallback in get_smart_summary per compatibilità
|
| 48 |
_REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
|
| 49 |
|
| 50 |
|
| 51 |
-
async def run_benchmark_task(chat_id: int, send_reply_fn
|
| 52 |
-
"""Esegue
|
| 53 |
-
if not await asyncio.to_thread(os.path.isfile, _BENCH_SCRIPT):
|
| 54 |
-
await send_reply_fn(chat_id, "❌ <b>Runner benchmark esteso non disponibile.</b>\n"
|
| 55 |
-
"Il deployment non ha incluso <code>benchmark-extended.mjs</code>.")
|
| 56 |
-
return
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
chat_id,
|
| 67 |
-
"🎯 <b>Benchmark Extended v5 mirato avviato</b>\n"
|
| 68 |
-
"<i>10 categorie più deboli della baseline 39,1 · seed 1337 · task API moderna.</i>",
|
| 69 |
-
)
|
| 70 |
-
else:
|
| 71 |
-
report_path = _REPORT_V7
|
| 72 |
-
flags = ["--full", "--json", f"--output={report_path}", "--gap-analysis"]
|
| 73 |
-
await send_reply_fn(
|
| 74 |
-
chat_id,
|
| 75 |
-
"🚀 <b>Benchmark Extended v5 avviato</b>\n"
|
| 76 |
-
"<i>20/20 categorie · seed 1337 · task API moderna · durata variabile fino a ~60 min.</i>",
|
| 77 |
-
)
|
| 78 |
env = {
|
| 79 |
**os.environ,
|
|
|
|
|
|
|
| 80 |
"INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
|
| 81 |
-
"BENCHMARK_BASE_URL": os.getenv("BENCHMARK_BASE_URL", "http://127.0.0.1:7860"),
|
| 82 |
}
|
| 83 |
process: asyncio.subprocess.Process | None = None
|
| 84 |
try:
|
| 85 |
process = await asyncio.create_subprocess_exec(
|
| 86 |
-
"node", _BENCH_SCRIPT,
|
| 87 |
stdout=asyncio.subprocess.PIPE,
|
| 88 |
stderr=asyncio.subprocess.PIPE,
|
| 89 |
env=env,
|
| 90 |
)
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
if process.returncode != 0:
|
| 93 |
err = stderr.decode(errors="replace")[:400]
|
| 94 |
-
logger.error("
|
| 95 |
-
await send_reply_fn(chat_id, f"❌ <b>Errore benchmark
|
| 96 |
return
|
| 97 |
except asyncio.TimeoutError:
|
|
|
|
| 98 |
if process is not None:
|
| 99 |
try:
|
| 100 |
process.kill()
|
| 101 |
await process.wait()
|
| 102 |
except Exception:
|
| 103 |
pass
|
| 104 |
-
logger.warning("
|
| 105 |
-
await send_reply_fn(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return
|
| 107 |
except Exception as exc:
|
| 108 |
if process is not None:
|
|
@@ -111,30 +86,26 @@ async def run_benchmark_task(chat_id: int, send_reply_fn, mode: str = "full") ->
|
|
| 111 |
await process.wait()
|
| 112 |
except Exception:
|
| 113 |
pass
|
| 114 |
-
logger.
|
| 115 |
-
await send_reply_fn(chat_id, f"💥 <b>Errore critico
|
| 116 |
return
|
| 117 |
|
| 118 |
-
report_exists = await asyncio.to_thread(os.path.exists,
|
| 119 |
if not report_exists:
|
| 120 |
-
await send_reply_fn(chat_id, "⚠️ <b>Benchmark
|
| 121 |
return
|
|
|
|
| 122 |
try:
|
| 123 |
-
|
|
|
|
| 124 |
except Exception as exc:
|
| 125 |
-
await send_reply_fn(chat_id, f"⚠️ <b>Report
|
| 126 |
return
|
| 127 |
|
| 128 |
-
|
| 129 |
-
expected_categories = len(_WEAK_CATEGORIES) if is_weak_run else 20
|
| 130 |
-
if len(categories) != expected_categories:
|
| 131 |
-
await send_reply_fn(chat_id, f"⚠️ <b>Run incompleta:</b> <code>{len(categories)}/{expected_categories}</code> categorie nel report."
|
| 132 |
-
" Nessun risultato incompleto viene presentato come benchmark completo.")
|
| 133 |
-
return
|
| 134 |
-
await send_reply_fn(chat_id, _format_v7_report(report, expected_categories=expected_categories, run_label="mirato · categorie deboli" if is_weak_run else None))
|
| 135 |
|
| 136 |
|
| 137 |
-
def _format_v7_report(report: dict[str, Any]
|
| 138 |
"""Formatta il report v7 per Telegram HTML."""
|
| 139 |
s = report.get("summary", {})
|
| 140 |
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
|
@@ -152,8 +123,7 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
|
|
| 152 |
lines: list[str] = [
|
| 153 |
f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
|
| 154 |
f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
|
| 155 |
-
f"📅 <b>Run:</b> <code>{ts}</code>\n"
|
| 156 |
-
+ (f"🎯 <b>Modalità:</b> <code>{run_label}</code>\n" if run_label else "") + "\n"
|
| 157 |
"📈 <b>Confronto vs riferimenti:</b>\n"
|
| 158 |
f" • Replit: <code>{repl}/100</code>\n"
|
| 159 |
f" • Cursor: <code>{curs}/100</code>\n"
|
|
@@ -166,24 +136,15 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
|
|
| 166 |
if canary:
|
| 167 |
lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
|
| 168 |
|
| 169 |
-
# Score per categoria
|
| 170 |
-
# nella media: non va trasformata silenziosamente in uno score pari a zero.
|
| 171 |
tasks = report.get("tasks", [])
|
| 172 |
if tasks:
|
| 173 |
-
attempted_categories = {str(t.get("cat")) for t in tasks if t.get("cat")}
|
| 174 |
by_cat: dict[str, list[float]] = {}
|
| 175 |
for t in tasks:
|
| 176 |
cat = t.get("cat", "?")
|
| 177 |
sc = t.get("score")
|
| 178 |
if isinstance(sc, (int, float)):
|
| 179 |
by_cat.setdefault(cat, []).append(float(sc))
|
| 180 |
-
attempted = s.get("attemptedTaskCount", len(tasks))
|
| 181 |
-
scored = s.get("scoredTaskCount", sum(len(v) for v in by_cat.values()))
|
| 182 |
-
skipped = s.get("skippedTaskCount", max(0, attempted - scored))
|
| 183 |
-
lines.append(
|
| 184 |
-
f"🧪 <b>Copertura:</b> <code>{len(attempted_categories)}/{expected_categories} categorie tentate · "
|
| 185 |
-
f"{scored} valutabili · {skipped} non valutabili</code>\n"
|
| 186 |
-
)
|
| 187 |
if by_cat:
|
| 188 |
lines.append("\n📂 <b>Per categoria:</b>\n")
|
| 189 |
for cat, scores in sorted(by_cat.items()):
|
|
@@ -191,16 +152,6 @@ def _format_v7_report(report: dict[str, Any], *, expected_categories: int = 20,
|
|
| 191 |
icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
|
| 192 |
lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
|
| 193 |
|
| 194 |
-
failures = report.get("taskFailures", [])
|
| 195 |
-
if failures:
|
| 196 |
-
lines.append("\n⚠️ <b>Categorie non valutabili:</b>\n")
|
| 197 |
-
for failure in failures[:3]:
|
| 198 |
-
cat = failure.get("cat", "?")
|
| 199 |
-
reason = str(failure.get("reason", "errore non specificato"))[:100]
|
| 200 |
-
lines.append(f" • <code>{cat}</code> — {reason}\n")
|
| 201 |
-
if len(failures) > 3:
|
| 202 |
-
lines.append(f" <i>...e altre {len(failures) - 3}.</i>\n")
|
| 203 |
-
|
| 204 |
# Gap cards (prime 3)
|
| 205 |
gap_cards = report.get("gapCards", [])
|
| 206 |
if gap_cards:
|
|
|
|
| 20 |
logger = logging.getLogger("agente_ai.benchmark_handler")
|
| 21 |
|
| 22 |
# ── Percorsi server Railway ────────────────────────────────────────────────────
|
| 23 |
+
_REPO_ROOT = os.getenv("REPO_ROOT", "/home/ubuntu/Baida98_AI")
|
| 24 |
+
|
| 25 |
+
# v7 (GAP-BENCH-2)
|
| 26 |
+
_BENCH_SCRIPT = os.path.join(_REPO_ROOT, "scripts", "benchmark-extended.mjs")
|
| 27 |
+
_REPORT_V7 = "/tmp/agente-ai/benchmark-v7-latest.json"
|
| 28 |
+
_BENCH_TIMEOUT = float(os.getenv("BENCH_TIMEOUT_SECS", "720")) # 12 min (era 360s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# v6.2 — usato come fallback in get_smart_summary per compatibilità
|
| 31 |
_REPORT_V6 = os.path.join(_REPO_ROOT, "benchmark-stress-report.json")
|
| 32 |
|
| 33 |
|
| 34 |
+
async def run_benchmark_task(chat_id: int, send_reply_fn) -> None:
|
| 35 |
+
"""Esegue benchmark-extended.mjs v7 con --json e invia risultati via Telegram.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
Flag --json → scrive /tmp/agente-ai/benchmark-v7-latest.json.
|
| 38 |
+
Variabili env richieste (Railway): GROQ_API_KEY, INTERNAL_TOKEN.
|
| 39 |
+
"""
|
| 40 |
+
await send_reply_fn(
|
| 41 |
+
chat_id,
|
| 42 |
+
"🚀 <b>Avvio Benchmark Extended v7…</b>\n"
|
| 43 |
+
"<i>10+ categorie · HF datasets · ref vs Replit/Cursor/Devin/Manus · ~10-12 min.</i>",
|
| 44 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
env = {
|
| 46 |
**os.environ,
|
| 47 |
+
"GROQ_API_KEY": os.getenv("GROQ_API_KEY", ""),
|
| 48 |
+
"NVIDIA_API_KEY": os.getenv("NVIDIA_API_KEY", ""),
|
| 49 |
"INTERNAL_TOKEN": os.getenv("INTERNAL_TOKEN", ""),
|
|
|
|
| 50 |
}
|
| 51 |
process: asyncio.subprocess.Process | None = None
|
| 52 |
try:
|
| 53 |
process = await asyncio.create_subprocess_exec(
|
| 54 |
+
"node", _BENCH_SCRIPT, "--json",
|
| 55 |
stdout=asyncio.subprocess.PIPE,
|
| 56 |
stderr=asyncio.subprocess.PIPE,
|
| 57 |
env=env,
|
| 58 |
)
|
| 59 |
+
stdout, stderr = await asyncio.wait_for(
|
| 60 |
+
process.communicate(), timeout=_BENCH_TIMEOUT
|
| 61 |
+
)
|
| 62 |
if process.returncode != 0:
|
| 63 |
err = stderr.decode(errors="replace")[:400]
|
| 64 |
+
logger.error("Benchmark v7 failed rc=%d: %s", process.returncode, err)
|
| 65 |
+
await send_reply_fn(chat_id, f"❌ <b>Errore benchmark v7:</b>\n<code>{err}</code>")
|
| 66 |
return
|
| 67 |
except asyncio.TimeoutError:
|
| 68 |
+
# FIX-1: kill del processo figlio prima di notificare
|
| 69 |
if process is not None:
|
| 70 |
try:
|
| 71 |
process.kill()
|
| 72 |
await process.wait()
|
| 73 |
except Exception:
|
| 74 |
pass
|
| 75 |
+
logger.warning("Benchmark v7 timeout (>%.0fs) — process killed", _BENCH_TIMEOUT)
|
| 76 |
+
await send_reply_fn(
|
| 77 |
+
chat_id,
|
| 78 |
+
f"⏱ <b>Timeout benchmark v7</b> (>{int(_BENCH_TIMEOUT // 60)} min) — "
|
| 79 |
+
"processo terminato, controlla log Railway.",
|
| 80 |
+
)
|
| 81 |
return
|
| 82 |
except Exception as exc:
|
| 83 |
if process is not None:
|
|
|
|
| 86 |
await process.wait()
|
| 87 |
except Exception:
|
| 88 |
pass
|
| 89 |
+
logger.error("run_benchmark_task v7 error: %s", exc, exc_info=True)
|
| 90 |
+
await send_reply_fn(chat_id, f"💥 <b>Errore critico:</b> <code>{exc}</code>")
|
| 91 |
return
|
| 92 |
|
| 93 |
+
report_exists = await asyncio.to_thread(os.path.exists, _REPORT_V7)
|
| 94 |
if not report_exists:
|
| 95 |
+
await send_reply_fn(chat_id, "⚠️ <b>Benchmark terminato ma report v7 non trovato.</b>")
|
| 96 |
return
|
| 97 |
+
|
| 98 |
try:
|
| 99 |
+
# FIX-3: lettura file in thread — non blocca l'event loop
|
| 100 |
+
report: dict[str, Any] = await asyncio.to_thread(_read_json, _REPORT_V7)
|
| 101 |
except Exception as exc:
|
| 102 |
+
await send_reply_fn(chat_id, f"⚠️ <b>Report v7 non leggibile:</b> <code>{exc}</code>")
|
| 103 |
return
|
| 104 |
|
| 105 |
+
await send_reply_fn(chat_id, _format_v7_report(report))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
+
def _format_v7_report(report: dict[str, Any]) -> str:
|
| 109 |
"""Formatta il report v7 per Telegram HTML."""
|
| 110 |
s = report.get("summary", {})
|
| 111 |
ts = (report.get("timestamp") or "")[:16].replace("T", " ")
|
|
|
|
| 123 |
lines: list[str] = [
|
| 124 |
f"🏆 <b>Benchmark {ver} completato!</b>\n\n"
|
| 125 |
f"📊 <b>Score agente:</b> <code>{avg}/100</code>\n"
|
| 126 |
+
f"📅 <b>Run:</b> <code>{ts}</code>\n\n"
|
|
|
|
| 127 |
"📈 <b>Confronto vs riferimenti:</b>\n"
|
| 128 |
f" • Replit: <code>{repl}/100</code>\n"
|
| 129 |
f" • Cursor: <code>{curs}/100</code>\n"
|
|
|
|
| 136 |
if canary:
|
| 137 |
lines.append(f"⚠️ <b>Canary leak:</b> {canary} task\n")
|
| 138 |
|
| 139 |
+
# Score per categoria
|
|
|
|
| 140 |
tasks = report.get("tasks", [])
|
| 141 |
if tasks:
|
|
|
|
| 142 |
by_cat: dict[str, list[float]] = {}
|
| 143 |
for t in tasks:
|
| 144 |
cat = t.get("cat", "?")
|
| 145 |
sc = t.get("score")
|
| 146 |
if isinstance(sc, (int, float)):
|
| 147 |
by_cat.setdefault(cat, []).append(float(sc))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
if by_cat:
|
| 149 |
lines.append("\n📂 <b>Per categoria:</b>\n")
|
| 150 |
for cat, scores in sorted(by_cat.items()):
|
|
|
|
| 152 |
icon = "🟢" if avg_cat >= 70 else "🟡" if avg_cat >= 50 else "🔴"
|
| 153 |
lines.append(f" {icon} <code>{avg_cat:5.1f}</code> {cat}\n")
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
# Gap cards (prime 3)
|
| 156 |
gap_cards = report.get("gapCards", [])
|
| 157 |
if gap_cards:
|
api/browser.py
CHANGED
|
@@ -260,16 +260,16 @@ def _trim_ax_tree(node: dict, depth: int) -> dict:
|
|
| 260 |
Mantiene: role, name, description, value, checked, expanded, required.
|
| 261 |
Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
|
| 262 |
"""
|
| 263 |
-
KEEP = frozenset({
|
| 264 |
-
|
| 265 |
-
|
| 266 |
result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
|
| 267 |
-
if depth > 0 and node.get(
|
| 268 |
-
trimmed = [_trim_ax_tree(c, depth - 1) for c in node[
|
| 269 |
# Filtra nodi completamente vuoti (solo role senza nome né figli)
|
| 270 |
-
trimmed = [c for c in trimmed if len(c) > 1 or c.get(
|
| 271 |
if trimmed:
|
| 272 |
-
result[
|
| 273 |
return result
|
| 274 |
|
| 275 |
|
|
@@ -494,16 +494,11 @@ async def _session_cleanup_loop() -> None:
|
|
| 494 |
await _close_session(sid, "TTL expired")
|
| 495 |
|
| 496 |
|
| 497 |
-
def _log_browser_bg_exc(t): # BUGFIX: log eccezioni da create_task fire-and-forget
|
| 498 |
-
if not t.cancelled() and t.exception():
|
| 499 |
-
_logger.warning("[browser] bg task raised: %s", t.exception())
|
| 500 |
-
|
| 501 |
-
|
| 502 |
def _start_cleanup() -> None:
|
| 503 |
try:
|
| 504 |
loop = asyncio.get_event_loop()
|
| 505 |
if loop.is_running():
|
| 506 |
-
asyncio.create_task(_session_cleanup_loop())
|
| 507 |
except Exception as _e:
|
| 508 |
_logger.warning("_start_cleanup: create_task failed — cleanup loop not running: %s", _e)
|
| 509 |
|
|
@@ -713,47 +708,6 @@ async def verify_goal_browser(
|
|
| 713 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 714 |
|
| 715 |
|
| 716 |
-
# ─── _take_screenshot (internal helper) ──────────────────────────────────────
|
| 717 |
-
|
| 718 |
-
async def _take_screenshot(
|
| 719 |
-
url: str,
|
| 720 |
-
mobile: bool = False,
|
| 721 |
-
width: int = 1280,
|
| 722 |
-
height: int = 800,
|
| 723 |
-
wait_ms: int = 1500,
|
| 724 |
-
) -> dict:
|
| 725 |
-
"""
|
| 726 |
-
Wrapper interno per screenshot Playwright headless. (GAP-6-fix)
|
| 727 |
-
Usato da gemini_vision.py senza passare per la route HTTP.
|
| 728 |
-
Ritorna: {"ok": bool, "screenshot_b64": str, "title": str, "url": str}
|
| 729 |
-
"""
|
| 730 |
-
if not _safe_url(url):
|
| 731 |
-
return {"ok": False, "error": "URL non consentita", "screenshot_b64": "", "title": url, "url": url}
|
| 732 |
-
async with _browser_lock:
|
| 733 |
-
try:
|
| 734 |
-
from playwright.async_api import async_playwright
|
| 735 |
-
async with async_playwright() as pw:
|
| 736 |
-
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 737 |
-
ctx = await _make_context(browser, width, height, mobile)
|
| 738 |
-
page = await ctx.new_page()
|
| 739 |
-
try:
|
| 740 |
-
await _goto_with_networkidle(page, url, GOTO_TIMEOUT)
|
| 741 |
-
await _dismiss_cookie_banner(page)
|
| 742 |
-
await page.wait_for_timeout(wait_ms)
|
| 743 |
-
png = await page.screenshot(type="png", full_page=False)
|
| 744 |
-
title = await page.title()
|
| 745 |
-
png_b64 = base64.b64encode(png).decode()
|
| 746 |
-
asyncio.create_task(_try_persist_screenshot(url, png_b64, title))
|
| 747 |
-
return {"ok": True, "screenshot_b64": png_b64, "title": title, "url": page.url}
|
| 748 |
-
except Exception as _e:
|
| 749 |
-
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 750 |
-
finally:
|
| 751 |
-
await ctx.close()
|
| 752 |
-
await browser.close()
|
| 753 |
-
except Exception as _e:
|
| 754 |
-
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 755 |
-
|
| 756 |
-
|
| 757 |
# ─── /screenshot ─────────────────────────────────────────────────────────────
|
| 758 |
|
| 759 |
@router.post("/screenshot", response_model=BrowserResult)
|
|
@@ -777,7 +731,7 @@ async def browser_screenshot(req: ScreenshotRequest, role: AuthRole = Depends(re
|
|
| 777 |
png = await page.screenshot(type="png", full_page=False)
|
| 778 |
title = await page.title()
|
| 779 |
png_b64 = base64.b64encode(png).decode()
|
| 780 |
-
asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
|
| 781 |
return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
|
| 782 |
except Exception as e:
|
| 783 |
return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
|
|
@@ -820,7 +774,7 @@ async def browser_navigate(req: NavigateRequest, role: AuthRole = Depends(requir
|
|
| 820 |
text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
|
| 821 |
|
| 822 |
png_b64 = base64.b64encode(png).decode()
|
| 823 |
-
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 824 |
ax_tree = await _get_ax_tree(page) # GAP-AX
|
| 825 |
return BrowserResult(
|
| 826 |
ok=True, screenshot_b64=png_b64, title=title,
|
|
@@ -885,7 +839,7 @@ async def browser_open(
|
|
| 885 |
"action_log": [],
|
| 886 |
"is_remote": _is_remote, # ARCH-7: True=CDP, False=locale
|
| 887 |
}
|
| 888 |
-
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 889 |
|
| 890 |
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 891 |
ax_tree = await _get_ax_tree(page) # GAP-AX: Accessibility Tree MCP-style
|
|
@@ -976,7 +930,7 @@ async def browser_act(
|
|
| 976 |
png = await page.screenshot(type="png", full_page=False)
|
| 977 |
png_b64 = base64.b64encode(png).decode()
|
| 978 |
result.screenshot_b64 = png_b64
|
| 979 |
-
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 980 |
|
| 981 |
return result
|
| 982 |
except Exception as e:
|
|
@@ -1010,7 +964,7 @@ async def browser_session_screenshot(session_id: str, full_page: bool = False, r
|
|
| 1010 |
png = await page.screenshot(type="png", full_page=full_page)
|
| 1011 |
title = await page.title()
|
| 1012 |
png_b64 = base64.b64encode(png).decode()
|
| 1013 |
-
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 1014 |
return BrowserResult(
|
| 1015 |
ok=True, session_id=session_id,
|
| 1016 |
screenshot_b64=png_b64, title=title, url=page.url,
|
|
|
|
| 260 |
Mantiene: role, name, description, value, checked, expanded, required.
|
| 261 |
Scarta: proprietà interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
|
| 262 |
"""
|
| 263 |
+
KEEP = frozenset({role, name, description, value, checked,
|
| 264 |
+
expanded, required, haspopup, level, pressed,
|
| 265 |
+
selected, multiselectable, orientation})
|
| 266 |
result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
|
| 267 |
+
if depth > 0 and node.get(children):
|
| 268 |
+
trimmed = [_trim_ax_tree(c, depth - 1) for c in node[children]]
|
| 269 |
# Filtra nodi completamente vuoti (solo role senza nome né figli)
|
| 270 |
+
trimmed = [c for c in trimmed if len(c) > 1 or c.get(children)]
|
| 271 |
if trimmed:
|
| 272 |
+
result[children] = trimmed
|
| 273 |
return result
|
| 274 |
|
| 275 |
|
|
|
|
| 494 |
await _close_session(sid, "TTL expired")
|
| 495 |
|
| 496 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
def _start_cleanup() -> None:
|
| 498 |
try:
|
| 499 |
loop = asyncio.get_event_loop()
|
| 500 |
if loop.is_running():
|
| 501 |
+
asyncio.create_task(_session_cleanup_loop())
|
| 502 |
except Exception as _e:
|
| 503 |
_logger.warning("_start_cleanup: create_task failed — cleanup loop not running: %s", _e)
|
| 504 |
|
|
|
|
| 708 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 709 |
|
| 710 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
# ─── /screenshot ─────────────────────────────────────────────────────────────
|
| 712 |
|
| 713 |
@router.post("/screenshot", response_model=BrowserResult)
|
|
|
|
| 731 |
png = await page.screenshot(type="png", full_page=False)
|
| 732 |
title = await page.title()
|
| 733 |
png_b64 = base64.b64encode(png).decode()
|
| 734 |
+
asyncio.create_task(_try_persist_screenshot(req.url, png_b64, title))
|
| 735 |
return BrowserResult(ok=True, screenshot_b64=png_b64, title=title, url=page.url)
|
| 736 |
except Exception as e:
|
| 737 |
return BrowserResult(ok=False, error=str(e)[:500]) # S599: 300→500
|
|
|
|
| 774 |
text = await _extract_text_trafilatura(page, req.url, max_chars=5000)
|
| 775 |
|
| 776 |
png_b64 = base64.b64encode(png).decode()
|
| 777 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 778 |
ax_tree = await _get_ax_tree(page) # GAP-AX
|
| 779 |
return BrowserResult(
|
| 780 |
ok=True, screenshot_b64=png_b64, title=title,
|
|
|
|
| 839 |
"action_log": [],
|
| 840 |
"is_remote": _is_remote, # ARCH-7: True=CDP, False=locale
|
| 841 |
}
|
| 842 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 843 |
|
| 844 |
dom = DomSnapshot(**dom_raw) if isinstance(dom_raw, dict) else None
|
| 845 |
ax_tree = await _get_ax_tree(page) # GAP-AX: Accessibility Tree MCP-style
|
|
|
|
| 930 |
png = await page.screenshot(type="png", full_page=False)
|
| 931 |
png_b64 = base64.b64encode(png).decode()
|
| 932 |
result.screenshot_b64 = png_b64
|
| 933 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 934 |
|
| 935 |
return result
|
| 936 |
except Exception as e:
|
|
|
|
| 964 |
png = await page.screenshot(type="png", full_page=full_page)
|
| 965 |
title = await page.title()
|
| 966 |
png_b64 = base64.b64encode(png).decode()
|
| 967 |
+
asyncio.create_task(_try_persist_screenshot(page.url, png_b64, title))
|
| 968 |
return BrowserResult(
|
| 969 |
ok=True, session_id=session_id,
|
| 970 |
screenshot_b64=png_b64, title=title, url=page.url,
|
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/conversations.py
CHANGED
|
@@ -34,7 +34,7 @@ class MessageIn(BaseModel):
|
|
| 34 |
@router.get('/api/conversations')
|
| 35 |
async def list_conversations():
|
| 36 |
try:
|
| 37 |
-
data = sb().table('conversations').select('*').order('updated_at', desc=True).
|
| 38 |
return {'conversations': data.data}
|
| 39 |
except Exception as exc:
|
| 40 |
_logger.warning("list_conversations: %s", exc)
|
|
@@ -78,7 +78,7 @@ async def delete_conversation(conv_id: str):
|
|
| 78 |
@router.get('/api/conversations/{conv_id}/messages')
|
| 79 |
async def list_messages(conv_id: str):
|
| 80 |
try:
|
| 81 |
-
data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').
|
| 82 |
return {'messages': data.data}
|
| 83 |
except Exception as exc:
|
| 84 |
_logger.warning("list_messages %s: %s", conv_id, exc)
|
|
|
|
| 34 |
@router.get('/api/conversations')
|
| 35 |
async def list_conversations():
|
| 36 |
try:
|
| 37 |
+
data = sb().table('conversations').select('*').order('updated_at', desc=True).execute()
|
| 38 |
return {'conversations': data.data}
|
| 39 |
except Exception as exc:
|
| 40 |
_logger.warning("list_conversations: %s", exc)
|
|
|
|
| 78 |
@router.get('/api/conversations/{conv_id}/messages')
|
| 79 |
async def list_messages(conv_id: str):
|
| 80 |
try:
|
| 81 |
+
data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').execute()
|
| 82 |
return {'messages': data.data}
|
| 83 |
except Exception as exc:
|
| 84 |
_logger.warning("list_messages %s: %s", conv_id, exc)
|
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,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, Depends
|
| 25 |
+
from .auth_guard import require_role, AuthRole
|
| 26 |
+
from pydantic import BaseModel
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/api/database", tags=["database"])
|
| 29 |
+
_logger = logging.getLogger("database_router")
|
| 30 |
+
|
| 31 |
+
# ─── Enumerazione Nodi Supabase ───────────────────────────────────────────
|
| 32 |
+
class SupabaseNode(str, Enum):
|
| 33 |
+
A = "A" # Analytics/Cache
|
| 34 |
+
B = "B" # PRIMARY (Sync/State)
|
| 35 |
+
C = "C" # Memory/RAG
|
| 36 |
+
D = "D" # Audit/Logging
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ─── Configurazione Nodi ──────────────────────────────────────────────────
|
| 40 |
+
SUPABASE_CONFIG = {
|
| 41 |
+
"A": {
|
| 42 |
+
"url": os.getenv("SUPABASE_URL_A", ""),
|
| 43 |
+
"key": os.getenv("SUPABASE_KEY_A", ""),
|
| 44 |
+
"role": "Analytics/Cache (Read-Heavy)",
|
| 45 |
+
"priority": 1, # Preferito per letture
|
| 46 |
+
},
|
| 47 |
+
"B": {
|
| 48 |
+
"url": os.getenv("SUPABASE_URL", ""), # PRIMARY
|
| 49 |
+
"key": os.getenv("SUPABASE_KEY", ""),
|
| 50 |
+
"role": "Sync/State (PRIMARY)",
|
| 51 |
+
"priority": 0, # Fallback universale
|
| 52 |
+
},
|
| 53 |
+
"C": {
|
| 54 |
+
"url": os.getenv("SUPABASE_URL_C", ""),
|
| 55 |
+
"key": os.getenv("SUPABASE_KEY_C", ""),
|
| 56 |
+
"role": "Memory/RAG/Embeddings",
|
| 57 |
+
"priority": 2,
|
| 58 |
+
},
|
| 59 |
+
"D": {
|
| 60 |
+
"url": os.getenv("SUPABASE_URL_D", ""),
|
| 61 |
+
"key": os.getenv("SUPABASE_KEY_D", ""),
|
| 62 |
+
"role": "Audit/Logging/Compliance",
|
| 63 |
+
"priority": 3,
|
| 64 |
+
},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# ─── Keyword Pericolosi (per read-only) ───────────────────────────────────
|
| 68 |
+
_DANGEROUS = frozenset(
|
| 69 |
+
{"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"}
|
| 70 |
+
)
|
| 71 |
+
_MAX_ROWS = 500
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ─── Modelli Pydantic ─────────────────────────────────────────────────────
|
| 75 |
+
class QueryRequest(BaseModel):
|
| 76 |
+
sql: str
|
| 77 |
+
params: list = []
|
| 78 |
+
read_only: bool = True
|
| 79 |
+
preferred_node: Optional[SupabaseNode] = None # Override routing logic
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class QueryResponse(BaseModel):
|
| 83 |
+
ok: bool
|
| 84 |
+
rows: list = []
|
| 85 |
+
columns: list = []
|
| 86 |
+
count: int = 0
|
| 87 |
+
truncated: bool = False
|
| 88 |
+
node_used: Optional[str] = None
|
| 89 |
+
error: Optional[str] = None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ─── Funzioni Utility ─────────────────────────────────────────────────────
|
| 93 |
+
def _is_dangerous(sql: str) -> Optional[str]:
|
| 94 |
+
"""Rilevamento keyword pericolose robusto contro CTE e multi-spazio."""
|
| 95 |
+
s_norm = " ".join(sql.strip().lower().split())
|
| 96 |
+
tokens = _re.split(r"[\s\(\),;]+", s_norm)
|
| 97 |
+
for tok in tokens:
|
| 98 |
+
if tok in _DANGEROUS:
|
| 99 |
+
return tok
|
| 100 |
+
for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm):
|
| 101 |
+
first_word = m.group(1).lower()
|
| 102 |
+
if first_word in _DANGEROUS:
|
| 103 |
+
return first_word
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]:
|
| 108 |
+
"""Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER)."""
|
| 109 |
+
s_norm = " ".join(sql.strip().upper().split())
|
| 110 |
+
if s_norm.startswith("SELECT"):
|
| 111 |
+
return "SELECT"
|
| 112 |
+
elif s_norm.startswith("INSERT"):
|
| 113 |
+
return "INSERT"
|
| 114 |
+
elif s_norm.startswith("UPDATE"):
|
| 115 |
+
return "UPDATE"
|
| 116 |
+
elif s_norm.startswith("DELETE"):
|
| 117 |
+
return "DELETE"
|
| 118 |
+
return "OTHER"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _detect_table_context(sql: str) -> Optional[str]:
|
| 122 |
+
"""Rileva il contesto della tabella per routing intelligente."""
|
| 123 |
+
sql_lower = sql.lower()
|
| 124 |
+
|
| 125 |
+
# Tabelle di memoria/RAG → Nodo C
|
| 126 |
+
if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]):
|
| 127 |
+
return "C"
|
| 128 |
+
|
| 129 |
+
# Tabelle di audit/logging → Nodo D
|
| 130 |
+
if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]):
|
| 131 |
+
return "D"
|
| 132 |
+
|
| 133 |
+
# Tabelle di stato globale → Nodo B
|
| 134 |
+
if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]):
|
| 135 |
+
return "B"
|
| 136 |
+
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _choose_node(
|
| 141 |
+
query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"],
|
| 142 |
+
table_context: Optional[str],
|
| 143 |
+
preferred_node: Optional[SupabaseNode],
|
| 144 |
+
) -> SupabaseNode:
|
| 145 |
+
"""
|
| 146 |
+
Logica di routing intelligente per scegliere il nodo Supabase.
|
| 147 |
+
|
| 148 |
+
Priorità:
|
| 149 |
+
1. preferred_node (override esplicito)
|
| 150 |
+
2. table_context (rilevamento tabella)
|
| 151 |
+
3. query_type (tipo di query)
|
| 152 |
+
4. Fallback a B (PRIMARY)
|
| 153 |
+
"""
|
| 154 |
+
# 1. Override esplicito
|
| 155 |
+
if preferred_node:
|
| 156 |
+
return preferred_node
|
| 157 |
+
|
| 158 |
+
# 2. Routing per contesto tabella
|
| 159 |
+
if table_context:
|
| 160 |
+
return SupabaseNode(table_context)
|
| 161 |
+
|
| 162 |
+
# 3. Routing per tipo query
|
| 163 |
+
if query_type == "SELECT":
|
| 164 |
+
# Preferisci A (read replica) se disponibile, altrimenti B
|
| 165 |
+
if SUPABASE_CONFIG["A"]["url"]:
|
| 166 |
+
return SupabaseNode.A
|
| 167 |
+
return SupabaseNode.B
|
| 168 |
+
elif query_type in ("INSERT", "UPDATE", "DELETE"):
|
| 169 |
+
# Sempre su B (PRIMARY)
|
| 170 |
+
return SupabaseNode.B
|
| 171 |
+
|
| 172 |
+
# 4. Fallback a B (PRIMARY)
|
| 173 |
+
return SupabaseNode.B
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ─── Endpoint Principale ──────────────────────────────────────────────────
|
| 177 |
+
@router.post("/query", response_model=QueryResponse)
|
| 178 |
+
async def database_query(
|
| 179 |
+
req: QueryRequest,
|
| 180 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 181 |
+
):
|
| 182 |
+
"""
|
| 183 |
+
Endpoint query con routing intelligente tra nodi Supabase A/B/C/D.
|
| 184 |
+
|
| 185 |
+
Parametri:
|
| 186 |
+
- sql: query SQL
|
| 187 |
+
- params: parametri query
|
| 188 |
+
- read_only: blocca query pericolose (default: true)
|
| 189 |
+
- preferred_node: forza un nodo specifico (opzionale)
|
| 190 |
+
|
| 191 |
+
Ritorna:
|
| 192 |
+
- ok: successo
|
| 193 |
+
- rows: righe risultato
|
| 194 |
+
- columns: nomi colonne
|
| 195 |
+
- count: numero righe
|
| 196 |
+
- truncated: se risultato è stato troncato
|
| 197 |
+
- node_used: nodo Supabase utilizzato
|
| 198 |
+
"""
|
| 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 |
+
# ── GAP-DB-QUERY-PUBLIC fix: forziamo read_only basandoci sul contenuto ──
|
| 227 |
+
# Non ci fidiamo di req.read_only dal client per la sicurezza.
|
| 228 |
+
_sql_upper = req.sql.upper()
|
| 229 |
+
_is_write = any(kw in _sql_upper for kw in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"])
|
| 230 |
+
|
| 231 |
+
# Se la query contiene keyword di scrittura, richiediamo esplicitamente permessi OPERATOR o superiore
|
| 232 |
+
# In questo endpoint MACHINE (default) permettiamo solo SELECT.
|
| 233 |
+
if _is_write and role < AuthRole.OPERATOR:
|
| 234 |
+
raise HTTPException(403, "Permessi insufficienti per query di scrittura (richiesto OPERATOR)")
|
| 235 |
+
|
| 236 |
+
# Verifica read-only
|
| 237 |
+
if req.read_only or not _is_write:
|
| 238 |
+
kw = _is_dangerous(req.sql)
|
| 239 |
+
if kw:
|
| 240 |
+
return QueryResponse(
|
| 241 |
+
ok=False,
|
| 242 |
+
error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.",
|
| 243 |
+
node_used=chosen_node.value,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# Esegui query
|
| 247 |
+
try:
|
| 248 |
+
result = await _execute_query(
|
| 249 |
+
node_config["url"],
|
| 250 |
+
node_config["key"],
|
| 251 |
+
req.sql,
|
| 252 |
+
req.params,
|
| 253 |
+
)
|
| 254 |
+
result["node_used"] = chosen_node.value
|
| 255 |
+
return QueryResponse(**result)
|
| 256 |
+
except Exception as e:
|
| 257 |
+
_logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}")
|
| 258 |
+
return QueryResponse(
|
| 259 |
+
ok=False,
|
| 260 |
+
error=str(e)[:400],
|
| 261 |
+
node_used=chosen_node.value,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ─── Esecuzione Query (Supabase PostgreSQL) ───────────────────────────────
|
| 266 |
+
async def _execute_query(url: str, key: str, sql: str, params: list) -> dict:
|
| 267 |
+
"""Esegue query su Supabase PostgreSQL."""
|
| 268 |
+
try:
|
| 269 |
+
import psycopg2
|
| 270 |
+
import psycopg2.extras
|
| 271 |
+
except ImportError:
|
| 272 |
+
return {
|
| 273 |
+
"ok": False,
|
| 274 |
+
"error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.",
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
def _run():
|
| 278 |
+
conn = psycopg2.connect(url)
|
| 279 |
+
try:
|
| 280 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
| 281 |
+
cur.execute(sql, params or None)
|
| 282 |
+
try:
|
| 283 |
+
rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)]
|
| 284 |
+
cols = [d.name for d in (cur.description or [])]
|
| 285 |
+
except psycopg2.ProgrammingError:
|
| 286 |
+
rows, cols = [], []
|
| 287 |
+
conn.commit()
|
| 288 |
+
finally:
|
| 289 |
+
conn.close()
|
| 290 |
+
return rows, cols
|
| 291 |
+
|
| 292 |
+
rows, cols = await asyncio.to_thread(_run)
|
| 293 |
+
return {
|
| 294 |
+
"ok": True,
|
| 295 |
+
"rows": rows,
|
| 296 |
+
"columns": cols,
|
| 297 |
+
"count": len(rows),
|
| 298 |
+
"truncated": len(rows) == _MAX_ROWS,
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ─── Endpoint Debug (info nodi) ───────────────────────────────────────────
|
| 303 |
+
@router.get("/nodes/status")
|
| 304 |
+
async def nodes_status():
|
| 305 |
+
"""Ritorna lo stato di configurazione di tutti i nodi Supabase."""
|
| 306 |
+
status = {}
|
| 307 |
+
for node_id, config in SUPABASE_CONFIG.items():
|
| 308 |
+
status[node_id] = {
|
| 309 |
+
"role": config["role"],
|
| 310 |
+
"configured": bool(config["url"]),
|
| 311 |
+
"url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET",
|
| 312 |
+
}
|
| 313 |
+
return {"nodes": status}
|
api/event_bus.py
DELETED
|
@@ -1,235 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
backend/api/event_bus.py — Event Bus pub/sub (Fase 1 ADR-S26-S30)
|
| 3 |
-
|
| 4 |
-
Responsabilità: TRASPORTARE eventi tra componenti in modo asincrono ed efimero.
|
| 5 |
-
NON persiste — per la persistenza usa event_store.py.
|
| 6 |
-
|
| 7 |
-
Architettura:
|
| 8 |
-
Publisher → Event Bus → [Subscriber1, Subscriber2, ...]
|
| 9 |
-
↓
|
| 10 |
-
Redis fanout (Upstash REST) per multi-process pub/sub
|
| 11 |
-
|
| 12 |
-
Topics predefiniti (espandibili via publish):
|
| 13 |
-
task.created | task.completed | task.failed
|
| 14 |
-
tool.started | tool.finished
|
| 15 |
-
memory.updated
|
| 16 |
-
response.generated
|
| 17 |
-
session.started | session.ended
|
| 18 |
-
workflow.started | workflow.step | workflow.completed | workflow.failed
|
| 19 |
-
|
| 20 |
-
Invarianti ADR:
|
| 21 |
-
S27: ogni evento pubblicato ha correlation_id tracciabile
|
| 22 |
-
S30: nessuna dipendenza da provider LLM specifico
|
| 23 |
-
|
| 24 |
-
Endpoints:
|
| 25 |
-
POST /api/events/publish — pubblica evento (auth: MACHINE)
|
| 26 |
-
GET /api/events/stream/{topic} — SSE stream (auth: MACHINE)
|
| 27 |
-
GET /api/events/bus/status — diagnostica (auth: MACHINE)
|
| 28 |
-
"""
|
| 29 |
-
import asyncio, json, time, uuid, logging, os
|
| 30 |
-
from typing import AsyncIterator
|
| 31 |
-
from fastapi import APIRouter, Depends, Request
|
| 32 |
-
from fastapi.responses import StreamingResponse
|
| 33 |
-
from pydantic import BaseModel, Field
|
| 34 |
-
from .auth_guard import require_role, AuthRole
|
| 35 |
-
from .state import safe_json_dumps
|
| 36 |
-
|
| 37 |
-
_logger = logging.getLogger("api.event_bus")
|
| 38 |
-
|
| 39 |
-
router = APIRouter(
|
| 40 |
-
prefix="/api/events",
|
| 41 |
-
tags=["event-bus"],
|
| 42 |
-
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
# ── In-memory subscriber registry ──────────────────────────────────────────────
|
| 46 |
-
# topic → set of asyncio.Queue (one per active SSE subscriber)
|
| 47 |
-
_subscribers: dict[str, set[asyncio.Queue]] = {}
|
| 48 |
-
_subscribers_lock = asyncio.Lock()
|
| 49 |
-
|
| 50 |
-
# ── Predefined topics (open set — publishers can add arbitrary topics) ─────────
|
| 51 |
-
BUILTIN_TOPICS = {
|
| 52 |
-
"task.created", "task.completed", "task.failed",
|
| 53 |
-
"tool.started", "tool.finished",
|
| 54 |
-
"memory.updated",
|
| 55 |
-
"response.generated",
|
| 56 |
-
"session.started", "session.ended",
|
| 57 |
-
"workflow.started", "workflow.step", "workflow.completed", "workflow.failed",
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
# ── Max queue depth per subscriber (prevents memory leak on slow clients) ──────
|
| 61 |
-
_MAX_QUEUE_DEPTH = int(os.getenv("EVENT_BUS_QUEUE_DEPTH", "256"))
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
# ── Pydantic models ────────────────────────────────────────────────────────────
|
| 65 |
-
|
| 66 |
-
class BusEvent(BaseModel):
|
| 67 |
-
topic: str = Field(..., description="Topic dell'evento (es. task.created)")
|
| 68 |
-
payload: dict = Field(default_factory=dict)
|
| 69 |
-
correlation_id: str | None = Field(None, description="ID tracciabilità cross-componente (S27)")
|
| 70 |
-
session_id: str | None = None
|
| 71 |
-
source: str | None = None # componente mittente (es. 'planner', 'executor')
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
class PublishResponse(BaseModel):
|
| 75 |
-
event_id: str
|
| 76 |
-
topic: str
|
| 77 |
-
delivered_to: int # numero di subscriber locali notificati
|
| 78 |
-
redis_fanout: bool # True se fanout Redis riuscito
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
# ── Redis fanout (fire-and-forget, non bloccante) ───────────────────────────────
|
| 82 |
-
|
| 83 |
-
async def _redis_fanout(topic: str, event_payload: dict) -> bool:
|
| 84 |
-
"""Pubblica l'evento su Redis LIST per fanout multi-process. Non lancia mai."""
|
| 85 |
-
try:
|
| 86 |
-
import httpx
|
| 87 |
-
redis_url = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 88 |
-
redis_token = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
| 89 |
-
if not redis_url or not redis_token:
|
| 90 |
-
return False
|
| 91 |
-
key = f"eb:{topic}"
|
| 92 |
-
data = json.dumps(event_payload)
|
| 93 |
-
async with httpx.AsyncClient(timeout=1.5) as c:
|
| 94 |
-
await c.post(
|
| 95 |
-
redis_url,
|
| 96 |
-
json=["LPUSH", key, data],
|
| 97 |
-
headers={"Authorization": f"Bearer {redis_token}"},
|
| 98 |
-
)
|
| 99 |
-
# TTL 60s: un evento non consumato entro 60s viene scartato
|
| 100 |
-
await c.post(
|
| 101 |
-
redis_url,
|
| 102 |
-
json=["EXPIRE", key, 60],
|
| 103 |
-
headers={"Authorization": f"Bearer {redis_token}"},
|
| 104 |
-
)
|
| 105 |
-
return True
|
| 106 |
-
except Exception as exc:
|
| 107 |
-
_logger.debug("[event_bus] redis fanout skip: %s", exc)
|
| 108 |
-
return False
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
# ── Core publish ───────────────────────────────────────────────────────────────
|
| 112 |
-
|
| 113 |
-
async def publish(
|
| 114 |
-
topic: str,
|
| 115 |
-
payload: dict,
|
| 116 |
-
correlation_id: str | None = None,
|
| 117 |
-
session_id: str | None = None,
|
| 118 |
-
source: str | None = None,
|
| 119 |
-
) -> dict:
|
| 120 |
-
"""
|
| 121 |
-
Pubblica un evento sul bus. Chiamabile internamente da qualsiasi modulo.
|
| 122 |
-
Restituisce il dizionario evento con event_id assegnato.
|
| 123 |
-
"""
|
| 124 |
-
event = {
|
| 125 |
-
"event_id": str(uuid.uuid4()),
|
| 126 |
-
"topic": topic,
|
| 127 |
-
"payload": payload,
|
| 128 |
-
"correlation_id": correlation_id or str(uuid.uuid4()),
|
| 129 |
-
"session_id": session_id,
|
| 130 |
-
"source": source,
|
| 131 |
-
"timestamp": time.time(),
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
delivered = 0
|
| 135 |
-
async with _subscribers_lock:
|
| 136 |
-
queues = _subscribers.get(topic, set()).copy()
|
| 137 |
-
|
| 138 |
-
for q in queues:
|
| 139 |
-
try:
|
| 140 |
-
q.put_nowait(event)
|
| 141 |
-
delivered += 1
|
| 142 |
-
except asyncio.QueueFull:
|
| 143 |
-
_logger.warning("[event_bus] subscriber queue full on topic=%s — drop event", topic)
|
| 144 |
-
|
| 145 |
-
# Redis fanout asincrono (non attende)
|
| 146 |
-
asyncio.create_task(_redis_fanout(topic, event))
|
| 147 |
-
|
| 148 |
-
_logger.debug("[event_bus] published topic=%s event_id=%s delivered_local=%d",
|
| 149 |
-
topic, event["event_id"][:8], delivered)
|
| 150 |
-
return event
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 154 |
-
|
| 155 |
-
@router.post("/publish", response_model=PublishResponse, summary="Pubblica evento sul bus")
|
| 156 |
-
async def publish_event(evt: BusEvent) -> PublishResponse:
|
| 157 |
-
"""
|
| 158 |
-
Pubblica un evento su un topic. I subscriber SSE attivi ricevono l'evento
|
| 159 |
-
immediatamente. Redis fanout notifica i processi remoti.
|
| 160 |
-
"""
|
| 161 |
-
result = await publish(
|
| 162 |
-
topic=evt.topic,
|
| 163 |
-
payload=evt.payload,
|
| 164 |
-
correlation_id=evt.correlation_id,
|
| 165 |
-
session_id=evt.session_id,
|
| 166 |
-
source=evt.source,
|
| 167 |
-
)
|
| 168 |
-
async with _subscribers_lock:
|
| 169 |
-
n = len(_subscribers.get(evt.topic, set()))
|
| 170 |
-
return PublishResponse(
|
| 171 |
-
event_id=result["event_id"],
|
| 172 |
-
topic=evt.topic,
|
| 173 |
-
delivered_to=n,
|
| 174 |
-
redis_fanout=True, # ottimistico — fanout è fire-and-forget
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
@router.get("/stream/{topic}", summary="SSE stream eventi per topic")
|
| 179 |
-
async def stream_events(topic: str, request: Request):
|
| 180 |
-
"""
|
| 181 |
-
Server-Sent Events stream per un topic specifico.
|
| 182 |
-
Il client rimane connesso e riceve ogni evento pubblicato su quel topic.
|
| 183 |
-
La connessione si chiude quando il client disconnette.
|
| 184 |
-
"""
|
| 185 |
-
q: asyncio.Queue = asyncio.Queue(maxsize=_MAX_QUEUE_DEPTH)
|
| 186 |
-
|
| 187 |
-
async with _subscribers_lock:
|
| 188 |
-
if topic not in _subscribers:
|
| 189 |
-
_subscribers[topic] = set()
|
| 190 |
-
_subscribers[topic].add(q)
|
| 191 |
-
|
| 192 |
-
_logger.info("[event_bus] SSE subscribe topic=%s (total=%d)",
|
| 193 |
-
topic, len(_subscribers.get(topic, set())))
|
| 194 |
-
|
| 195 |
-
async def generator() -> AsyncIterator[str]:
|
| 196 |
-
try:
|
| 197 |
-
yield f"data: {json.dumps({'type': 'connected', 'topic': topic})}\n\n"
|
| 198 |
-
while True:
|
| 199 |
-
if await request.is_disconnected():
|
| 200 |
-
break
|
| 201 |
-
try:
|
| 202 |
-
event = await asyncio.wait_for(q.get(), timeout=15.0)
|
| 203 |
-
yield f"data: {safe_json_dumps(event)}\n\n"
|
| 204 |
-
except asyncio.TimeoutError:
|
| 205 |
-
# heartbeat keepalive
|
| 206 |
-
yield f": keepalive {int(time.time())}\n\n"
|
| 207 |
-
finally:
|
| 208 |
-
async with _subscribers_lock:
|
| 209 |
-
_subscribers.get(topic, set()).discard(q)
|
| 210 |
-
_logger.info("[event_bus] SSE unsubscribe topic=%s", topic)
|
| 211 |
-
|
| 212 |
-
return StreamingResponse(
|
| 213 |
-
generator(),
|
| 214 |
-
media_type="text/event-stream",
|
| 215 |
-
headers={
|
| 216 |
-
"Cache-Control": "no-cache",
|
| 217 |
-
"X-Accel-Buffering": "no",
|
| 218 |
-
"Connection": "keep-alive",
|
| 219 |
-
},
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
@router.get("/bus/status", summary="Diagnostica Event Bus")
|
| 224 |
-
async def bus_status():
|
| 225 |
-
"""Restituisce il numero di subscriber attivi per topic."""
|
| 226 |
-
async with _subscribers_lock:
|
| 227 |
-
status = {t: len(qs) for t, qs in _subscribers.items()}
|
| 228 |
-
total = sum(status.values())
|
| 229 |
-
return {
|
| 230 |
-
"status": "ok",
|
| 231 |
-
"component": "event_bus",
|
| 232 |
-
"topics": status,
|
| 233 |
-
"total_subscribers": total,
|
| 234 |
-
"builtin_topics": sorted(BUILTIN_TOPICS),
|
| 235 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/event_store.py
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
backend/api/event_store.py — Event Store (persistenza, Fase 1 ADR-S26-S30)
|
| 3 |
-
|
| 4 |
-
Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
|
| 5 |
-
NON instrada — per pub/sub usa event_bus.py.
|
| 6 |
-
|
| 7 |
-
Schema Supabase (tabella `event_store`, auto-created se non esiste):
|
| 8 |
-
id UUID PK default gen_random_uuid()
|
| 9 |
-
topic TEXT NOT NULL
|
| 10 |
-
payload JSONB NOT NULL default '{}'
|
| 11 |
-
correlation_id TEXT
|
| 12 |
-
session_id TEXT
|
| 13 |
-
source TEXT
|
| 14 |
-
created_at TIMESTAMPTZ NOT NULL default now()
|
| 15 |
-
|
| 16 |
-
Invarianti ADR:
|
| 17 |
-
S26: ogni workflow (e ogni evento del workflow) è persistente
|
| 18 |
-
S27: correlation_id garantisce tracciabilità cross-componente
|
| 19 |
-
|
| 20 |
-
Endpoints:
|
| 21 |
-
POST /api/events/store — salva evento (auth: MACHINE)
|
| 22 |
-
GET /api/events/replay — replay eventi filtrati (auth: MACHINE)
|
| 23 |
-
GET /api/events/store/{id} — recupera evento singolo (auth: MACHINE)
|
| 24 |
-
GET /api/events/store/status — diagnostica store (auth: MACHINE)
|
| 25 |
-
"""
|
| 26 |
-
import json, time, uuid, logging, os
|
| 27 |
-
from typing import Any
|
| 28 |
-
from fastapi import APIRouter, Depends, Query, HTTPException
|
| 29 |
-
from pydantic import BaseModel, Field
|
| 30 |
-
from .auth_guard import require_role, AuthRole
|
| 31 |
-
from .state import _sb
|
| 32 |
-
|
| 33 |
-
_logger = logging.getLogger("api.event_store")
|
| 34 |
-
|
| 35 |
-
router = APIRouter(
|
| 36 |
-
prefix="/api/events",
|
| 37 |
-
tags=["event-store"],
|
| 38 |
-
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
_TABLE = "event_store"
|
| 42 |
-
|
| 43 |
-
# ── Auto-create table (best-effort, richiede service role key) ─────────────────
|
| 44 |
-
|
| 45 |
-
_TABLE_CREATED = False
|
| 46 |
-
|
| 47 |
-
async def _ensure_table() -> bool:
|
| 48 |
-
"""Crea la tabella event_store su Supabase se non esiste. Best-effort."""
|
| 49 |
-
global _TABLE_CREATED
|
| 50 |
-
if _TABLE_CREATED:
|
| 51 |
-
return True
|
| 52 |
-
if not _sb:
|
| 53 |
-
return False
|
| 54 |
-
try:
|
| 55 |
-
# Prova una SELECT — se la tabella non esiste, Supabase ritorna un errore
|
| 56 |
-
res = _sb.table(_TABLE).select("id").limit(1).execute()
|
| 57 |
-
_TABLE_CREATED = True
|
| 58 |
-
return True
|
| 59 |
-
except Exception as exc:
|
| 60 |
-
_logger.warning("[event_store] tabella '%s' non raggiungibile: %s — "
|
| 61 |
-
"crea manualmente con migration Supabase", _TABLE, exc)
|
| 62 |
-
return False
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
# ── Pydantic models ────────────────────────────────────────────────────────────
|
| 66 |
-
|
| 67 |
-
class StoreEventRequest(BaseModel):
|
| 68 |
-
topic: str
|
| 69 |
-
payload: dict = Field(default_factory=dict)
|
| 70 |
-
correlation_id: str | None = None
|
| 71 |
-
session_id: str | None = None
|
| 72 |
-
source: str | None = None
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class StoredEvent(BaseModel):
|
| 76 |
-
id: str
|
| 77 |
-
topic: str
|
| 78 |
-
payload: dict
|
| 79 |
-
correlation_id: str | None
|
| 80 |
-
session_id: str | None
|
| 81 |
-
source: str | None
|
| 82 |
-
created_at: str | None
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
| 86 |
-
|
| 87 |
-
@router.post("/store", summary="Salva evento nello store")
|
| 88 |
-
async def store_event(req: StoreEventRequest) -> StoredEvent:
|
| 89 |
-
"""
|
| 90 |
-
Persiste un evento nel Supabase Event Store.
|
| 91 |
-
Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
|
| 92 |
-
dai componenti che vogliono garantire persistenza.
|
| 93 |
-
"""
|
| 94 |
-
await _ensure_table()
|
| 95 |
-
if not _sb:
|
| 96 |
-
raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
|
| 97 |
-
|
| 98 |
-
record = {
|
| 99 |
-
"topic": req.topic,
|
| 100 |
-
"payload": req.payload,
|
| 101 |
-
"correlation_id": req.correlation_id or str(uuid.uuid4()),
|
| 102 |
-
"session_id": req.session_id,
|
| 103 |
-
"source": req.source,
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
try:
|
| 107 |
-
res = _sb.table(_TABLE).insert(record).execute()
|
| 108 |
-
row = res.data[0] if res.data else {**record, "id": str(uuid.uuid4()), "created_at": None}
|
| 109 |
-
return StoredEvent(**{
|
| 110 |
-
"id": row.get("id", ""),
|
| 111 |
-
"topic": row.get("topic", req.topic),
|
| 112 |
-
"payload": row.get("payload", req.payload),
|
| 113 |
-
"correlation_id": row.get("correlation_id"),
|
| 114 |
-
"session_id": row.get("session_id"),
|
| 115 |
-
"source": row.get("source"),
|
| 116 |
-
"created_at": str(row.get("created_at", "")),
|
| 117 |
-
})
|
| 118 |
-
except Exception as exc:
|
| 119 |
-
_logger.error("[event_store] insert failed: %s", exc)
|
| 120 |
-
raise HTTPException(500, detail=f"Event Store insert error: {exc}")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
@router.get("/replay", summary="Replay eventi filtrati")
|
| 124 |
-
async def replay_events(
|
| 125 |
-
topic: str | None = Query(None, description="Filtra per topic"),
|
| 126 |
-
session_id: str | None = Query(None, description="Filtra per session_id"),
|
| 127 |
-
correlation_id: str | None = Query(None, description="Filtra per correlation_id"),
|
| 128 |
-
from_ts: float | None = Query(None, description="Unix timestamp minimo (created_at >=)"),
|
| 129 |
-
limit: int = Query(100, ge=1, le=1000),
|
| 130 |
-
):
|
| 131 |
-
"""
|
| 132 |
-
Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
|
| 133 |
-
test di regressione e audit trail.
|
| 134 |
-
"""
|
| 135 |
-
await _ensure_table()
|
| 136 |
-
if not _sb:
|
| 137 |
-
raise HTTPException(503, detail="Event Store non disponibile")
|
| 138 |
-
|
| 139 |
-
try:
|
| 140 |
-
q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
|
| 141 |
-
if topic: q = q.eq("topic", topic)
|
| 142 |
-
if session_id: q = q.eq("session_id", session_id)
|
| 143 |
-
if correlation_id: q = q.eq("correlation_id", correlation_id)
|
| 144 |
-
if from_ts:
|
| 145 |
-
import datetime
|
| 146 |
-
dt = datetime.datetime.utcfromtimestamp(from_ts).isoformat() + "Z"
|
| 147 |
-
q = q.gte("created_at", dt)
|
| 148 |
-
|
| 149 |
-
res = q.execute()
|
| 150 |
-
return {
|
| 151 |
-
"events": res.data or [],
|
| 152 |
-
"count": len(res.data or []),
|
| 153 |
-
"filters": {
|
| 154 |
-
"topic": topic, "session_id": session_id,
|
| 155 |
-
"correlation_id": correlation_id, "from_ts": from_ts, "limit": limit,
|
| 156 |
-
},
|
| 157 |
-
}
|
| 158 |
-
except Exception as exc:
|
| 159 |
-
_logger.error("[event_store] replay failed: %s", exc)
|
| 160 |
-
raise HTTPException(500, detail=f"Event Store query error: {exc}")
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
@router.get("/store/{event_id}", summary="Recupera evento singolo")
|
| 164 |
-
async def get_event(event_id: str) -> StoredEvent:
|
| 165 |
-
"""Recupera un evento specifico per ID."""
|
| 166 |
-
await _ensure_table()
|
| 167 |
-
if not _sb:
|
| 168 |
-
raise HTTPException(503, detail="Event Store non disponibile")
|
| 169 |
-
try:
|
| 170 |
-
res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
|
| 171 |
-
if not res.data:
|
| 172 |
-
raise HTTPException(404, detail=f"Evento {event_id} non trovato")
|
| 173 |
-
row = res.data[0]
|
| 174 |
-
return StoredEvent(**{
|
| 175 |
-
"id": row.get("id", event_id),
|
| 176 |
-
"topic": row.get("topic", ""),
|
| 177 |
-
"payload": row.get("payload", {}),
|
| 178 |
-
"correlation_id": row.get("correlation_id"),
|
| 179 |
-
"session_id": row.get("session_id"),
|
| 180 |
-
"source": row.get("source"),
|
| 181 |
-
"created_at": str(row.get("created_at", "")),
|
| 182 |
-
})
|
| 183 |
-
except HTTPException:
|
| 184 |
-
raise
|
| 185 |
-
except Exception as exc:
|
| 186 |
-
raise HTTPException(500, detail=f"Event Store get error: {exc}")
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
@router.get("/store/status", summary="Diagnostica Event Store")
|
| 190 |
-
async def store_status():
|
| 191 |
-
"""Verifica connettività dello store e restituisce statistiche."""
|
| 192 |
-
if not _sb:
|
| 193 |
-
return {"status": "unavailable", "reason": "Supabase non configurato"}
|
| 194 |
-
try:
|
| 195 |
-
res = _sb.table(_TABLE).select("topic", count="exact").execute()
|
| 196 |
-
total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
|
| 197 |
-
return {
|
| 198 |
-
"status": "ok",
|
| 199 |
-
"component": "event_store",
|
| 200 |
-
"total_events": total,
|
| 201 |
-
"table": _TABLE,
|
| 202 |
-
}
|
| 203 |
-
except Exception as exc:
|
| 204 |
-
return {"status": "error", "detail": str(exc)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/exec.py
CHANGED
|
@@ -3,11 +3,6 @@ import os, asyncio, sys, tempfile, time, resource as _resource, signal as _signa
|
|
| 3 |
import re as _re_exec
|
| 4 |
from tools._shell_safety import validate_shell_command as _validate_shell
|
| 5 |
import ast as _ast_mod
|
| 6 |
-
import inspect as _inspect
|
| 7 |
-
try:
|
| 8 |
-
import httpx as _httpx_fix # type: ignore[import-untyped]
|
| 9 |
-
except ImportError:
|
| 10 |
-
_httpx_fix = None # type: ignore[assignment] # httpx opzionale
|
| 11 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 12 |
from pydantic import BaseModel, model_validator
|
| 13 |
from .auth_guard import require_role, AuthRole
|
|
@@ -542,6 +537,7 @@ async def llm_fix_code(
|
|
| 542 |
return text
|
| 543 |
|
| 544 |
async def _call_openai_compat(base_url: str, api_key: str, model: str) -> str | None:
|
|
|
|
| 545 |
payload = {
|
| 546 |
'model': model,
|
| 547 |
'max_tokens': 2000,
|
|
@@ -568,14 +564,15 @@ async def llm_fix_code(
|
|
| 568 |
_FIX_CHAIN = []
|
| 569 |
groq_key = os.getenv('GROQ_API_KEY', '')
|
| 570 |
if groq_key:
|
| 571 |
-
_FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, '
|
|
|
|
| 572 |
|
| 573 |
or_key = os.getenv('OPENROUTER_API_KEY', '')
|
| 574 |
if or_key:
|
| 575 |
for m in [
|
| 576 |
-
'
|
| 577 |
-
'
|
| 578 |
-
'
|
| 579 |
]:
|
| 580 |
_FIX_CHAIN.append(('https://openrouter.ai/api/v1', or_key, m))
|
| 581 |
|
|
@@ -622,13 +619,15 @@ async def exec_tool_dispatch(
|
|
| 622 |
if not _fn:
|
| 623 |
return {'ok': False, 'error': f"Tool '{req.tool}' non ha handler (_fn) — non eseguibile via dispatcher"}
|
| 624 |
try:
|
| 625 |
-
|
|
|
|
| 626 |
result = await _fn(**req.args)
|
| 627 |
else:
|
| 628 |
result = _fn(**req.args)
|
| 629 |
return {'ok': True, 'tool': req.tool, 'result': result}
|
| 630 |
except TypeError as _te:
|
| 631 |
# Parametri sbagliati — mostra la firma corretta
|
|
|
|
| 632 |
_sig = str(_inspect.signature(_fn))
|
| 633 |
return {'ok': False, 'error': f"Parametri non validi per '{req.tool}'{_sig}: {str(_te)[:200]}"}
|
| 634 |
except Exception as _e:
|
|
|
|
| 3 |
import re as _re_exec
|
| 4 |
from tools._shell_safety import validate_shell_command as _validate_shell
|
| 5 |
import ast as _ast_mod
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 7 |
from pydantic import BaseModel, model_validator
|
| 8 |
from .auth_guard import require_role, AuthRole
|
|
|
|
| 537 |
return text
|
| 538 |
|
| 539 |
async def _call_openai_compat(base_url: str, api_key: str, model: str) -> str | None:
|
| 540 |
+
import httpx as _httpx_fix
|
| 541 |
payload = {
|
| 542 |
'model': model,
|
| 543 |
'max_tokens': 2000,
|
|
|
|
| 564 |
_FIX_CHAIN = []
|
| 565 |
groq_key = os.getenv('GROQ_API_KEY', '')
|
| 566 |
if groq_key:
|
| 567 |
+
_FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.3-70b-versatile'))
|
| 568 |
+
_FIX_CHAIN.append(('https://api.groq.com/openai/v1', groq_key, 'llama-3.1-8b-instant'))
|
| 569 |
|
| 570 |
or_key = os.getenv('OPENROUTER_API_KEY', '')
|
| 571 |
if or_key:
|
| 572 |
for m in [
|
| 573 |
+
'meta-llama/llama-3.1-8b-instruct:free',
|
| 574 |
+
'mistralai/mistral-7b-instruct:free',
|
| 575 |
+
'qwen/qwen-2.5-coder-7b-instruct:free',
|
| 576 |
]:
|
| 577 |
_FIX_CHAIN.append(('https://openrouter.ai/api/v1', or_key, m))
|
| 578 |
|
|
|
|
| 619 |
if not _fn:
|
| 620 |
return {'ok': False, 'error': f"Tool '{req.tool}' non ha handler (_fn) — non eseguibile via dispatcher"}
|
| 621 |
try:
|
| 622 |
+
import asyncio as _asyncio
|
| 623 |
+
if _asyncio.iscoroutinefunction(_fn):
|
| 624 |
result = await _fn(**req.args)
|
| 625 |
else:
|
| 626 |
result = _fn(**req.args)
|
| 627 |
return {'ok': True, 'tool': req.tool, 'result': result}
|
| 628 |
except TypeError as _te:
|
| 629 |
# Parametri sbagliati — mostra la firma corretta
|
| 630 |
+
import inspect as _inspect
|
| 631 |
_sig = str(_inspect.signature(_fn))
|
| 632 |
return {'ok': False, 'error': f"Parametri non validi per '{req.tool}'{_sig}: {str(_te)[:200]}"}
|
| 633 |
except Exception as _e:
|
api/files.py
CHANGED
|
@@ -101,17 +101,12 @@ async def _write_file_internal(path: str, content: str,
|
|
| 101 |
asyncio.create_task(_lint_and_update_manifest(
|
| 102 |
_fid, content, body["language"], path, body["conversation_id"],
|
| 103 |
content_updated_at=_saved_at,
|
| 104 |
-
))
|
| 105 |
return saved
|
| 106 |
except Exception as exc:
|
| 107 |
import logging as _lg
|
| 108 |
_lg.getLogger("files").warning("_write_file_internal %s: %s", path, exc)
|
| 109 |
return body # in-memory fallback
|
| 110 |
-
def _log_files_exc(t): # BUGFIX: log eccezioni background da create_task
|
| 111 |
-
if not t.cancelled() and t.exception():
|
| 112 |
-
import logging
|
| 113 |
-
logging.getLogger("files").warning("[files] bg task raised: %s", t.exception())
|
| 114 |
-
|
| 115 |
|
| 116 |
@router.get('/api/files')
|
| 117 |
async def list_files(conversation_id: Optional[str] = None):
|
|
@@ -171,7 +166,7 @@ async def save_file(body: dict = Body(...)):
|
|
| 171 |
asyncio.create_task(_lint_and_update_manifest(
|
| 172 |
_file_id, _content, _lang, _path, _conv_id,
|
| 173 |
content_updated_at=_saved_at,
|
| 174 |
-
))
|
| 175 |
return {'file': saved}
|
| 176 |
|
| 177 |
|
|
@@ -245,7 +240,7 @@ async def update_file(file_id: str, body: dict = Body(...)):
|
|
| 245 |
asyncio.create_task(_lint_and_update_manifest(
|
| 246 |
file_id, _content, _lang, _path, _conv_id,
|
| 247 |
content_updated_at=_saved_at,
|
| 248 |
-
))
|
| 249 |
return {'file': saved}
|
| 250 |
except HTTPException:
|
| 251 |
raise
|
|
|
|
| 101 |
asyncio.create_task(_lint_and_update_manifest(
|
| 102 |
_fid, content, body["language"], path, body["conversation_id"],
|
| 103 |
content_updated_at=_saved_at,
|
| 104 |
+
))
|
| 105 |
return saved
|
| 106 |
except Exception as exc:
|
| 107 |
import logging as _lg
|
| 108 |
_lg.getLogger("files").warning("_write_file_internal %s: %s", path, exc)
|
| 109 |
return body # in-memory fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
@router.get('/api/files')
|
| 112 |
async def list_files(conversation_id: Optional[str] = None):
|
|
|
|
| 166 |
asyncio.create_task(_lint_and_update_manifest(
|
| 167 |
_file_id, _content, _lang, _path, _conv_id,
|
| 168 |
content_updated_at=_saved_at,
|
| 169 |
+
))
|
| 170 |
return {'file': saved}
|
| 171 |
|
| 172 |
|
|
|
|
| 240 |
asyncio.create_task(_lint_and_update_manifest(
|
| 241 |
file_id, _content, _lang, _path, _conv_id,
|
| 242 |
content_updated_at=_saved_at,
|
| 243 |
+
))
|
| 244 |
return {'file': saved}
|
| 245 |
except HTTPException:
|
| 246 |
raise
|