Spaces:
Running
Running
sync: 171 files from Baida98/AI [deploy-all]
#1
by Baida07 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .env.example +10 -10
- .gitattributes +35 -0
- Dockerfile +1 -1
- REBUILD_TRIGGER +1 -0
- agents/audit_semantic_l2.py +303 -0
- agents/context_manager.py +0 -23
- agents/engineering_state.py +0 -255
- agents/executor.py +59 -49
- 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/strategic_healer.py +0 -11
- agents/unified_loop.py +68 -460
- 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 +544 -367
- 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 +127 -0
- api/admin_state.py +0 -75
- api/advanced_complex_benchmark.py +108 -0
- api/agent.py +47 -475
- api/agent_checkpoint.py +0 -131
- api/agent_checkpoint_routes.py +279 -0
- api/agent_fsm.py +375 -0
- api/agent_loop_routes.py +410 -0
- api/agent_memory.py +38 -46
- api/agent_task_routes.py +800 -0
- api/agent_telemetry.py +0 -51
- api/auth_guard.py +13 -145
- api/auth_managed.py +1 -24
- api/background_tasks.py +0 -53
- api/benchmark.py +1 -1
- api/benchmark_handler.py +43 -92
- api/benchmarks_hub.py +100 -0
- api/bootstrap_tools.py +44 -0
- api/brain_planner.py +253 -0
- api/browser.py +7 -48
- api/cache_endpoints.py +164 -0
.env.example
CHANGED
|
@@ -12,17 +12,20 @@ VAULT_KEY= # AES-256 Hex
|
|
| 12 |
NOTIFY_TOKEN= # Notifiche Interne
|
| 13 |
|
| 14 |
# ββ 2. Quadrante A (BRAIN - Primary) βββββββββββββββββββββββββ
|
| 15 |
-
BACKEND_URL=https://
|
| 16 |
RAILWAY_TOKEN=
|
| 17 |
RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
|
| 18 |
SUPABASE_URL=
|
| 19 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 20 |
GITHUB_TOKEN=
|
| 21 |
-
# Hugging Face Router: endpoint OpenAI-compatible per inferenza.
|
| 22 |
HF_TOKEN=
|
| 23 |
-
|
| 24 |
-
#
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# ββ 3. Quadrante B (HANDS - Collab/Failover) βββββββββββββββββ
|
| 28 |
RAILWAY_TOKEN_B=
|
|
@@ -51,11 +54,9 @@ RAILWAY_PROJECT_ID_E=YOUR_RAILWAY_PROJECT_ID_E
|
|
| 51 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 52 |
GROQ_API_KEY=
|
| 53 |
OPENROUTER_API_KEY=
|
| 54 |
-
# Pool opzionale: JSON senza loggare le chiavi. Ogni profilo deve avere profile e api_key.
|
| 55 |
-
# Esempio: OPENROUTER_PROFILES_JSON=[{"profile":"primary","api_key":"..."},{"profile":"backup","api_key":"..."}]
|
| 56 |
-
OPENROUTER_PROFILES_JSON=
|
| 57 |
GEMINI_API_KEY=
|
| 58 |
NVIDIA_API_KEY=
|
|
|
|
| 59 |
|
| 60 |
# ββ 8. Sandboxes & Tools βββββββββββββββββββββββββββββββββββββ
|
| 61 |
E2B_API_KEY=
|
|
@@ -68,5 +69,4 @@ UPSTASH_REDIS_REST_TOKEN=
|
|
| 68 |
# ββ 9. Feature Flags βββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 70 |
UNIFIED_LOOP_MAX_STEPS=8
|
| 71 |
-
LLM_MODEL=
|
| 72 |
-
|
|
|
|
| 12 |
NOTIFY_TOKEN= # Notifiche Interne
|
| 13 |
|
| 14 |
# ββ 2. Quadrante A (BRAIN - Primary) βββββββββββββββββββββββββ
|
| 15 |
+
BACKEND_URL=https://arjanit98-terminal.hf.space
|
| 16 |
RAILWAY_TOKEN=
|
| 17 |
RAILWAY_PROJECT_ID=YOUR_RAILWAY_PROJECT_ID_A
|
| 18 |
SUPABASE_URL=
|
| 19 |
SUPABASE_SERVICE_ROLE_KEY=
|
| 20 |
GITHUB_TOKEN=
|
|
|
|
| 21 |
HF_TOKEN=
|
| 22 |
+
# HF Spaces URLs (configurare per ogni Space del fleet)
|
| 23 |
+
HF_SPACE_URL= # Brain / Backend principale
|
| 24 |
+
HF_SPACE_B_URL= # Daemon / Telegram worker
|
| 25 |
+
HF_SPACE_C_URL= # Worker A (Collab/GPU)
|
| 26 |
+
HF_SPACE_D_URL= # Worker B
|
| 27 |
+
HF_SPACE_E_URL= # Worker C
|
| 28 |
+
ORACLE_CLOUD_VM_URL= # Oracle Cloud A1 compute VM
|
| 29 |
|
| 30 |
# ββ 3. Quadrante B (HANDS - Collab/Failover) βββββββββββββββββ
|
| 31 |
RAILWAY_TOKEN_B=
|
|
|
|
| 54 |
# Configurare nei Secrets del provider hosting (HF/Railway)
|
| 55 |
GROQ_API_KEY=
|
| 56 |
OPENROUTER_API_KEY=
|
|
|
|
|
|
|
|
|
|
| 57 |
GEMINI_API_KEY=
|
| 58 |
NVIDIA_API_KEY=
|
| 59 |
+
OPENAI_API_KEY=
|
| 60 |
|
| 61 |
# ββ 8. Sandboxes & Tools βββββββββββββββββββββββββββββββββββββ
|
| 62 |
E2B_API_KEY=
|
|
|
|
| 69 |
# ββ 9. Feature Flags βββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
VITE_ENABLE_BROWSER_SANDBOX=false
|
| 71 |
UNIFIED_LOOP_MAX_STEPS=8
|
| 72 |
+
LLM_MODEL=deepseek/deepseek-r1:free
|
|
|
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -38,4 +38,4 @@ COPY --chown=user . /home/user/app/
|
|
| 38 |
|
| 39 |
EXPOSE 7860
|
| 40 |
|
| 41 |
-
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers
|
|
|
|
| 38 |
|
| 39 |
EXPOSE 7860
|
| 40 |
|
| 41 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"]
|
REBUILD_TRIGGER
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
rebuild
|
agents/audit_semantic_l2.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audit_semantic_l2.py β S303: Audit Semantico L2 (Critico Senior) su Nodo D.
|
| 3 |
+
|
| 4 |
+
L1 (goal_verifier.py) valida se la risposta *aderisce* al goal.
|
| 5 |
+
L2 (questo file) verifica la *coerenza logica interna* dell'output:
|
| 6 |
+
- Nessuna contraddizione auto-referenziale
|
| 7 |
+
- Claim verificabili non inventati (anti-hallucination guard)
|
| 8 |
+
- Completezza rispetto ai sotto-obiettivi esplicitati nel goal
|
| 9 |
+
- Stato outcome: PASS / FAIL / UNKNOWN β mai forzare PASS
|
| 10 |
+
|
| 11 |
+
Integrazione: chiamato DOPO GoalVerifier L1 in unified_loop_fallback.py.
|
| 12 |
+
Se L1 = FAIL β L2 non viene invocato (risparmio token).
|
| 13 |
+
Se L1 = PASS o UNKNOWN β L2 aggiunge una seconda garanzia semantica.
|
| 14 |
+
|
| 15 |
+
Output: AuditL2Result (dataclass) con status, issues[], confidence, repair_hint.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from enum import Enum
|
| 25 |
+
from typing import Any, Optional
|
| 26 |
+
|
| 27 |
+
_logger = logging.getLogger("agents.audit_l2")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AuditStatus(str, Enum):
|
| 31 |
+
PASS = "PASS"
|
| 32 |
+
FAIL = "FAIL"
|
| 33 |
+
UNKNOWN = "UNKNOWN"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class AuditL2Result:
|
| 38 |
+
status: AuditStatus
|
| 39 |
+
confidence: float = 0.0 # 0.0 β 1.0
|
| 40 |
+
issues: list[str] = field(default_factory=list)
|
| 41 |
+
repair_hint: str = ""
|
| 42 |
+
engine: str = "heuristic" # "heuristic" | "llm"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ββ Pattern anti-hallucination ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
# Claim di azioni che l'agente NON puΓ² eseguire da solo senza tool confirmation.
|
| 47 |
+
# Copre IT / EN / ES / FR β le 4 lingue attive nel cluster.
|
| 48 |
+
_HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
|
| 49 |
+
# Deploy / publish
|
| 50 |
+
(re.compile(
|
| 51 |
+
r"\b(ho deployato|ho pubblicato|ho pushato|ho committato|ho inviato|"
|
| 52 |
+
r"ho caricato|ho aggiornato il server|ho rilasciato|"
|
| 53 |
+
r"i deployed|i pushed|i committed|i published|i sent|i uploaded|i released|"
|
| 54 |
+
r"he desplegado|he publicado|he enviado|he subido|he lanzado|"
|
| 55 |
+
r"j'ai dΓ©ployΓ©|j'ai publiΓ©|j'ai envoyΓ©|j'ai poussΓ©|j'ai mis en ligne)\b",
|
| 56 |
+
re.I),
|
| 57 |
+
"claim di deploy/push/send non verificato da tool"),
|
| 58 |
+
|
| 59 |
+
# Stato esterno live
|
| 60 |
+
(re.compile(
|
| 61 |
+
r"\b(il sito Γ¨ live|the site is live|ora funziona|it now works|"
|
| 62 |
+
r"Γ¨ online|is online|Γ¨ andato live|went live|"
|
| 63 |
+
r"the app is running|l'app Γ¨ in esecuzione|"
|
| 64 |
+
r"el sitio estΓ‘ en vivo|el sistema funciona ahora|"
|
| 65 |
+
r"le site est en ligne|l'application fonctionne maintenant)\b",
|
| 66 |
+
re.I),
|
| 67 |
+
"claim di stato esterno non verificabile"),
|
| 68 |
+
|
| 69 |
+
# Assunzioni sull'utente
|
| 70 |
+
(re.compile(
|
| 71 |
+
r"\b(l'utente ha|the user has|hai giΓ |you already|"
|
| 72 |
+
r"your database (is|has)|il tuo database (Γ¨|ha)|"
|
| 73 |
+
r"el usuario ya|vous avez dΓ©jΓ )\b",
|
| 74 |
+
re.I),
|
| 75 |
+
"assunzione su stato dell'utente non verificabile"),
|
| 76 |
+
|
| 77 |
+
# Test / CI passati senza prova
|
| 78 |
+
(re.compile(
|
| 79 |
+
r"\b(tutti i test passano|all tests pass|i test sono verdi|tests are green|"
|
| 80 |
+
r"la CI Γ¨ verde|CI is green|build successful|build riuscita|"
|
| 81 |
+
r"todos los tests pasan|tous les tests passent)\b",
|
| 82 |
+
re.I),
|
| 83 |
+
"claim di test/CI passati senza esecuzione verificata"),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
# ββ Pattern contraddizione interna ββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
_CONTRADICTION_PAIRS: list[tuple[str, str]] = [
|
| 88 |
+
("errore", "nessun errore"),
|
| 89 |
+
("error", "no error"),
|
| 90 |
+
("fallito", "completato con successo"),
|
| 91 |
+
("failed", "completed successfully"),
|
| 92 |
+
("non trovato", "trovato correttamente"),
|
| 93 |
+
("not found", "found correctly"),
|
| 94 |
+
("timeout", "risposta ricevuta"),
|
| 95 |
+
("timeout", "response received"),
|
| 96 |
+
("impossibile", "funziona"),
|
| 97 |
+
("impossible", "works"),
|
| 98 |
+
("non funziona", "funziona correttamente"),
|
| 99 |
+
("doesn't work", "works correctly"),
|
| 100 |
+
("eccezione", "nessuna eccezione"),
|
| 101 |
+
("exception", "no exception"),
|
| 102 |
+
("crash", "stabile"),
|
| 103 |
+
("crash", "stable"),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _check_hallucinations(text: str) -> list[str]:
|
| 108 |
+
issues = []
|
| 109 |
+
for pattern, label in _HALLUCINATION_PATTERNS:
|
| 110 |
+
if pattern.search(text):
|
| 111 |
+
issues.append(f"Possibile hallucination: {label}")
|
| 112 |
+
return issues
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _check_contradictions(text: str) -> list[str]:
|
| 116 |
+
issues = []
|
| 117 |
+
text_lower = text.lower()
|
| 118 |
+
for a, b in _CONTRADICTION_PAIRS:
|
| 119 |
+
if a in text_lower and b in text_lower:
|
| 120 |
+
issues.append(f"Contraddizione interna: '{a}' e '{b}' co-presenti")
|
| 121 |
+
return issues
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _check_completeness(goal: str, answer: str) -> list[str]:
|
| 125 |
+
"""
|
| 126 |
+
Controlla che i sotto-obiettivi espliciti del goal (identificati da liste numerate
|
| 127 |
+
o bullet points) siano almeno menzionati nella risposta.
|
| 128 |
+
"""
|
| 129 |
+
issues = []
|
| 130 |
+
sub_goals = re.findall(
|
| 131 |
+
r"(?:^|\n)\s*(?:\d+\.|[-*β’])\s+(.+?)(?:\n|$)", goal
|
| 132 |
+
)
|
| 133 |
+
if not sub_goals:
|
| 134 |
+
return []
|
| 135 |
+
answer_lower = answer.lower()
|
| 136 |
+
missing = []
|
| 137 |
+
for sg in sub_goals[:8]: # max 8 sotto-obiettivi
|
| 138 |
+
words = [w for w in sg.lower().split() if len(w) > 4][:4]
|
| 139 |
+
if words and sum(1 for w in words if w in answer_lower) < max(1, len(words) // 2):
|
| 140 |
+
missing.append(sg.strip()[:60])
|
| 141 |
+
if missing:
|
| 142 |
+
issues.append(f"Sotto-obiettivi non indirizzati: {missing[:3]}")
|
| 143 |
+
return issues
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _heuristic_audit(goal: str, answer: str) -> AuditL2Result:
|
| 147 |
+
"""Audit euristico: pattern matching su testo, senza LLM."""
|
| 148 |
+
issues: list[str] = []
|
| 149 |
+
issues.extend(_check_hallucinations(answer))
|
| 150 |
+
issues.extend(_check_contradictions(answer))
|
| 151 |
+
issues.extend(_check_completeness(goal, answer))
|
| 152 |
+
|
| 153 |
+
if not issues:
|
| 154 |
+
return AuditL2Result(
|
| 155 |
+
status=AuditStatus.PASS,
|
| 156 |
+
confidence=0.75,
|
| 157 |
+
engine="heuristic",
|
| 158 |
+
)
|
| 159 |
+
# Gravi (hallucination o contraddizione) β FAIL; solo completeness β UNKNOWN
|
| 160 |
+
has_severe = any(
|
| 161 |
+
"hallucination" in i or "Contraddizione" in i or "contradiction" in i.lower()
|
| 162 |
+
for i in issues
|
| 163 |
+
)
|
| 164 |
+
return AuditL2Result(
|
| 165 |
+
status=AuditStatus.FAIL if has_severe else AuditStatus.UNKNOWN,
|
| 166 |
+
confidence=0.82 if has_severe else 0.55,
|
| 167 |
+
issues=issues,
|
| 168 |
+
repair_hint="Rivedere e rimuovere claim non verificati o contraddizioni.",
|
| 169 |
+
engine="heuristic",
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
_AUDIT_SYSTEM = (
|
| 174 |
+
"Sei un Critico Senior che verifica la coerenza logica delle risposte di un agente AI. "
|
| 175 |
+
"Rispondi SOLO con JSON valido, senza markdown. Formato:\n"
|
| 176 |
+
'{"status":"PASS"|"FAIL"|"UNKNOWN","confidence":0.0-1.0,'
|
| 177 |
+
'"issues":["..."],"repair_hint":"..."}\n\n'
|
| 178 |
+
"Regole: FAIL solo per problemi gravi (hallucination, contraddizioni). "
|
| 179 |
+
"UNKNOWN per incertezze moderate. PASS se la risposta Γ¨ coerente. "
|
| 180 |
+
"Mai forzare PASS se ci sono dubbi fondati."
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _build_audit_prompt(goal: str, answer: str) -> str:
|
| 185 |
+
# Tronca intelligentemente: preserva inizio e fine dell'answer
|
| 186 |
+
max_ans = 1400
|
| 187 |
+
if len(answer) > max_ans:
|
| 188 |
+
half = max_ans // 2
|
| 189 |
+
answer_trunc = answer[:half] + "\n[...]\n" + answer[-half:]
|
| 190 |
+
else:
|
| 191 |
+
answer_trunc = answer
|
| 192 |
+
return (
|
| 193 |
+
f"GOAL ORIGINALE:\n{goal[:500]}\n\n"
|
| 194 |
+
f"RISPOSTA AGENTE:\n{answer_trunc}\n\n"
|
| 195 |
+
"VERIFICA (rispondi solo con JSON):\n"
|
| 196 |
+
"1. Ci sono claim di azioni esterne non verificabili (deploy/push/send/test-pass senza tool proof)?\n"
|
| 197 |
+
"2. Ci sono contraddizioni interne (es. 'errore' e 'completato con successo' co-presenti)?\n"
|
| 198 |
+
"3. La risposta indirizza almeno i sotto-obiettivi espliciti del goal?\n"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class SemanticAuditorL2:
|
| 203 |
+
"""
|
| 204 |
+
S303 β Audit Semantico L2.
|
| 205 |
+
Istanziato come singleton.
|
| 206 |
+
Usato in unified_loop_fallback.py dopo GoalVerifier L1 (solo se L1 β FAIL).
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
def __init__(self, ai_client: Any = None, timeout_s: float = 12.0):
|
| 210 |
+
self.ai_client = ai_client
|
| 211 |
+
self.timeout_s = timeout_s
|
| 212 |
+
|
| 213 |
+
async def audit(self, goal: str, answer: str) -> AuditL2Result:
|
| 214 |
+
"""
|
| 215 |
+
Punto di ingresso principale.
|
| 216 |
+
1. Prova audit LLM se ai_client disponibile.
|
| 217 |
+
2. Fallback a audit euristico in caso di errore o timeout.
|
| 218 |
+
"""
|
| 219 |
+
if not goal or not answer:
|
| 220 |
+
return AuditL2Result(status=AuditStatus.UNKNOWN, confidence=0.0,
|
| 221 |
+
issues=["goal o answer vuoti"])
|
| 222 |
+
|
| 223 |
+
# Euristico sempre eseguito β base line gratuita
|
| 224 |
+
heuristic_result = _heuristic_audit(goal, answer)
|
| 225 |
+
|
| 226 |
+
# Se euristico ha giΓ trovato problemi gravi, non invocare LLM per efficienza
|
| 227 |
+
if heuristic_result.status == AuditStatus.FAIL and len(heuristic_result.issues) >= 2:
|
| 228 |
+
_logger.debug("[AuditL2] heuristic FAIL con %d issues β skip LLM", len(heuristic_result.issues))
|
| 229 |
+
return heuristic_result
|
| 230 |
+
|
| 231 |
+
if self.ai_client is not None:
|
| 232 |
+
try:
|
| 233 |
+
result = await asyncio.wait_for(
|
| 234 |
+
self._llm_audit(goal, answer),
|
| 235 |
+
timeout=self.timeout_s
|
| 236 |
+
)
|
| 237 |
+
if result:
|
| 238 |
+
# Merge: se LLM dice PASS ma euristico ha trovato issue β UNKNOWN
|
| 239 |
+
if result.status == AuditStatus.PASS and heuristic_result.issues:
|
| 240 |
+
result.status = AuditStatus.UNKNOWN
|
| 241 |
+
result.issues = heuristic_result.issues
|
| 242 |
+
result.confidence = min(result.confidence, 0.65)
|
| 243 |
+
return result
|
| 244 |
+
except asyncio.TimeoutError:
|
| 245 |
+
_logger.warning("[AuditL2] timeout LLM (%.1fs) β fallback euristico", self.timeout_s)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
_logger.warning("[AuditL2] errore LLM (%s) β fallback euristico", type(e).__name__)
|
| 248 |
+
|
| 249 |
+
return heuristic_result
|
| 250 |
+
|
| 251 |
+
async def _llm_audit(self, goal: str, answer: str) -> Optional[AuditL2Result]:
|
| 252 |
+
"""Chiamata LLM reale per l'audit semantico."""
|
| 253 |
+
prompt = _build_audit_prompt(goal, answer)
|
| 254 |
+
# Preferisce modello veloce/economico (8B) β audit non richiede ragionamento profondo
|
| 255 |
+
_model = getattr(self.ai_client, "_audit_model", None) or "llama-3.1-8b-instant"
|
| 256 |
+
response = await self.ai_client.chat.completions.create(
|
| 257 |
+
model=_model,
|
| 258 |
+
messages=[
|
| 259 |
+
{"role": "system", "content": _AUDIT_SYSTEM},
|
| 260 |
+
{"role": "user", "content": prompt},
|
| 261 |
+
],
|
| 262 |
+
max_tokens=256,
|
| 263 |
+
temperature=0.0, # deterministico
|
| 264 |
+
)
|
| 265 |
+
raw = response.choices[0].message.content or ""
|
| 266 |
+
match = re.search(r"\{[\s\S]*?\}", raw)
|
| 267 |
+
if not match:
|
| 268 |
+
_logger.warning("[AuditL2] risposta LLM non contiene JSON: %.80s", raw)
|
| 269 |
+
return None
|
| 270 |
+
try:
|
| 271 |
+
parsed = json.loads(match.group(0))
|
| 272 |
+
except json.JSONDecodeError as _je:
|
| 273 |
+
_logger.warning("[AuditL2] JSON decode error: %s", _je)
|
| 274 |
+
return None
|
| 275 |
+
status_raw = parsed.get("status", "UNKNOWN").upper()
|
| 276 |
+
try:
|
| 277 |
+
status = AuditStatus(status_raw)
|
| 278 |
+
except ValueError:
|
| 279 |
+
status = AuditStatus.UNKNOWN
|
| 280 |
+
return AuditL2Result(
|
| 281 |
+
status=status,
|
| 282 |
+
confidence=float(parsed.get("confidence", 0.70)),
|
| 283 |
+
issues=parsed.get("issues", []),
|
| 284 |
+
repair_hint=parsed.get("repair_hint", ""),
|
| 285 |
+
engine="llm",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ββ Singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 290 |
+
_auditor: Optional[SemanticAuditorL2] = None
|
| 291 |
+
|
| 292 |
+
def get_auditor(ai_client: Any = None, timeout_s: float = 12.0) -> SemanticAuditorL2:
|
| 293 |
+
"""
|
| 294 |
+
Ritorna o crea il singleton SemanticAuditorL2.
|
| 295 |
+
Se chiamato con ai_client=<client> e il singleton esiste giΓ senza client,
|
| 296 |
+
aggiorna il client sul singleton esistente (upgrade lazy).
|
| 297 |
+
"""
|
| 298 |
+
global _auditor
|
| 299 |
+
if _auditor is None:
|
| 300 |
+
_auditor = SemanticAuditorL2(ai_client=ai_client, timeout_s=timeout_s)
|
| 301 |
+
elif ai_client is not None and _auditor.ai_client is None:
|
| 302 |
+
_auditor.ai_client = ai_client # upgrade: inserisce client dopo init
|
| 303 |
+
return _auditor
|
agents/context_manager.py
CHANGED
|
@@ -425,26 +425,3 @@ async def get_context_for_goal(
|
|
| 425 |
return '\n\n'.join(parts) if parts else ''
|
| 426 |
except Exception:
|
| 427 |
return ''
|
| 428 |
-
|
| 429 |
-
# ββ S-CONTEXT-SHARDING: Gestione intelligente del contesto lungo (S482) ββββββ
|
| 430 |
-
def shard_context(full_context: str, max_shard_size: int = 2000) -> list[str]:
|
| 431 |
-
"""Divide il contesto in shard logici basati sulla rilevanza semantica."""
|
| 432 |
-
shards = []
|
| 433 |
-
current_shard = []
|
| 434 |
-
current_size = 0
|
| 435 |
-
|
| 436 |
-
# Dividiamo per blocchi logici (paragrafi o sezioni di codice)
|
| 437 |
-
blocks = re.split(r'\n(?=\s*[A-Z#])', full_context)
|
| 438 |
-
|
| 439 |
-
for block in blocks:
|
| 440 |
-
block_size = len(block)
|
| 441 |
-
if current_size + block_size > max_shard_size and current_shard:
|
| 442 |
-
shards.append("\n".join(current_shard))
|
| 443 |
-
current_shard = []
|
| 444 |
-
current_size = 0
|
| 445 |
-
current_shard.append(block)
|
| 446 |
-
current_size += block_size
|
| 447 |
-
|
| 448 |
-
if current_shard:
|
| 449 |
-
shards.append("\n".join(current_shard))
|
| 450 |
-
return shards
|
|
|
|
| 425 |
return '\n\n'.join(parts) if parts else ''
|
| 426 |
except Exception:
|
| 427 |
return ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/engineering_state.py
DELETED
|
@@ -1,255 +0,0 @@
|
|
| 1 |
-
"""Versioned, bounded engineering lifecycle state for the unified agent loop.
|
| 2 |
-
|
| 3 |
-
The module is deliberately dependency-free. It mirrors the legacy lifecycle without
|
| 4 |
-
being authoritative for recovery when the rollout mode is enabled, and it never stores
|
| 5 |
-
raw prompts, credentials, or arbitrary tool output.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import hashlib
|
| 10 |
-
import os
|
| 11 |
-
import re
|
| 12 |
-
import time
|
| 13 |
-
from dataclasses import dataclass, field
|
| 14 |
-
from enum import Enum
|
| 15 |
-
from typing import Any, Mapping
|
| 16 |
-
|
| 17 |
-
SCHEMA_VERSION = 1
|
| 18 |
-
MAX_HISTORY = 64
|
| 19 |
-
MAX_DIAGNOSTICS = 24
|
| 20 |
-
MAX_PREVIEW_CHARS = 256
|
| 21 |
-
MAX_ID_CHARS = 180
|
| 22 |
-
|
| 23 |
-
_SECRET_PATTERNS = (
|
| 24 |
-
re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"),
|
| 25 |
-
re.compile(r"(?i)(api[_-]?key\s*[:=]\s*)[^\s,;]+"),
|
| 26 |
-
re.compile(r"(?i)(token\s*[:=]\s*)[^\s,;]+"),
|
| 27 |
-
re.compile(r"(?i)\b(?:ghp|gho|github_pat|hf|sk|xoxb|xapp|r8)_[A-Za-z0-9_-]{8,}\b"),
|
| 28 |
-
re.compile(r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"),
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class EngineeringStateMode(str, Enum):
|
| 33 |
-
OFF = "off"
|
| 34 |
-
SHADOW = "shadow"
|
| 35 |
-
CANARY = "canary"
|
| 36 |
-
AUTHORITATIVE = "authoritative"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
@dataclass(frozen=True)
|
| 40 |
-
class EngineeringStateConfig:
|
| 41 |
-
"""Conservative rollout configuration read once per run."""
|
| 42 |
-
|
| 43 |
-
mode: EngineeringStateMode = EngineeringStateMode.OFF
|
| 44 |
-
canary_rate: float = 0.0
|
| 45 |
-
|
| 46 |
-
@classmethod
|
| 47 |
-
def from_env(cls) -> "EngineeringStateConfig":
|
| 48 |
-
raw_mode = os.getenv("ENGINEERING_STATE_MODE", "authoritative").strip().lower() # P1 default; off remains an explicit rollback mode
|
| 49 |
-
try:
|
| 50 |
-
mode = EngineeringStateMode(raw_mode)
|
| 51 |
-
except ValueError:
|
| 52 |
-
mode = EngineeringStateMode.OFF
|
| 53 |
-
try:
|
| 54 |
-
rate = float(os.getenv("ENGINEERING_STATE_CANARY_RATE", "0"))
|
| 55 |
-
except (TypeError, ValueError):
|
| 56 |
-
rate = 0.0
|
| 57 |
-
return cls(mode=mode, canary_rate=max(0.0, min(rate, 1.0)))
|
| 58 |
-
|
| 59 |
-
@property
|
| 60 |
-
def enabled(self) -> bool:
|
| 61 |
-
return self.mode is not EngineeringStateMode.OFF
|
| 62 |
-
|
| 63 |
-
def selects_canary(self, run_id: str, session_id: str) -> bool:
|
| 64 |
-
if self.mode is not EngineeringStateMode.CANARY or not session_id:
|
| 65 |
-
return False
|
| 66 |
-
if self.canary_rate >= 1.0:
|
| 67 |
-
return True
|
| 68 |
-
if self.canary_rate <= 0.0:
|
| 69 |
-
return False
|
| 70 |
-
digest = hashlib.sha256(f"{run_id}:{session_id}".encode()).digest()
|
| 71 |
-
bucket = int.from_bytes(digest[:8], "big") / float(2**64)
|
| 72 |
-
return bucket < self.canary_rate
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _bounded_id(value: str | None) -> str:
|
| 76 |
-
return re.sub(r"[^A-Za-z0-9_.:/-]", "_", str(value or ""))[:MAX_ID_CHARS]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def redact_text(value: object, max_chars: int = MAX_PREVIEW_CHARS) -> str:
|
| 80 |
-
"""Redact common credential forms before anything reaches a checkpoint."""
|
| 81 |
-
text = str(value or "")[: max_chars * 4]
|
| 82 |
-
for pattern in _SECRET_PATTERNS:
|
| 83 |
-
if pattern.groups:
|
| 84 |
-
text = pattern.sub(lambda match: f"{match.group(1)}[REDACTED]", text)
|
| 85 |
-
else:
|
| 86 |
-
text = pattern.sub("[REDACTED]", text)
|
| 87 |
-
return text[:max_chars]
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
|
| 91 |
-
"IDLE": frozenset({"CLASSIFYING", "FAILED"}),
|
| 92 |
-
"CLASSIFYING": frozenset({"TOOL_EXECUTING", "THINKING", "COMPLETED", "FAILED"}),
|
| 93 |
-
"TOOL_EXECUTING": frozenset({"THINKING", "COMPLETED", "FAILED"}),
|
| 94 |
-
"THINKING": frozenset({"COMPLETED", "FAILED"}),
|
| 95 |
-
"FAILED": frozenset({"IDLE"}),
|
| 96 |
-
"COMPLETED": frozenset({"IDLE", "FAILED"}),
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
@dataclass
|
| 101 |
-
class EngineeringState:
|
| 102 |
-
"""Bounded state envelope that can be persisted and safely restored."""
|
| 103 |
-
|
| 104 |
-
run_id: str
|
| 105 |
-
session_id: str
|
| 106 |
-
checkpoint_id: str
|
| 107 |
-
goal_digest: str
|
| 108 |
-
goal_preview: str
|
| 109 |
-
current_state: str = "IDLE"
|
| 110 |
-
history: list[dict[str, Any]] = field(default_factory=list)
|
| 111 |
-
diagnostics: list[str] = field(default_factory=list)
|
| 112 |
-
revision: int = 0
|
| 113 |
-
sequence: int = 0
|
| 114 |
-
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 115 |
-
updated_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
| 116 |
-
|
| 117 |
-
@classmethod
|
| 118 |
-
def start(
|
| 119 |
-
cls,
|
| 120 |
-
goal: str,
|
| 121 |
-
*,
|
| 122 |
-
run_id: str,
|
| 123 |
-
session_id: str = "",
|
| 124 |
-
checkpoint_id: str | None = None,
|
| 125 |
-
now_ms: int | None = None,
|
| 126 |
-
) -> "EngineeringState":
|
| 127 |
-
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 128 |
-
normalized_goal = str(goal or "")
|
| 129 |
-
return cls(
|
| 130 |
-
run_id=_bounded_id(run_id),
|
| 131 |
-
session_id=_bounded_id(session_id),
|
| 132 |
-
checkpoint_id=_bounded_id(checkpoint_id or session_id or run_id),
|
| 133 |
-
goal_digest=hashlib.sha256(normalized_goal.encode("utf-8", "replace")).hexdigest(),
|
| 134 |
-
goal_preview=redact_text(normalized_goal),
|
| 135 |
-
created_at_ms=now,
|
| 136 |
-
updated_at_ms=now,
|
| 137 |
-
)
|
| 138 |
-
|
| 139 |
-
@property
|
| 140 |
-
def status(self) -> str:
|
| 141 |
-
if self.current_state == "COMPLETED":
|
| 142 |
-
return "completed"
|
| 143 |
-
if self.current_state == "FAILED":
|
| 144 |
-
return "failed"
|
| 145 |
-
return "active"
|
| 146 |
-
|
| 147 |
-
def transition(self, next_state: str, *, now_ms: int | None = None) -> bool:
|
| 148 |
-
"""Apply an idempotent transition; reject illegal transitions deterministically."""
|
| 149 |
-
target = str(next_state)
|
| 150 |
-
if target == self.current_state:
|
| 151 |
-
return False
|
| 152 |
-
allowed = _ALLOWED_TRANSITIONS.get(self.current_state, frozenset())
|
| 153 |
-
if target not in allowed:
|
| 154 |
-
raise ValueError(f"Invalid EngineeringState transition: {self.current_state} -> {target}")
|
| 155 |
-
now = int(time.time() * 1000) if now_ms is None else int(now_ms)
|
| 156 |
-
self.sequence += 1
|
| 157 |
-
self.revision += 1
|
| 158 |
-
self.history.append({
|
| 159 |
-
"sequence": self.sequence,
|
| 160 |
-
"from_state": self.current_state,
|
| 161 |
-
"to_state": target,
|
| 162 |
-
"at_ms": now,
|
| 163 |
-
})
|
| 164 |
-
if len(self.history) > MAX_HISTORY:
|
| 165 |
-
del self.history[:-MAX_HISTORY]
|
| 166 |
-
self.current_state = target
|
| 167 |
-
self.updated_at_ms = now
|
| 168 |
-
return True
|
| 169 |
-
|
| 170 |
-
def prepare_for_resume(self) -> None:
|
| 171 |
-
"""Normalize a restored snapshot before a new loop execution."""
|
| 172 |
-
if self.current_state != "IDLE":
|
| 173 |
-
self.current_state = "IDLE"
|
| 174 |
-
self.revision += 1
|
| 175 |
-
self.updated_at_ms = int(time.time() * 1000)
|
| 176 |
-
self.diagnostic("resume normalized state to IDLE")
|
| 177 |
-
|
| 178 |
-
def diagnostic(self, message: str) -> None:
|
| 179 |
-
value = redact_text(message, 180)
|
| 180 |
-
if not value or value in self.diagnostics:
|
| 181 |
-
return
|
| 182 |
-
self.diagnostics.append(value)
|
| 183 |
-
if len(self.diagnostics) > MAX_DIAGNOSTICS:
|
| 184 |
-
del self.diagnostics[:-MAX_DIAGNOSTICS]
|
| 185 |
-
self.revision += 1
|
| 186 |
-
self.updated_at_ms = int(time.time() * 1000)
|
| 187 |
-
|
| 188 |
-
def snapshot(self) -> dict[str, Any]:
|
| 189 |
-
"""Return a bounded JSON-compatible envelope; never expose the raw goal."""
|
| 190 |
-
return {
|
| 191 |
-
"schema_version": SCHEMA_VERSION,
|
| 192 |
-
"run_id": self.run_id,
|
| 193 |
-
"session_id": self.session_id,
|
| 194 |
-
"checkpoint_id": self.checkpoint_id,
|
| 195 |
-
"goal_digest": self.goal_digest,
|
| 196 |
-
"goal_preview": self.goal_preview,
|
| 197 |
-
"status": self.status,
|
| 198 |
-
"current_state": self.current_state,
|
| 199 |
-
"revision": self.revision,
|
| 200 |
-
"sequence": self.sequence,
|
| 201 |
-
"history": list(self.history[-MAX_HISTORY:]),
|
| 202 |
-
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 203 |
-
"created_at_ms": self.created_at_ms,
|
| 204 |
-
"updated_at_ms": self.updated_at_ms,
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
def projection(self) -> dict[str, Any]:
|
| 208 |
-
"""Small read-only view safe for API/SSE consumers."""
|
| 209 |
-
return {
|
| 210 |
-
"schema_version": SCHEMA_VERSION,
|
| 211 |
-
"status": self.status,
|
| 212 |
-
"current_state": self.current_state,
|
| 213 |
-
"revision": self.revision,
|
| 214 |
-
"sequence": self.sequence,
|
| 215 |
-
"checkpoint_id": self.checkpoint_id,
|
| 216 |
-
"history": [dict(item) for item in self.history[-16:]],
|
| 217 |
-
"diagnostics": list(self.diagnostics[-MAX_DIAGNOSTICS:]),
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
@classmethod
|
| 221 |
-
def from_snapshot(cls, payload: Mapping[str, Any]) -> "EngineeringState":
|
| 222 |
-
if not isinstance(payload, Mapping):
|
| 223 |
-
raise ValueError("engineering state must be an object")
|
| 224 |
-
if int(payload.get("schema_version", -1)) != SCHEMA_VERSION:
|
| 225 |
-
raise ValueError("unsupported engineering state schema")
|
| 226 |
-
history = payload.get("history", [])
|
| 227 |
-
diagnostics = payload.get("diagnostics", [])
|
| 228 |
-
if not isinstance(history, list) or len(history) > MAX_HISTORY:
|
| 229 |
-
raise ValueError("invalid engineering state history")
|
| 230 |
-
if not isinstance(diagnostics, list) or len(diagnostics) > MAX_DIAGNOSTICS:
|
| 231 |
-
raise ValueError("invalid engineering state diagnostics")
|
| 232 |
-
current = str(payload.get("current_state", ""))
|
| 233 |
-
if current not in _ALLOWED_TRANSITIONS:
|
| 234 |
-
raise ValueError("invalid engineering state current state")
|
| 235 |
-
revision = int(payload.get("revision", -1))
|
| 236 |
-
sequence = int(payload.get("sequence", -1))
|
| 237 |
-
if revision < 0 or sequence < 0 or revision < sequence:
|
| 238 |
-
raise ValueError("invalid engineering state revision")
|
| 239 |
-
state = cls(
|
| 240 |
-
run_id=_bounded_id(str(payload.get("run_id", ""))),
|
| 241 |
-
session_id=_bounded_id(str(payload.get("session_id", ""))),
|
| 242 |
-
checkpoint_id=_bounded_id(str(payload.get("checkpoint_id", ""))),
|
| 243 |
-
goal_digest=str(payload.get("goal_digest", "")),
|
| 244 |
-
goal_preview=redact_text(payload.get("goal_preview", "")),
|
| 245 |
-
current_state=current,
|
| 246 |
-
history=[dict(item) for item in history if isinstance(item, Mapping)],
|
| 247 |
-
diagnostics=[redact_text(item, 180) for item in diagnostics],
|
| 248 |
-
revision=revision,
|
| 249 |
-
sequence=sequence,
|
| 250 |
-
created_at_ms=int(payload.get("created_at_ms", 0)),
|
| 251 |
-
updated_at_ms=int(payload.get("updated_at_ms", 0)),
|
| 252 |
-
)
|
| 253 |
-
if len(state.goal_digest) != 64 or not re.fullmatch(r"[0-9a-f]{64}", state.goal_digest):
|
| 254 |
-
raise ValueError("invalid engineering state goal digest")
|
| 255 |
-
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/executor.py
CHANGED
|
@@ -12,6 +12,7 @@ import asyncio
|
|
| 12 |
import collections
|
| 13 |
import logging
|
| 14 |
import time as _time_mod
|
|
|
|
| 15 |
|
| 16 |
from models.ai_client import AIClient
|
| 17 |
from memory.manager import MemoryManager
|
|
@@ -93,10 +94,12 @@ class Executor:
|
|
| 93 |
llm_client: AIClient | None = None,
|
| 94 |
memory: MemoryManager | None = None,
|
| 95 |
max_retries: int = 2,
|
|
|
|
| 96 |
):
|
| 97 |
self.llm = llm_client or AIClient()
|
| 98 |
self.memory = memory
|
| 99 |
self.max_retries = max_retries
|
|
|
|
| 100 |
# GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
|
| 101 |
self._circuit_recovery_counts: dict[str, int] = {}
|
| 102 |
|
|
@@ -105,6 +108,53 @@ class Executor:
|
|
| 105 |
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 106 |
return cls(memory=memory, max_retries=max_retries)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
# ββ Circuit breaker helper ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
|
| 110 |
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
|
@@ -213,26 +263,11 @@ class Executor:
|
|
| 213 |
|
| 214 |
# ββ run_tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 215 |
|
| 216 |
-
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0
|
| 217 |
-
"""
|
| 218 |
-
Esegue un tool. Se worker_hint Γ¨ fornito, tenta l'esecuzione sul worker specifico.
|
| 219 |
-
ARCH-I4.3: Tool Engine evoluto con Capability Resolver.
|
| 220 |
-
"""
|
| 221 |
tool = TOOL_REGISTRY.get(tool_name)
|
| 222 |
if not tool:
|
| 223 |
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 224 |
|
| 225 |
-
# ARCH-E3.2/ARCH-I4.3: Risoluzione dinamica della capability via Kernel
|
| 226 |
-
if not worker_hint:
|
| 227 |
-
try:
|
| 228 |
-
from api.kernel import kernel
|
| 229 |
-
res = await kernel.resolve_capability(tool_name)
|
| 230 |
-
if res.get("status") == "resolved":
|
| 231 |
-
worker_hint = res["worker"]["id"]
|
| 232 |
-
_logger.info(f"[executor] capability '{tool_name}' risolta su worker: {worker_hint}")
|
| 233 |
-
except Exception as e:
|
| 234 |
-
_logger.debug(f"[executor] resolver bypass: {e}")
|
| 235 |
-
|
| 236 |
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 237 |
if missing:
|
| 238 |
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
|
@@ -292,39 +327,15 @@ class Executor:
|
|
| 292 |
_t0 = _time_mod.monotonic()
|
| 293 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 294 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 295 |
-
|
| 296 |
-
# Il tool ha giΓ prodotto il side effect: la persistenza memoria
|
| 297 |
-
# Γ¨ osservabilitΓ e non deve riaprire il retry del tool.
|
| 298 |
-
_memory_persisted = True
|
| 299 |
-
_memory_error = None
|
| 300 |
if self.memory:
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
except Exception as _memory_exc:
|
| 310 |
-
_memory_persisted = False
|
| 311 |
-
_memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}"
|
| 312 |
-
_logger.warning(
|
| 313 |
-
"[executor] tool %s completato ma save_episode fallito; "
|
| 314 |
-
"nessun retry del side effect: %s",
|
| 315 |
-
tool_name,
|
| 316 |
-
_memory_error,
|
| 317 |
-
)
|
| 318 |
-
response = {
|
| 319 |
-
"success": True,
|
| 320 |
-
"tool": tool_name,
|
| 321 |
-
"output": result,
|
| 322 |
-
"attempt": attempt + 1,
|
| 323 |
-
"memory_persisted": _memory_persisted,
|
| 324 |
-
}
|
| 325 |
-
if _memory_error:
|
| 326 |
-
response["memory_error"] = _memory_error
|
| 327 |
-
return response
|
| 328 |
|
| 329 |
except asyncio.TimeoutError:
|
| 330 |
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
|
@@ -357,4 +368,3 @@ class Executor:
|
|
| 357 |
await asyncio.sleep(0.5)
|
| 358 |
|
| 359 |
return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
|
| 360 |
-
|
|
|
|
| 12 |
import collections
|
| 13 |
import logging
|
| 14 |
import time as _time_mod
|
| 15 |
+
from typing import Any
|
| 16 |
|
| 17 |
from models.ai_client import AIClient
|
| 18 |
from memory.manager import MemoryManager
|
|
|
|
| 94 |
llm_client: AIClient | None = None,
|
| 95 |
memory: MemoryManager | None = None,
|
| 96 |
max_retries: int = 2,
|
| 97 |
+
kernel: Any | None = None, # ARCH-K2.2: BrainβKernel abstraction
|
| 98 |
):
|
| 99 |
self.llm = llm_client or AIClient()
|
| 100 |
self.memory = memory
|
| 101 |
self.max_retries = max_retries
|
| 102 |
+
self._kernel = kernel # ARCH-K2.2: usato da submit_background_task()
|
| 103 |
# GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
|
| 104 |
self._circuit_recovery_counts: dict[str, int] = {}
|
| 105 |
|
|
|
|
| 108 |
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 109 |
return cls(memory=memory, max_retries=max_retries)
|
| 110 |
|
| 111 |
+
# ββ ARCH-K2.2: submit background task via Kernel ββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
async def submit_background_task(
|
| 114 |
+
self,
|
| 115 |
+
payload: dict,
|
| 116 |
+
priority: str = "BACKGROUND",
|
| 117 |
+
session_id: str | None = None,
|
| 118 |
+
) -> str | None:
|
| 119 |
+
"""
|
| 120 |
+
Invia un task in background tramite kernel.submit_task() (ARCH-K2.2).
|
| 121 |
+
|
| 122 |
+
Il Brain/Executor non conosce l'implementazione della coda sottostante
|
| 123 |
+
(S9: ogni servizio ignora l'impl interna degli altri).
|
| 124 |
+
|
| 125 |
+
Fallback: asyncio.create_task() locale se il Kernel non Γ¨ disponibile.
|
| 126 |
+
Sempre non-bloccante β non aspetta il completamento del task.
|
| 127 |
+
|
| 128 |
+
Ritorna il task_id se il Kernel Γ¨ disponibile, None altrimenti.
|
| 129 |
+
"""
|
| 130 |
+
# Lazy-load kernel singleton se non iniettato
|
| 131 |
+
k = self._kernel
|
| 132 |
+
if k is None:
|
| 133 |
+
try:
|
| 134 |
+
from api.kernel import kernel as _k
|
| 135 |
+
k = _k
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
if k is not None:
|
| 140 |
+
try:
|
| 141 |
+
result = await k.submit_task(
|
| 142 |
+
payload=payload,
|
| 143 |
+
priority=priority,
|
| 144 |
+
session_id=session_id,
|
| 145 |
+
)
|
| 146 |
+
_logger.info(
|
| 147 |
+
"[executor] submit_background_task via Kernel id=%s priority=%s",
|
| 148 |
+
result.task_id, priority,
|
| 149 |
+
)
|
| 150 |
+
return result.task_id
|
| 151 |
+
except Exception as exc:
|
| 152 |
+
_logger.warning("[executor] kernel submit_background_task err: %s", exc)
|
| 153 |
+
|
| 154 |
+
# Fallback: esecuzione diretta asincrona locale (non attraverso la Queue)
|
| 155 |
+
_logger.debug("[executor] submit_background_task fallback: asyncio.create_task")
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
# ββ Circuit breaker helper ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
|
| 160 |
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
|
|
|
| 263 |
|
| 264 |
# ββ run_tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 265 |
|
| 266 |
+
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
tool = TOOL_REGISTRY.get(tool_name)
|
| 268 |
if not tool:
|
| 269 |
return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
|
| 272 |
if missing:
|
| 273 |
return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
|
|
|
|
| 327 |
_t0 = _time_mod.monotonic()
|
| 328 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 329 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
if self.memory:
|
| 331 |
+
# S577βS600: inputs 100β500 β parity con altri handler
|
| 332 |
+
await self.memory.save_episode(
|
| 333 |
+
"tool",
|
| 334 |
+
f"{tool_name}: {str(inputs)[:500]}",
|
| 335 |
+
str(result)[:500],
|
| 336 |
+
True,
|
| 337 |
+
)
|
| 338 |
+
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
except asyncio.TimeoutError:
|
| 341 |
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
|
|
|
| 368 |
await asyncio.sleep(0.5)
|
| 369 |
|
| 370 |
return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}
|
|
|
agents/fallback_healer.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fallback_healer.py β Logica di Self-Healing strategico per il loop di fallback.
|
| 2 |
+
Estratto da unified_loop_fallback.py (split 2026-06-30).
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
_logger = logging.getLogger("api.agent.healer")
|
| 8 |
+
|
| 9 |
+
class StrategicHealer:
|
| 10 |
+
@staticmethod
|
| 11 |
+
def analyze_errors(exec_errors: list, exec_warn: list) -> None:
|
| 12 |
+
"""
|
| 13 |
+
Analizza gli errori ripetuti e inietta messaggi di 'CAMBIO STRATEGIA' (GAP-SELFHEAL v2).
|
| 14 |
+
"""
|
| 15 |
+
if not exec_errors:
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
# Fingerprinting degli errori (Dual-mode: raw + error-class)
|
| 19 |
+
_selfheal_raw = {}
|
| 20 |
+
_selfheal_cls = {}
|
| 21 |
+
|
| 22 |
+
for _err in exec_errors:
|
| 23 |
+
if not isinstance(_err, str): continue
|
| 24 |
+
# Mode 1: raw fingerprinting
|
| 25 |
+
_fp = _err[:120]
|
| 26 |
+
_selfheal_raw[_fp] = _selfheal_raw.get(_fp, 0) + 1
|
| 27 |
+
# Mode 2: error-class extraction
|
| 28 |
+
_m = re.search(r"([A-Z][a-z]+Error):", _err)
|
| 29 |
+
if _m:
|
| 30 |
+
_c = _m.group(1).lower()
|
| 31 |
+
_selfheal_cls[_c] = _selfheal_cls.get(_c, 0) + 1
|
| 32 |
+
|
| 33 |
+
_selfheal_raw_max = max(_selfheal_raw.values()) if _selfheal_raw else 0
|
| 34 |
+
_selfheal_cls_max = max(_selfheal_cls.values()) if _selfheal_cls else 0
|
| 35 |
+
_selfheal_max = max(_selfheal_raw_max, _selfheal_cls_max)
|
| 36 |
+
|
| 37 |
+
if _selfheal_max >= 2:
|
| 38 |
+
_ERRCLASS_HINTS = {
|
| 39 |
+
"typeerror": "Controlla i tipi degli argomenti, aggiungi conversioni esplicite.",
|
| 40 |
+
"keyerror": "Usa .get(key, default) invece di [], controlla l'esistenza.",
|
| 41 |
+
"attributeerror": "Controlla che l'oggetto non sia None.",
|
| 42 |
+
"nameerror": "Controlla typo nel nome variabile/funzione.",
|
| 43 |
+
"syntaxerror": "Controlla la sintassi o le quote del comando.",
|
| 44 |
+
"memoryerror": "Processa in chunk, riduci dimensione dati.",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
_dom_cls = max(_selfheal_cls, key=_selfheal_cls.get) if _selfheal_cls else ""
|
| 48 |
+
_specific = _ERRCLASS_HINTS.get(_dom_cls, "Usa un approccio completamente diverso.")
|
| 49 |
+
|
| 50 |
+
_selfheal_msg = (
|
| 51 |
+
f"β οΈ CAMBIO STRATEGIA OBBLIGATORIO [{_dom_cls or 'errore ripetuto'}Γ{_selfheal_max}]: "
|
| 52 |
+
f"Hint specifico: {_specific} "
|
| 53 |
+
"NON ripetere lo stesso metodo β cambia libreria o pattern."
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Evita doppia iniezione
|
| 57 |
+
if not any(isinstance(w, str) and "CAMBIO STRATEGIA" in w for w in exec_warn):
|
| 58 |
+
exec_warn.insert(0, _selfheal_msg)
|
| 59 |
+
_logger.info("GAP-SELFHEAL: Strategia di healing iniettata per %s", _dom_cls or "errore raw")
|
agents/fallback_utils.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""fallback_utils.py β Funzioni di utilitΓ per il loop di fallback.
|
| 2 |
+
Estratto da unified_loop_fallback.py (split 2026-06-30).
|
| 3 |
+
"""
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
def _is_refusal(text: str) -> bool:
|
| 7 |
+
"""Verifica se la risposta del modello Γ¨ un rifiuto (S129)."""
|
| 8 |
+
if not text: return False
|
| 9 |
+
refusals = ["mi dispiace", "non posso", "i apologize", "i cannot", "unauthorized", "access denied"]
|
| 10 |
+
t = text.lower()
|
| 11 |
+
return any(r in t for r in refusals)
|
| 12 |
+
|
| 13 |
+
def _s759_bjac(a: str, b: str) -> float:
|
| 14 |
+
"""Calcola la somiglianza di Jaccard tra due stringhe (S759)."""
|
| 15 |
+
if not a or not b: return 0.0
|
| 16 |
+
set_a = set(a.lower().split())
|
| 17 |
+
set_b = set(b.lower().split())
|
| 18 |
+
intersection = len(set_a.intersection(set_b))
|
| 19 |
+
union = len(set_a.union(set_b))
|
| 20 |
+
return intersection / union if union > 0 else 0.0
|
| 21 |
+
|
| 22 |
+
def _avg10(lst: list) -> float:
|
| 23 |
+
"""Calcola la media degli ultimi 10 elementi di una lista."""
|
| 24 |
+
if not lst: return 0.0
|
| 25 |
+
sub = lst[-10:]
|
| 26 |
+
return sum(sub) / len(sub)
|
agents/file_conversion.py
DELETED
|
@@ -1,163 +0,0 @@
|
|
| 1 |
-
"""Conversioni tabellari deterministiche per dati CSV espliciti nel goal.
|
| 2 |
-
|
| 3 |
-
Il modulo interpreta solo CSV allegati oppure richiesti con ``contenuto esatto:``.
|
| 4 |
-
Non apre path arbitrari, non esegue istruzioni contenute nel file e non invoca LLM.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import csv
|
| 9 |
-
import io
|
| 10 |
-
import json
|
| 11 |
-
import re
|
| 12 |
-
from dataclasses import dataclass
|
| 13 |
-
from typing import Any
|
| 14 |
-
|
| 15 |
-
_ATTACHMENT_RE = re.compile(
|
| 16 |
-
r"###\s*π\s*(?P<name>[^\n`]+?\.csv)\s*\([^\n]*\)\s*```\s*(?P<body>[\s\S]*?)```",
|
| 17 |
-
re.IGNORECASE,
|
| 18 |
-
)
|
| 19 |
-
# Il target puΓ² essere espresso come "file chiamato foo.json" oppure come
|
| 20 |
-
# "poi crea foo.json". Il gruppo Γ¨ limitato a nomi semplici, quindi il parser
|
| 21 |
-
# non accetta path traversal o istruzioni aggiuntive.
|
| 22 |
-
_TARGET_RE = re.compile(
|
| 23 |
-
r"(?:\b(?:chiamat[oa]|nome|denominat[oa]|come)\s+|\b(?:crea|scrivi)\s+)"
|
| 24 |
-
r"['`\"]?(?P<name>[\w.-]+\.json)\b",
|
| 25 |
-
re.IGNORECASE,
|
| 26 |
-
)
|
| 27 |
-
_CONVERSION_RE = re.compile(
|
| 28 |
-
r"\b(?:converti|trasforma|conversione|convert|transform)\b[\s\S]{0,240}\b(?:csv|json)\b",
|
| 29 |
-
re.IGNORECASE,
|
| 30 |
-
)
|
| 31 |
-
_INLINE_CSV_RE = re.compile(
|
| 32 |
-
r"\b(?:crea|scrivi)\s+(?P<name>[\w.-]+\.csv)\s+con\s+contenuto\s+esatto\s*:\s*"
|
| 33 |
-
r"(?P<body>[\s\S]*?)(?=\s*\.\s*(?:poi\s+)?(?:crea|scrivi)\s+[\w.-]+\.json\b|\Z)",
|
| 34 |
-
re.IGNORECASE,
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@dataclass(frozen=True)
|
| 39 |
-
class CsvJsonConversion:
|
| 40 |
-
source_name: str
|
| 41 |
-
target_name: str
|
| 42 |
-
content: str
|
| 43 |
-
row_count: int
|
| 44 |
-
source_content: str
|
| 45 |
-
source_is_inline: bool = False
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _coerce_scalar(value: str) -> Any:
|
| 49 |
-
value = value.strip()
|
| 50 |
-
if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
|
| 51 |
-
return int(value)
|
| 52 |
-
if re.fullmatch(r"-?(?:0|[1-9]\d*)\.\d+", value):
|
| 53 |
-
return float(value)
|
| 54 |
-
return value
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _csv_body(raw_body: str) -> str:
|
| 58 |
-
lines = raw_body.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
| 59 |
-
while lines and (not lines[0].strip() or lines[0].lstrip().startswith("## Foglio:")):
|
| 60 |
-
lines.pop(0)
|
| 61 |
-
return "\n".join(lines).strip()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _parse_csv_rows(csv_body: str) -> list[dict[str, Any]] | None:
|
| 65 |
-
"""Legge CSV senza tollerare header/colonne ambigue o righe tronche."""
|
| 66 |
-
try:
|
| 67 |
-
reader = csv.DictReader(io.StringIO(csv_body))
|
| 68 |
-
raw_headers = reader.fieldnames
|
| 69 |
-
if not raw_headers:
|
| 70 |
-
return None
|
| 71 |
-
headers = [str(header or "").strip() for header in raw_headers]
|
| 72 |
-
if any(not header for header in headers) or len(set(headers)) != len(headers):
|
| 73 |
-
return None
|
| 74 |
-
|
| 75 |
-
rows: list[dict[str, Any]] = []
|
| 76 |
-
for raw_row in reader:
|
| 77 |
-
# DictReader usa None per colonne in eccesso e per celle mancanti.
|
| 78 |
-
if None in raw_row or any(raw_row.get(header) is None for header in raw_headers):
|
| 79 |
-
return None
|
| 80 |
-
row = {
|
| 81 |
-
headers[index]: _coerce_scalar(raw_row[raw_headers[index]] or "")
|
| 82 |
-
for index in range(len(headers))
|
| 83 |
-
}
|
| 84 |
-
rows.append(row)
|
| 85 |
-
return rows
|
| 86 |
-
except (csv.Error, UnicodeError):
|
| 87 |
-
return None
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def validate_csv_json_equivalence(csv_content: str, json_content: str) -> tuple[bool, str]:
|
| 91 |
-
"""Verifica che il JSON sia lβarray esatto dei record CSV normalizzati.
|
| 92 |
-
|
| 93 |
-
La verifica Γ¨ intenzionalmente stretta: stessa cardinalitΓ , stesso ordine,
|
| 94 |
-
stesse chiavi e stessi valori dopo la coercizione deterministica del CSV.
|
| 95 |
-
"""
|
| 96 |
-
expected = _parse_csv_rows(_csv_body(csv_content))
|
| 97 |
-
if expected is None:
|
| 98 |
-
return False, "CSV non valido o ambiguo"
|
| 99 |
-
try:
|
| 100 |
-
actual = json.loads(json_content)
|
| 101 |
-
except (TypeError, json.JSONDecodeError):
|
| 102 |
-
return False, "JSON non valido"
|
| 103 |
-
if not isinstance(actual, list):
|
| 104 |
-
return False, "il JSON deve essere un array"
|
| 105 |
-
if any(not isinstance(record, dict) for record in actual):
|
| 106 |
-
return False, "ogni record JSON deve essere un oggetto"
|
| 107 |
-
if actual != expected:
|
| 108 |
-
return False, "i record JSON non corrispondono esattamente al CSV"
|
| 109 |
-
return True, ""
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def _build_conversion(source_name: str, target_name: str, raw_body: str, *, source_is_inline: bool) -> CsvJsonConversion | None:
|
| 113 |
-
csv_body = _csv_body(raw_body)
|
| 114 |
-
rows = _parse_csv_rows(csv_body)
|
| 115 |
-
if rows is None:
|
| 116 |
-
return None
|
| 117 |
-
content = json.dumps(rows, ensure_ascii=False, indent=2) + "\n"
|
| 118 |
-
is_valid, _reason = validate_csv_json_equivalence(csv_body, content)
|
| 119 |
-
if not is_valid:
|
| 120 |
-
# Difesa di coerenza interna: una conversione diretta non puΓ² dichiararsi
|
| 121 |
-
# riuscita se il proprio serializzatore non supera il medesimo contratto.
|
| 122 |
-
return None
|
| 123 |
-
return CsvJsonConversion(
|
| 124 |
-
source_name=source_name.strip(),
|
| 125 |
-
target_name=target_name.strip(),
|
| 126 |
-
content=content,
|
| 127 |
-
row_count=len(rows),
|
| 128 |
-
source_content=csv_body + "\n",
|
| 129 |
-
source_is_inline=source_is_inline,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def convert_csv_attachment_to_json(goal: str) -> CsvJsonConversion | None:
|
| 134 |
-
"""Converte un CSV allegato o esplicitamente incluso nel goal in JSON.
|
| 135 |
-
|
| 136 |
-
Il ritorno Γ¨ ``None`` quando il goal non definisce una conversione tabellare
|
| 137 |
-
completa: il resto del loop conserva quindi il comportamento esistente.
|
| 138 |
-
"""
|
| 139 |
-
if not _CONVERSION_RE.search(goal):
|
| 140 |
-
return None
|
| 141 |
-
|
| 142 |
-
target = _TARGET_RE.search(goal)
|
| 143 |
-
if not target:
|
| 144 |
-
return None
|
| 145 |
-
|
| 146 |
-
inline = _INLINE_CSV_RE.search(goal)
|
| 147 |
-
if inline:
|
| 148 |
-
return _build_conversion(
|
| 149 |
-
inline.group("name"),
|
| 150 |
-
target.group("name"),
|
| 151 |
-
inline.group("body"),
|
| 152 |
-
source_is_inline=True,
|
| 153 |
-
)
|
| 154 |
-
|
| 155 |
-
attachment = _ATTACHMENT_RE.search(goal)
|
| 156 |
-
if not attachment:
|
| 157 |
-
return None
|
| 158 |
-
return _build_conversion(
|
| 159 |
-
attachment.group("name"),
|
| 160 |
-
target.group("name"),
|
| 161 |
-
attachment.group("body"),
|
| 162 |
-
source_is_inline=False,
|
| 163 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/goal_verifier.py
CHANGED
|
@@ -40,7 +40,7 @@ class GoalVerificationStatus(str, Enum):
|
|
| 40 |
FAIL = "FAIL"
|
| 41 |
UNKNOWN = "UNKNOWN"
|
| 42 |
|
| 43 |
-
RETRY_THRESHOLD = 0.
|
| 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/strategic_healer.py
CHANGED
|
@@ -67,17 +67,6 @@ class StrategyDecision:
|
|
| 67 |
# ββ Healer principale ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
|
| 69 |
class StrategicHealer:
|
| 70 |
-
|
| 71 |
-
# ββ S-DYNAMIC-TOOL-HEALING: Fallback dinamico per tool (S512) ββββββββββββ
|
| 72 |
-
async def get_tool_fallback_strategy(self, tool_name: str, error: str) -> str:
|
| 73 |
-
"""Determina una strategia alternativa se un tool specifico fallisce."""
|
| 74 |
-
fallbacks = {
|
| 75 |
-
"google_search": "Il tool di ricerca web Γ¨ instabile. Usa 'webpage_extract' direttamente sugli URL noti o tenta una ricerca mirata su GitHub/Wikipedia via shell.",
|
| 76 |
-
"web_fetch": "L'estrazione fallisce. Usa 'curl -s' via shell per ottenere il contenuto grezzo e analizzalo con regex.",
|
| 77 |
-
"python_exec": "L'esecuzione Python ha fallito. Tenta di risolvere il task tramite logica shell (bc, awk, sed) o semplifica lo script."
|
| 78 |
-
}
|
| 79 |
-
return fallbacks.get(tool_name, f"Il tool {tool_name} ha fallito. Analizza l'errore {error} e cambia approccio.")
|
| 80 |
-
|
| 81 |
"""
|
| 82 |
Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
|
| 83 |
|
|
|
|
| 67 |
# ββ Healer principale ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
|
| 69 |
class StrategicHealer:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
| 71 |
Cognitive self-healing: costruisce comprensione incrementale dei fallimenti.
|
| 72 |
|
agents/unified_loop.py
CHANGED
|
@@ -55,57 +55,10 @@ from agents.unified_loop_types import (
|
|
| 55 |
_ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
|
| 56 |
_is_goal_ambiguous,
|
| 57 |
_is_borderline_ambiguous,
|
| 58 |
-
AgentState,
|
| 59 |
UnifiedLoopState,
|
| 60 |
_maybe_await,
|
| 61 |
)
|
| 62 |
|
| 63 |
-
# I4.5: active state is scoped to the current asyncio task, not the loop instance.
|
| 64 |
-
# This lets the public guard close unexpected exceptions without sharing state across runs.
|
| 65 |
-
_ACTIVE_LOOP_STATE: ContextVar[UnifiedLoopState | None] = ContextVar("active_loop_state", default=None)
|
| 66 |
-
# P0: EngineeringState is a shadow/canary projection of the legacy lifecycle.
|
| 67 |
-
# Context-local storage keeps parallel runs isolated even when one loop instance is reused.
|
| 68 |
-
from agents.engineering_state import EngineeringState, EngineeringStateConfig, EngineeringStateMode
|
| 69 |
-
|
| 70 |
-
_ACTIVE_ENGINEERING_STATE: ContextVar[EngineeringState | None] = ContextVar(
|
| 71 |
-
"active_engineering_state", default=None
|
| 72 |
-
)
|
| 73 |
-
_ACTIVE_ENGINEERING_MODE: ContextVar[EngineeringStateMode | None] = ContextVar(
|
| 74 |
-
"active_engineering_mode", default=None
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _schedule_engineering_persist(engineering_state: EngineeringState) -> None:
|
| 79 |
-
"""Persist a snapshot without blocking the loop or making observability fatal."""
|
| 80 |
-
snapshot = engineering_state.snapshot()
|
| 81 |
-
|
| 82 |
-
async def _persist() -> None:
|
| 83 |
-
try:
|
| 84 |
-
from api.persistence import sb_save_engineering_state
|
| 85 |
-
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot)
|
| 86 |
-
except Exception as exc: # shadow state must never break the user task
|
| 87 |
-
_logger.debug("[engineering-state] persist silenced: %s", type(exc).__name__)
|
| 88 |
-
|
| 89 |
-
try:
|
| 90 |
-
task = asyncio.create_task(_persist())
|
| 91 |
-
task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None)
|
| 92 |
-
except RuntimeError:
|
| 93 |
-
# No running event loop during defensive/test-only calls.
|
| 94 |
-
return
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
async def _flush_engineering_persist(engineering_state: EngineeringState | None) -> None:
|
| 98 |
-
"""Flush the terminal snapshot before returning a run result."""
|
| 99 |
-
if engineering_state is None:
|
| 100 |
-
return
|
| 101 |
-
snapshot = engineering_state.snapshot()
|
| 102 |
-
try:
|
| 103 |
-
from api.persistence import sb_save_engineering_state
|
| 104 |
-
await sb_save_engineering_state(snapshot["checkpoint_id"], snapshot, force=True)
|
| 105 |
-
except Exception as exc: # persistence must not turn a completed task into a crash
|
| 106 |
-
engineering_state.diagnostic(f"final persist failed: {type(exc).__name__}")
|
| 107 |
-
_logger.debug("[engineering-state] final persist silenced: %s", type(exc).__name__)
|
| 108 |
-
|
| 109 |
# S404: Error Classifier Γ’ΒΒ import lazy per evitare circular import issues
|
| 110 |
def _get_classifier():
|
| 111 |
from agents.error_classifier import classify_error, format_for_context
|
|
@@ -176,84 +129,38 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 176 |
self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
|
| 177 |
self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback β iniettato in exec_warn prima di StrategicHealer
|
| 178 |
# Γ’ΒΒΓ’ΒΒ GAP-3: Rollback atomico scritture Γ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒ
|
| 179 |
-
async def _transition_state(
|
| 180 |
-
self,
|
| 181 |
-
state: UnifiedLoopState,
|
| 182 |
-
next_state: AgentState,
|
| 183 |
-
on_step: StepCallback | None = None,
|
| 184 |
-
) -> None:
|
| 185 |
-
"""Validate and publish one per-run state transition."""
|
| 186 |
-
previous = state.state_machine.current
|
| 187 |
-
state.state_machine.transition(next_state)
|
| 188 |
-
|
| 189 |
-
# P0 adapter: mirror every legacy transition into the versioned state.
|
| 190 |
-
engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 191 |
-
if engineering_state is not None:
|
| 192 |
-
try:
|
| 193 |
-
engineering_state.transition(next_state.value)
|
| 194 |
-
_schedule_engineering_persist(engineering_state)
|
| 195 |
-
except Exception as exc:
|
| 196 |
-
engineering_state.diagnostic(f"transition adapter: {type(exc).__name__}")
|
| 197 |
-
if _ACTIVE_ENGINEERING_MODE.get() == EngineeringStateMode.AUTHORITATIVE:
|
| 198 |
-
raise
|
| 199 |
-
_logger.debug("[engineering-state] transition silenced: %s", type(exc).__name__)
|
| 200 |
-
|
| 201 |
-
if previous == next_state or on_step is None:
|
| 202 |
-
return
|
| 203 |
-
try:
|
| 204 |
-
event = {
|
| 205 |
-
"action": "state_transition",
|
| 206 |
-
"status": "done",
|
| 207 |
-
"from_state": previous.value,
|
| 208 |
-
"to_state": next_state.value,
|
| 209 |
-
}
|
| 210 |
-
if engineering_state is not None:
|
| 211 |
-
event["engineering_state"] = engineering_state.projection()
|
| 212 |
-
await _maybe_await(on_step(event))
|
| 213 |
-
except Exception as _state_callback_error:
|
| 214 |
-
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
-
|
| 216 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 217 |
-
"""
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 221 |
"""
|
| 222 |
if not self._write_snapshots or not self.executor:
|
| 223 |
return
|
| 224 |
if on_step:
|
| 225 |
await _maybe_await(on_step({
|
| 226 |
"action": "text_chunk",
|
| 227 |
-
"token":
|
| 228 |
"status": "streaming",
|
| 229 |
}))
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
tool_name = "delete_file" if original is None else "write_file"
|
| 235 |
-
inputs = {"path": path} if original is None else {"path": path, "content": original}
|
| 236 |
try:
|
| 237 |
-
|
| 238 |
-
self.executor.run_tool(
|
| 239 |
timeout=10.0,
|
| 240 |
)
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
except Exception as exc:
|
| 248 |
-
remaining[path] = original
|
| 249 |
-
_logger.error("GAP-3 rollback fallito per %s: %s", path, str(exc)[:240])
|
| 250 |
-
|
| 251 |
-
total = len(self._write_snapshots)
|
| 252 |
-
self._write_snapshots = remaining
|
| 253 |
-
_logger.info("GAP-3 rollback: %d/%d file ripristinati", rolled, total)
|
| 254 |
-
if remaining:
|
| 255 |
-
raise RuntimeError(f"VFS rollback incompleto: {len(remaining)}/{total} file non ripristinati")
|
| 256 |
|
|
|
|
| 257 |
async def _vfs_git_backup(self) -> None:
|
| 258 |
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 259 |
|
|
@@ -559,29 +466,9 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 559 |
# F17+B7: planner per task di progettazione/implementazione Γ’ΒΒ soglia ridotta a 10 chars
|
| 560 |
# Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
|
| 561 |
# _NEEDS_PLAN_RE filtra giΓΒ query semplici Γ’ΒΒ len guard serve solo per 1-8 char input.
|
| 562 |
-
try:
|
| 563 |
-
from agents.html_fast_path import classify_html_fast_path
|
| 564 |
-
_html_fast_decision = classify_html_fast_path(state.goal)
|
| 565 |
-
except Exception as _html_cls_exc:
|
| 566 |
-
_logger.debug("[html-fast-path] classifier unavailable: %s", type(_html_cls_exc).__name__)
|
| 567 |
-
_html_fast_decision = None
|
| 568 |
-
_html_fast_plan = None
|
| 569 |
-
if _html_fast_decision is not None and _html_fast_decision.eligible and not tool_results:
|
| 570 |
-
_html_fast_plan = {
|
| 571 |
-
"summary": "Piano locale mini-app HTML a file singolo",
|
| 572 |
-
"goal": state.goal,
|
| 573 |
-
"subtasks": [
|
| 574 |
-
{"id": 1, "description": f"Scrivi {_html_fast_decision.path}: {state.goal}", "tool": "write_file", "requires": []},
|
| 575 |
-
{"id": 2, "description": f"Rileggi {_html_fast_decision.path} e verifica la scrittura", "tool": "read_file", "requires": [1]},
|
| 576 |
-
],
|
| 577 |
-
"complexity": "low",
|
| 578 |
-
"source": "local_html_fast_path",
|
| 579 |
-
}
|
| 580 |
-
_logger.info("[html-fast-path] planner bypass: %s", _html_fast_decision.path)
|
| 581 |
_should_plan = (
|
| 582 |
self.planner
|
| 583 |
and not tool_results
|
| 584 |
-
and _html_fast_plan is None
|
| 585 |
and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
|
| 586 |
and len(state.goal) > 10
|
| 587 |
)
|
|
@@ -598,7 +485,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 598 |
}
|
| 599 |
_logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
|
| 600 |
_t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
|
| 601 |
-
if _should_plan
|
| 602 |
if on_step:
|
| 603 |
await _maybe_await(on_step({
|
| 604 |
"loop": 0, "action": "plan", "status": "started",
|
|
@@ -607,10 +494,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 607 |
}))
|
| 608 |
# S640: timeout planner + S-FMT-ORCH fast-fix bypass
|
| 609 |
# Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
|
| 610 |
-
if
|
| 611 |
-
plan = _html_fast_plan
|
| 612 |
-
_logger.info("[html-fast-path] ARCHITECT bypassato")
|
| 613 |
-
elif _fast_fix_plan is not None:
|
| 614 |
plan = _fast_fix_plan
|
| 615 |
_logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
|
| 616 |
else:
|
|
@@ -635,12 +519,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 635 |
"explanation": "Il pianificatore ha impiegato troppo Γ’ΒΒ procedo senza piano",
|
| 636 |
"visibility": "progress",
|
| 637 |
}))
|
| 638 |
-
|
| 639 |
-
existing_plan_step = next((s for s in state.steps if s.get("action") == "plan"), None)
|
| 640 |
-
if existing_plan_step:
|
| 641 |
-
plan = existing_plan_step.get("result")
|
| 642 |
-
_logger.info("[P1-RECOVERY] Plan restored from steps")
|
| 643 |
-
elif plan is not None:
|
| 644 |
state.steps.append({"action": "plan", "result": plan})
|
| 645 |
try:
|
| 646 |
from api.state import record_timing as _rtc_pl
|
|
@@ -1047,15 +926,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1047 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 1048 |
reg_name, inp_builder = tool_key_pair
|
| 1049 |
if reg_name and inp_builder is not None:
|
| 1050 |
-
# P1-RECOVERY: skip subtasks already completed in state.steps
|
| 1051 |
-
_st_id = subtask.get("id")
|
| 1052 |
-
_done_step = next((s for s in state.steps if s.get("subtask_id") == _st_id), None)
|
| 1053 |
-
if _done_step:
|
| 1054 |
-
_logger.info("[P1-RECOVERY] Skipping already completed subtask #%s", _st_id)
|
| 1055 |
-
# Ripristiniamo l'output nel buffer per i dipendenti
|
| 1056 |
-
_existing_out = _done_step.get("output", "")
|
| 1057 |
-
_subtask_outputs[str(_st_id)] = _existing_out
|
| 1058 |
-
continue
|
| 1059 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 1060 |
elif _s_tool:
|
| 1061 |
# COG-4: tool non in _TOOL_MAP β tenta generazione dinamica
|
|
@@ -1328,28 +1198,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1328 |
{"path": _wf_path, "content": _wf_generated} if rn == "write_file"
|
| 1329 |
else {"path": _wf_path, "patch": _wf_generated}
|
| 1330 |
)
|
| 1331 |
-
# GAP-3: snapshot pre-write
|
| 1332 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
_snap_content = _snap_payload.get("content")
|
| 1341 |
-
_snap_error = str(_snap_payload.get("error", ""))
|
| 1342 |
-
else:
|
| 1343 |
-
_snap_content = _snap_payload
|
| 1344 |
-
_snap_error = str(_snap_r.get("error", "")) if isinstance(_snap_r, dict) else ""
|
| 1345 |
-
if isinstance(_snap_content, str) and not _snap_failed:
|
| 1346 |
-
self._write_snapshots[_wf_path] = _snap_content
|
| 1347 |
-
elif "File non trovato" in _snap_error or "File not found" in _snap_error:
|
| 1348 |
-
self._write_snapshots[_wf_path] = None
|
| 1349 |
-
else:
|
| 1350 |
-
raise RuntimeError(
|
| 1351 |
-
f"Snapshot VFS non disponibile per {_wf_path}: {_snap_error[:240]}"
|
| 1352 |
)
|
|
|
|
|
|
|
| 1353 |
# GAP-VFS: lock per-path β serializza scritture parallele sullo stesso file
|
| 1354 |
_vfs_lock = self._get_vfs_lock(_wf_path)
|
| 1355 |
async with _vfs_lock:
|
|
@@ -1801,16 +1661,16 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1801 |
_logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
|
| 1802 |
self._tdd_fail_inject = None
|
| 1803 |
# GAP-4: StrategicHealer β analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
|
| 1804 |
-
if
|
| 1805 |
try:
|
| 1806 |
_sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
|
| 1807 |
-
_sh_decision = await self._strategic_healer.analyze_and_decide(
|
| 1808 |
if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
|
| 1809 |
exec_warn.insert(0, _sh_decision.strategy_prompt)
|
| 1810 |
_logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
|
| 1811 |
if _sh_decision and getattr(_sh_decision, 'should_stop', False):
|
| 1812 |
_logger.info("GAP-4: StrategicHealer β should_stop, interruzione fallback")
|
| 1813 |
-
return
|
| 1814 |
except Exception as _sh_loop_err:
|
| 1815 |
_logger.debug("GAP-4: StrategicHealer loop silenced β %s", _sh_loop_err)
|
| 1816 |
# GAP-SELFHEAL v2: dual-mode fingerprinting β raw + error-class extraction.
|
|
@@ -2273,58 +2133,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 2273 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2274 |
except Exception as _exc:
|
| 2275 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 2276 |
-
# BENCH-SHADOW: validator osservazionale MMLU/coding. Fail-open: non
|
| 2277 |
-
# modifica answer, retry, provider routing o scoring.
|
| 2278 |
-
try:
|
| 2279 |
-
from benchmarks.shadow_telemetry import validate_and_record_shadow
|
| 2280 |
-
validate_and_record_shadow(
|
| 2281 |
-
goal=state.goal,
|
| 2282 |
-
answer=answer,
|
| 2283 |
-
metadata={
|
| 2284 |
-
"provider": getattr(_active_llm, "provider", None),
|
| 2285 |
-
"model": getattr(_active_llm, "model", None),
|
| 2286 |
-
"profile": getattr(_active_llm, "profile", None),
|
| 2287 |
-
"attempt": _llm_try,
|
| 2288 |
-
"latency_ms": round(_llm_elapsed, 2),
|
| 2289 |
-
"source": "unified_loop",
|
| 2290 |
-
},
|
| 2291 |
-
)
|
| 2292 |
-
except Exception as _exc:
|
| 2293 |
-
_logger.debug("[unified_loop] shadow telemetry silenced %s", type(_exc).__name__)
|
| 2294 |
-
|
| 2295 |
-
# BENCH-CODE-RETRY: retry strutturato solo per output TypeScript
|
| 2296 |
-
# non estraibile/non conforme. Non aggiunge tentativi oltre il budget
|
| 2297 |
-
# esistente e non scatta su goal non-coding.
|
| 2298 |
-
if not _is_last:
|
| 2299 |
-
try:
|
| 2300 |
-
from benchmarks.validators import validate_coding_retry
|
| 2301 |
-
_code_validation = validate_coding_retry(
|
| 2302 |
-
state.goal,
|
| 2303 |
-
answer,
|
| 2304 |
-
is_last_attempt=_is_last,
|
| 2305 |
-
)
|
| 2306 |
-
if _code_validation is not None:
|
| 2307 |
-
state.steps.append({
|
| 2308 |
-
"action": f"typescript_contract_retry_{_llm_try}",
|
| 2309 |
-
"failure_code": _code_validation.failure_code,
|
| 2310 |
-
})
|
| 2311 |
-
_code_repair = (
|
| 2312 |
-
"CONTRATTO TYPESCRIPT FALLITO: "
|
| 2313 |
-
f"{_code_validation.failure_code}.\n"
|
| 2314 |
-
"Ripeti ora la risposta da zero. Restituisci ESATTAMENTE un solo blocco "
|
| 2315 |
-
"```typescript ... ``` non vuoto, completo e compilabile. "
|
| 2316 |
-
"Mantieni la firma e tutti i simboli richiesti dal task. "
|
| 2317 |
-
"Non usare pseudocodice, Python, testo al posto del codice, TODO o placeholder."
|
| 2318 |
-
)
|
| 2319 |
-
messages = [
|
| 2320 |
-
messages[0],
|
| 2321 |
-
{"role": "system", "content": _code_repair},
|
| 2322 |
-
*messages[1:],
|
| 2323 |
-
]
|
| 2324 |
-
_error_severity = "syntax"
|
| 2325 |
-
continue
|
| 2326 |
-
except Exception as _exc:
|
| 2327 |
-
_logger.debug("[unified_loop] coding validator retry silenced %s", type(_exc).__name__)
|
| 2328 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2329 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2330 |
if _fr == 'length' and on_step:
|
|
@@ -3080,7 +2888,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3080 |
except Exception:
|
| 3081 |
pass
|
| 3082 |
# S455-P10: task supervisionato β done_callback logga eccezioni silenziate
|
| 3083 |
-
|
| 3084 |
_rv_t.add_done_callback(
|
| 3085 |
lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
|
| 3086 |
)
|
|
@@ -3552,63 +3360,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3552 |
|
| 3553 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3554 |
on_step: StepCallback | None = None,
|
| 3555 |
-
session_id: str = ""
|
| 3556 |
-
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3557 |
-
"""Run the loop and close unexpected exceptions as a controlled FAILED state."""
|
| 3558 |
-
previous_state = _ACTIVE_LOOP_STATE.get()
|
| 3559 |
-
previous_engineering_state = _ACTIVE_ENGINEERING_STATE.get()
|
| 3560 |
-
previous_engineering_mode = _ACTIVE_ENGINEERING_MODE.get()
|
| 3561 |
-
try:
|
| 3562 |
-
return await self._run_impl(
|
| 3563 |
-
goal, context, max_steps, on_step, session_id, allow_tools,
|
| 3564 |
-
allow_local_csv_conversion,
|
| 3565 |
-
)
|
| 3566 |
-
except Exception as _run_error:
|
| 3567 |
-
state = _ACTIVE_LOOP_STATE.get()
|
| 3568 |
-
error_text = f"{type(_run_error).__name__}: {str(_run_error)[:500]}"
|
| 3569 |
-
if state is None:
|
| 3570 |
-
return {
|
| 3571 |
-
"success": False,
|
| 3572 |
-
"goal": goal,
|
| 3573 |
-
"error": error_text,
|
| 3574 |
-
"agent_state": AgentState.FAILED.value,
|
| 3575 |
-
"state_history": [AgentState.IDLE.value, AgentState.FAILED.value],
|
| 3576 |
-
}
|
| 3577 |
-
|
| 3578 |
-
state.errors.append(error_text)
|
| 3579 |
-
if getattr(self, "_write_snapshots", None):
|
| 3580 |
-
try:
|
| 3581 |
-
await self._rollback_writes(on_step)
|
| 3582 |
-
except Exception as rollback_error:
|
| 3583 |
-
state.errors.append(str(rollback_error)[:500])
|
| 3584 |
-
_logger.error("[unified_loop] rollback inatteso incompleto: %s", rollback_error)
|
| 3585 |
-
previous = state.state_machine.current
|
| 3586 |
-
if previous != AgentState.FAILED:
|
| 3587 |
-
try:
|
| 3588 |
-
await self._transition_state(state, AgentState.FAILED, on_step)
|
| 3589 |
-
except Exception as _state_transition_error:
|
| 3590 |
-
_logger.debug(
|
| 3591 |
-
"[unified_loop] failure transition silenced: %s",
|
| 3592 |
-
_state_transition_error,
|
| 3593 |
-
)
|
| 3594 |
-
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
| 3595 |
-
return {
|
| 3596 |
-
"success": False,
|
| 3597 |
-
"goal": state.goal,
|
| 3598 |
-
"steps": state.steps,
|
| 3599 |
-
"errors": state.errors,
|
| 3600 |
-
"error": error_text,
|
| 3601 |
-
**state.state_machine.snapshot(),
|
| 3602 |
-
}
|
| 3603 |
-
finally:
|
| 3604 |
-
_ACTIVE_LOOP_STATE.set(previous_state)
|
| 3605 |
-
_ACTIVE_ENGINEERING_STATE.set(previous_engineering_state)
|
| 3606 |
-
_ACTIVE_ENGINEERING_MODE.set(previous_engineering_mode)
|
| 3607 |
-
|
| 3608 |
-
async def _run_impl(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3609 |
-
on_step: StepCallback | None = None,
|
| 3610 |
-
session_id: str = "", allow_tools: bool = True,
|
| 3611 |
-
allow_local_csv_conversion: bool = False) -> dict[str, Any]:
|
| 3612 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 3613 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 3614 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso Γ’ΒΒ input come
|
|
@@ -3643,17 +3395,17 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3643 |
except Exception:
|
| 3644 |
_sid_token = None # fallback silente Γ’ΒΒ registry usa default "agent_default"
|
| 3645 |
|
| 3646 |
-
# S750-GAP-B: pre-warm sandbox
|
| 3647 |
-
#
|
| 3648 |
-
|
| 3649 |
-
|
| 3650 |
-
|
| 3651 |
-
|
| 3652 |
-
|
| 3653 |
-
|
| 3654 |
-
|
| 3655 |
-
|
| 3656 |
-
|
| 3657 |
|
| 3658 |
# S568-B: reset _session_files ogni run Γ’ΒΒ previene memory leak su sessioni lunghe.
|
| 3659 |
# Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
|
|
@@ -3668,127 +3420,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3668 |
|
| 3669 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 3670 |
|
| 3671 |
-
# P1: EngineeringState is the recovery authority unless explicitly disabled.
|
| 3672 |
-
engineering_config = EngineeringStateConfig.from_env()
|
| 3673 |
-
_effective_mode = engineering_config.mode
|
| 3674 |
-
_ACTIVE_ENGINEERING_MODE.set(_effective_mode)
|
| 3675 |
-
|
| 3676 |
-
engineering_state: EngineeringState | None = None
|
| 3677 |
-
recovery_status = "disabled"
|
| 3678 |
-
if _effective_mode != EngineeringStateMode.OFF:
|
| 3679 |
-
engineering_state = EngineeringState.start(
|
| 3680 |
-
goal,
|
| 3681 |
-
run_id=self._run_task_id,
|
| 3682 |
-
session_id=session_id,
|
| 3683 |
-
checkpoint_id=session_id or self._run_task_id,
|
| 3684 |
-
)
|
| 3685 |
-
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3686 |
-
recovery_status = "started"
|
| 3687 |
-
|
| 3688 |
-
# RECOV-P1.1/P1.2: load and validate EngineeringState before the first transition.
|
| 3689 |
-
if _effective_mode.value in {"canary", "authoritative"} and engineering_state.checkpoint_id:
|
| 3690 |
-
try:
|
| 3691 |
-
from api.persistence import sb_get_checkpoint
|
| 3692 |
-
legacy_checkpoint = await sb_get_checkpoint(engineering_state.checkpoint_id)
|
| 3693 |
-
candidate = (legacy_checkpoint or {}).get("engineering_state")
|
| 3694 |
-
if candidate:
|
| 3695 |
-
restored = EngineeringState.from_snapshot(candidate)
|
| 3696 |
-
if restored.session_id != engineering_state.session_id or restored.goal_digest != engineering_state.goal_digest:
|
| 3697 |
-
engineering_state.diagnostic("restore conflict: identity mismatch")
|
| 3698 |
-
recovery_status = "conflict"
|
| 3699 |
-
elif _effective_mode == EngineeringStateMode.AUTHORITATIVE:
|
| 3700 |
-
engineering_state = restored
|
| 3701 |
-
engineering_state.prepare_for_resume()
|
| 3702 |
-
_ACTIVE_ENGINEERING_STATE.set(engineering_state)
|
| 3703 |
-
if legacy_checkpoint:
|
| 3704 |
-
checkpoint_steps = legacy_checkpoint.get("steps")
|
| 3705 |
-
checkpoint_errors = legacy_checkpoint.get("errors")
|
| 3706 |
-
state.steps = list(checkpoint_steps)[-64:] if isinstance(checkpoint_steps, list) else []
|
| 3707 |
-
state.errors = [str(item)[:512] for item in checkpoint_errors][-24:] if isinstance(checkpoint_errors, list) else []
|
| 3708 |
-
recovery_status = "restored"
|
| 3709 |
-
_logger.info("[P1-RECOVERY] authoritative checkpoint restored revision=%d", restored.revision)
|
| 3710 |
-
else:
|
| 3711 |
-
engineering_state.diagnostic("restore validated read-only")
|
| 3712 |
-
recovery_status = "validated"
|
| 3713 |
-
else:
|
| 3714 |
-
recovery_status = "checkpoint_missing"
|
| 3715 |
-
except Exception as restore_error:
|
| 3716 |
-
engineering_state.diagnostic(f"restore rejected: {type(restore_error).__name__}")
|
| 3717 |
-
recovery_status = "rejected"
|
| 3718 |
-
_logger.debug("[engineering-state] restore silenced: %s", type(restore_error).__name__)
|
| 3719 |
-
|
| 3720 |
-
_ACTIVE_LOOP_STATE.set(state)
|
| 3721 |
-
await self._transition_state(state, AgentState.CLASSIFYING, on_step)
|
| 3722 |
-
|
| 3723 |
-
def _with_state(result: dict[str, Any]) -> dict[str, Any]:
|
| 3724 |
-
result.update(state.state_machine.snapshot())
|
| 3725 |
-
if engineering_state is not None:
|
| 3726 |
-
result["engineering_state"] = engineering_state.projection()
|
| 3727 |
-
return result
|
| 3728 |
-
|
| 3729 |
-
if engineering_state is not None and on_step is not None:
|
| 3730 |
-
try:
|
| 3731 |
-
await _maybe_await(on_step({
|
| 3732 |
-
"action": "engineering_state",
|
| 3733 |
-
"status": recovery_status,
|
| 3734 |
-
"mode": _effective_mode.value,
|
| 3735 |
-
"engineering_state": engineering_state.projection(),
|
| 3736 |
-
}))
|
| 3737 |
-
except Exception as recovery_event_error:
|
| 3738 |
-
_logger.debug("[engineering-state] recovery event silenced: %s", type(recovery_event_error).__name__)
|
| 3739 |
-
|
| 3740 |
-
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3741 |
-
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
| 3742 |
-
if next_state == AgentState.FAILED and getattr(self, "_write_snapshots", None):
|
| 3743 |
-
try:
|
| 3744 |
-
await self._rollback_writes(on_step)
|
| 3745 |
-
except Exception as rollback_error:
|
| 3746 |
-
result.setdefault("errors", []).append(str(rollback_error)[:500])
|
| 3747 |
-
result["rollback_incomplete"] = True
|
| 3748 |
-
try:
|
| 3749 |
-
await self._transition_state(state, next_state, on_step)
|
| 3750 |
-
finally:
|
| 3751 |
-
# P1 contract: persist the terminal state before returning to the caller.
|
| 3752 |
-
await _flush_engineering_persist(_ACTIVE_ENGINEERING_STATE.get())
|
| 3753 |
-
return _with_state(result)
|
| 3754 |
-
|
| 3755 |
-
# Policy fail-closed: con divieto esplicito nessun ramo tool-first, planner,
|
| 3756 |
-
# sandbox, speculazione o tool card Γ¨ raggiungibile. L'unica eccezione Γ¨ la
|
| 3757 |
-
# conversione CSVβJSON giΓ riconosciuta e validata dal parser puro al confine HTTP.
|
| 3758 |
-
if not allow_tools:
|
| 3759 |
-
if allow_local_csv_conversion:
|
| 3760 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 3761 |
-
direct_results, _tools_count, _exec_success, _exec_errors = await self._run_direct_tools(
|
| 3762 |
-
goal, on_step=on_step, local_csv_only=True,
|
| 3763 |
-
)
|
| 3764 |
-
if direct_results.startswith("[DIRECT_TERMINAL]\n"):
|
| 3765 |
-
_r = await _finish({
|
| 3766 |
-
"success": _exec_success > 0,
|
| 3767 |
-
"output": direct_results.removeprefix("[DIRECT_TERMINAL]\n"),
|
| 3768 |
-
"steps": state.steps,
|
| 3769 |
-
})
|
| 3770 |
-
else:
|
| 3771 |
-
_r = await _finish({
|
| 3772 |
-
"success": False,
|
| 3773 |
-
"output": direct_results,
|
| 3774 |
-
"steps": state.steps,
|
| 3775 |
-
"errors": ["conversione CSV locale non completata"],
|
| 3776 |
-
})
|
| 3777 |
-
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3778 |
-
_r["effective_max_steps"] = state.max_steps
|
| 3779 |
-
if _sid_token is not None:
|
| 3780 |
-
try: _sid_var.reset(_sid_token)
|
| 3781 |
-
except Exception: pass
|
| 3782 |
-
return _r
|
| 3783 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 3784 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 3785 |
-
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3786 |
-
_r["effective_max_steps"] = state.max_steps
|
| 3787 |
-
if _sid_token is not None:
|
| 3788 |
-
try: _sid_var.reset(_sid_token)
|
| 3789 |
-
except Exception: pass
|
| 3790 |
-
return _r
|
| 3791 |
-
|
| 3792 |
# GAP-4: StrategicHealer β init + load past failures (LLM-based self-healing cognitivo)
|
| 3793 |
try:
|
| 3794 |
from agents.strategic_healer import StrategicHealer as _SHClass
|
|
@@ -3870,7 +3501,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3870 |
"title": "Specifica cosa vuoi fare",
|
| 3871 |
"explanation": _amb_answer,
|
| 3872 |
}))
|
| 3873 |
-
_r_amb =
|
| 3874 |
if _sid_token is not None:
|
| 3875 |
try: _sid_var.reset(_sid_token)
|
| 3876 |
except Exception: pass
|
|
@@ -3987,7 +3618,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3987 |
"title": "Puoi essere piΓΉ specifico?",
|
| 3988 |
"explanation": _bl_answer,
|
| 3989 |
}))
|
| 3990 |
-
_r_bl =
|
| 3991 |
if _sid_token is not None:
|
| 3992 |
try: _sid_var.reset(_sid_token)
|
| 3993 |
except Exception: pass
|
|
@@ -4004,8 +3635,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4004 |
_rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 4005 |
except Exception as _exc:
|
| 4006 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 4007 |
-
await self.
|
| 4008 |
-
_r = await _finish(await self._run_fast_path(state, on_step))
|
| 4009 |
_r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
|
| 4010 |
_r["effective_max_steps"] = state.max_steps # GAP-2-FIX
|
| 4011 |
# S749-D: reset ContextVar
|
|
@@ -4024,8 +3654,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4024 |
except Exception as _exc:
|
| 4025 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 4026 |
# Puro ragionamento Γ’ΒΒ LLM diretto, nessun overhead tool
|
| 4027 |
-
await self.
|
| 4028 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4029 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4030 |
try:
|
| 4031 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4055,8 +3684,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4055 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 4056 |
except Exception:
|
| 4057 |
pass
|
| 4058 |
-
await self.
|
| 4059 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4060 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4061 |
_r["effective_max_steps"] = state.max_steps
|
| 4062 |
if _sid_token is not None:
|
|
@@ -4123,15 +3751,14 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4123 |
if _sid_token is not None:
|
| 4124 |
try: _sid_var.reset(_sid_token)
|
| 4125 |
except Exception: pass
|
| 4126 |
-
|
| 4127 |
-
return await _finish({
|
| 4128 |
"success": True,
|
| 4129 |
"answer": _p36_answer,
|
| 4130 |
"timing_ms": _p36_ms,
|
| 4131 |
"effective_max_steps": state.max_steps,
|
| 4132 |
"steps": [{"action": "p36_python_analyze", "status": "done",
|
| 4133 |
"output": _p36_answer[:300]}],
|
| 4134 |
-
}
|
| 4135 |
except Exception as _p36_exc:
|
| 4136 |
_logger.debug("P36 fast-path silenced: %s", _p36_exc)
|
| 4137 |
# fail-open: cade nel percorso normale
|
|
@@ -4143,7 +3770,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4143 |
except Exception as _exc:
|
| 4144 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 4145 |
_t_tool = _time.monotonic()
|
| 4146 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 4147 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 4148 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 4149 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
@@ -4152,20 +3778,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4152 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4153 |
"tools_fired": _tools_count,
|
| 4154 |
}))
|
| 4155 |
-
|
| 4156 |
-
|
| 4157 |
-
|
| 4158 |
-
|
| 4159 |
-
|
| 4160 |
-
|
| 4161 |
-
else:
|
| 4162 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4163 |
-
_r = await _finish(await self._run_fallback(
|
| 4164 |
-
state, on_step,
|
| 4165 |
-
preloaded_tool_results=direct_results or None,
|
| 4166 |
-
preloaded_tool_exec_successes=_exec_success,
|
| 4167 |
-
preloaded_tool_exec_errors=_exec_errors,
|
| 4168 |
-
))
|
| 4169 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4170 |
try:
|
| 4171 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4192,7 +3810,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4192 |
# S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
|
| 4193 |
# S402: unpack 4-tuple Γ’ΒΒ aggiunto _exec_success/_exec_errors per Tool Integrity Guard
|
| 4194 |
_t_tool = _time.monotonic()
|
| 4195 |
-
await self._transition_state(state, AgentState.TOOL_EXECUTING, on_step)
|
| 4196 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 4197 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 4198 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
@@ -4204,20 +3821,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4204 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 4205 |
"tools_fired": _tools_count,
|
| 4206 |
}))
|
| 4207 |
-
|
| 4208 |
-
|
| 4209 |
-
|
| 4210 |
-
|
| 4211 |
-
|
| 4212 |
-
|
| 4213 |
-
else:
|
| 4214 |
-
await self._transition_state(state, AgentState.THINKING, on_step)
|
| 4215 |
-
_r = await _finish(await self._run_fallback(
|
| 4216 |
-
state, on_step,
|
| 4217 |
-
preloaded_tool_results=direct_results,
|
| 4218 |
-
preloaded_tool_exec_successes=_exec_success,
|
| 4219 |
-
preloaded_tool_exec_errors=_exec_errors,
|
| 4220 |
-
))
|
| 4221 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4222 |
try:
|
| 4223 |
from api.state import record_timing as _rtc_ttr
|
|
@@ -4241,8 +3850,7 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 4241 |
# Rimosso: -25s worst case, path sempre: direct_tools Γ’ΒΒ _run_fallback.
|
| 4242 |
|
| 4243 |
# Fallback: LLM senza tool results (tool non triggered o tutti skip)
|
| 4244 |
-
await self.
|
| 4245 |
-
_r = await _finish(await self._run_fallback(state, on_step))
|
| 4246 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 4247 |
try:
|
| 4248 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 55 |
_ANALYTICAL_VERBS_RE, # Item 1+5: min-length gate + fast-pass non-coding
|
| 56 |
_is_goal_ambiguous,
|
| 57 |
_is_borderline_ambiguous,
|
|
|
|
| 58 |
UnifiedLoopState,
|
| 59 |
_maybe_await,
|
| 60 |
)
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# S404: Error Classifier Γ’ΒΒ import lazy per evitare circular import issues
|
| 63 |
def _get_classifier():
|
| 64 |
from agents.error_classifier import classify_error, format_for_context
|
|
|
|
| 129 |
self._run_task_id: str = "" # S568-A: ID unico per run, evita race condition su task paralleli
|
| 130 |
self._tdd_fail_inject: str | None = None # GAP-NEW-2: TDD FAIL traceback β iniettato in exec_warn prima di StrategicHealer
|
| 131 |
# Γ’ΒΒΓ’ΒΒ GAP-3: Rollback atomico scritture Γ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 133 |
+
"""
|
| 134 |
+
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metΓΒ .
|
| 135 |
+
Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
|
| 136 |
+
Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
|
| 137 |
+
File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
|
| 138 |
"""
|
| 139 |
if not self._write_snapshots or not self.executor:
|
| 140 |
return
|
| 141 |
if on_step:
|
| 142 |
await _maybe_await(on_step({
|
| 143 |
"action": "text_chunk",
|
| 144 |
+
"token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
|
| 145 |
"status": "streaming",
|
| 146 |
}))
|
| 147 |
+
_rolled = 0
|
| 148 |
+
for path, original in self._write_snapshots.items():
|
| 149 |
+
if original is None:
|
| 150 |
+
continue # file non esisteva prima Γ’ΒΒ saltiamo (non eliminiamo)
|
|
|
|
|
|
|
| 151 |
try:
|
| 152 |
+
await asyncio.wait_for(
|
| 153 |
+
self.executor.run_tool("write_file", {"path": path, "content": original}),
|
| 154 |
timeout=10.0,
|
| 155 |
)
|
| 156 |
+
_rolled += 1
|
| 157 |
+
except Exception:
|
| 158 |
+
pass # non-fatal Γ’ΒΒ best effort rollback
|
| 159 |
+
_total = len(self._write_snapshots) # salva prima del clear
|
| 160 |
+
self._write_snapshots = {}
|
| 161 |
+
_logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
# ββ GAP-NEW-4: Git VFS auto-snapshot ββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
async def _vfs_git_backup(self) -> None:
|
| 165 |
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 166 |
|
|
|
|
| 466 |
# F17+B7: planner per task di progettazione/implementazione Γ’ΒΒ soglia ridotta a 10 chars
|
| 467 |
# Bug: "crea app react" (14 chars) non attivava mai il planner (soglia era 50).
|
| 468 |
# _NEEDS_PLAN_RE filtra giΓΒ query semplici Γ’ΒΒ len guard serve solo per 1-8 char input.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
_should_plan = (
|
| 470 |
self.planner
|
| 471 |
and not tool_results
|
|
|
|
| 472 |
and bool(self._NEEDS_PLAN_RE.search(state.goal[:200]))
|
| 473 |
and len(state.goal) > 10
|
| 474 |
)
|
|
|
|
| 485 |
}
|
| 486 |
_logger.info("S-FMT-ORCH fast-fix: piano sintetico iniettato, skip ARCHITECT")
|
| 487 |
_t0_plan = asyncio.get_running_loop().time() # Sprint 5 ITEM 13: plan_ms timing
|
| 488 |
+
if _should_plan:
|
| 489 |
if on_step:
|
| 490 |
await _maybe_await(on_step({
|
| 491 |
"loop": 0, "action": "plan", "status": "started",
|
|
|
|
| 494 |
}))
|
| 495 |
# S640: timeout planner + S-FMT-ORCH fast-fix bypass
|
| 496 |
# Se _fast_fix_plan disponibile, salta ARCHITECT (~15s risparmiati)
|
| 497 |
+
if _fast_fix_plan is not None:
|
|
|
|
|
|
|
|
|
|
| 498 |
plan = _fast_fix_plan
|
| 499 |
_logger.info("S-FMT-ORCH fast-fix: ARCHITECT bypassato")
|
| 500 |
else:
|
|
|
|
| 519 |
"explanation": "Il pianificatore ha impiegato troppo Γ’ΒΒ procedo senza piano",
|
| 520 |
"visibility": "progress",
|
| 521 |
}))
|
| 522 |
+
if plan is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
state.steps.append({"action": "plan", "result": plan})
|
| 524 |
try:
|
| 525 |
from api.state import record_timing as _rtc_pl
|
|
|
|
| 926 |
tool_key_pair = _TOOL_MAP.get(_s_tool, (None, None))
|
| 927 |
reg_name, inp_builder = tool_key_pair
|
| 928 |
if reg_name and inp_builder is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
_pending_exec.append((subtask, reg_name, inp_builder))
|
| 930 |
elif _s_tool:
|
| 931 |
# COG-4: tool non in _TOOL_MAP β tenta generazione dinamica
|
|
|
|
| 1198 |
{"path": _wf_path, "content": _wf_generated} if rn == "write_file"
|
| 1199 |
else {"path": _wf_path, "patch": _wf_generated}
|
| 1200 |
)
|
| 1201 |
+
# GAP-3: snapshot pre-write Γ’ΒΒ cattura originale per rollback atomico
|
| 1202 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1203 |
+
try:
|
| 1204 |
+
_snap_r = await asyncio.wait_for(
|
| 1205 |
+
self.executor.run_tool("read_file", {"path": _wf_path}),
|
| 1206 |
+
timeout=4.0,
|
| 1207 |
+
)
|
| 1208 |
+
self._write_snapshots[_wf_path] = (
|
| 1209 |
+
_snap_r.get("output") if _snap_r.get("success") else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1210 |
)
|
| 1211 |
+
except Exception:
|
| 1212 |
+
self._write_snapshots[_wf_path] = None # file non esisteva
|
| 1213 |
# GAP-VFS: lock per-path β serializza scritture parallele sullo stesso file
|
| 1214 |
_vfs_lock = self._get_vfs_lock(_wf_path)
|
| 1215 |
async with _vfs_lock:
|
|
|
|
| 1661 |
_logger.info("GAP-NEW-2: TDD fail iniettato in exec_warn (%d chars)", len(self._tdd_fail_inject))
|
| 1662 |
self._tdd_fail_inject = None
|
| 1663 |
# GAP-4: StrategicHealer β analisi LLM pattern di fallimento (integra GAP-SELFHEAL v2)
|
| 1664 |
+
if exec_errors and getattr(self, '_strategic_healer', None):
|
| 1665 |
try:
|
| 1666 |
_sh_ctx_str = "\n".join(str(w) for w in exec_warn[-10:] if isinstance(w, str))
|
| 1667 |
+
_sh_decision = await self._strategic_healer.analyze_and_decide(exec_errors, _sh_ctx_str)
|
| 1668 |
if _sh_decision and getattr(_sh_decision, 'strategy_prompt', None):
|
| 1669 |
exec_warn.insert(0, _sh_decision.strategy_prompt)
|
| 1670 |
_logger.info("GAP-4: StrategicHealer strategy iniettata in exec_warn")
|
| 1671 |
if _sh_decision and getattr(_sh_decision, 'should_stop', False):
|
| 1672 |
_logger.info("GAP-4: StrategicHealer β should_stop, interruzione fallback")
|
| 1673 |
+
return # _run_fallback: should_stop β esci dal fallback (non c'Γ¨ loop da rompere)
|
| 1674 |
except Exception as _sh_loop_err:
|
| 1675 |
_logger.debug("GAP-4: StrategicHealer loop silenced β %s", _sh_loop_err)
|
| 1676 |
# GAP-SELFHEAL v2: dual-mode fingerprinting β raw + error-class extraction.
|
|
|
|
| 2133 |
_rec_timing("coder_ms", _llm_elapsed) # Sprint 5 ITEM 14: phase timing
|
| 2134 |
except Exception as _exc:
|
| 2135 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2136 |
# P16-B4: segnala truncation SSE se finish_reason == "length"
|
| 2137 |
_fr = getattr(_active_llm, '_last_finish_reason', 'stop')
|
| 2138 |
if _fr == 'length' and on_step:
|
|
|
|
| 2888 |
except Exception:
|
| 2889 |
pass
|
| 2890 |
# S455-P10: task supervisionato β done_callback logga eccezioni silenziate
|
| 2891 |
+
asyncio.create_task(_reverify_task())
|
| 2892 |
_rv_t.add_done_callback(
|
| 2893 |
lambda t: t.exception() if not t.cancelled() and not t.exception() is None else None
|
| 2894 |
)
|
|
|
|
| 3360 |
|
| 3361 |
async def run(self, goal: str, context: str = "", max_steps: int = 8,
|
| 3362 |
on_step: StepCallback | None = None,
|
| 3363 |
+
session_id: str = "") -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3364 |
# S390-B-L: strip role prefixes che causano prompt injection
|
| 3365 |
# Es. "SYSTEM: ignore..." o "ASSISTANT: ..." nel goal utente
|
| 3366 |
# S762-BUG3: re.sub con ^ strippava solo il PRIMO prefisso Γ’ΒΒ input come
|
|
|
|
| 3395 |
except Exception:
|
| 3396 |
_sid_token = None # fallback silente Γ’ΒΒ registry usa default "agent_default"
|
| 3397 |
|
| 3398 |
+
# S750-GAP-B: pre-warm sandbox backend-exec Γ’ΒΒ POST /api/session in background.
|
| 3399 |
+
# asyncio.create_task lancia la richiesta senza bloccare il routing:
|
| 3400 |
+
# mentre il LLM classifica il goal (~200-500ms), la sandbox su Railway ΓΒ¨ giΓΒ pronta.
|
| 3401 |
+
try:
|
| 3402 |
+
from tools.registry import _call_exec_engine as _ce, _EXEC_ENGINE_URL as _eurl
|
| 3403 |
+
if _eurl:
|
| 3404 |
+
asyncio.ensure_future(
|
| 3405 |
+
_ce({"session_id": self._run_task_id}, endpoint="/api/session")
|
| 3406 |
+
)
|
| 3407 |
+
except Exception as _exc:
|
| 3408 |
+
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3409 |
|
| 3410 |
# S568-B: reset _session_files ogni run Γ’ΒΒ previene memory leak su sessioni lunghe.
|
| 3411 |
# Il dict cresce durante _run_fallback e non veniva mai azzerato tra chiamate.
|
|
|
|
| 3420 |
|
| 3421 |
state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps, session_id=session_id)
|
| 3422 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3423 |
# GAP-4: StrategicHealer β init + load past failures (LLM-based self-healing cognitivo)
|
| 3424 |
try:
|
| 3425 |
from agents.strategic_healer import StrategicHealer as _SHClass
|
|
|
|
| 3501 |
"title": "Specifica cosa vuoi fare",
|
| 3502 |
"explanation": _amb_answer,
|
| 3503 |
}))
|
| 3504 |
+
_r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 3505 |
if _sid_token is not None:
|
| 3506 |
try: _sid_var.reset(_sid_token)
|
| 3507 |
except Exception: pass
|
|
|
|
| 3618 |
"title": "Puoi essere piΓΉ specifico?",
|
| 3619 |
"explanation": _bl_answer,
|
| 3620 |
}))
|
| 3621 |
+
_r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 3622 |
if _sid_token is not None:
|
| 3623 |
try: _sid_var.reset(_sid_token)
|
| 3624 |
except Exception: pass
|
|
|
|
| 3635 |
_rtc_cls("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3636 |
except Exception as _exc:
|
| 3637 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3638 |
+
_r = await self._run_fast_path(state, on_step)
|
|
|
|
| 3639 |
_r.setdefault("timing_ms", int((_time.monotonic() - _t_run) * 1000))
|
| 3640 |
_r["effective_max_steps"] = state.max_steps # GAP-2-FIX
|
| 3641 |
# S749-D: reset ContextVar
|
|
|
|
| 3654 |
except Exception as _exc:
|
| 3655 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3656 |
# Puro ragionamento Γ’ΒΒ LLM diretto, nessun overhead tool
|
| 3657 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3658 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3659 |
try:
|
| 3660 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3684 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 3685 |
except Exception:
|
| 3686 |
pass
|
| 3687 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3688 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3689 |
_r["effective_max_steps"] = state.max_steps
|
| 3690 |
if _sid_token is not None:
|
|
|
|
| 3751 |
if _sid_token is not None:
|
| 3752 |
try: _sid_var.reset(_sid_token)
|
| 3753 |
except Exception: pass
|
| 3754 |
+
return {
|
|
|
|
| 3755 |
"success": True,
|
| 3756 |
"answer": _p36_answer,
|
| 3757 |
"timing_ms": _p36_ms,
|
| 3758 |
"effective_max_steps": state.max_steps,
|
| 3759 |
"steps": [{"action": "p36_python_analyze", "status": "done",
|
| 3760 |
"output": _p36_answer[:300]}],
|
| 3761 |
+
}
|
| 3762 |
except Exception as _p36_exc:
|
| 3763 |
_logger.debug("P36 fast-path silenced: %s", _p36_exc)
|
| 3764 |
# fail-open: cade nel percorso normale
|
|
|
|
| 3770 |
except Exception as _exc:
|
| 3771 |
_logger.debug("[unified_loop] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 3772 |
_t_tool = _time.monotonic()
|
|
|
|
| 3773 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 3774 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 3775 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
|
|
| 3778 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 3779 |
"tools_fired": _tools_count,
|
| 3780 |
}))
|
| 3781 |
+
_r = await self._run_fallback(
|
| 3782 |
+
state, on_step,
|
| 3783 |
+
preloaded_tool_results=direct_results or None,
|
| 3784 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 3785 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 3786 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3787 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3788 |
try:
|
| 3789 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3810 |
# S193: tool diretti PRIMA (deterministici, nessun LLM per routing)
|
| 3811 |
# S402: unpack 4-tuple Γ’ΒΒ aggiunto _exec_success/_exec_errors per Tool Integrity Guard
|
| 3812 |
_t_tool = _time.monotonic()
|
|
|
|
| 3813 |
direct_results, _tools_count, _exec_success, _exec_errors = \
|
| 3814 |
await self._run_direct_tools(goal, on_step=on_step)
|
| 3815 |
_tool_ms = int((_time.monotonic() - _t_tool) * 1000)
|
|
|
|
| 3821 |
"loop": 0, "action": "direct_tools", "status": "done",
|
| 3822 |
"tools_fired": _tools_count,
|
| 3823 |
}))
|
| 3824 |
+
_r = await self._run_fallback(
|
| 3825 |
+
state, on_step,
|
| 3826 |
+
preloaded_tool_results=direct_results,
|
| 3827 |
+
preloaded_tool_exec_successes=_exec_success,
|
| 3828 |
+
preloaded_tool_exec_errors=_exec_errors,
|
| 3829 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3830 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3831 |
try:
|
| 3832 |
from api.state import record_timing as _rtc_ttr
|
|
|
|
| 3850 |
# Rimosso: -25s worst case, path sempre: direct_tools Γ’ΒΒ _run_fallback.
|
| 3851 |
|
| 3852 |
# Fallback: LLM senza tool results (tool non triggered o tutti skip)
|
| 3853 |
+
_r = await self._run_fallback(state, on_step)
|
|
|
|
| 3854 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 3855 |
try:
|
| 3856 |
from api.state import record_timing as _rtc_ttr
|
agents/unified_loop_delegate.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_delegate.py β DelegateMixin: debug riflessivo, replan, delega in-loop.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale.
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_reflective_debug(goal, errors): BGAP-GUARD diagnosi breve da errori tool
|
| 7 |
+
_budget_replan_check(state, step): BGAP-1 replan probabilistico su budget critico
|
| 8 |
+
_DELEGATE_RESEARCH_RE: regex riconoscimento sub-goal tipo ricerca
|
| 9 |
+
_run_in_loop_delegate(sub_goal): GAP-1 micro-agente specializzato in-loop
|
| 10 |
+
|
| 11 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 12 |
+
MRO garantisce che DelegateMixin._budget_replan_check sovrascriva HelpersMixin
|
| 13 |
+
(DelegateMixin precede HelpersMixin nella lista basi di UnifiedAgentLoop).
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import logging
|
| 19 |
+
import re
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from agents.unified_loop_types import StepCallback, UnifiedLoopState, _maybe_await
|
| 23 |
+
|
| 24 |
+
_logger = logging.getLogger("agente_ai")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class DelegateMixin:
|
| 28 |
+
async def _reflective_debug(
|
| 29 |
+
self, goal: str = "", errors: Any = None, **kwargs: Any
|
| 30 |
+
) -> str:
|
| 31 |
+
"""Reflective debug: analizza errori e propone diagnosi in max 2 frasi.
|
| 32 |
+
Chiamato dopo tool failures per arricchire state.context con ipotesi fix.
|
| 33 |
+
Fail-open: non blocca mai il loop in caso di errore LLM."""
|
| 34 |
+
try:
|
| 35 |
+
_ctx = f"Goal: {str(goal)[:200]}\nErrori: {'; '.join(str(e)[:300] for e in (errors if isinstance(errors, list) else [errors])[:3])}" # S573: 150β300
|
| 36 |
+
_fast = self._get_fast_llm()
|
| 37 |
+
_diag = await asyncio.wait_for(
|
| 38 |
+
_fast.chat([{"role": "user", "content": f"Diagnosi breve (max 2 frasi):\n{_ctx}"}], max_tokens=300), # S586: 120->180->300
|
| 39 |
+
timeout=5.0,
|
| 40 |
+
)
|
| 41 |
+
return (str(_diag) if _diag else "").strip()[:300]
|
| 42 |
+
except Exception:
|
| 43 |
+
pass # fail-open
|
| 44 |
+
return ""
|
| 45 |
+
|
| 46 |
+
# ββ BGAP-1: Probabilistic Re-planning Trigger ββββββββββββββββββββββββββββ
|
| 47 |
+
async def _budget_replan_check(
|
| 48 |
+
self, state: Any, step_count: int, on_step: Any = None
|
| 49 |
+
) -> str:
|
| 50 |
+
"""BGAP-1: probabilistic re-planning trigger.
|
| 51 |
+
Guards: skip se _n_err < 2 OR _budget_ratio < 0.6.
|
| 52 |
+
Usa _get_fast_llm() con max_tokens=120. Fail-open."""
|
| 53 |
+
_n_err = len(state.errors) if getattr(state, 'errors', None) else 0
|
| 54 |
+
if _n_err < 2:
|
| 55 |
+
return ''
|
| 56 |
+
_budget_ratio = step_count / max(state.max_steps, 1)
|
| 57 |
+
if _budget_ratio < 0.6:
|
| 58 |
+
return ''
|
| 59 |
+
# dedup guard [GAP-1-REPLAN]: skip se giΓ replanned in questo loop
|
| 60 |
+
if '[GAP-1-REPLAN]' in (state.context or ''):
|
| 61 |
+
return ''
|
| 62 |
+
try:
|
| 63 |
+
_fast_llm = self._get_fast_llm()
|
| 64 |
+
_prompt = (
|
| 65 |
+
f'Task ha avuto {_n_err} errori e usato {_budget_ratio:.0%} del budget. '
|
| 66 |
+
f'Suggerisci UN approccio alternativo in max 2 frasi. Goal: {state.goal[:500]}' # S597: 200->300->500
|
| 67 |
+
)
|
| 68 |
+
_hint = await asyncio.wait_for(
|
| 69 |
+
_fast_llm.chat([{'role': 'user', 'content': _prompt}], max_tokens=120),
|
| 70 |
+
timeout=5.0,
|
| 71 |
+
)
|
| 72 |
+
return (str(_hint) if _hint else '').strip()[:200]
|
| 73 |
+
except Exception:
|
| 74 |
+
pass # fail-open totale
|
| 75 |
+
return ''
|
| 76 |
+
|
| 77 |
+
# ββ GAP-1: Delega Dinamica In-Loop βββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
_DELEGATE_RESEARCH_RE = re.compile(
|
| 79 |
+
r'\b(cerca|research|trova|web|url|leggi|analisi|analizza|documenta|'
|
| 80 |
+
r'news|notizie|fetch|scrape|pagina|sito|http)\b',
|
| 81 |
+
re.IGNORECASE,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
async def _run_in_loop_delegate(self, sub_goal: str, timeout: float = 40.0) -> dict:
|
| 85 |
+
"""GAP-1: Delega Dinamica In-Loop.
|
| 86 |
+
Lancia un micro-agente specializzato per sub_goal DURANTE il loop principale.
|
| 87 |
+
Architettura:
|
| 88 |
+
- Stesso executor del parent β accesso ai tool reali (write_file, run_python, ...)
|
| 89 |
+
- LLM selezionato per ruolo β RESEARCHER, CODER o REASONER in base al goal
|
| 90 |
+
- _is_delegate_child = True β blocca ricorsione (max 1 livello di delega)
|
| 91 |
+
- max_steps = 4 β micro-agente leggero, non un loop completo
|
| 92 |
+
- output troncato a 4000 chars β evita context-window explosion nel parent
|
| 93 |
+
"""
|
| 94 |
+
# P18: defensive anti-recursion guard at entry point
|
| 95 |
+
if getattr(self, '_is_delegate_child', False):
|
| 96 |
+
_logger.debug("[delegate] anti-recursion guard triggered at _run_in_loop_delegate entry")
|
| 97 |
+
return {"output": "[DELEGATE] Ricorsione bloccata: _is_delegate_child=True.", "steps": [], "goal_met": False}
|
| 98 |
+
try:
|
| 99 |
+
from models.role_router import RoleRouter as _RR_d, Role as _Role_d
|
| 100 |
+
# Seleziona LLM specializzato in base al tipo di sotto-obiettivo
|
| 101 |
+
if self._DELEGATE_RESEARCH_RE.search(sub_goal[:300]):
|
| 102 |
+
_sub_llm = _RR_d.get_client(_Role_d.RESEARCHER) # Gemini 2.5-flash
|
| 103 |
+
elif self._CODE_RE.search(sub_goal[:300]):
|
| 104 |
+
_sub_llm = _RR_d.get_client(_Role_d.CODER) # Llama 4 Scout
|
| 105 |
+
else:
|
| 106 |
+
_sub_llm = _RR_d.get_client(_Role_d.REASONER) # Cerebras 120B
|
| 107 |
+
except Exception:
|
| 108 |
+
_sub_llm = self.llm # fallback: usa LLM del parent
|
| 109 |
+
|
| 110 |
+
# Crea loop figlio: stessi executor/planner/memory, LLM specializzato
|
| 111 |
+
_sub_loop = UnifiedAgentLoop(
|
| 112 |
+
llm_client=_sub_llm,
|
| 113 |
+
planner=self.planner,
|
| 114 |
+
executor=self.executor,
|
| 115 |
+
critic=None, # no critic β micro-agente leggero
|
| 116 |
+
memory=self.memory,
|
| 117 |
+
verifier=None, # no verifier β massima velocitΓ
|
| 118 |
+
)
|
| 119 |
+
# Anti-ricorsione: il figlio non puΓ² delegare ulteriormente
|
| 120 |
+
_sub_loop._is_delegate_child = True
|
| 121 |
+
# Propaga session_id per isolare sandbox backend-exec
|
| 122 |
+
_sub_loop._run_task_id = self._run_task_id + "_d"
|
| 123 |
+
# GAP-6: condividi dict mutabile _session_files con il parent loop
|
| 124 |
+
# Prima: delegate inizializzava _session_files={} -> file scritti non visibili al parent
|
| 125 |
+
# Ora: stessa referenza -> parent vede automaticamente tutti i file scritti dal delegate
|
| 126 |
+
_sub_loop._session_files = self._session_files
|
| 127 |
+
|
| 128 |
+
# P17-F1: buffer output parziale via on_step β sopravvive al timeout
|
| 129 |
+
_partial_steps: list[dict] = []
|
| 130 |
+
async def _capture_partial(step: dict) -> None:
|
| 131 |
+
if step.get("output") or step.get("explanation"):
|
| 132 |
+
_partial_steps.append(step)
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
_res = await asyncio.wait_for(
|
| 136 |
+
_sub_loop.run(sub_goal, max_steps=4, on_step=_capture_partial),
|
| 137 |
+
timeout=timeout,
|
| 138 |
+
)
|
| 139 |
+
_out = (_res.get("output") or "")[:4000]
|
| 140 |
+
_logger.info(
|
| 141 |
+
"GAP-1 delegate OK [%s] steps=%d: %s",
|
| 142 |
+
_res.get("engine", "?"), len(_res.get("steps", [])), sub_goal[:60],
|
| 143 |
+
)
|
| 144 |
+
return {
|
| 145 |
+
"success": _res.get("success", False),
|
| 146 |
+
"output": _out,
|
| 147 |
+
"engine": _res.get("engine", "delegate"),
|
| 148 |
+
"steps": len(_res.get("steps", [])),
|
| 149 |
+
}
|
| 150 |
+
except asyncio.TimeoutError:
|
| 151 |
+
# P17-F1: esponi stato parziale invece di stringa vuota
|
| 152 |
+
# _session_files giΓ condiviso con parent β parent vede file scritti
|
| 153 |
+
_partial_files = list(getattr(_sub_loop, "_session_files", {}).keys())
|
| 154 |
+
_partial_out = " ".join(
|
| 155 |
+
(s.get("output") or s.get("explanation") or "")[:300]
|
| 156 |
+
for s in _partial_steps[-3:]
|
| 157 |
+
).strip()[:1500]
|
| 158 |
+
_logger.warning(
|
| 159 |
+
"GAP-1 delegate timeout (%.0fs, %d steps, %d files): %s",
|
| 160 |
+
timeout, len(_partial_steps), len(_partial_files), sub_goal[:60],
|
| 161 |
+
)
|
| 162 |
+
# S-PARTIAL: emetti evento SSE partial_output al frontend PRIMA di restituire
|
| 163 |
+
# cosΓ¬ l'utente vede il chip "β output parziale β riprendo" in tempo reale
|
| 164 |
+
if on_step:
|
| 165 |
+
await _maybe_await(on_step({
|
| 166 |
+
"event": "partial_output",
|
| 167 |
+
"action": "partial_output",
|
| 168 |
+
"visibility": "progress",
|
| 169 |
+
"partial": True,
|
| 170 |
+
"steps_done": len(_partial_steps),
|
| 171 |
+
"partial_files": _partial_files,
|
| 172 |
+
"partial_output": _partial_out,
|
| 173 |
+
"output": _partial_out,
|
| 174 |
+
"explanation": f"Output parziale dopo {timeout:.0f}s β l'agente sta recuperando",
|
| 175 |
+
"status": "warning",
|
| 176 |
+
}))
|
| 177 |
+
return {
|
| 178 |
+
"success": False,
|
| 179 |
+
"output": _partial_out,
|
| 180 |
+
"error": f"delegate timeout ({timeout:.0f}s) β risultato parziale",
|
| 181 |
+
"partial": True,
|
| 182 |
+
"partial_files": _partial_files,
|
| 183 |
+
"steps_done": len(_partial_steps),
|
| 184 |
+
}
|
| 185 |
+
except Exception as _de:
|
| 186 |
+
_logger.warning("GAP-1 delegate error: %s", _de)
|
| 187 |
+
return {"success": False, "output": "", "error": str(_de)[:200]}
|
| 188 |
+
|
| 189 |
+
# Γ’ΒΒΓ’ΒΒ S362: Role routing helpers Γ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒΓ’ΒΒ
|
| 190 |
+
|
| 191 |
+
# S427: ampliato con verbi IT/EN mancanti + framework/pattern aggiuntivi.
|
| 192 |
+
# Stesso set di goal_verifier._CODE_RE + keyword tecnologiche per routing CODER LLM.
|
agents/unified_loop_fallback.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_helpers.py
CHANGED
|
@@ -27,19 +27,7 @@ import logging
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi β zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
-
from agents.unified_loop_types import
|
| 31 |
-
StepCallback,
|
| 32 |
-
UnifiedLoopState,
|
| 33 |
-
_LANG_INSTRUCTIONS,
|
| 34 |
-
_detect_user_lang,
|
| 35 |
-
_maybe_await,
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _get_classifier():
|
| 40 |
-
"""Load the error classifier lazily, avoiding import cycles."""
|
| 41 |
-
from agents.error_classifier import classify_error, format_for_context
|
| 42 |
-
return classify_error, format_for_context
|
| 43 |
|
| 44 |
|
| 45 |
class HelpersMixin:
|
|
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi β zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
+
from agents.unified_loop_types import StepCallback, UnifiedLoopState
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class HelpersMixin:
|
agents/unified_loop_llm.py
CHANGED
|
@@ -34,28 +34,13 @@ class LLMSelectionMixin:
|
|
| 34 |
|
| 35 |
def _get_llm_for_goal(self, goal: str) -> Any:
|
| 36 |
"""S362: return CODER-role LLM for code-heavy goals, default otherwise.
|
| 37 |
-
GAP-ROUT: route SQL/Reasoning/MMLU to REASONER role (Cerebras 120B).
|
| 38 |
S416-Fix3: anche app complesse (tok_budget >= 6144) usano CODER (70B)
|
| 39 |
-
anche se _CODE_RE non matcha
|
| 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,57 +59,158 @@ class DirectToolsMixin:
|
|
| 58 |
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 59 |
re.IGNORECASE,
|
| 60 |
)
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
r"\
|
| 64 |
-
r"fammi\s+sapere|dimmi\s+di\s+piΓΉ\s+su|informazioni\s+su|info\s+su|news\s+su|notizie\s+su|"
|
| 65 |
-
r"chi\s+Γ¨|cos['\u2019]Γ¨|dove\s+si\s+trova|quando\s+Γ¨\s+successo|perchΓ©\s+il|storia\s+di|"
|
| 66 |
-
r"tell\s+me\s+about|who\s+is|what\s+is|where\s+is|when\s+did|why\s+is|history\s+of|"
|
| 67 |
-
r"latest\s+on|ultime\s+su|prezzo\s+di|valore\s+di|quotazione\s+di|stock\s+price\s+of|"
|
| 68 |
-
r"crypto|bitcoin|ethereum|market\s+cap|capitalizzazione)\b",
|
| 69 |
re.IGNORECASE,
|
| 70 |
)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
r"
|
| 78 |
-
r"
|
| 79 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
re.IGNORECASE,
|
| 81 |
)
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
r"\
|
| 87 |
-
r"
|
|
|
|
|
|
|
|
|
|
| 88 |
re.IGNORECASE,
|
| 89 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
_CALC_INTENT_RE = re.compile(
|
| 91 |
-
r"\b(calcola|quanto\s+fa|risultato\s+di|compute|
|
| 92 |
-
r"
|
| 93 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
re.IGNORECASE,
|
| 95 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def _extract_city(self, goal: str) -> str:
|
| 97 |
m = self._CITY_RE.search(goal)
|
| 98 |
if m:
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def _extract_search_query(self, goal: str) -> str:
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def _extract_calc_expr(self, goal: str) -> str:
|
| 107 |
-
m =
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
def _extract_dir_path(self, goal: str) -> str:
|
| 111 |
-
|
| 112 |
m = re.search(
|
| 113 |
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
|
| 114 |
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
|
|
@@ -121,16 +223,18 @@ class DirectToolsMixin:
|
|
| 121 |
return "."
|
| 122 |
|
| 123 |
def _extract_file_pattern(self, goal: str) -> str:
|
| 124 |
-
|
| 125 |
m = re.search(
|
| 126 |
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
|
| 127 |
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
|
| 128 |
goal, re.IGNORECASE,
|
| 129 |
)
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
|
| 132 |
def _extract_git_cwd(self, goal: str) -> str:
|
| 133 |
-
|
| 134 |
m = re.search(
|
| 135 |
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
|
| 136 |
goal, re.IGNORECASE,
|
|
@@ -140,84 +244,92 @@ class DirectToolsMixin:
|
|
| 140 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 141 |
return candidate
|
| 142 |
return "."
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
goal: str,
|
| 146 |
-
on_step: StepCallback | None = None,
|
| 147 |
-
*,
|
| 148 |
-
local_csv_only: bool = False,
|
| 149 |
-
) -> tuple[str, int, int, int]:
|
| 150 |
"""
|
| 151 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 152 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 153 |
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 154 |
n_called: numero totale di tool chiamati
|
| 155 |
-
n_success:
|
| 156 |
-
n_errors:
|
|
|
|
|
|
|
| 157 |
"""
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
| 162 |
|
| 163 |
results: list[str] = []
|
| 164 |
-
n_called = 0
|
| 165 |
-
n_success = 0
|
| 166 |
-
n_errors = 0
|
| 167 |
-
TOOL_TIMEOUT = 25
|
| 168 |
|
| 169 |
-
# Governor
|
| 170 |
_gov_called: set[str] = set()
|
| 171 |
-
_gov_total = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 173 |
-
|
| 174 |
|
| 175 |
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
_gov_called
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
return True
|
| 185 |
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
try:
|
| 188 |
-
|
|
|
|
| 189 |
except Exception:
|
| 190 |
-
# Cache speculativa opzionale: mai bloccare l'esecuzione reale.
|
| 191 |
return None
|
| 192 |
|
| 193 |
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 194 |
# Pre-check intent (sincrono) β costruisce lista coroutine β gather
|
|
|
|
|
|
|
| 195 |
url_m = self._URL_RE.search(goal)
|
|
|
|
| 196 |
async def _t_get_weather() -> str | None:
|
| 197 |
if not self._WEATHER_INTENT_RE.search(goal):
|
| 198 |
return None
|
| 199 |
-
city = self._extract_city(goal)
|
| 200 |
if not _gov_check("get_weather", city):
|
| 201 |
return None
|
| 202 |
try:
|
| 203 |
-
if on_step:
|
| 204 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 205 |
-
"title": "Meteo", "explanation": f"Recupero meteo per {city}β¦"}))
|
| 206 |
_sc = _spec_hit("get_weather", {"city": city})
|
| 207 |
if _sc is not None:
|
| 208 |
return _sc
|
|
|
|
|
|
|
|
|
|
| 209 |
_t0 = asyncio.get_event_loop().time()
|
| 210 |
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 211 |
try:
|
| 212 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 213 |
-
except Exception
|
| 214 |
-
if "
|
| 215 |
_wdesc = {
|
| 216 |
-
0: "
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 223 |
try:
|
|
@@ -230,11 +342,12 @@ class DirectToolsMixin:
|
|
| 230 |
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 231 |
f"Condizioni: {desc}"
|
| 232 |
)
|
| 233 |
-
return f"[get_weather: errore β {r['error'][:300]}]"
|
| 234 |
except asyncio.TimeoutError:
|
| 235 |
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 236 |
except Exception as exc:
|
| 237 |
-
return f"[get_weather: errore β {str(exc)[:300]}]"
|
|
|
|
| 238 |
async def _t_read_page() -> str | None:
|
| 239 |
if not url_m:
|
| 240 |
return None
|
|
@@ -252,14 +365,15 @@ class DirectToolsMixin:
|
|
| 252 |
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 253 |
try:
|
| 254 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 255 |
-
except Exception
|
| 256 |
if r.get("content"):
|
| 257 |
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 258 |
-
return f"[read_page: errore β {r.get('error', 'nessun contenuto')[:300]}]"
|
| 259 |
except asyncio.TimeoutError:
|
| 260 |
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 261 |
except Exception as exc:
|
| 262 |
-
return f"[read_page: errore β {str(exc)[:300]}]"
|
|
|
|
| 263 |
async def _t_calculate() -> str | None:
|
| 264 |
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 265 |
return None
|
|
@@ -277,14 +391,15 @@ class DirectToolsMixin:
|
|
| 277 |
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 278 |
try:
|
| 279 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 280 |
-
except Exception
|
| 281 |
if "result" in r:
|
| 282 |
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 283 |
-
return f"[calculate: errore β {r.get('error', '?')[:300]}]"
|
| 284 |
except asyncio.TimeoutError:
|
| 285 |
return "[calculate: timeout]"
|
| 286 |
except Exception as exc:
|
| 287 |
-
return f"[calculate: errore β {str(exc)[:300]}]"
|
|
|
|
| 288 |
async def _t_web_search() -> str | None:
|
| 289 |
if not self._SEARCH_INTENT_RE.search(goal):
|
| 290 |
return None
|
|
@@ -302,95 +417,27 @@ class DirectToolsMixin:
|
|
| 302 |
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 303 |
try:
|
| 304 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 305 |
-
except Exception
|
| 306 |
hits = r.get("results", [])
|
| 307 |
if hits:
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
if conversion is None:
|
| 320 |
-
return None
|
| 321 |
-
if not _gov_check("convert_csv_to_json", conversion.target_name):
|
| 322 |
-
return None
|
| 323 |
-
|
| 324 |
-
# Il successo diretto Γ¨ consentito solo dopo il confronto semantico
|
| 325 |
-
# record-per-record. Questo blocca cataloghi generici/allucinati prima
|
| 326 |
-
# che il loop possa dichiarare una conversione corretta.
|
| 327 |
-
is_valid, validation_error = validate_csv_json_equivalence(
|
| 328 |
-
conversion.source_content,
|
| 329 |
-
conversion.content,
|
| 330 |
-
)
|
| 331 |
-
if not is_valid:
|
| 332 |
-
return f"[convert_csv_to_json: validazione fallita β {validation_error}]"
|
| 333 |
-
|
| 334 |
-
async def _write(path: str, content: str) -> dict[str, Any]:
|
| 335 |
-
return await asyncio.wait_for(
|
| 336 |
-
TOOL_REGISTRY["write_file"]["_fn"](path=path, content=content),
|
| 337 |
-
timeout=TOOL_TIMEOUT,
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
try:
|
| 341 |
-
if on_step:
|
| 342 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 343 |
-
"title": "Conversione CSV in JSON",
|
| 344 |
-
"explanation": f"Converto {conversion.source_name} in {conversion.target_name} con verifica recordβ¦"}))
|
| 345 |
-
_t0 = asyncio.get_event_loop().time()
|
| 346 |
-
|
| 347 |
-
# Per il CSV inline il goal richiede esplicitamente entrambi gli
|
| 348 |
-
# artefatti. Gli allegati conservano il comportamento esistente:
|
| 349 |
-
# viene scritto soltanto il JSON, poichΓ© la fonte Γ¨ giΓ disponibile.
|
| 350 |
-
written_paths: list[str] = []
|
| 351 |
-
if conversion.source_is_inline:
|
| 352 |
-
source_written = await _write(conversion.source_name, conversion.source_content)
|
| 353 |
-
if not source_written.get("ok"):
|
| 354 |
-
return f"[convert_csv_to_json: errore sorgente β {str(source_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 355 |
-
written_paths.append(conversion.source_name)
|
| 356 |
-
if on_step:
|
| 357 |
-
await _maybe_await(on_step({
|
| 358 |
-
"action": "file_written", "status": "done",
|
| 359 |
-
"path": conversion.source_name, "content": conversion.source_content,
|
| 360 |
-
"title": "File CSV creato",
|
| 361 |
-
"explanation": f"Creato {conversion.source_name} con i dati sorgente verificati.",
|
| 362 |
-
}))
|
| 363 |
-
|
| 364 |
-
target_written = await _write(conversion.target_name, conversion.content)
|
| 365 |
-
if not target_written.get("ok"):
|
| 366 |
-
return f"[convert_csv_to_json: errore JSON β {str(target_written.get('error', 'scrittura non riuscita'))[:300]}]"
|
| 367 |
-
written_paths.append(conversion.target_name)
|
| 368 |
-
if on_step:
|
| 369 |
-
await _maybe_await(on_step({
|
| 370 |
-
"action": "file_written", "status": "done",
|
| 371 |
-
"path": conversion.target_name, "content": conversion.content,
|
| 372 |
-
"title": "File JSON creato",
|
| 373 |
-
"explanation": f"Creato {conversion.target_name} con {conversion.row_count} record verificati.",
|
| 374 |
-
}))
|
| 375 |
-
try:
|
| 376 |
-
from api.state import record_timing as _rtc
|
| 377 |
-
_rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 378 |
-
except Exception as _e:
|
| 379 |
-
_logger.debug('[timing/record_timing] %s', _e)
|
| 380 |
-
paths = ", ".join(f"`{path}`" for path in written_paths)
|
| 381 |
-
return (
|
| 382 |
-
"[DIRECT_TERMINAL]\n"
|
| 383 |
-
f"E2E_CONVERSION_OK: verificati {conversion.row_count} record tra `{conversion.source_name}` "
|
| 384 |
-
f"e `{conversion.target_name}`.\n\n"
|
| 385 |
-
f"File workspace salvati: {paths}."
|
| 386 |
-
)
|
| 387 |
except asyncio.TimeoutError:
|
| 388 |
-
return "[
|
| 389 |
except Exception as exc:
|
| 390 |
-
return f"[
|
| 391 |
|
| 392 |
async def _t_generate_image() -> str | None:
|
| 393 |
-
if not self.
|
| 394 |
return None
|
| 395 |
_img_prompt = re.sub(
|
| 396 |
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
|
@@ -402,52 +449,28 @@ class DirectToolsMixin:
|
|
| 402 |
if on_step:
|
| 403 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 404 |
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}β¦"}))
|
| 405 |
-
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]})
|
| 406 |
if _sc is not None:
|
| 407 |
return _sc
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
# resta solo per garantire la creazione gratuita se il secret non Γ¨
|
| 411 |
-
# ancora disponibile durante un riavvio del runtime.
|
| 412 |
try:
|
| 413 |
-
from api.
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
f"https://image.pollinations.ai/prompt/{quote(_img_prompt, safe='')}"
|
| 423 |
-
f"?width=512&height=512&seed={_img_seed}&nologo=true&enhance=true"
|
| 424 |
)
|
| 425 |
-
|
| 426 |
-
_artifact_id = hashlib.sha256(_img_prompt.encode("utf-8")).hexdigest()[:12]
|
| 427 |
-
_artifact_path = f"generated-image-{_artifact_id}.jpg"
|
| 428 |
-
if on_step:
|
| 429 |
-
await _maybe_await(on_step({
|
| 430 |
-
"action": "file_written",
|
| 431 |
-
"status": "done",
|
| 432 |
-
"path": _artifact_path,
|
| 433 |
-
"source_url": img_url,
|
| 434 |
-
"mime_type": img_mime,
|
| 435 |
-
"title": "Immagine salvata nel workspace",
|
| 436 |
-
"explanation": f"Salvo {_artifact_path} nel VFSβ¦",
|
| 437 |
-
}))
|
| 438 |
-
return (
|
| 439 |
-
"[DIRECT_TERMINAL]\n"
|
| 440 |
-
f"\n\n"
|
| 441 |
-
"E2E_IMAGE_OK: immagine generata, visualizzata e salvata nel workspace. "
|
| 442 |
-
f"[Apri o scarica lβimmagine]({img_url}).\n\n"
|
| 443 |
-
f"File VFS: `{_artifact_path}`\n"
|
| 444 |
-
f"Prompt usato: {_img_prompt[:200]}\n"
|
| 445 |
-
"Dimensioni: 512x512 px"
|
| 446 |
-
)
|
| 447 |
except asyncio.TimeoutError:
|
| 448 |
return "[generate_image: timeout β provider non raggiungibile]"
|
| 449 |
except Exception as exc:
|
| 450 |
-
return f"[generate_image: errore β {str(exc)[:300]}]"
|
|
|
|
| 451 |
async def _t_run_python() -> str | None:
|
| 452 |
_RUN_CODE_RE = re.compile(
|
| 453 |
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
|
@@ -466,52 +489,139 @@ class DirectToolsMixin:
|
|
| 466 |
if on_step:
|
| 467 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 468 |
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandboxβ¦"}))
|
| 469 |
-
_sc = _spec_hit("run_python", {"code": _code[:400]})
|
| 470 |
if _sc is not None:
|
| 471 |
return _sc
|
| 472 |
_t0 = asyncio.get_event_loop().time()
|
| 473 |
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 474 |
try:
|
| 475 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 476 |
-
except Exception
|
| 477 |
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 478 |
_out = (
|
| 479 |
"[CODICE PYTHON ESEGUITO]\n"
|
| 480 |
f"```python\n{_code[:500]}\n```\n"
|
| 481 |
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 482 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
return _out
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
except asyncio.TimeoutError:
|
| 486 |
return "[run_python: timeout 18s]"
|
| 487 |
except Exception as exc:
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
async def _t_web_research() -> str | None:
|
| 490 |
-
|
| 491 |
-
if not _RESEARCH_RE.search(goal):
|
| 492 |
return None
|
| 493 |
-
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
return None
|
| 496 |
try:
|
| 497 |
if on_step:
|
| 498 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 499 |
-
"title": "Ricerca approfondita", "explanation": f"
|
|
|
|
|
|
|
|
|
|
| 500 |
_t0 = asyncio.get_event_loop().time()
|
| 501 |
-
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](
|
| 502 |
try:
|
| 503 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 504 |
-
except Exception
|
| 505 |
-
if r.get("
|
| 506 |
-
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
except asyncio.TimeoutError:
|
| 509 |
-
return "[web_research: timeout
|
| 510 |
except Exception as exc:
|
| 511 |
return f"[web_research: errore β {str(exc)[:300]}]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
async def _t_directory_tree() -> str | None:
|
| 513 |
-
|
| 514 |
-
if not _TREE_RE.search(goal):
|
| 515 |
return None
|
| 516 |
_path = self._extract_dir_path(goal)
|
| 517 |
if not _gov_check("directory_tree", _path):
|
|
@@ -520,17 +630,23 @@ class DirectToolsMixin:
|
|
| 520 |
if on_step:
|
| 521 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 522 |
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
|
|
|
|
| 523 |
r = await asyncio.wait_for(
|
| 524 |
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
|
| 525 |
)
|
|
|
|
|
|
|
|
|
|
| 526 |
if r.get("ok") and r.get("tree"):
|
| 527 |
-
return f"[STRUTTURA PROGETTO
|
| 528 |
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 529 |
except Exception as exc:
|
| 530 |
-
return f"[directory_tree: errore β {str(exc)[:
|
|
|
|
| 531 |
async def _t_file_search() -> str | None:
|
| 532 |
-
|
| 533 |
-
if not _SEARCH_RE.search(goal):
|
| 534 |
return None
|
| 535 |
_pattern = self._extract_file_pattern(goal)
|
| 536 |
if not _pattern or not _gov_check("file_search", _pattern):
|
|
@@ -539,40 +655,29 @@ class DirectToolsMixin:
|
|
| 539 |
try:
|
| 540 |
if on_step:
|
| 541 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 542 |
-
"title": "Ricerca
|
|
|
|
| 543 |
r = await asyncio.wait_for(
|
| 544 |
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
|
| 545 |
)
|
|
|
|
|
|
|
|
|
|
| 546 |
if r.get("ok"):
|
| 547 |
_matches = r.get("matches", [])
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
|
|
|
| 552 |
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 553 |
except Exception as exc:
|
| 554 |
-
return f"[file_search: errore β {str(exc)[:
|
| 555 |
-
|
| 556 |
-
_NEWS_RE = re.compile(r"\b(news|notizie|ultim[ae]\s+ora|breaking)\b", re.IGNORECASE)
|
| 557 |
-
if not _NEWS_RE.search(goal):
|
| 558 |
-
return None
|
| 559 |
-
query = self._extract_search_query(goal)
|
| 560 |
-
try:
|
| 561 |
-
if on_step:
|
| 562 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 563 |
-
"title": "Notizie", "explanation": f"Cerco notizie su: {query[:60]}β¦"}))
|
| 564 |
-
r = await asyncio.wait_for(TOOL_REGISTRY["get_news"]["_fn"](query=query), timeout=15)
|
| 565 |
-
if r.get("news"):
|
| 566 |
-
_out = [f"[NOTIZIE REALI: {query}]"]
|
| 567 |
-
for n in r["news"][:5]:
|
| 568 |
-
_out.append(f"β’ {n['title']} ({n.get('source', '?')}): {n.get('description', '')[:150]}")
|
| 569 |
-
return "\n".join(_out)
|
| 570 |
-
return "[get_news: nessuna notizia trovata]"
|
| 571 |
-
except Exception as exc:
|
| 572 |
-
return f"[get_news: errore β {str(exc)[:200]}]"
|
| 573 |
async def _t_git_status() -> str | None:
|
| 574 |
-
|
| 575 |
-
if not _GIT_RE.search(goal):
|
| 576 |
return None
|
| 577 |
_cwd = self._extract_git_cwd(goal)
|
| 578 |
if not _gov_check("git_status", _cwd):
|
|
@@ -580,96 +685,84 @@ class DirectToolsMixin:
|
|
| 580 |
try:
|
| 581 |
if on_step:
|
| 582 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 583 |
-
"title": "Stato Git", "explanation":
|
|
|
|
| 584 |
r = await asyncio.wait_for(
|
| 585 |
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
|
| 586 |
)
|
|
|
|
|
|
|
|
|
|
| 587 |
if r.get("ok"):
|
| 588 |
-
|
| 589 |
if r.get("status"):
|
| 590 |
-
|
| 591 |
if r.get("log"):
|
| 592 |
-
|
| 593 |
-
return
|
| 594 |
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
|
|
|
|
|
|
|
| 595 |
except Exception as exc:
|
| 596 |
-
return f"[git_status: errore β {str(exc)[:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
async def _t_analyze_python() -> str | None:
|
| 598 |
-
# P30-B1: Analisi statica Python integrata nel tool layer
|
| 599 |
if not self._ANALYZE_PY_RE.search(goal):
|
| 600 |
return None
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
|
|
|
|
|
|
| 605 |
try:
|
| 606 |
if on_step:
|
| 607 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 608 |
-
"title": "Analisi
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
_out.append("
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
return "\n".join(_out)
|
| 621 |
except asyncio.TimeoutError:
|
| 622 |
return "[python_analyze: timeout]"
|
| 623 |
except Exception as _exc:
|
| 624 |
return f"[python_analyze: errore β {str(_exc)[:200]}]"
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
return None
|
| 628 |
-
try:
|
| 629 |
-
if on_step:
|
| 630 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 631 |
-
"title": "PDF", "explanation": "Genero il PDF richiesto e preparo il file scaricabileβ¦"}))
|
| 632 |
-
content = re.sub(r"\b(?:crea|genera|scrivi|esporta|salva|create|generate|export|make)\b", "", goal, flags=re.IGNORECASE).strip() or "Documento compilabile"
|
| 633 |
-
filename = "curriculum_vitae_modello.pdf" if re.search(r"\b(?:cv|curriculum)\b", goal, re.I) else "documento.pdf"
|
| 634 |
-
raw = await asyncio.wait_for(TOOL_REGISTRY["create_pdf"]["_fn"](content=content, filename=filename, format="a4"), timeout=TOOL_TIMEOUT)
|
| 635 |
-
if isinstance(raw, dict) and raw.get("ok") and raw.get("pdf_b64"):
|
| 636 |
-
return "[PDF REALE GENERATO]\n" + str(raw.get("filename", filename)) + "\n" + str(raw.get("pdf_b64"))
|
| 637 |
-
return f"[create_pdf: errore β {str(raw)[:300]}]"
|
| 638 |
-
except asyncio.TimeoutError:
|
| 639 |
-
return f"[create_pdf: timeout {TOOL_TIMEOUT}s]"
|
| 640 |
-
except Exception as exc:
|
| 641 |
-
return f"[create_pdf: errore β {str(exc)[:300]}]"
|
| 642 |
-
|
| 643 |
-
# Conversione, PDF e immagine sono artefatti terminali: eseguirli prima del
|
| 644 |
-
# fan-out evita risultati accessori e, soprattutto, una successiva chiamata LLM.
|
| 645 |
-
_terminal_conversion = await _t_convert_csv_attachment()
|
| 646 |
-
if _terminal_conversion is not None:
|
| 647 |
-
return (_terminal_conversion, 1,
|
| 648 |
-
int(_terminal_conversion.startswith("[DIRECT_TERMINAL]")),
|
| 649 |
-
int(": errore" in _terminal_conversion or ": timeout" in _terminal_conversion))
|
| 650 |
-
# Policy ristretta: dopo il riconoscimento HTTP del CSV locale non sono
|
| 651 |
-
# ammessi altri direct tool, nΓ© fallback impliciti a immagine/rete.
|
| 652 |
-
if local_csv_only:
|
| 653 |
-
return ("[convert_csv_to_json: conversione locale non riconosciuta]", 0, 0, 1)
|
| 654 |
-
_terminal_pdf = await _t_create_pdf()
|
| 655 |
-
if _terminal_pdf is not None:
|
| 656 |
-
return (_terminal_pdf, 1, int(_terminal_pdf.startswith("[PDF REALE GENERATO]")), int(": errore" in _terminal_pdf or ": timeout" in _terminal_pdf))
|
| 657 |
-
_terminal_image = await _t_generate_image()
|
| 658 |
-
if _terminal_image is not None:
|
| 659 |
-
return (_terminal_image, 1,
|
| 660 |
-
int(_terminal_image.startswith("[DIRECT_TERMINAL]")),
|
| 661 |
-
int(": errore" in _terminal_image or ": timeout" in _terminal_image))
|
| 662 |
-
|
| 663 |
-
# Esecuzione parallela per i tool non terminali.
|
| 664 |
-
_sem = asyncio.Semaphore(3)
|
| 665 |
-
async def _sem_wrap(coro):
|
| 666 |
-
if coro is None: return None
|
| 667 |
-
async with _sem: return await coro
|
| 668 |
-
_parallel_results = await asyncio.gather(
|
| 669 |
_sem_wrap(_t_get_weather()),
|
| 670 |
_sem_wrap(_t_read_page()),
|
| 671 |
_sem_wrap(_t_calculate()),
|
| 672 |
_sem_wrap(_t_web_search()),
|
|
|
|
| 673 |
_sem_wrap(_t_run_python()),
|
| 674 |
_sem_wrap(_t_web_research()),
|
| 675 |
_sem_wrap(_t_directory_tree()),
|
|
@@ -682,22 +775,54 @@ class DirectToolsMixin:
|
|
| 682 |
for _pr in _parallel_results:
|
| 683 |
if isinstance(_pr, str):
|
| 684 |
results.append(_pr)
|
| 685 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
_REAL_DATA_PREFIXES = (
|
| 687 |
-
"[RICERCA WEB REALE",
|
| 688 |
-
"[
|
| 689 |
-
"[
|
| 690 |
-
"[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
)
|
| 692 |
-
for
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
# ββ Claim Validation (S428 Sprint1-Fix3) βββββββββββββββββββββββββββββββββ
|
| 700 |
-
#
|
|
|
|
|
|
|
| 701 |
_FALSE_CLAIM_RE = re.compile(
|
| 702 |
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 703 |
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
|
@@ -725,38 +850,78 @@ class DirectToolsMixin:
|
|
| 725 |
false_claim_re: "re.Pattern[str]",
|
| 726 |
realtime_goal_re: "re.Pattern[str]",
|
| 727 |
) -> str:
|
| 728 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
if n_success > 0 or n_errors == 0:
|
| 730 |
-
return response
|
| 731 |
if not realtime_goal_re.search(goal):
|
| 732 |
-
return response
|
| 733 |
if not false_claim_re.search(response):
|
| 734 |
-
return response
|
|
|
|
| 735 |
disclaimer = (
|
| 736 |
"\n\n---\n"
|
| 737 |
-
"**Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 738 |
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 739 |
"dal mio training e potrebbero non essere aggiornate. "
|
| 740 |
-
"Per dati live consulta
|
|
|
|
| 741 |
)
|
| 742 |
return response + disclaimer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
_TOOL_NEEDED_RE = re.compile(
|
| 744 |
-
r"\b(meteo|
|
| 745 |
-
r"
|
| 746 |
-
r"
|
| 747 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
re.IGNORECASE,
|
| 749 |
)
|
|
|
|
| 750 |
def _needs_tools(self, goal: str) -> bool:
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
if bool(self._CODE_GOAL_RE.search(goal)): return True
|
| 759 |
-
return False
|
| 760 |
_SIMPLE_CONV_RE = re.compile(
|
| 761 |
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 762 |
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
|
@@ -773,6 +938,11 @@ class DirectToolsMixin:
|
|
| 773 |
r")\.?\s*[!?]?$",
|
| 774 |
re.IGNORECASE,
|
| 775 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
_SIMPLE_MATH_RE = re.compile(
|
| 777 |
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
|
| 778 |
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
|
|
@@ -780,6 +950,7 @@ class DirectToolsMixin:
|
|
| 780 |
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
|
| 781 |
re.IGNORECASE,
|
| 782 |
)
|
|
|
|
| 783 |
_ANALYZE_PY_RE = re.compile(
|
| 784 |
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
|
| 785 |
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
|
|
@@ -791,18 +962,24 @@ class DirectToolsMixin:
|
|
| 791 |
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
|
| 792 |
re.IGNORECASE,
|
| 793 |
)
|
|
|
|
| 794 |
_PY_BLOCK_IN_GOAL_RE = re.compile(
|
| 795 |
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 796 |
re.IGNORECASE,
|
| 797 |
)
|
| 798 |
-
|
| 799 |
-
_CODE_RE = re.compile(r"```[\s\S]*?```")
|
| 800 |
def _is_simple_query(self, goal: str) -> bool:
|
|
|
|
|
|
|
|
|
|
| 801 |
g = goal.strip()
|
| 802 |
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 803 |
return False
|
|
|
|
|
|
|
| 804 |
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
|
| 805 |
return True
|
|
|
|
| 806 |
if len(g) > 70 or self._needs_tools(g):
|
| 807 |
return False
|
| 808 |
return bool(self._SIMPLE_CONV_RE.match(g))
|
|
|
|
| 1 |
"""unified_loop_tools.py β DirectToolsMixin: tool execution layer.
|
| 2 |
+
|
| 3 |
Estratto da unified_loop.py per ridurre il file principale da 2541 a ~2000 righe.
|
| 4 |
+
|
| 5 |
Contiene (nell'ordine originale del file):
|
| 6 |
- Regex class attrs: meteo, URL, ricerca, immagini, calcolo
|
| 7 |
- Helper: _extract_city / _extract_search_query / _extract_calc_expr
|
| 8 |
- _run_direct_tools: layer deterministico parallelo via TOOL_REGISTRY (S193/S419)
|
| 9 |
- _FALSE_CLAIM_RE / _REALTIME_GOAL_RE / _validate_claims: anti-hallucination (S428)
|
| 10 |
- _TOOL_NEEDED_RE / _needs_tools / _SIMPLE_CONV_RE / _is_simple_query: routing (S402)
|
| 11 |
+
|
| 12 |
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 13 |
Python MRO garantisce che self.xxx funzioni per attr definite su UnifiedAgentLoop.
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
+
|
| 17 |
import asyncio
|
|
|
|
| 18 |
import os
|
| 19 |
import re
|
| 20 |
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
import logging
|
| 23 |
_logger = logging.getLogger("agents.unified_loop_tools")
|
| 24 |
+
|
| 25 |
# StepCallback centralizzato in unified_loop_types.py (P20-TD1 Fase 1)
|
| 26 |
+
from agents.unified_loop_types import StepCallback
|
| 27 |
+
|
| 28 |
+
|
| 29 |
class DirectToolsMixin:
|
| 30 |
# ββ Direct tool execution (S193) βββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
# Chiama TOOL_REGISTRY direttamente, senza smolagents, senza LLM per routing.
|
| 32 |
# Deterministico, veloce, testabile. Restituisce i risultati come stringa
|
| 33 |
# pronta per essere iniettata nel prompt LLM.
|
| 34 |
+
|
| 35 |
_WEATHER_INTENT_RE = re.compile(
|
| 36 |
# S390-B-O: aggiunto 'temperature' (inglese) + 'forecast' come sinonimi weather
|
| 37 |
# S427: aggiunti fenomeni meteo, allerte, condizioni IT/EN
|
|
|
|
| 59 |
r"|\s+today|\s+now|\s+tomorrow|\s+currently|\s+right\s+now)",
|
| 60 |
re.IGNORECASE,
|
| 61 |
)
|
| 62 |
+
_CITY_BARE_RE = re.compile(
|
| 63 |
+
r"\b(?:a|in)\s+([A-Za-z\xc0-\xff][a-zA-Z\xc0-\xff]{2,20})"
|
| 64 |
+
r"(?:\s*[\?,\.]|\s+(?:adesso|ora|oggi|attuale|domani)|\s*$)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
re.IGNORECASE,
|
| 66 |
)
|
| 67 |
+
|
| 68 |
+
_URL_RE = re.compile(r"https?://[^\s\)\"']+")
|
| 69 |
+
|
| 70 |
+
# NOTE: patterns ending in non-word chars (: \s) are placed OUTSIDE the \bβ¦\b wrapper
|
| 71 |
+
# to avoid false-negative from word-boundary check after non-word char.
|
| 72 |
+
_SEARCH_INTENT_RE = re.compile(
|
| 73 |
+
r"(?:"
|
| 74 |
+
r"\b(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo|informazioni)|"
|
| 75 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 76 |
+
r"notizie\s+(?:recenti|di\s+oggi|aggiornate|live|breaking|su|sull[ao']+|di|riguard[ao]|dal\s+mondo)|"
|
| 77 |
+
r"notizie\s+\w+|" # B2/S390-B-J: usa \w+ (non [a-zA-Z]) β \b finale falliva con singola lettera
|
| 78 |
+
r"ultime\s+notizie|news\s+su|news\s+\w+|breaking\s+news|" # B2/S390-B-J
|
| 79 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 80 |
+
r"cosa\s+e\s+uscito|aggiornamenti\s+su|release|changelog|"
|
| 81 |
+
r"search\s+for\s+|find\s+online\s+)\b"
|
| 82 |
+
r"|\bcerca\s*:|\bsearch\s*:"
|
| 83 |
+
r")",
|
| 84 |
re.IGNORECASE,
|
| 85 |
)
|
| 86 |
+
_SEARCH_QUERY_RE = re.compile(
|
| 87 |
+
r"(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing)?|"
|
| 88 |
+
r"cerca\s*:\s*['\"]?|search\s*:\s*['\"]?|search\s+for\s+|find\s+online\s+|"
|
| 89 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 90 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|" # B1: notizie su/sull/di + bare 'notizie X'
|
| 91 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 92 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 93 |
+
r"web\s+search\s*:?\s*)"
|
| 94 |
+
r"(['\"]?.{2,180}?['\"]?)(?:\?|$|\s*\.)", # B1: soglia da 3 a 2 per topic brevi (AI, LLM)
|
| 95 |
re.IGNORECASE,
|
| 96 |
)
|
| 97 |
+
|
| 98 |
+
_IMAGE_GEN_INTENT_RE = re.compile(
|
| 99 |
+
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
|
| 100 |
+
# perchΓ© "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
|
| 101 |
+
r"\b(genera|crea|disegna|illustra|fai|mostra)\b.*(immagine|foto|illustrazione|sfondo|logo|banner|png|jpg)"
|
| 102 |
+
r"|\b(immagine|foto)\b.*\b(ai|artificiale|generata|gen)\b"
|
| 103 |
+
r"|pollinations|dall[- ]e|stable\s*diffusion|midjourney|image\s+gen",
|
| 104 |
+
re.IGNORECASE
|
| 105 |
+
)
|
| 106 |
+
# S427: aggiunti trigger di calcolo IT/EN comuni
|
| 107 |
_CALC_INTENT_RE = re.compile(
|
| 108 |
+
r"\b(calcola|computa|quanto\s+fa|risultato\s+di|evaluate|compute|"
|
| 109 |
+
r"quant[oei]\s+[eè]|qual\s+[eè]\s+il\s+risultato|"
|
| 110 |
+
r"risolvi|risolvimi|dammi\s+il\s+valore|quanto\s+vale|"
|
| 111 |
+
r"how\s+much\s+is|what\s+is\s+the\s+result\s+of|"
|
| 112 |
+
r"solve\s+this|calculate\s+this|what\s+does\s+.{0,20}\s+equal)\b",
|
| 113 |
+
re.IGNORECASE,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
_WEB_RESEARCH_INTENT_RE = re.compile(
|
| 117 |
+
r"\b(ricerca\s+approfondita|analisi\s+(?:multi|multi-fonte|fonti)|"
|
| 118 |
+
r"web\s+research|deep\s+research|esplora\s+(?:il\s+web|online)|"
|
| 119 |
+
r"approfondisci\s+(?:il\s+tema|l[a']|lo\s+)"
|
| 120 |
+
r"|\b(studia|analizza)\s+(?:nel\s+dettaglio|approfonditamente|in\s+modo\s+approfondito))",
|
| 121 |
+
re.IGNORECASE,
|
| 122 |
+
)
|
| 123 |
+
_WEB_RESEARCH_TOPIC_RE = re.compile(
|
| 124 |
+
r"(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s+(?:su\s+|di\s+|sul\s+tema\s+)?(.{3,200}?)(?:\?|$|\s*\.)",
|
| 125 |
+
re.IGNORECASE,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# S764: intent regex per i 3 nuovi fast-path tool (directory_tree / file_search / git_status)
|
| 129 |
+
_DIRECTORY_TREE_INTENT_RE = re.compile(
|
| 130 |
+
r"\b(directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
|
| 131 |
+
r"struttura\s+(?:del\s+)?(?:progetto|directory|cartell[ae]|file)|"
|
| 132 |
+
r"elenca\s+(?:file|cartell[ae]|directory)|lista\s+(?:file|cartell[ae])|"
|
| 133 |
+
r"show\s+(?:directory|folder)\s+tree|tree\s+(?:command|cmd|del\s+progetto)|"
|
| 134 |
+
r"ls\s+-[lRra]|find\s+\.\s+-type)\b",
|
| 135 |
+
re.IGNORECASE,
|
| 136 |
+
)
|
| 137 |
+
_FILE_SEARCH_INTENT_RE = re.compile(
|
| 138 |
+
r"\b(cerca\s+nel\s+(?:codice|progetto|file)|"
|
| 139 |
+
r"trova\s+(?:nel\s+codice|nel\s+progetto|nei\s+file)|"
|
| 140 |
+
r"grep\s+|file[\s_]search|cerca\s+la\s+stringa|"
|
| 141 |
+
r"search\s+in\s+(?:code|files|project)|find\s+in\s+files|"
|
| 142 |
+
r"dove\s+[eè]\s+(?:definit[ao]|usato|chiamato)|"
|
| 143 |
+
r"occorrenze\s+di|tutte\s+le\s+occorrenze)\b",
|
| 144 |
+
re.IGNORECASE,
|
| 145 |
+
)
|
| 146 |
+
_GIT_INTENT_RE = re.compile(
|
| 147 |
+
r"\b(git\s+status|git\s+diff|stato\s+git|stato\s+del\s+repository|"
|
| 148 |
+
r"file\s+modificat[i]|modifiche\s+in\s+sospeso|"
|
| 149 |
+
r"branch\s+corrente|current\s+branch|ultimi\s+commit|recent\s+commits|"
|
| 150 |
+
r"git\s+log|repository\s+status)\b",
|
| 151 |
+
re.IGNORECASE,
|
| 152 |
+
)
|
| 153 |
+
# S766: news intent β attiva _t_get_news fast-path
|
| 154 |
+
_NEWS_INTENT_RE = re.compile(
|
| 155 |
+
r"\b(notizie|ultime\s+notizie|news|headlines|notiziario|"
|
| 156 |
+
r"ultime\s+ore|breaking\s+news|novit\u00e0|"
|
| 157 |
+
r"aggiornamenti\s+su|cosa\s+succede|what.s\s+happening)\b",
|
| 158 |
re.IGNORECASE,
|
| 159 |
)
|
| 160 |
+
_CALC_EXPR_RE = re.compile(
|
| 161 |
+
r"(?:calcola|computa|risultato\s+di|quanto\s+fa|evaluate\s*:?)[:\s]+"
|
| 162 |
+
# S390-B-M: aggiunto % (modulo) e // (floor division) al char class
|
| 163 |
+
r"([\d\(\)\+\-\*\/\^\s\.\,%]+)",
|
| 164 |
+
re.IGNORECASE,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
def _extract_city(self, goal: str) -> str:
|
| 168 |
m = self._CITY_RE.search(goal)
|
| 169 |
if m:
|
| 170 |
+
return m.group(1).strip()
|
| 171 |
+
m2 = self._CITY_BARE_RE.search(goal)
|
| 172 |
+
if m2:
|
| 173 |
+
city = m2.group(1).strip()
|
| 174 |
+
_stop = {"me", "te", "lui", "lei", "noi", "voi", "loro", "casa", "fare",
|
| 175 |
+
"meno", "piu", "dire", "cui", "poi", "gia", "qui", "li", "la"}
|
| 176 |
+
if city.lower() not in _stop:
|
| 177 |
+
return city
|
| 178 |
+
return ""
|
| 179 |
+
|
| 180 |
def _extract_search_query(self, goal: str) -> str:
|
| 181 |
+
m = self._SEARCH_QUERY_RE.search(goal)
|
| 182 |
+
if m:
|
| 183 |
+
q = m.group(1).strip().rstrip(".,?!")
|
| 184 |
+
if len(q) > 1: # B1: soglia da >3 a >1 β topic brevi come 'AI', 'LLM', 'GPT'
|
| 185 |
+
return q
|
| 186 |
+
if self._SEARCH_INTENT_RE.search(goal):
|
| 187 |
+
clean = re.sub(
|
| 188 |
+
r"^\s*(?:cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet)?|"
|
| 189 |
+
r"ricerca\s+(?:web\s+)?(?:su\s+)?|trova\s+(?:online\s+)?|"
|
| 190 |
+
r"notizie\s+(?:su\s+|sull[ao']+\s+|di\s+|riguard[ao]\s+)?|"
|
| 191 |
+
r"ultime\s+notizie\s+(?:su\s+|sull[ao']+\s+|di\s+)?|"
|
| 192 |
+
r"versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente)\s+(?:di\s+)?|"
|
| 193 |
+
r"web\s+search\s*:?\s*)",
|
| 194 |
+
"", goal.strip(), flags=re.IGNORECASE
|
| 195 |
+
).strip().rstrip(".,?!")
|
| 196 |
+
if len(clean) > 1: # B1: soglia abbassata da >3 a >1
|
| 197 |
+
return clean
|
| 198 |
+
# B1: ultimo fallback β usa il goal intero (es. 'ultime notizie AI' β 'ultime notizie AI')
|
| 199 |
+
if len(goal.strip()) > 1:
|
| 200 |
+
return goal.strip()[:200] # S579: 120β200 (fallback query usa il goal intero)
|
| 201 |
+
return ""
|
| 202 |
+
|
| 203 |
def _extract_calc_expr(self, goal: str) -> str:
|
| 204 |
+
m = self._CALC_EXPR_RE.search(goal)
|
| 205 |
+
if m:
|
| 206 |
+
expr = m.group(1).strip().rstrip(".?!, ").replace(",", ".").replace("^", "**")
|
| 207 |
+
if re.search(r"[\d]", expr) and re.search(r"[\+\-\*\/\(\)]|\*\*", expr):
|
| 208 |
+
return expr
|
| 209 |
+
return ""
|
| 210 |
+
|
| 211 |
|
| 212 |
def _extract_dir_path(self, goal: str) -> str:
|
| 213 |
+
# Estrae il path della directory dal goal, default '.'
|
| 214 |
m = re.search(
|
| 215 |
r"(?:di|in|dentro|in\s+path|nel\s+path|directory|folder|cartella)\s+"
|
| 216 |
r"['\"]?([./\w\-]+/[./\w\-]*|[./\w\-]+)['\"]?",
|
|
|
|
| 223 |
return "."
|
| 224 |
|
| 225 |
def _extract_file_pattern(self, goal: str) -> str:
|
| 226 |
+
# Estrae il pattern di ricerca file dal goal
|
| 227 |
m = re.search(
|
| 228 |
r"(?:grep\s+|cerca\s+(?:la\s+stringa\s+)?|trova\s+(?:la\s+stringa\s+)?|"
|
| 229 |
r"search\s+for\s+|find\s+in\s+files\s+)['\"]?([^\s'\"?,]{2,80})['\"]?",
|
| 230 |
goal, re.IGNORECASE,
|
| 231 |
)
|
| 232 |
+
if m:
|
| 233 |
+
return m.group(1).strip()
|
| 234 |
+
return ""
|
| 235 |
|
| 236 |
def _extract_git_cwd(self, goal: str) -> str:
|
| 237 |
+
# Estrae il cwd per git dal goal, default '.'
|
| 238 |
m = re.search(
|
| 239 |
r"(?:in|nel\s+repo|nel\s+repository|in\s+path)\s+['\"]?([./\w\-]+)['\"]?",
|
| 240 |
goal, re.IGNORECASE,
|
|
|
|
| 244 |
if len(candidate) > 1 and candidate not in {"in", "nel", "un", "il"}:
|
| 245 |
return candidate
|
| 246 |
return "."
|
| 247 |
+
|
| 248 |
+
async def _run_direct_tools(self, goal: str, on_step: StepCallback | None = None) -> tuple[str, int, int, int]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
"""
|
| 250 |
S193: Esegue tool direttamente via TOOL_REGISTRY senza smolagents o LLM per routing.
|
| 251 |
Returns: 4-tuple (results_str, n_called, n_success, n_errors).
|
| 252 |
results_str: stringa reale da iniettare nel prompt (join di tutti i tool output)
|
| 253 |
n_called: numero totale di tool chiamati
|
| 254 |
+
n_success: tool che hanno prodotto dati reali verificati (prefisso REAL_DATA_PREFIXES)
|
| 255 |
+
n_errors: tool che NON hanno prodotto dati reali (falliti, timeout, skip)
|
| 256 |
+
S376: Tool Governor β previene chiamate duplicate identiche (stesso tool + stessi arg chiave).
|
| 257 |
+
S390: Return type cambiato da str a tuple[str, int] per fix tools_fired metric.
|
| 258 |
"""
|
| 259 |
+
try:
|
| 260 |
+
from tools.registry import TOOL_REGISTRY
|
| 261 |
+
except ImportError:
|
| 262 |
+
# S649: fix tipo ritorno β run() aspetta 4-tuple, non 2-tuple
|
| 263 |
+
return "", 0, 0, 0
|
| 264 |
|
| 265 |
results: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
# S376/S393: Tool Governor β previene duplicate E supero budget globale per run
|
| 268 |
_gov_called: set[str] = set()
|
| 269 |
+
_gov_total: list[int] = [0] # S393: contatore totale chiamate tool nel run
|
| 270 |
+
# S650: budget adattivo β task complessi necessitano piΓΉ tool calls
|
| 271 |
+
# _max_tokens_for_goal >= 6144 indica app multi-feature β 9 tool calls
|
| 272 |
+
# _max_tokens_for_goal >= 4096 indica task singolo complesso β 7 tool calls
|
| 273 |
+
# Default: 6 (query semplice, meteo, news, calcolo)
|
| 274 |
_tok_budget_gov = self._max_tokens_for_goal(goal)
|
| 275 |
+
_GOV_MAX_CALLS = 9 if _tok_budget_gov >= 6144 else 7 if _tok_budget_gov >= 4096 else 6
|
| 276 |
|
| 277 |
def _gov_check(tool_name: str, key_arg: str) -> bool:
|
| 278 |
+
"""S393 Tool Governor: previene duplicate e supero budget.
|
| 279 |
+
Returns True solo se il tool NON Γ¨ stato giΓ chiamato con questi arg
|
| 280 |
+
E il budget totale del run non Γ¨ esaurito."""
|
| 281 |
+
if _gov_total[0] >= _GOV_MAX_CALLS:
|
| 282 |
+
return False # budget esaurito β blocca TUTTE le chiamate successive
|
| 283 |
+
sig = f"{tool_name}:{key_arg[:150]}" # S608: 80β150
|
| 284 |
+
if sig in _gov_called:
|
| 285 |
+
return False # chiamata duplicata β skip silenzioso
|
| 286 |
+
_gov_called.add(sig)
|
| 287 |
+
_gov_total[0] += 1
|
| 288 |
return True
|
| 289 |
|
| 290 |
+
# Doc2-1a-FIX: helper cache speculativa (S361) β 0ms latency su cache hit.
|
| 291 |
+
# get_speculative_result() non era mai chiamata: la cache veniva riempita (quota Groq)
|
| 292 |
+
# ma mai letta. Ora ogni tool controlla la cache prima di eseguire la chiamata di rete.
|
| 293 |
+
def _spec_hit(tool_name: str, args: dict) -> "str | None":
|
| 294 |
try:
|
| 295 |
+
from api.speculative import get_speculative_result as _gsr
|
| 296 |
+
return _gsr(goal, tool_name, args)
|
| 297 |
except Exception:
|
|
|
|
| 298 |
return None
|
| 299 |
|
| 300 |
# S419: esegui i tool eligible in parallelo con asyncio.gather
|
| 301 |
# Pre-check intent (sincrono) β costruisce lista coroutine β gather
|
| 302 |
+
# Il governor usa stato locale; asyncio Γ¨ single-threaded β nessuna race condition
|
| 303 |
+
|
| 304 |
url_m = self._URL_RE.search(goal)
|
| 305 |
+
|
| 306 |
async def _t_get_weather() -> str | None:
|
| 307 |
if not self._WEATHER_INTENT_RE.search(goal):
|
| 308 |
return None
|
| 309 |
+
city = self._extract_city(goal) or "Milano"
|
| 310 |
if not _gov_check("get_weather", city):
|
| 311 |
return None
|
| 312 |
try:
|
|
|
|
|
|
|
|
|
|
| 313 |
_sc = _spec_hit("get_weather", {"city": city})
|
| 314 |
if _sc is not None:
|
| 315 |
return _sc
|
| 316 |
+
if on_step:
|
| 317 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 318 |
+
"title": f"Meteo: {city}", "explanation": f"Recupero dati meteo reali per {city}β¦"}))
|
| 319 |
_t0 = asyncio.get_event_loop().time()
|
| 320 |
r = await asyncio.wait_for(TOOL_REGISTRY["get_weather"]["_fn"](city=city), timeout=TOOL_TIMEOUT)
|
| 321 |
try:
|
| 322 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 323 |
+
except Exception: pass
|
| 324 |
+
if "error" not in r:
|
| 325 |
_wdesc = {
|
| 326 |
+
0: "sereno", 1: "prevalentemente sereno", 2: "parzialmente nuvoloso",
|
| 327 |
+
3: "coperto", 45: "nebbia", 48: "nebbia ghiacciata",
|
| 328 |
+
51: "pioggerella leggera", 53: "pioggerella", 55: "pioggerella intensa",
|
| 329 |
+
61: "pioggia leggera", 63: "pioggia", 65: "pioggia intensa",
|
| 330 |
+
71: "neve leggera", 73: "neve", 75: "neve intensa",
|
| 331 |
+
80: "rovesci leggeri", 81: "rovesci", 82: "rovesci forti",
|
| 332 |
+
95: "temporale", 96: "temporale con grandine",
|
| 333 |
}
|
| 334 |
wcode = r.get("code"); temp_c = r.get("temp_c"); wind_kmh = r.get("wind_kmh")
|
| 335 |
try:
|
|
|
|
| 342 |
f"Vento: {f'{wind_kmh} km/h' if wind_kmh is not None else 'N/D'}\n"
|
| 343 |
f"Condizioni: {desc}"
|
| 344 |
)
|
| 345 |
+
return f"[get_weather: errore β {r['error'][:300]}]" # S605: 200β300
|
| 346 |
except asyncio.TimeoutError:
|
| 347 |
return f"[get_weather: timeout {TOOL_TIMEOUT}s]"
|
| 348 |
except Exception as exc:
|
| 349 |
+
return f"[get_weather: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 350 |
+
|
| 351 |
async def _t_read_page() -> str | None:
|
| 352 |
if not url_m:
|
| 353 |
return None
|
|
|
|
| 365 |
r = await asyncio.wait_for(TOOL_REGISTRY["read_page"]["_fn"](url=url), timeout=TOOL_TIMEOUT)
|
| 366 |
try:
|
| 367 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 368 |
+
except Exception: pass
|
| 369 |
if r.get("content"):
|
| 370 |
return (f"[PAGINA REALE: {url}]\n(status {r.get('status', '?')})\n{r['content'][:3000]}")
|
| 371 |
+
return f"[read_page: errore β {r.get('error', 'nessun contenuto')[:300]}]" # S605: 200β300
|
| 372 |
except asyncio.TimeoutError:
|
| 373 |
return f"[read_page: timeout {TOOL_TIMEOUT}s]"
|
| 374 |
except Exception as exc:
|
| 375 |
+
return f"[read_page: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 376 |
+
|
| 377 |
async def _t_calculate() -> str | None:
|
| 378 |
if url_m or not self._CALC_INTENT_RE.search(goal):
|
| 379 |
return None
|
|
|
|
| 391 |
r = await asyncio.wait_for(TOOL_REGISTRY["calculate"]["_fn"](expression=expr), timeout=8)
|
| 392 |
try:
|
| 393 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 394 |
+
except Exception: pass
|
| 395 |
if "result" in r:
|
| 396 |
return f"[CALCOLO REALE]\n{r['expression']} = {r['result']}"
|
| 397 |
+
return f"[calculate: errore β {r.get('error', '?')[:300]}]" # S605: 200β300
|
| 398 |
except asyncio.TimeoutError:
|
| 399 |
return "[calculate: timeout]"
|
| 400 |
except Exception as exc:
|
| 401 |
+
return f"[calculate: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 402 |
+
|
| 403 |
async def _t_web_search() -> str | None:
|
| 404 |
if not self._SEARCH_INTENT_RE.search(goal):
|
| 405 |
return None
|
|
|
|
| 417 |
r = await asyncio.wait_for(TOOL_REGISTRY["web_search"]["_fn"](query=query, max_results=5), timeout=TOOL_TIMEOUT)
|
| 418 |
try:
|
| 419 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 420 |
+
except Exception: pass
|
| 421 |
hits = r.get("results", [])
|
| 422 |
if hits:
|
| 423 |
+
snippets = "\n".join(
|
| 424 |
+
f"β’ [{item['title']}] {item['snippet']}"
|
| 425 |
+
+ (f"\n URL: {item['url']}" if item.get("url") else "")
|
| 426 |
+
for item in hits[:6] # S591: 4β6 β piΓΉ risultati web nel context
|
| 427 |
+
)
|
| 428 |
+
return f"[RICERCA WEB REALE: '{query}']\n{snippets}"
|
| 429 |
+
# S428 Sprint1-Fix2: rimosso "rispondo con dati del training" β invitava LLM
|
| 430 |
+
# ad allucinare training data come se fosse una ricerca reale riuscita.
|
| 431 |
+
# Ora Γ¨ un errore esplicito β contato come _n_errors β _all_errors=True β
|
| 432 |
+
# _build_messages usa sezione "TENTATIVO TOOL FALLITO" che proibisce false claim.
|
| 433 |
+
return f"[web_search: NESSUN_RISULTATO β nessun dato trovato per '{query[:150]}']" # S608: 80β150
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
except asyncio.TimeoutError:
|
| 435 |
+
return f"[web_search: TIMEOUT_{TOOL_TIMEOUT}s β nessun dato disponibile]"
|
| 436 |
except Exception as exc:
|
| 437 |
+
return f"[web_search: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 438 |
|
| 439 |
async def _t_generate_image() -> str | None:
|
| 440 |
+
if not self._IMAGE_GEN_INTENT_RE.search(goal):
|
| 441 |
return None
|
| 442 |
_img_prompt = re.sub(
|
| 443 |
r"^.*?(?:genera|crea|disegna|illustra|fai|mostra).*?(?:immagine|foto|illustrazione|di|un[a']?|del?la?|del?l[o']?)\s*",
|
|
|
|
| 449 |
if on_step:
|
| 450 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 451 |
"title": "Generazione immagine", "explanation": f"Genero: {_img_prompt[:60]}β¦"}))
|
| 452 |
+
_sc = _spec_hit("generate_image", {"prompt": _img_prompt[:600]}) # S607: 400β600
|
| 453 |
if _sc is not None:
|
| 454 |
return _sc
|
| 455 |
+
_t0 = asyncio.get_event_loop().time()
|
| 456 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["generate_image"]["_fn"](prompt=_img_prompt[:600]), timeout=12) # S607: 400β600
|
|
|
|
|
|
|
| 457 |
try:
|
| 458 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 459 |
+
except Exception: pass
|
| 460 |
+
img_url = r.get("url", "")
|
| 461 |
+
if img_url:
|
| 462 |
+
return (
|
| 463 |
+
f"[IMMAGINE AI GENERATA]\n"
|
| 464 |
+
f"URL: {img_url}\n"
|
| 465 |
+
f"Prompt usato: {r.get('prompt', _img_prompt)[:200]}\n" # S579: 100β200
|
| 466 |
+
f"Dimensioni: {r.get('width')}x{r.get('height')} px"
|
|
|
|
|
|
|
| 467 |
)
|
| 468 |
+
return "[generate_image: nessun URL restituito]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
except asyncio.TimeoutError:
|
| 470 |
return "[generate_image: timeout β provider non raggiungibile]"
|
| 471 |
except Exception as exc:
|
| 472 |
+
return f"[generate_image: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 473 |
+
|
| 474 |
async def _t_run_python() -> str | None:
|
| 475 |
_RUN_CODE_RE = re.compile(
|
| 476 |
r"\b(?:run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|"
|
|
|
|
| 489 |
if on_step:
|
| 490 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 491 |
"title": "Esecuzione codice Python", "explanation": "Eseguo il codice in sandboxβ¦"}))
|
| 492 |
+
_sc = _spec_hit("run_python", {"code": _code[:400]}) # S608: 200β400
|
| 493 |
if _sc is not None:
|
| 494 |
return _sc
|
| 495 |
_t0 = asyncio.get_event_loop().time()
|
| 496 |
r = await asyncio.wait_for(TOOL_REGISTRY["run_python"]["_fn"](code=_code), timeout=18)
|
| 497 |
try:
|
| 498 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 499 |
+
except Exception: pass
|
| 500 |
if r.get("returncode", -1) == 0 and r.get("stdout"):
|
| 501 |
_out = (
|
| 502 |
"[CODICE PYTHON ESEGUITO]\n"
|
| 503 |
f"```python\n{_code[:500]}\n```\n"
|
| 504 |
f"Output:\n```\n{r['stdout'][:1500]}\n```"
|
| 505 |
)
|
| 506 |
+
# S-GAP3: TDD auto-check β solo su codice complesso (>=8 righe, def/class)
|
| 507 |
+
try:
|
| 508 |
+
from agents.tdd_runner import run_tdd_check as _tdd_chk, _should_test as _tdd_gate
|
| 509 |
+
if _tdd_gate(_code):
|
| 510 |
+
class _TDDExec:
|
| 511 |
+
async def run_tool(self, name, args):
|
| 512 |
+
fn = TOOL_REGISTRY.get(name, {}).get("_fn")
|
| 513 |
+
return await fn(**args) if fn else {}
|
| 514 |
+
from api.state import _get_ai_client as _tdd_ai
|
| 515 |
+
_tdd_r = await asyncio.wait_for(_tdd_chk(_code, _TDDExec(), _tdd_ai()), timeout=35.0)
|
| 516 |
+
if _tdd_r["ran"]:
|
| 517 |
+
_ok = _tdd_r["passed"]
|
| 518 |
+
_badge = ("Auto-test: OK" if _ok else f"Auto-test: FAIL\n```\n{_tdd_r['output'][:300]}\n```")
|
| 519 |
+
_out += f"\n{_badge}"
|
| 520 |
+
# GAP-NEW-2: se TDD FAIL, inietta traceback in exec_warn
|
| 521 |
+
# via self._tdd_fail_inject β letto da unified_loop.py
|
| 522 |
+
# prima del campionamento StrategicHealer (riga ~2142).
|
| 523 |
+
if not _ok:
|
| 524 |
+
self._tdd_fail_inject = (
|
| 525 |
+
f"[TDD-AUTO-FAIL] traceback del test generato:\n"
|
| 526 |
+
f"```\n{_tdd_r['output'][:400]}\n```"
|
| 527 |
+
)
|
| 528 |
+
except Exception as _exc:
|
| 529 |
+
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 530 |
return _out
|
| 531 |
+
if r.get("error"):
|
| 532 |
+
return f"[run_python: errore β {r['error'][:300]}]" # S605: 200β300
|
| 533 |
+
if r.get("stderr"):
|
| 534 |
+
# S573: 200β400 β stderr spesso contiene tracebacks multi-riga
|
| 535 |
+
# S593: 400β600 β tracebacks Python possono superare 400 chars
|
| 536 |
+
return f"[run_python: stderr β {r['stderr'][:600]}]"
|
| 537 |
+
return None
|
| 538 |
except asyncio.TimeoutError:
|
| 539 |
return "[run_python: timeout 18s]"
|
| 540 |
except Exception as exc:
|
| 541 |
+
# S593: 200β300 β exception str puΓ² includere path + msg
|
| 542 |
+
# S600: 300β500 β parity con altri exception handler
|
| 543 |
+
return f"[run_python: errore β {str(exc)[:500]}]"
|
| 544 |
+
|
| 545 |
+
|
| 546 |
async def _t_web_research() -> str | None:
|
| 547 |
+
if not self._WEB_RESEARCH_INTENT_RE.search(goal):
|
|
|
|
| 548 |
return None
|
| 549 |
+
_topic_m = self._WEB_RESEARCH_TOPIC_RE.search(goal)
|
| 550 |
+
_topic = _topic_m.group(1).strip() if _topic_m else re.sub(
|
| 551 |
+
r"^.*?(?:ricerca\s+approfondita|web\s+research|approfondisci|deep\s+research)\s*(?:su\s+|di\s+)?",
|
| 552 |
+
"", goal, flags=re.IGNORECASE
|
| 553 |
+
).strip()[:200] or goal[:200]
|
| 554 |
+
if not _topic or not _gov_check("web_research", _topic):
|
| 555 |
return None
|
| 556 |
try:
|
| 557 |
if on_step:
|
| 558 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 559 |
+
"title": "Ricerca approfondita", "explanation": f"Analizzo fonti multiple: {_topic[:60]}β¦"}))
|
| 560 |
+
_sc = _spec_hit("web_research", {"topic": _topic[:400]})
|
| 561 |
+
if _sc is not None:
|
| 562 |
+
return _sc
|
| 563 |
_t0 = asyncio.get_event_loop().time()
|
| 564 |
+
r = await asyncio.wait_for(TOOL_REGISTRY["web_research"]["_fn"](topic=_topic[:400], depth=4, synthesize=True), timeout=55)
|
| 565 |
try:
|
| 566 |
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 567 |
+
except Exception: pass
|
| 568 |
+
if r.get("ok"):
|
| 569 |
+
_synthesis = r.get("synthesis", "")
|
| 570 |
+
_sources = r.get("sources", [])
|
| 571 |
+
out = f"[RICERCA APPROFONDITA: '{r.get('topic', _topic)}'\n{r.get('count', 0)} fonti analizzate]\n"
|
| 572 |
+
if _synthesis:
|
| 573 |
+
out += f"Sintesi:\n{_synthesis[:1500]}\n\n"
|
| 574 |
+
if _sources:
|
| 575 |
+
for s in _sources[:4]:
|
| 576 |
+
out += f"β’ {s.get('title', s.get('url','?'))}: {s.get('excerpt', '')[:200]}\n"
|
| 577 |
+
return out.strip()
|
| 578 |
+
return f"[web_research: {r.get('error', 'nessun risultato')[:200]}]"
|
| 579 |
except asyncio.TimeoutError:
|
| 580 |
+
return "[web_research: timeout 55s]"
|
| 581 |
except Exception as exc:
|
| 582 |
return f"[web_research: errore β {str(exc)[:300]}]"
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# S766: _t_get_news β notizie in tempo reale tramite TOOL_REGISTRY["get_news"]
|
| 586 |
+
async def _t_get_news() -> str | None:
|
| 587 |
+
if not self._NEWS_INTENT_RE.search(goal):
|
| 588 |
+
return None
|
| 589 |
+
_qm = re.search(
|
| 590 |
+
r"(?:notizie|news|ultime\s+notizie|headlines)\s+(?:su\s+|di\s+|about\s+)?(.{3,120})(?:\?|$|\.|,)",
|
| 591 |
+
goal, re.IGNORECASE,
|
| 592 |
+
)
|
| 593 |
+
_query = _qm.group(1).strip() if _qm else goal.strip()[:120]
|
| 594 |
+
if not _gov_check("get_news", _query):
|
| 595 |
+
return None
|
| 596 |
+
try:
|
| 597 |
+
if on_step:
|
| 598 |
+
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 599 |
+
"title": "Ultime notizie", "explanation": f"Cerco notizie: {_query[:60]}\u2026"}))
|
| 600 |
+
_sc = _spec_hit("get_news", {"query": _query, "max_results": 5})
|
| 601 |
+
if _sc is not None:
|
| 602 |
+
return _sc
|
| 603 |
+
r = await asyncio.wait_for(
|
| 604 |
+
TOOL_REGISTRY["get_news"]["_fn"](query=_query, max_results=5), timeout=20
|
| 605 |
+
)
|
| 606 |
+
if r.get("ok"):
|
| 607 |
+
items = r.get("results", r.get("articles", []))
|
| 608 |
+
if items:
|
| 609 |
+
out = [f"[NOTIZIE: '{_query[:60]}']"]
|
| 610 |
+
for it in items[:5]:
|
| 611 |
+
t = it.get("title", it.get("headline", "?"))
|
| 612 |
+
s = it.get("source", it.get("publisher", ""))
|
| 613 |
+
d = it.get("published_at", it.get("date", ""))
|
| 614 |
+
out.append(f"\u2022 {t}" + (f" [{s}]" if s else "") + (f" ({d})" if d else ""))
|
| 615 |
+
return "\n".join(out)
|
| 616 |
+
return f"[get_news: {r.get('error', 'nessun risultato')[:200]}]"
|
| 617 |
+
except asyncio.TimeoutError:
|
| 618 |
+
return "[get_news: timeout 20s]"
|
| 619 |
+
except Exception as exc:
|
| 620 |
+
return f"[get_news: errore β {str(exc)[:200]}]"
|
| 621 |
+
|
| 622 |
+
# S764: 3 nuovi tool fast-path β directory_tree / file_search / git_status
|
| 623 |
async def _t_directory_tree() -> str | None:
|
| 624 |
+
if not self._DIRECTORY_TREE_INTENT_RE.search(goal):
|
|
|
|
| 625 |
return None
|
| 626 |
_path = self._extract_dir_path(goal)
|
| 627 |
if not _gov_check("directory_tree", _path):
|
|
|
|
| 630 |
if on_step:
|
| 631 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 632 |
"title": "Struttura progetto", "explanation": f"Analisi directory: {_path}"}))
|
| 633 |
+
_t0 = asyncio.get_event_loop().time()
|
| 634 |
r = await asyncio.wait_for(
|
| 635 |
TOOL_REGISTRY["directory_tree"]["_fn"](path=_path, max_depth=3), timeout=8
|
| 636 |
)
|
| 637 |
+
try:
|
| 638 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 639 |
+
except Exception: pass
|
| 640 |
if r.get("ok") and r.get("tree"):
|
| 641 |
+
return f"[STRUTTURA PROGETTO: '{_path}']\n{r['tree']}"
|
| 642 |
return f"[directory_tree: {r.get('error', 'nessun risultato')[:200]}]"
|
| 643 |
+
except asyncio.TimeoutError:
|
| 644 |
+
return "[directory_tree: timeout 8s]"
|
| 645 |
except Exception as exc:
|
| 646 |
+
return f"[directory_tree: errore β {str(exc)[:300]}]"
|
| 647 |
+
|
| 648 |
async def _t_file_search() -> str | None:
|
| 649 |
+
if not self._FILE_SEARCH_INTENT_RE.search(goal):
|
|
|
|
| 650 |
return None
|
| 651 |
_pattern = self._extract_file_pattern(goal)
|
| 652 |
if not _pattern or not _gov_check("file_search", _pattern):
|
|
|
|
| 655 |
try:
|
| 656 |
if on_step:
|
| 657 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 658 |
+
"title": "Ricerca nel codice", "explanation": f"Cerco '{_pattern[:40]}' nei file..."}))
|
| 659 |
+
_t0 = asyncio.get_event_loop().time()
|
| 660 |
r = await asyncio.wait_for(
|
| 661 |
TOOL_REGISTRY["file_search"]["_fn"](pattern=_pattern, path=_search_path), timeout=10
|
| 662 |
)
|
| 663 |
+
try:
|
| 664 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 665 |
+
except Exception: pass
|
| 666 |
if r.get("ok"):
|
| 667 |
_matches = r.get("matches", [])
|
| 668 |
+
_count = r.get("count", len(_matches))
|
| 669 |
+
out = f"[FILE TROVATI: pattern='{_pattern}', {_count} occorrenze]\n"
|
| 670 |
+
for m in _matches[:20]:
|
| 671 |
+
out += f"{m.get('file','?')}:{m.get('line','?')}: {m.get('text','')[:120]}\n"
|
| 672 |
+
return out.strip()
|
| 673 |
return f"[file_search: {r.get('error', 'nessun risultato')[:200]}]"
|
| 674 |
+
except asyncio.TimeoutError:
|
| 675 |
+
return "[file_search: timeout 10s]"
|
| 676 |
except Exception as exc:
|
| 677 |
+
return f"[file_search: errore β {str(exc)[:300]}]"
|
| 678 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
async def _t_git_status() -> str | None:
|
| 680 |
+
if not self._GIT_INTENT_RE.search(goal):
|
|
|
|
| 681 |
return None
|
| 682 |
_cwd = self._extract_git_cwd(goal)
|
| 683 |
if not _gov_check("git_status", _cwd):
|
|
|
|
| 685 |
try:
|
| 686 |
if on_step:
|
| 687 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 688 |
+
"title": "Stato Git", "explanation": "Controllo branch e file modificati..."}))
|
| 689 |
+
_t0 = asyncio.get_event_loop().time()
|
| 690 |
r = await asyncio.wait_for(
|
| 691 |
TOOL_REGISTRY["git_status"]["_fn"](cwd=_cwd), timeout=8
|
| 692 |
)
|
| 693 |
+
try:
|
| 694 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 695 |
+
except Exception: pass
|
| 696 |
if r.get("ok"):
|
| 697 |
+
out = f"[STATO GIT (branch: {r.get('branch', '?')})\n"
|
| 698 |
if r.get("status"):
|
| 699 |
+
out += f"File modificati:\n{r['status'][:600]}\n"
|
| 700 |
if r.get("log"):
|
| 701 |
+
out += f"Ultimi commit:\n{r['log'][:400]}\n"
|
| 702 |
+
return out.strip() + "]"
|
| 703 |
return f"[git_status: {r.get('error', 'nessun risultato')[:200]}]"
|
| 704 |
+
except asyncio.TimeoutError:
|
| 705 |
+
return "[git_status: timeout 8s]"
|
| 706 |
except Exception as exc:
|
| 707 |
+
return f"[git_status: errore β {str(exc)[:300]}]"
|
| 708 |
+
|
| 709 |
+
# S419/S734: gather parallelo con Semaphore β limita concorrenza su mobile
|
| 710 |
+
# Default 4: max 4 tool simultanei β previene saturazione TCP su iPhone Safari.
|
| 711 |
+
# Impatto su goal normali (2-3 tool): ZERO (semaforo mai raggiunto).
|
| 712 |
+
# GAP-P3: configurabile via env TOOL_CONCURRENCY_LIMIT per ambienti server/desktop.
|
| 713 |
+
_TOOL_CONCURRENCY = int(os.getenv('TOOL_CONCURRENCY_LIMIT', '4'))
|
| 714 |
+
_gather_sem = asyncio.Semaphore(_TOOL_CONCURRENCY)
|
| 715 |
+
|
| 716 |
+
async def _sem_wrap(coro):
|
| 717 |
+
async with _gather_sem:
|
| 718 |
+
return await coro
|
| 719 |
+
|
| 720 |
+
# S764: 7->10 tool in gather (Semaphore(4) invariato)
|
| 721 |
+
# P30-B1: analisi statica Python β zero exec_engine, <5ms
|
| 722 |
async def _t_analyze_python() -> str | None:
|
|
|
|
| 723 |
if not self._ANALYZE_PY_RE.search(goal):
|
| 724 |
return None
|
| 725 |
+
_pm = self._PY_BLOCK_IN_GOAL_RE.search(goal)
|
| 726 |
+
if not _pm:
|
| 727 |
+
return None
|
| 728 |
+
_code = _pm.group(1)
|
| 729 |
+
if not _gov_check("python_analyze", _code[:80]):
|
| 730 |
+
return None
|
| 731 |
try:
|
| 732 |
if on_step:
|
| 733 |
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 734 |
+
"title": "Analisi Python", "explanation": "Analisi statica codice Python (AST)β¦"}))
|
| 735 |
+
_t0 = asyncio.get_event_loop().time()
|
| 736 |
+
_r = await asyncio.wait_for(
|
| 737 |
+
TOOL_REGISTRY["python_analyze"]["_fn"](code=_code), timeout=5
|
| 738 |
+
)
|
| 739 |
+
try:
|
| 740 |
+
from api.state import record_timing as _rtc; _rtc("tool_call", (asyncio.get_event_loop().time() - _t0) * 1000)
|
| 741 |
+
except Exception: pass
|
| 742 |
+
_out = [f"[ANALISI PYTHON β {_r.get('summary', '?')}]"]
|
| 743 |
+
for _e in _r.get("errors", []):
|
| 744 |
+
_out.append(f"ERR {_e['type']} riga {_e['line']}: {_e['message']}" + (f" β {_e['text']}" if _e.get('text') else ""))
|
| 745 |
+
_c = _r.get("complexity", {})
|
| 746 |
+
if _c:
|
| 747 |
+
_out.append(
|
| 748 |
+
f"Struttura: {_c.get('total_lines',0)} righe, "
|
| 749 |
+
f"{_c.get('functions',0)} funzioni, "
|
| 750 |
+
f"{_c.get('classes',0)} classi, nesting max {_c.get('max_nesting',0)}"
|
| 751 |
+
)
|
| 752 |
+
for _s in _r.get("suggestions", []):
|
| 753 |
+
_out.append(f"Suggerimento: {_s}")
|
| 754 |
return "\n".join(_out)
|
| 755 |
except asyncio.TimeoutError:
|
| 756 |
return "[python_analyze: timeout]"
|
| 757 |
except Exception as _exc:
|
| 758 |
return f"[python_analyze: errore β {str(_exc)[:200]}]"
|
| 759 |
+
|
| 760 |
+
_parallel_results = await asyncio.gather(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
_sem_wrap(_t_get_weather()),
|
| 762 |
_sem_wrap(_t_read_page()),
|
| 763 |
_sem_wrap(_t_calculate()),
|
| 764 |
_sem_wrap(_t_web_search()),
|
| 765 |
+
_sem_wrap(_t_generate_image()),
|
| 766 |
_sem_wrap(_t_run_python()),
|
| 767 |
_sem_wrap(_t_web_research()),
|
| 768 |
_sem_wrap(_t_directory_tree()),
|
|
|
|
| 775 |
for _pr in _parallel_results:
|
| 776 |
if isinstance(_pr, str):
|
| 777 |
results.append(_pr)
|
| 778 |
+
|
| 779 |
+
# S428 Sprint1-Fix1: Tool Success Contract β conta successi per prefisso positivo.
|
| 780 |
+
# Il vecchio check ": errore β"/": timeout" NON catturava "NESSUN_RISULTATO" e
|
| 781 |
+
# "rispondo con dati del training" β contati come successi β _build_messages
|
| 782 |
+
# wrappava come "DATI REALI RECUPERATI" β LLM allucinava training data come reale.
|
| 783 |
+
# Soluzione: whitelist di prefissi che certificano dati REALI verificati.
|
| 784 |
_REAL_DATA_PREFIXES = (
|
| 785 |
+
"[RICERCA WEB REALE",
|
| 786 |
+
"[METEO",
|
| 787 |
+
"[CALCOLO REALE",
|
| 788 |
+
"[IMMAGINE AI GENERATA",
|
| 789 |
+
"[CODICE PYTHON ESEGUITO",
|
| 790 |
+
"[PAGINA REALE",
|
| 791 |
+
"[DATI REALI",
|
| 792 |
+
"[RICERCA APPROFONDITA",
|
| 793 |
+
"[STRUTTURA PROGETTO", # S764: directory_tree
|
| 794 |
+
"[FILE TROVATI", # S764: file_search
|
| 795 |
+
"[NOTIZIE",
|
| 796 |
+
"[STATO GIT", # S764: git_status
|
| 797 |
+
"[ANALISI PYTHON", # P30-B1: python_analyze
|
| 798 |
)
|
| 799 |
+
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
|
| 800 |
+
_n_errors = len(results) - _n_success
|
| 801 |
+
# Sprint 5 ITEM 13: tool_failure_count β mai incrementato prima
|
| 802 |
+
if _n_errors > 0:
|
| 803 |
+
try:
|
| 804 |
+
from api.state import increment_stat as _inc_tf
|
| 805 |
+
_inc_tf("tool_failure_count")
|
| 806 |
+
except Exception as _exc:
|
| 807 |
+
_logger.debug("[unified_loop_tools] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 808 |
+
# P-HARNESS: traccia fallimenti per-tool; warn se threshold raggiunto
|
| 809 |
+
try:
|
| 810 |
+
from tools.harness_gate import record_failures_from_results as _hg_rec
|
| 811 |
+
from tools.registry import _agent_session_id_var as _hg_sid
|
| 812 |
+
_hg_n = _hg_rec(_hg_sid.get(), results)
|
| 813 |
+
if _hg_n:
|
| 814 |
+
_logger.warning(
|
| 815 |
+
"[harness_gate] %d tool(s) hit failure threshold β provider switch recommended",
|
| 816 |
+
_hg_n,
|
| 817 |
+
)
|
| 818 |
+
except Exception as _hg_exc: # noqa: BLE001
|
| 819 |
+
_logger.debug("[unified_loop_tools] harness silenced: %s", _hg_exc)
|
| 820 |
+
return "\n\n".join(results), len(results), _n_success, _n_errors
|
| 821 |
+
|
| 822 |
# ββ Claim Validation (S428 Sprint1-Fix3) βββββββββββββββββββββββββββββββββ
|
| 823 |
+
# Quando tutti i tool hanno fallito, il LLM puΓ² ancora affermare "Ho trovato / Ho recuperato"
|
| 824 |
+
# nonostante le istruzioni di _build_messages. Questo post-processing aggiunge un disclaimer
|
| 825 |
+
# esplicito SOLO se rileva false claim nella risposta β non riscrive il testo, lo estende.
|
| 826 |
_FALSE_CLAIM_RE = re.compile(
|
| 827 |
r"\b(ho\s+trovato(?:\s+che)?|ho\s+recuperato|ho\s+cercato\s+e\s+trovato|"
|
| 828 |
r"dai\s+risultati(?:\s+della\s+ricerca)?|stando\s+ai\s+risultati|"
|
|
|
|
| 850 |
false_claim_re: "re.Pattern[str]",
|
| 851 |
realtime_goal_re: "re.Pattern[str]",
|
| 852 |
) -> str:
|
| 853 |
+
"""S428 Sprint1-Fix3: Claim Validation.
|
| 854 |
+
Se tutti i tool hanno fallito (n_success=0, n_errors>0) E la risposta
|
| 855 |
+
contiene false claim di dati reali, aggiunge un disclaimer di trasparenza.
|
| 856 |
+
Non riscrive la risposta β la estende con una nota visibile all'utente.
|
| 857 |
+
"""
|
| 858 |
if n_success > 0 or n_errors == 0:
|
| 859 |
+
return response # dati reali presenti o nessun tool eseguito β ok
|
| 860 |
if not realtime_goal_re.search(goal):
|
| 861 |
+
return response # goal non richiede dati live β ok
|
| 862 |
if not false_claim_re.search(response):
|
| 863 |
+
return response # nessuna false claim β ok
|
| 864 |
+
# Rileva false claim + goal realtime + tutti tool falliti
|
| 865 |
disclaimer = (
|
| 866 |
"\n\n---\n"
|
| 867 |
+
"β οΈ **Nota tecnica**: i servizi di ricerca in tempo reale non erano "
|
| 868 |
"raggiungibili durante questa risposta. Le informazioni sopra provengono "
|
| 869 |
"dal mio training e potrebbero non essere aggiornate. "
|
| 870 |
+
"Per dati live consulta: Google News, Reuters, BBC, Corriere della Sera "
|
| 871 |
+
"o il sito ufficiale della tecnologia."
|
| 872 |
)
|
| 873 |
return response + disclaimer
|
| 874 |
+
|
| 875 |
+
# ββ _needs_tools (S193) β regex ampliata βββββββββββββββββββββββββββββββββ
|
| 876 |
+
|
| 877 |
+
# S427: ampliato con fenomeni meteo, valute, knowledge lookup, calcoli
|
| 878 |
_TOOL_NEEDED_RE = re.compile(
|
| 879 |
+
r"\b(meteo|previsioni|tempo\s+(?:fa|a\b)|temperatura|clima|weather|"
|
| 880 |
+
r"che\s+tempo\s+fa|quanto\s+(?:fa\s+)?(?:freddo|caldo)|gradi\s+a\b|"
|
| 881 |
+
r"piove|nevica|neve|temporale|nebbia|umiditΓ |vento|forecast|"
|
| 882 |
+
r"notizie|news|cerca\s+(?:online|sul\s+web|in\s+rete|su\s+internet|su\s+google|su\s+bing|su\s+yahoo)|"
|
| 883 |
+
r"cerca\s*:|search\s*:|search\s+for\s+|find\s+online\s+|"
|
| 884 |
+
r"ricerca\s+(?:web|online)|trova\s+(?:online|in\s+rete)|web\s+search|"
|
| 885 |
+
r"ultime\s+notizie|versione\s+(?:attuale|corrente|pi[u\xf9]\s+recente|latest)|"
|
| 886 |
+
r"aggiornamenti\s+su|bitcoin|ethereum|cambio\s+valuta|crypto|tasso\s+di\s+cambio|"
|
| 887 |
+
r"euro|dollaro|yen|sterlina|libbra|release|changelog|"
|
| 888 |
+
r"https?://|leggi\s+(?:la\s+)?pagina|leggi\s+(?:il\s+)?sito|fetch|scarica\s+da|"
|
| 889 |
+
r"wikipedia|chi\s+[eè]\b|chi\s+era\b|cosa\s+[eè]\b|storia\s+di\b|"
|
| 890 |
+
r"visita\s+(?:il\s+)?sito|apri\s+(?:la\s+)?pagina|"
|
| 891 |
+
r"calcola\b|computa\b|quanto\s+fa\s+[\d]|risultato\s+di\s+[\d(]|"
|
| 892 |
+
r"quant[oei]\s+[eè]|risolvi\b|risolvimi\b|"
|
| 893 |
+
r"genera.*immagine|crea.*immagine|genera.*foto|disegna\b|illustra\b|pollinations|image.*gen|"
|
| 894 |
+
r"run\s+(?:python\s+)?code|esegui\s+(?:\w+\s+){0,2}codice|execute\s+(?:python\s+)?code|"
|
| 895 |
+
r"lancia\s+(?:il\s+)?codice|esegui\s+(?:questo\s+|il\s+)?(?:script|programma)|"
|
| 896 |
+
r"installa|pip\s+install|shell|bash|terminal|api\s+pubblica|"
|
| 897 |
+
r"traduci|traduzione|translate|che\s+(?:ore\s+sono|giorno\s+[eè])|"
|
| 898 |
+
# S648: email/PDF keyword
|
| 899 |
+
r"invia\s+email|scrivi\s+email|manda\s+email|invia\s+mail|"
|
| 900 |
+
r"send\s+email|send\s+mail|crea\s+pdf|genera\s+pdf|"
|
| 901 |
+
r"crea\s+documento|crea\s+report|create\s+pdf|generate\s+pdf|"
|
| 902 |
+
# S764: git / npm / pip / file-search / directory-tree keywords
|
| 903 |
+
r"git\s+status|git\s+diff|git\s+log|git\s+clone|git\s+commit|"
|
| 904 |
+
r"stato\s+git|branch\s+corrente|file\s+modificati|ultimi\s+commit|"
|
| 905 |
+
r"npm\s+install|npm\s+run|npm\s+test|npm\s+build|pnpm\s+|yarn\s+add|"
|
| 906 |
+
r"pip\s+install|pip3\s+install|installa\s+(?:il\s+)?pacchett|"
|
| 907 |
+
r"directory[\s_]tree|albero\s+(?:del\s+)?(?:progetto|directory)|"
|
| 908 |
+
r"struttura\s+(?:del\s+)?progetto|elenca\s+(?:file|cartell[ae])|"
|
| 909 |
+
r"cerca\s+nel\s+(?:codice|progetto)|grep\s+|file[\s_]search|"
|
| 910 |
+
r"type[\s_]check|verifica\s+tipi|typescript\s+check|mypy\s+|"
|
| 911 |
+
# R9: webhook/call_api keywords β mancanti da _TOOL_NEEDED_RE
|
| 912 |
+
r"webhook|trigger\s+webhook|chiama\s+(?:il\s+)?webhook|send\s+webhook|"
|
| 913 |
+
r"call[\s_]api|chiama\s+api|http\s+(?:post|get|request)|zapier|n8n)\b",
|
| 914 |
re.IGNORECASE,
|
| 915 |
)
|
| 916 |
+
|
| 917 |
def _needs_tools(self, goal: str) -> bool:
|
| 918 |
+
return bool(self._TOOL_NEEDED_RE.search(goal))
|
| 919 |
+
|
| 920 |
+
# ββ S402: Fast Path βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 921 |
+
# Query conversazionali semplici: bypass memoria/planner/verifier/goal_verifier.
|
| 922 |
+
# Target: <3s vs 20-60s per il full pipeline.
|
| 923 |
+
|
| 924 |
+
# S427: aggiunti ack comuni IT/EN per fast path piΓΉ ampio
|
|
|
|
|
|
|
| 925 |
_SIMPLE_CONV_RE = re.compile(
|
| 926 |
r"^(?:ciao|salve|hey\b|hi\b|hello\b|buongiorno|buonasera|buonanotte|"
|
| 927 |
r"grazie(?:\s+mille)?|prego|perfetto|ottimo|esatto|capito|ok\b|bene\b|"
|
|
|
|
| 938 |
r")\.?\s*[!?]?$",
|
| 939 |
re.IGNORECASE,
|
| 940 |
)
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
# S-FAST-MATH: espressioni aritmetiche semplici β fast-path (Groq 8B, ~150ms)
|
| 944 |
+
# Override del check _needs_tools: "calcola 2+2" non richiede tool di ricerca web.
|
| 945 |
+
# Pattern: prefisso opzionale (calcola/quanto fa) + espressione numerica.
|
| 946 |
_SIMPLE_MATH_RE = re.compile(
|
| 947 |
r'^(?:(?:calcola|quanto\s+(?:fa|fanno|vale|valgono)|quant[oei]\s+(?:fa|fanno)|'
|
| 948 |
r'dimmi\s+(?:solo\s+)?(?:il\s+)?(?:risultato|valore)\s+di|'
|
|
|
|
| 950 |
r'[\d\s\+\-\*\/\^\(\)\.]+\s*[=?]?$',
|
| 951 |
re.IGNORECASE,
|
| 952 |
)
|
| 953 |
+
# P30-B1: trigger analisi statica Python (IT + EN)
|
| 954 |
_ANALYZE_PY_RE = re.compile(
|
| 955 |
r"(?:analizza\s+(?:questo\s+)?(?:codice|script|programma)(?:\s+python)?"
|
| 956 |
r"|analisi\s+(?:del\s+)?(?:codice|script)(?:\s+python)?"
|
|
|
|
| 962 |
r"|esamina\s+(?:il\s+)?(?:codice|script)(?:\s+python)?)",
|
| 963 |
re.IGNORECASE,
|
| 964 |
)
|
| 965 |
+
# Regex per estrarre blocco python dal goal β P30-B1
|
| 966 |
_PY_BLOCK_IN_GOAL_RE = re.compile(
|
| 967 |
r"```(?:python|py)\s*\n([\s\S]+?)```",
|
| 968 |
re.IGNORECASE,
|
| 969 |
)
|
| 970 |
+
|
|
|
|
| 971 |
def _is_simple_query(self, goal: str) -> bool:
|
| 972 |
+
"""S402: True per greeting/ack/identitΓ semplice (<70 chars, no tool/code intent).
|
| 973 |
+
S-FAST-MATH: aggiunto check math semplice β fast-path, bypassa _needs_tools.
|
| 974 |
+
Attiva il fast path che salta memoria, planner, verifier e self-healing."""
|
| 975 |
g = goal.strip()
|
| 976 |
if self._CODE_GOAL_RE.search(g) or self._CODE_RE.search(g):
|
| 977 |
return False
|
| 978 |
+
# S-FAST-MATH: "calcola 2+2", "quanto fa 15*3" β fast-path (Groq 8B, 150ms)
|
| 979 |
+
# Controllo separato da _needs_tools: la matematica pura non richiede tool web.
|
| 980 |
if len(g) <= 100 and self._SIMPLE_MATH_RE.match(g):
|
| 981 |
return True
|
| 982 |
+
# Percorso originale: greeting/ack con limite 70 chars
|
| 983 |
if len(g) > 70 or self._needs_tools(g):
|
| 984 |
return False
|
| 985 |
return bool(self._SIMPLE_CONV_RE.match(g))
|
agents/unified_loop_types.py
CHANGED
|
@@ -19,61 +19,10 @@ from __future__ import annotations
|
|
| 19 |
import asyncio
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field
|
| 22 |
-
from enum import Enum
|
| 23 |
from typing import Any, Awaitable, Callable
|
| 24 |
|
| 25 |
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 26 |
|
| 27 |
-
class AgentState(str, Enum):
|
| 28 |
-
"""Lifecycle states for one UnifiedAgentLoop execution."""
|
| 29 |
-
|
| 30 |
-
IDLE = "IDLE"
|
| 31 |
-
CLASSIFYING = "CLASSIFYING"
|
| 32 |
-
TOOL_EXECUTING = "TOOL_EXECUTING"
|
| 33 |
-
THINKING = "THINKING"
|
| 34 |
-
FAILED = "FAILED"
|
| 35 |
-
COMPLETED = "COMPLETED"
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
_AGENT_STATE_TRANSITIONS: dict[AgentState, frozenset[AgentState]] = {
|
| 39 |
-
AgentState.IDLE: frozenset({AgentState.CLASSIFYING, AgentState.FAILED}),
|
| 40 |
-
AgentState.CLASSIFYING: frozenset({
|
| 41 |
-
AgentState.TOOL_EXECUTING, AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 42 |
-
}),
|
| 43 |
-
AgentState.TOOL_EXECUTING: frozenset({
|
| 44 |
-
AgentState.THINKING, AgentState.COMPLETED, AgentState.FAILED,
|
| 45 |
-
}),
|
| 46 |
-
AgentState.THINKING: frozenset({AgentState.COMPLETED, AgentState.FAILED}),
|
| 47 |
-
AgentState.FAILED: frozenset({AgentState.IDLE}),
|
| 48 |
-
# Exceptional finalization errors must be able to surface as FAILED.
|
| 49 |
-
AgentState.COMPLETED: frozenset({AgentState.IDLE, AgentState.FAILED}),
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class AgentLoopStateMachine:
|
| 54 |
-
"""Deterministic lifecycle machine owned by one loop invocation."""
|
| 55 |
-
|
| 56 |
-
def __init__(self) -> None:
|
| 57 |
-
self.current: AgentState = AgentState.IDLE
|
| 58 |
-
self.history: list[AgentState] = [AgentState.IDLE]
|
| 59 |
-
|
| 60 |
-
def transition(self, next_state: AgentState) -> None:
|
| 61 |
-
if next_state == self.current:
|
| 62 |
-
return
|
| 63 |
-
if next_state not in _AGENT_STATE_TRANSITIONS[self.current]:
|
| 64 |
-
raise ValueError(
|
| 65 |
-
f"Invalid AgentLoop transition: {self.current.value} -> {next_state.value}"
|
| 66 |
-
)
|
| 67 |
-
self.current = next_state
|
| 68 |
-
self.history.append(next_state)
|
| 69 |
-
|
| 70 |
-
def snapshot(self) -> dict[str, Any]:
|
| 71 |
-
return {
|
| 72 |
-
"agent_state": self.current.value,
|
| 73 |
-
"state_history": [state.value for state in self.history],
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
|
| 78 |
def _detect_user_lang(goal: str) -> str:
|
| 79 |
"""P27-B2: rilevamento lingua leggero β zero I/O, zero LLM, <1ms.
|
|
@@ -209,7 +158,6 @@ class UnifiedLoopState:
|
|
| 209 |
errors: list[str] = field(default_factory=list)
|
| 210 |
has_files: bool = False # B10: flag separato Γ’ΒΒ evita di inquinare il context string
|
| 211 |
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
| 212 |
-
state_machine: AgentLoopStateMachine = field(default_factory=AgentLoopStateMachine)
|
| 213 |
|
| 214 |
|
| 215 |
async def _maybe_await(val: Any) -> None:
|
|
|
|
| 19 |
import asyncio
|
| 20 |
import re
|
| 21 |
from dataclasses import dataclass, field
|
|
|
|
| 22 |
from typing import Any, Awaitable, Callable
|
| 23 |
|
| 24 |
StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
def _detect_user_lang(goal: str) -> str:
|
| 28 |
"""P27-B2: rilevamento lingua leggero β zero I/O, zero LLM, <1ms.
|
|
|
|
| 158 |
errors: list[str] = field(default_factory=list)
|
| 159 |
has_files: bool = False # B10: flag separato Γ’ΒΒ evita di inquinare il context string
|
| 160 |
session_id: str = "" # P17-F2: blackboard session key per sync Upstash
|
|
|
|
| 161 |
|
| 162 |
|
| 163 |
async def _maybe_await(val: Any) -> None:
|
agents/unified_loop_vfs.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""unified_loop_vfs.py β VFSMixin: scritture VFS, git backup, lock per path.
|
| 2 |
+
|
| 3 |
+
Estratto da unified_loop.py per ridurre il file principale.
|
| 4 |
+
|
| 5 |
+
Contiene:
|
| 6 |
+
_rollback_writes(on_step): GAP-3 rollback atomico scritture parziali
|
| 7 |
+
_vfs_git_backup(): GAP-NEW-4 push session_files su branch vfs-backup GitHub
|
| 8 |
+
_get_vfs_lock(path): GAP-VFS per-path asyncio.Lock (lazy init)
|
| 9 |
+
|
| 10 |
+
Invariante B1: nessun corpo duplicato con unified_loop.py.
|
| 11 |
+
MRO Python garantisce self._write_snapshots / self._session_files / self.executor
|
| 12 |
+
siano risolti su UnifiedAgentLoop.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import logging
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from agents.unified_loop_types import _maybe_await
|
| 21 |
+
|
| 22 |
+
_logger = logging.getLogger("agente_ai")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class VFSMixin:
|
| 26 |
+
async def _rollback_writes(self, on_step=None) -> None:
|
| 27 |
+
"""
|
| 28 |
+
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metΓΒ .
|
| 29 |
+
Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
|
| 30 |
+
Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
|
| 31 |
+
File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
|
| 32 |
+
"""
|
| 33 |
+
if not self._write_snapshots or not self.executor:
|
| 34 |
+
return
|
| 35 |
+
if on_step:
|
| 36 |
+
await _maybe_await(on_step({
|
| 37 |
+
"action": "text_chunk",
|
| 38 |
+
"token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
|
| 39 |
+
"status": "streaming",
|
| 40 |
+
}))
|
| 41 |
+
_rolled = 0
|
| 42 |
+
for path, original in self._write_snapshots.items():
|
| 43 |
+
if original is None:
|
| 44 |
+
continue # file non esisteva prima Γ’ΒΒ saltiamo (non eliminiamo)
|
| 45 |
+
try:
|
| 46 |
+
await asyncio.wait_for(
|
| 47 |
+
self.executor.run_tool("write_file", {"path": path, "content": original}),
|
| 48 |
+
timeout=10.0,
|
| 49 |
+
)
|
| 50 |
+
_rolled += 1
|
| 51 |
+
except Exception:
|
| 52 |
+
pass # non-fatal Γ’ΒΒ best effort rollback
|
| 53 |
+
_total = len(self._write_snapshots) # salva prima del clear
|
| 54 |
+
self._write_snapshots = {}
|
| 55 |
+
_logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
|
| 56 |
+
|
| 57 |
+
# ββ GAP-NEW-4: Git VFS auto-snapshot ββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
async def _vfs_git_backup(self) -> None:
|
| 59 |
+
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 60 |
+
|
| 61 |
+
Fire-and-forget β non blocca mai il loop principale, non solleva eccezioni.
|
| 62 |
+
Requisiti env: GH_TOKEN (o GITHUB_TOKEN) + GITHUB_REPO = "owner/repo".
|
| 63 |
+
Crea automaticamente il branch vfs-backup se non esiste.
|
| 64 |
+
Force-push consentito su vfs-backup (non Γ¨ main β nessun rischio di perdita).
|
| 65 |
+
"""
|
| 66 |
+
import os as _os_vfs
|
| 67 |
+
gh_token = (_os_vfs.getenv("GH_TOKEN") or _os_vfs.getenv("GITHUB_TOKEN", "")).strip()
|
| 68 |
+
gh_repo = _os_vfs.getenv("GITHUB_REPO", "").strip()
|
| 69 |
+
if not gh_token or not gh_repo:
|
| 70 |
+
return
|
| 71 |
+
files = dict(self._session_files) # snapshot immutabile
|
| 72 |
+
if not files:
|
| 73 |
+
return
|
| 74 |
+
run_id = self._run_task_id[:8] or "unknown"
|
| 75 |
+
try:
|
| 76 |
+
import httpx as _hx4
|
| 77 |
+
headers = {
|
| 78 |
+
"Authorization": f"Bearer {gh_token}",
|
| 79 |
+
"Accept": "application/vnd.github+json",
|
| 80 |
+
"User-Agent": "agente-ai-vfs/1.0",
|
| 81 |
+
}
|
| 82 |
+
base = f"https://api.github.com/repos/{gh_repo}"
|
| 83 |
+
async with _hx4.AsyncClient(timeout=20.0) as _cli:
|
| 84 |
+
# 1. Leggi (o crea) branch vfs-backup
|
| 85 |
+
r_ref = await _cli.get(f"{base}/git/ref/heads/vfs-backup", headers=headers)
|
| 86 |
+
if r_ref.status_code == 404:
|
| 87 |
+
r_main = await _cli.get(f"{base}/git/ref/heads/main", headers=headers)
|
| 88 |
+
if r_main.status_code != 200:
|
| 89 |
+
return
|
| 90 |
+
r_cr = await _cli.post(f"{base}/git/refs", headers=headers,
|
| 91 |
+
json={"ref": "refs/heads/vfs-backup", "sha": r_main.json()["object"]["sha"]})
|
| 92 |
+
if r_cr.status_code not in (200, 201):
|
| 93 |
+
return
|
| 94 |
+
backup_head = r_main.json()["object"]["sha"]
|
| 95 |
+
elif r_ref.status_code == 200:
|
| 96 |
+
backup_head = r_ref.json()["object"]["sha"]
|
| 97 |
+
else:
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
# 2. Leggi base tree del backup HEAD
|
| 101 |
+
r_c = await _cli.get(f"{base}/git/commits/{backup_head}", headers=headers)
|
| 102 |
+
if r_c.status_code != 200:
|
| 103 |
+
return
|
| 104 |
+
base_tree = r_c.json()["tree"]["sha"]
|
| 105 |
+
|
| 106 |
+
# 3. Crea blob per ogni file (max 20 per backup, max 50KB per file)
|
| 107 |
+
tree_items = []
|
| 108 |
+
for _path, _content in list(files.items())[:20]:
|
| 109 |
+
rb = await _cli.post(f"{base}/git/blobs", headers=headers,
|
| 110 |
+
json={"content": str(_content)[:50_000], "encoding": "utf-8"})
|
| 111 |
+
if rb.status_code == 201:
|
| 112 |
+
tree_items.append({
|
| 113 |
+
"path": f"vfs/{_path.lstrip('/')}",
|
| 114 |
+
"mode": "100644",
|
| 115 |
+
"type": "blob",
|
| 116 |
+
"sha": rb.json()["sha"],
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
if not tree_items:
|
| 120 |
+
return
|
| 121 |
+
|
| 122 |
+
# 4. Tree + commit + force-push su vfs-backup
|
| 123 |
+
rt = await _cli.post(f"{base}/git/trees", headers=headers,
|
| 124 |
+
json={"base_tree": base_tree, "tree": tree_items})
|
| 125 |
+
if rt.status_code != 201:
|
| 126 |
+
return
|
| 127 |
+
rc = await _cli.post(f"{base}/git/commits", headers=headers,
|
| 128 |
+
json={
|
| 129 |
+
"message": f"vfs-backup: {len(tree_items)} file (run {run_id})",
|
| 130 |
+
"tree": rt.json()["sha"],
|
| 131 |
+
"parents": [backup_head],
|
| 132 |
+
})
|
| 133 |
+
if rc.status_code != 201:
|
| 134 |
+
return
|
| 135 |
+
# force=True consentito: vfs-backup non Γ¨ main, nessun rischio
|
| 136 |
+
await _cli.patch(f"{base}/git/refs/heads/vfs-backup", headers=headers,
|
| 137 |
+
json={"sha": rc.json()["sha"], "force": True})
|
| 138 |
+
_logger.info(
|
| 139 |
+
"GAP-NEW-4: vfs-backup aggiornato β %d file, run %s",
|
| 140 |
+
len(tree_items), run_id,
|
| 141 |
+
)
|
| 142 |
+
except Exception as _vfs_err:
|
| 143 |
+
# Silent: il backup non deve MAI bloccare o crashare il loop principale
|
| 144 |
+
_logger.debug("GAP-NEW-4 _vfs_git_backup skip: %s", str(_vfs_err)[:80])
|
| 145 |
+
|
| 146 |
+
# ββ GAP-VFS: per-path write lock βββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
def _get_vfs_lock(self, path: str) -> asyncio.Lock:
|
| 148 |
+
"""GAP-VFS: restituisce (o crea) il Lock asyncio per un path VFS.
|
| 149 |
+
Previene race condition quando subtask paralleli (asyncio.gather)
|
| 150 |
+
scrivono lo stesso file contemporaneamente.
|
| 151 |
+
Lock creato lazy: zero overhead per run che non usano write paralleli."""
|
| 152 |
+
if path not in self._vfs_write_locks:
|
| 153 |
+
self._vfs_write_locks[path] = asyncio.Lock()
|
| 154 |
+
return self._vfs_write_locks[path]
|
| 155 |
+
|
| 156 |
+
# ββ BGAP-GUARD: Reflective Debug (no-regression invariante) ββββββββββββββββ
|
agents/watchdog.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
watchdog.py Γ’ΒΒ S-WATCHDOG: Bidirectional Self-Healing Heartbeat.
|
| 3 |
+
Monitora il loop agentico e interviene in caso di stallo o deviazione dal goal.
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
from typing import Callable, Awaitable, Optional
|
| 9 |
+
|
| 10 |
+
_logger = logging.getLogger("agente_ai.agents.watchdog")
|
| 11 |
+
|
| 12 |
+
class BidirectionalWatchdog:
|
| 13 |
+
def __init__(self,
|
| 14 |
+
timeout_seconds: float = 45.0,
|
| 15 |
+
on_stale_callback: Optional[Callable[[], Awaitable[None]]] = None):
|
| 16 |
+
self.timeout = timeout_seconds
|
| 17 |
+
self.on_stale = on_stale_callback
|
| 18 |
+
self.last_heartbeat = time.monotonic()
|
| 19 |
+
self._running = False
|
| 20 |
+
self._monitor_task = None
|
| 21 |
+
|
| 22 |
+
def heartbeat(self):
|
| 23 |
+
"""Segnala che l'agente Γ¨ ancora attivo e progredisce."""
|
| 24 |
+
self.last_heartbeat = time.monotonic()
|
| 25 |
+
_logger.debug("[Watchdog] Heartbeat ricevuto.")
|
| 26 |
+
|
| 27 |
+
async def start(self):
|
| 28 |
+
"""Avvia il monitoraggio in background."""
|
| 29 |
+
if self._running:
|
| 30 |
+
return
|
| 31 |
+
self._running = True
|
| 32 |
+
self.last_heartbeat = time.monotonic()
|
| 33 |
+
self._monitor_task = asyncio.create_task(self._monitor_loop())
|
| 34 |
+
_logger.info(f"[Watchdog] Monitoraggio avviato (timeout: {self.timeout}s)")
|
| 35 |
+
|
| 36 |
+
async def stop(self):
|
| 37 |
+
"""Ferma il monitoraggio."""
|
| 38 |
+
self._running = False
|
| 39 |
+
if self._monitor_task:
|
| 40 |
+
self._monitor_task.cancel()
|
| 41 |
+
try:
|
| 42 |
+
await self._monitor_task
|
| 43 |
+
except asyncio.CancelledError:
|
| 44 |
+
pass
|
| 45 |
+
_logger.info("[Watchdog] Monitoraggio fermato.")
|
| 46 |
+
|
| 47 |
+
async def _monitor_loop(self):
|
| 48 |
+
while self._running:
|
| 49 |
+
await asyncio.sleep(5.0)
|
| 50 |
+
elapsed = time.monotonic() - self.last_heartbeat
|
| 51 |
+
if elapsed > self.timeout:
|
| 52 |
+
_logger.warning(f"[Watchdog] Rilevato stallo! Nessun heartbeat da {elapsed:.1f}s.")
|
| 53 |
+
if self.on_stale:
|
| 54 |
+
try:
|
| 55 |
+
await self.on_stale()
|
| 56 |
+
# Resetta il timer dopo l'intervento per evitare interventi a raffica
|
| 57 |
+
self.heartbeat()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
_logger.error(f"[Watchdog] Errore durante l'intervento di self-healing: {e}")
|
| 60 |
+
|
| 61 |
+
async def self_check(self, state_summary: str) -> bool:
|
| 62 |
+
"""
|
| 63 |
+
L'agente chiama questo metodo per un controllo esterno di coerenza.
|
| 64 |
+
"""
|
| 65 |
+
_logger.info(f"[Watchdog] Eseguo Self-Check dello stato: {state_summary[:100]}...")
|
| 66 |
+
# Implementazione futura: chiamata a un modello critico esterno (Critic Layer)
|
| 67 |
+
return True
|
agents/workflow_engine.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import time
|
| 3 |
-
import uuid
|
| 4 |
-
from typing import Any, Dict, List, Optional
|
| 5 |
-
|
| 6 |
-
from pydantic import BaseModel, Field
|
| 7 |
-
|
| 8 |
-
_logger = logging.getLogger("agents.workflow_engine")
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class WorkflowStep(BaseModel):
|
| 12 |
-
step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 13 |
-
tool_name: str
|
| 14 |
-
args: Dict[str, Any]
|
| 15 |
-
status: str = "pending" # pending, running, completed, failed
|
| 16 |
-
result: Any = None
|
| 17 |
-
error: Optional[str] = None
|
| 18 |
-
started_at: Optional[float] = None
|
| 19 |
-
finished_at: Optional[float] = None
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class Workflow(BaseModel):
|
| 23 |
-
workflow_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 24 |
-
name: str
|
| 25 |
-
steps: List[WorkflowStep]
|
| 26 |
-
status: str = "pending"
|
| 27 |
-
created_at: float = Field(default_factory=time.time)
|
| 28 |
-
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class WorkflowExecutor:
|
| 32 |
-
"""
|
| 33 |
-
ARCH-I4.3: Workflow Engine.
|
| 34 |
-
|
| 35 |
-
Coordina workflow in-memory step-by-step tramite Kernel ed Executor. La
|
| 36 |
-
persistenza o il resume inter-processo non sono garantiti da questo motore;
|
| 37 |
-
i caller possono consultare lo stato del workflow corrente tramite
|
| 38 |
-
``get_workflow``.
|
| 39 |
-
"""
|
| 40 |
-
|
| 41 |
-
def __init__(self, kernel: Any, executor: Any):
|
| 42 |
-
self.kernel = kernel
|
| 43 |
-
self.executor = executor
|
| 44 |
-
self.active_workflows: Dict[str, Workflow] = {}
|
| 45 |
-
|
| 46 |
-
def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
| 47 |
-
"""Ritorna il workflow noto, inclusi gli stati terminali in memoria."""
|
| 48 |
-
return self.active_workflows.get(workflow_id)
|
| 49 |
-
|
| 50 |
-
async def execute_workflow(self, workflow: Workflow) -> Workflow:
|
| 51 |
-
"""Esegue un workflow step-by-step, mantenendo il fallback locale."""
|
| 52 |
-
self.active_workflows[workflow.workflow_id] = workflow
|
| 53 |
-
workflow.status = "running"
|
| 54 |
-
_logger.info("Avvio workflow: %s (%s)", workflow.name, workflow.workflow_id)
|
| 55 |
-
|
| 56 |
-
for step in workflow.steps:
|
| 57 |
-
step.status = "running"
|
| 58 |
-
step.started_at = time.time()
|
| 59 |
-
_logger.info(
|
| 60 |
-
"Esecuzione step: %s in workflow %s",
|
| 61 |
-
step.tool_name,
|
| 62 |
-
workflow.workflow_id,
|
| 63 |
-
)
|
| 64 |
-
try:
|
| 65 |
-
# ARCH-I4.3: il Kernel risolve la capability senza esporre
|
| 66 |
-
# l'infrastruttura al workflow.
|
| 67 |
-
resolution = await self.kernel.resolve_capability(step.tool_name)
|
| 68 |
-
if resolution.get("status") == "resolved":
|
| 69 |
-
worker = resolution["worker"]
|
| 70 |
-
worker_id = worker.id if hasattr(worker, "id") else worker["id"]
|
| 71 |
-
_logger.info(
|
| 72 |
-
"Step %s risolto su worker: %s",
|
| 73 |
-
step.tool_name,
|
| 74 |
-
worker_id,
|
| 75 |
-
)
|
| 76 |
-
result = await self.executor.run_tool(
|
| 77 |
-
tool_name=step.tool_name,
|
| 78 |
-
inputs=step.args,
|
| 79 |
-
worker_hint=worker_id,
|
| 80 |
-
)
|
| 81 |
-
else:
|
| 82 |
-
# Nessun worker registrato: il comportamento storico resta
|
| 83 |
-
# l'esecuzione locale tramite lo stesso Executor.
|
| 84 |
-
_logger.warning(
|
| 85 |
-
"Nessun worker per %s, provo esecuzione locale",
|
| 86 |
-
step.tool_name,
|
| 87 |
-
)
|
| 88 |
-
result = await self.executor.run_tool(
|
| 89 |
-
tool_name=step.tool_name,
|
| 90 |
-
inputs=step.args,
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
step.result = result
|
| 94 |
-
if isinstance(result, dict) and result.get("success") is False:
|
| 95 |
-
step.status = "failed"
|
| 96 |
-
step.error = str(result.get("error", "Tool execution failed"))
|
| 97 |
-
workflow.status = "failed"
|
| 98 |
-
break
|
| 99 |
-
step.status = "completed"
|
| 100 |
-
except Exception as exc:
|
| 101 |
-
step.status = "failed"
|
| 102 |
-
step.error = str(exc)
|
| 103 |
-
workflow.status = "failed"
|
| 104 |
-
_logger.error("Step %s fallito: %s", step.tool_name, exc)
|
| 105 |
-
break
|
| 106 |
-
finally:
|
| 107 |
-
step.finished_at = time.time()
|
| 108 |
-
|
| 109 |
-
if workflow.status == "running":
|
| 110 |
-
workflow.status = "completed"
|
| 111 |
-
_logger.info("Workflow %s terminato con stato: %s", workflow.name, workflow.status)
|
| 112 |
-
return workflow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/TELEGRAM_MODULES.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/api β Moduli Telegram (M2 Split)
|
| 2 |
+
|
| 3 |
+
> **Refactor M2 β 2 Luglio 2026**
|
| 4 |
+
> Il monolite `telegram_webhook.py` (2844 righe, 5 hotfix consecutivi) Γ¨ stato
|
| 5 |
+
> spezzato in 6 moduli a responsabilitΓ singola. Hotfix futuri toccano **un solo file**.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Grafo delle dipendenze
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
telegram_tg_client.py (stdlib + httpx only)
|
| 13 |
+
β
|
| 14 |
+
ββββΊ telegram_keyboards.py (solo costanti/dict β zero import interni)
|
| 15 |
+
β β
|
| 16 |
+
β ββββΊ telegram_cmd_monitoring.py
|
| 17 |
+
β β ββββΊ (esposto via telegram_webhook.py)
|
| 18 |
+
β β
|
| 19 |
+
β ββββΊ telegram_cmd_ai.py
|
| 20 |
+
β β ββββΊ (esposto via telegram_webhook.py)
|
| 21 |
+
β β
|
| 22 |
+
β ββββΊ telegram_callbacks.py
|
| 23 |
+
β βββ importa _cmd_do, _cmd_autofix ... da telegram_cmd_ai
|
| 24 |
+
β βββ importa _cmd_help, _cmd_status ... da telegram_cmd_monitoring
|
| 25 |
+
β
|
| 26 |
+
ββββΊ telegram_webhook.py (router FastAPI puro β importa da tutti)
|
| 27 |
+
ββββΊ montato in backend/main.py come _tg_webhook_router
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Moduli
|
| 33 |
+
|
| 34 |
+
### `telegram_tg_client.py` β 276 righe
|
| 35 |
+
**Ruolo:** Telegram Bot API puro. Layer 0 senza dipendenze interne.
|
| 36 |
+
|
| 37 |
+
| Simbolo | Descrizione |
|
| 38 |
+
|---------|-------------|
|
| 39 |
+
| `_get_bot_token()` | Legge `TELEGRAM_BOT_TOKEN` dall'env |
|
| 40 |
+
| `_log_tg_exc(task)` | Log eccezioni fire-and-forget (Gap-2.6) |
|
| 41 |
+
| `_fmt_elapsed(sec)` | Formatta durata in human-readable |
|
| 42 |
+
| `_tg_reply(chat_id, text)` | Invia messaggio con parse_mode=HTML |
|
| 43 |
+
| `_tg_send(chat_id, text)` | Come reply, ritorna `message_id` |
|
| 44 |
+
| `_tg_edit(chat_id, msg_id, text)` | Modifica messaggio esistente |
|
| 45 |
+
| `_tg_photo(chat_id, url, caption)` | Invia foto via URL |
|
| 46 |
+
| `_tg_typing(chat_id)` | Invia `sendChatAction typing` |
|
| 47 |
+
| `_tg_react(chat_id, msg_id, emoji)` | Imposta reaction emoji |
|
| 48 |
+
| `_tg_answer_callback(callback_id)` | Risponde a `callback_query` (β€3s) |
|
| 49 |
+
|
| 50 |
+
**Import:** `asyncio, html, logging, os, time, httpx`
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### `telegram_keyboards.py` β 109 righe
|
| 55 |
+
**Ruolo:** Costanti UI e keyboards. Zero logica, zero import interni.
|
| 56 |
+
|
| 57 |
+
| Simbolo | Tipo | Descrizione |
|
| 58 |
+
|---------|------|-------------|
|
| 59 |
+
| `_QUICK_PICK_KB` | `dict` | Inline keyboard selezione task rapida |
|
| 60 |
+
| `_MAIN_KB` | `dict` | Reply keyboard principale |
|
| 61 |
+
| `_BENCH_ACTION_KB` | `dict` | Keyboard post-benchmark |
|
| 62 |
+
| `_TASK_MENU_KB` | `dict` | Sub-menu task |
|
| 63 |
+
| `_STATUS_MENU_KB` | `dict` | Sub-menu stato sistema |
|
| 64 |
+
| `_PERF_MENU_KB` | `dict` | Sub-menu performance |
|
| 65 |
+
| `_HEALTH_MENU_KB` | `dict` | Sub-menu health |
|
| 66 |
+
| `_DEV_MENU_KB` | `dict` | Sub-menu developer |
|
| 67 |
+
| `_LAST_GOAL` | `dict[int,str]` | Memoria per bottone π Rifai |
|
| 68 |
+
| `_BENCH_CACHE` | `dict[int,dict]` | Ultimo run benchmark per chat |
|
| 69 |
+
| `_after_task_kb(chat_id)` | func | Keyboard dinamica post-task |
|
| 70 |
+
|
| 71 |
+
**Import:** solo `from __future__ import annotations`
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
### `telegram_cmd_monitoring.py` β 456 righe
|
| 76 |
+
**Ruolo:** Comandi di monitoraggio, stato e diagnostica.
|
| 77 |
+
|
| 78 |
+
| Comando / Funzione | Trigger Telegram |
|
| 79 |
+
|--------------------|-----------------|
|
| 80 |
+
| `_cmd_help(chat_id)` | `/start` `/help` `tgw_help` |
|
| 81 |
+
| `_cmd_logs(chat_id)` | `/logs` `tgw_logs` |
|
| 82 |
+
| `_cmd_status(chat_id)` | `/stato` `tgw_status` |
|
| 83 |
+
| `_cmd_commit_summary(chat_id)` | `/commit` `tgw_commits` |
|
| 84 |
+
| `_cmd_check(chat_id)` | `/salute` `tgw_health` |
|
| 85 |
+
| `_cmd_tasks(chat_id)` | `/attivitΓ ` `tgw_tasks` |
|
| 86 |
+
| `_cmd_git(chat_id, args)` | `/git` `tgw_git` |
|
| 87 |
+
| `_cmd_coord(chat_id)` | `/coord` `tgw_coord` |
|
| 88 |
+
| `_cmd_scan_now(chat_id)` | `/scan` `tgw_scan` |
|
| 89 |
+
| `_cmd_telemetry(chat_id)` | `/telemetria` `tgw_telemetry` |
|
| 90 |
+
|
| 91 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
### `telegram_cmd_ai.py` β 1152 righe
|
| 96 |
+
**Ruolo:** Comandi AI, LLM e operativi. Modulo piΓΉ pesante β contiene lo streaming loop.
|
| 97 |
+
|
| 98 |
+
| Comando / Funzione | Trigger Telegram |
|
| 99 |
+
|--------------------|-----------------|
|
| 100 |
+
| `_cmd_do(chat_id, goal)` | `/avvia` β lancia task AI con streaming |
|
| 101 |
+
| `_cmd_autofix(chat_id)` | `/fix` `tgw_autofix` |
|
| 102 |
+
| `_cmd_nota(chat_id, text)` | `/nota` `tgw_nota` |
|
| 103 |
+
| `_cmd_cerca(chat_id, query)` | `/cerca` `tgw_cerca` |
|
| 104 |
+
| `_cmd_meteo(chat_id, city)` | `/meteo` `tgw_meteo` |
|
| 105 |
+
| `_cmd_riepilogo(chat_id)` | `/riepilogo` `tgw_briefing` |
|
| 106 |
+
| `_cmd_score(chat_id)` | `/score` `tgw_score` |
|
| 107 |
+
| `_cmd_bench(chat_id)` | `/bench` `tgw_bench` |
|
| 108 |
+
| `_cmd_improve(chat_id, target)` | `/migliora` `tgw_improve` |
|
| 109 |
+
|
| 110 |
+
**Import:** stdlib + `telegram_tg_client` + `telegram_keyboards`
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
### `telegram_callbacks.py` β 353 righe
|
| 115 |
+
**Ruolo:** Smista `callback_query` e `inline_query` in arrivo da Telegram.
|
| 116 |
+
|
| 117 |
+
| Funzione | Descrizione |
|
| 118 |
+
|----------|-------------|
|
| 119 |
+
| `_handle_inline(iq, token)` | Risponde alle inline query (`@ARJagent_ap_bot testo`) |
|
| 120 |
+
| `_handle_callback(cb, token)` | Dispatch per tutti i `callback_data` `tgw_*` / `agent` / `qp_*` |
|
| 121 |
+
|
| 122 |
+
**Import:** `telegram_tg_client` + `telegram_keyboards` + `telegram_cmd_ai` + `telegram_cmd_monitoring`
|
| 123 |
+
|
| 124 |
+
> β οΈ **Questo modulo crea dipendenze circolari potenziali** β non importare
|
| 125 |
+
> `telegram_callbacks` da `telegram_cmd_ai` o `telegram_cmd_monitoring`.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### `telegram_webhook.py` β 605 righe *(era 2844)*
|
| 130 |
+
**Ruolo:** Router FastAPI puro. Solo endpoint HTTP, zero logica di business.
|
| 131 |
+
|
| 132 |
+
| Endpoint | Metodo | Descrizione |
|
| 133 |
+
|----------|--------|-------------|
|
| 134 |
+
| `/api/telegram/webhook` | POST | Ricezione update getUpdates (polling daemon) |
|
| 135 |
+
| `/api/telegram/process` | POST | Endpoint alternativo per CF Pages proxy |
|
| 136 |
+
| `/api/telegram/config/invalidate` | POST | Invalida cache config bot |
|
| 137 |
+
|
| 138 |
+
**Import:** FastAPI + tutti i 5 moduli sopra
|
| 139 |
+
**Montato in:** `backend/main.py` β `app.include_router(_tg_webhook_router)`
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
## Regole per gli hotfix
|
| 144 |
+
|
| 145 |
+
| Vuoi cambiare... | Tocca solo... |
|
| 146 |
+
|------------------|---------------|
|
| 147 |
+
| Aspetto di un bottone / label | `telegram_keyboards.py` |
|
| 148 |
+
| Risposta a un comando /help, /stato⦠| `telegram_cmd_monitoring.py` |
|
| 149 |
+
| Logica task AI / streaming / bench | `telegram_cmd_ai.py` |
|
| 150 |
+
| Routing dei bottoni inline (tgw_*) | `telegram_callbacks.py` |
|
| 151 |
+
| Helpers HTTP verso api.telegram.org | `telegram_tg_client.py` |
|
| 152 |
+
| Routing endpoint FastAPI | `telegram_webhook.py` |
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Daemon Node.js (scripts/lib/daemon-callbacks.mjs)
|
| 157 |
+
|
| 158 |
+
Il daemon usa **getUpdates polling** (non webhook registrato).
|
| 159 |
+
Riceve gli update, li smista ai command handler Python via HTTP interno.
|
| 160 |
+
Il flusso `callback_data tgw_*` viene elaborato da `telegram_callbacks.py::_handle_callback`.
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
Telegram βββΊ getUpdates daemon βββΊ POST /api/telegram/process
|
| 164 |
+
β
|
| 165 |
+
telegram_webhook.py (router)
|
| 166 |
+
β
|
| 167 |
+
βββββββββββββ΄ββββββββββββ
|
| 168 |
+
message? callback_query?
|
| 169 |
+
β β
|
| 170 |
+
telegram_cmd_*.py telegram_callbacks.py
|
| 171 |
+
::_handle_callback
|
| 172 |
+
```
|
api/_agent_helpers.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/_agent_helpers.py β Funzioni helper condivise tra i sub-router agent.
|
| 2 |
+
|
| 3 |
+
Estratto da agent_loop_routes / agent_task_routes / agent_checkpoint_routes
|
| 4 |
+
per eliminare le triplicazioni create dallo split S359 (2026-06-30).
|
| 5 |
+
|
| 6 |
+
Esportazioni:
|
| 7 |
+
_RE_SURROGATES β regex surrogati UTF-16
|
| 8 |
+
_ss(s) β sanitizza string (rimuove surrogati)
|
| 9 |
+
_log_task_exc(task) β done-callback asyncio con log eccezioni
|
| 10 |
+
_PERSONA_KEYWORD_MAP β dict globale (regex per persona routing)
|
| 11 |
+
_PERSONA_CLIENT_CACHE β dict globale (cache LLM client per persona)
|
| 12 |
+
_build_persona_kw_map β costruisce _PERSONA_KEYWORD_MAP (P17-F5)
|
| 13 |
+
_classify_persona_server β classifica persona via regex scoring (zero LLM)
|
| 14 |
+
_get_persona_llm_client β ritorna LLM client persona-appropriato
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
import re
|
| 18 |
+
import logging
|
| 19 |
+
from fastapi import APIRouter
|
| 20 |
+
|
| 21 |
+
_logger = logging.getLogger("api.agent")
|
| 22 |
+
|
| 23 |
+
_RE_SURROGATES = re.compile(r"[\uD800-\uDFFF]", re.UNICODE)
|
| 24 |
+
def _ss(s: object) -> str:
|
| 25 |
+
if not isinstance(s, str):
|
| 26 |
+
return s
|
| 27 |
+
try:
|
| 28 |
+
cleaned = _RE_SURROGATES.sub("", s)
|
| 29 |
+
cleaned = cleaned.encode("utf-8", errors="replace").decode("utf-8", errors="replace")
|
| 30 |
+
except Exception:
|
| 31 |
+
cleaned = s
|
| 32 |
+
return cleaned
|
| 33 |
+
def _log_task_exc(task):
|
| 34 |
+
if not task.cancelled():
|
| 35 |
+
exc = task.exception()
|
| 36 |
+
if exc:
|
| 37 |
+
_logger.warning("[agent] background task raised %s: %s", type(exc).__name__, exc)
|
| 38 |
+
try:
|
| 39 |
+
from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
|
| 40 |
+
except Exception:
|
| 41 |
+
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
|
| 42 |
+
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 43 |
+
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 44 |
+
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 45 |
+
|
| 46 |
+
router = APIRouter()
|
| 47 |
+
|
| 48 |
+
@router.post('/run_loop', deprecated=True)
|
| 49 |
+
async def run_loop():
|
| 50 |
+
"""Deprecated β use /agent/task instead."""
|
| 51 |
+
from fastapi.responses import JSONResponse
|
| 52 |
+
return JSONResponse(status_code=410, content={"detail": {"error": "Gone", "migration": "/api/agent/tasks"}})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# βββ P17-F5: Persona helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
import re as _re_persona
|
| 57 |
+
|
| 58 |
+
_PERSONA_KEYWORD_MAP: dict = {}
|
| 59 |
+
|
| 60 |
+
def _build_persona_kw_map() -> dict:
|
| 61 |
+
import re
|
| 62 |
+
return {
|
| 63 |
+
'researcher': re.compile(
|
| 64 |
+
r'\b(cerca|ricerca|research|trova|notizie|news|url|leggi|articolo|wikipedia|'
|
| 65 |
+
r'google|fonte|source|scrape|fetch|sito|pagina|web|http|verifica|fact.?check)\b',
|
| 66 |
+
re.IGNORECASE
|
| 67 |
+
),
|
| 68 |
+
'coder': re.compile(
|
| 69 |
+
r'\b(codice|code|funzione|function|bug|script|implementa|python|javascript|'
|
| 70 |
+
r'typescript|refactor|debug|test|classe|class|api|endpoint|sql|database|html|'
|
| 71 |
+
r'css|react|app|applicazione|programma|sviluppa)\b',
|
| 72 |
+
re.IGNORECASE
|
| 73 |
+
),
|
| 74 |
+
'reasoner': re.compile(
|
| 75 |
+
r'\b(analizza|pianifica|strategia|decide|ragiona|valuta|confronta|'
|
| 76 |
+
r'piano|roadmap|architettura|valutazione|decisione|ottimale|consiglia)\b',
|
| 77 |
+
re.IGNORECASE
|
| 78 |
+
),
|
| 79 |
+
'analyst': re.compile(
|
| 80 |
+
r'\b(dati|statistiche|grafico|dataset|csv|dataframe|pandas|matplotlib|'
|
| 81 |
+
r'metriche|kpi|trend|visualizza|dashboard|excel|tabella|percentuale|distribuzione)\b',
|
| 82 |
+
re.IGNORECASE
|
| 83 |
+
),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
def _classify_persona_server(goal: str) -> str:
|
| 87 |
+
"""P17-F5: classifica la persona dal goal via regex scoring. Zero LLM β zero latency."""
|
| 88 |
+
global _PERSONA_KEYWORD_MAP
|
| 89 |
+
if not _PERSONA_KEYWORD_MAP:
|
| 90 |
+
_PERSONA_KEYWORD_MAP = _build_persona_kw_map()
|
| 91 |
+
if not goal or len(goal) < 4:
|
| 92 |
+
return ''
|
| 93 |
+
best, best_score = '', 0
|
| 94 |
+
for persona_id, pattern in _PERSONA_KEYWORD_MAP.items():
|
| 95 |
+
score = len(pattern.findall(goal))
|
| 96 |
+
if score > best_score:
|
| 97 |
+
best_score, best = score, persona_id
|
| 98 |
+
return best if best_score >= 1 else ''
|
| 99 |
+
|
| 100 |
+
_PERSONA_CLIENT_CACHE: dict = {}
|
| 101 |
+
|
| 102 |
+
def _get_persona_llm_client(persona: str, default_client: object) -> object:
|
| 103 |
+
"""P17-F5: ritorna il client LLM persona-appropriate via role_router.
|
| 104 |
+
Fallback silente su default_client se la chiave API manca o role_router fallisce.
|
| 105 |
+
Cache in-process β zero overhead dopo il primo accesso.""";
|
| 106 |
+
if not persona:
|
| 107 |
+
return default_client
|
| 108 |
+
if persona in _PERSONA_CLIENT_CACHE:
|
| 109 |
+
return _PERSONA_CLIENT_CACHE[persona]
|
| 110 |
+
_ROLE_MAP = {'researcher': 'RESEARCHER', 'analyst': 'RESEARCHER',
|
| 111 |
+
'coder': 'CODER', 'reasoner': 'REASONER', 'architect': 'ARCHITECT'}
|
| 112 |
+
role_name = _ROLE_MAP.get(persona.lower())
|
| 113 |
+
if not role_name:
|
| 114 |
+
return default_client
|
| 115 |
+
try:
|
| 116 |
+
from models.role_router import RoleRouter, Role as _Role
|
| 117 |
+
role = getattr(_Role, role_name, None)
|
| 118 |
+
if role is None:
|
| 119 |
+
return default_client
|
| 120 |
+
client = RoleRouter.get_client(role)
|
| 121 |
+
_PERSONA_CLIENT_CACHE[persona] = client
|
| 122 |
+
return client
|
| 123 |
+
except Exception:
|
| 124 |
+
return default_client
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
|
api/admin_state.py
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
"""Stato operativo amministrativo protetto da JWT Supabase admin."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from datetime import datetime, timedelta, timezone
|
| 5 |
-
from typing import Any
|
| 6 |
-
|
| 7 |
-
from fastapi import APIRouter, Depends, Query
|
| 8 |
-
|
| 9 |
-
from .auth_guard import require_admin_user
|
| 10 |
-
from .private_state import _MAX_TASK_PAGE, _as_epoch_ms, _call, _json_object
|
| 11 |
-
|
| 12 |
-
router = APIRouter(
|
| 13 |
-
prefix="/api/admin/state",
|
| 14 |
-
tags=["admin"],
|
| 15 |
-
dependencies=[Depends(require_admin_user)],
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
@router.get("/sessions")
|
| 20 |
-
async def admin_sessions(
|
| 21 |
-
max_age_ms: int = Query(default=300_000, ge=10_000, le=3_600_000),
|
| 22 |
-
limit: int = Query(default=100, ge=1, le=200),
|
| 23 |
-
) -> dict[str, object]:
|
| 24 |
-
cutoff = (datetime.now(timezone.utc) - timedelta(milliseconds=max_age_ms)).isoformat()
|
| 25 |
-
|
| 26 |
-
def operation(client: Any):
|
| 27 |
-
return client.table("agent_tasks").select("task_id,context,updated_at").eq("status", "__session__").gte("updated_at", cutoff).order("updated_at", desc=True).limit(limit).execute()
|
| 28 |
-
|
| 29 |
-
result = await _call(operation)
|
| 30 |
-
sessions = []
|
| 31 |
-
for row in result.data or []:
|
| 32 |
-
context = _json_object(row.get("context"))
|
| 33 |
-
session_id = str(context.get("sessionId") or row.get("task_id") or "").strip()
|
| 34 |
-
if not session_id:
|
| 35 |
-
continue
|
| 36 |
-
claimed = context.get("claimedFiles")
|
| 37 |
-
sessions.append({
|
| 38 |
-
"session_id": session_id,
|
| 39 |
-
"session_name": str(context.get("sessionName") or session_id)[:160],
|
| 40 |
-
"sprint": str(context["sprint"])[:120] if context.get("sprint") else None,
|
| 41 |
-
"claimed_files": [str(item)[:300] for item in claimed[:100]] if isinstance(claimed, list) else [],
|
| 42 |
-
"last_heartbeat": _as_epoch_ms(context.get("lastHeartbeat")) or _as_epoch_ms(row.get("updated_at")),
|
| 43 |
-
"current_task": str(context["currentTask"])[:500] if context.get("currentTask") else None,
|
| 44 |
-
})
|
| 45 |
-
return {"sessions": sessions}
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@router.get("/tasks")
|
| 49 |
-
async def admin_tasks(
|
| 50 |
-
limit: int = Query(default=20, ge=1, le=_MAX_TASK_PAGE),
|
| 51 |
-
offset: int = Query(default=0, ge=0, le=10_000),
|
| 52 |
-
status: str | None = Query(default=None, max_length=64),
|
| 53 |
-
) -> dict[str, object]:
|
| 54 |
-
normalized_status = status.strip().upper() if status else ""
|
| 55 |
-
|
| 56 |
-
def operation(client: Any):
|
| 57 |
-
query = client.table("agent_tasks").select("task_id,goal,status,updated_at").neq("status", "__session__").neq("status", "__config__")
|
| 58 |
-
if normalized_status:
|
| 59 |
-
query = query.eq("status", normalized_status)
|
| 60 |
-
page = query.order("updated_at", desc=True).range(offset, offset + limit - 1).execute()
|
| 61 |
-
all_statuses = client.table("agent_tasks").select("status").neq("status", "__session__").neq("status", "__config__").limit(2_000).execute()
|
| 62 |
-
return page, all_statuses
|
| 63 |
-
|
| 64 |
-
page, all_statuses = await _call(operation)
|
| 65 |
-
counts: dict[str, int] = {}
|
| 66 |
-
for row in all_statuses.data or []:
|
| 67 |
-
key = str(row.get("status") or "UNKNOWN").upper()
|
| 68 |
-
counts[key] = counts.get(key, 0) + 1
|
| 69 |
-
tasks = [{
|
| 70 |
-
"task_id": str(row.get("task_id") or ""),
|
| 71 |
-
"goal": str(row.get("goal") or "")[:1_000],
|
| 72 |
-
"status": str(row.get("status") or "UNKNOWN"),
|
| 73 |
-
"updated_at": _as_epoch_ms(row.get("updated_at")),
|
| 74 |
-
} for row in page.data or []]
|
| 75 |
-
return {"tasks": tasks, "counts": counts, "offset": offset, "limit": limit}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/advanced_complex_benchmark.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/advanced_complex_benchmark.py β Stress test per task complessi e concorrenza.
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
import random
|
| 7 |
+
import statistics
|
| 8 |
+
from .execution_fabric import ExecutionFabric, DispatchRequest, ProviderKind, ProviderSpec, AlwaysOn, ProviderHealth
|
| 9 |
+
from .token_rotator import rotator
|
| 10 |
+
|
| 11 |
+
class AdvancedBenchmark:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.fabric = ExecutionFabric()
|
| 14 |
+
self.metrics = {
|
| 15 |
+
"complex_reasoning": [],
|
| 16 |
+
"data_heavy_sync": [],
|
| 17 |
+
"multi_provider_chain": [],
|
| 18 |
+
"failures": 0,
|
| 19 |
+
"successes": 0
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async def setup(self):
|
| 23 |
+
# Configurazione flotta
|
| 24 |
+
for char in ['a', 'b', 'c', 'd']:
|
| 25 |
+
self.fabric._specs[f"sb-{char}"] = ProviderSpec(
|
| 26 |
+
provider_id=f"sb-{char}", name=f"Supabase {char.upper()}", kind=ProviderKind.LOCAL,
|
| 27 |
+
capabilities=["memory"], always_on=AlwaysOn.YES
|
| 28 |
+
)
|
| 29 |
+
self.fabric._states[f"sb-{char}"] = type('State', (), {"health": ProviderHealth.OK})()
|
| 30 |
+
|
| 31 |
+
self.fabric._specs["oracle-core"] = ProviderSpec(
|
| 32 |
+
provider_id="oracle-core", name="Oracle Core", kind=ProviderKind.ORACLE,
|
| 33 |
+
capabilities=["reasoning", "sandbox"], always_on=AlwaysOn.YES, timeout=60.0
|
| 34 |
+
)
|
| 35 |
+
self.fabric._states["oracle-core"] = type('State', (), {"health": ProviderHealth.OK})()
|
| 36 |
+
|
| 37 |
+
self.fabric._specs["railway-core"] = ProviderSpec(
|
| 38 |
+
provider_id="railway-core", name="Railway Space E", kind=ProviderKind.RAILWAY,
|
| 39 |
+
capabilities=["reasoning", "sandbox"], always_on=AlwaysOn.YES
|
| 40 |
+
)
|
| 41 |
+
self.fabric._states["railway-core"] = type('State', (), {"health": ProviderHealth.OK})()
|
| 42 |
+
|
| 43 |
+
self.fabric._initialized = True
|
| 44 |
+
|
| 45 |
+
async def _simulate_call(self, spec, req):
|
| 46 |
+
# Simula complessitΓ variabile
|
| 47 |
+
if "reasoning" in spec.capabilities:
|
| 48 |
+
await asyncio.sleep(random.uniform(0.5, 2.0)) # Calcolo pesante
|
| 49 |
+
if random.random() < 0.05: # 5% probabilitΓ di errore casuale
|
| 50 |
+
raise Exception("Random Provider Glitch")
|
| 51 |
+
if spec.provider_id == "sb-a" and random.random() < 0.3:
|
| 52 |
+
return {"status_code": 402} # 30% probabilitΓ rate limit su A
|
| 53 |
+
return {"status": "ok", "provider": spec.name}
|
| 54 |
+
|
| 55 |
+
async def run_complex_task(self, name, capability, count=20):
|
| 56 |
+
print(f"βοΈ Esecuzione: {name} ({count} task)...")
|
| 57 |
+
tasks = []
|
| 58 |
+
for _ in range(count):
|
| 59 |
+
req = DispatchRequest(capability=capability, payload={"complexity": "high"})
|
| 60 |
+
tasks.append(self.fabric.dispatch(req))
|
| 61 |
+
|
| 62 |
+
t0 = time.time()
|
| 63 |
+
results = await asyncio.gather(*tasks)
|
| 64 |
+
duration = (time.time() - t0) * 1000
|
| 65 |
+
|
| 66 |
+
latencies = []
|
| 67 |
+
for r in results:
|
| 68 |
+
if r.status == "executed":
|
| 69 |
+
self.metrics["successes"] += 1
|
| 70 |
+
latencies.append(r.latency_ms)
|
| 71 |
+
else:
|
| 72 |
+
self.metrics["failures"] += 1
|
| 73 |
+
|
| 74 |
+
self.metrics[name] = latencies
|
| 75 |
+
|
| 76 |
+
def print_stats(self):
|
| 77 |
+
print("\n" + "="*50)
|
| 78 |
+
print("π REPORT AVANZATO PRESTAZIONI SISTEMA")
|
| 79 |
+
print("="*50)
|
| 80 |
+
|
| 81 |
+
total = self.metrics["successes"] + self.metrics["failures"]
|
| 82 |
+
print(f"Success Rate Totale: {(self.metrics['successes']/total)*100:.2f}% ({self.metrics['successes']}/{total})")
|
| 83 |
+
|
| 84 |
+
for name in ["complex_reasoning", "data_heavy_sync"]:
|
| 85 |
+
data = self.metrics.get(name, [])
|
| 86 |
+
if data:
|
| 87 |
+
print(f"\n[{name.upper()}]")
|
| 88 |
+
print(f" - Media Latenza: {statistics.mean(data):.2f}ms")
|
| 89 |
+
print(f" - P95: {statistics.quantiles(data, n=20)[18]:.2f}ms")
|
| 90 |
+
print(f" - P99: {max(data):.2f}ms")
|
| 91 |
+
print(f" - Efficienza: {len(data)} task completati con successo")
|
| 92 |
+
|
| 93 |
+
print("\n" + "="*50)
|
| 94 |
+
|
| 95 |
+
async def run(self):
|
| 96 |
+
await self.setup()
|
| 97 |
+
self.fabric._call_provider = self._simulate_call
|
| 98 |
+
|
| 99 |
+
# Scenario 1: Ragionamento Complesso (Oracle/Railway)
|
| 100 |
+
await self.run_complex_task("complex_reasoning", "reasoning", count=30)
|
| 101 |
+
|
| 102 |
+
# Scenario 2: Sincronizzazione Dati (Supabase A-D)
|
| 103 |
+
await self.run_complex_task("data_heavy_sync", "memory", count=50)
|
| 104 |
+
|
| 105 |
+
self.print_stats()
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
asyncio.run(AdvancedBenchmark().run())
|
api/agent.py
CHANGED
|
@@ -42,30 +42,16 @@ def _sanitize_for_json(obj: object) -> object:
|
|
| 42 |
from fastapi import APIRouter, Depends, HTTPException, Request, Body
|
| 43 |
from fastapi.responses import StreamingResponse
|
| 44 |
from .auth_guard import require_role, AuthRole
|
| 45 |
-
from pydantic import BaseModel,
|
| 46 |
from typing import Literal
|
| 47 |
-
|
| 48 |
-
# P0 JSON contract: expose structured JSON only after schema validation.
|
| 49 |
-
_STRUCTURED_JSON_ADAPTER = TypeAdapter(dict[str, object] | list[object])
|
| 50 |
-
|
| 51 |
-
def _validated_response_json(output: object) -> dict[str, object] | list[object] | None:
|
| 52 |
-
if not isinstance(output, str) or not output.strip():
|
| 53 |
-
return None
|
| 54 |
-
try:
|
| 55 |
-
parsed = json.loads(output)
|
| 56 |
-
return _STRUCTURED_JSON_ADAPTER.validate_python(parsed)
|
| 57 |
-
except (json.JSONDecodeError, ValidationError, TypeError, ValueError):
|
| 58 |
-
return None
|
| 59 |
-
|
| 60 |
from .state import (
|
| 61 |
-
_agent_tasks,
|
| 62 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 63 |
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 64 |
ReasonLoopIn, AgentTaskIn,
|
|
|
|
| 65 |
)
|
| 66 |
from .speculative import fire_speculative_tools
|
| 67 |
-
from .vfs_sync import build_vfs_sync_complete
|
| 68 |
-
from .task_tool_policy import build_task_tool_policy
|
| 69 |
try:
|
| 70 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 71 |
except Exception:
|
|
@@ -102,98 +88,6 @@ except Exception:
|
|
| 102 |
router = APIRouter()
|
| 103 |
|
| 104 |
|
| 105 |
-
class _LLMAdmission:
|
| 106 |
-
"""Bounded in-process admission control for LLM loops."""
|
| 107 |
-
def __init__(self) -> None:
|
| 108 |
-
self.max_concurrency = max(1, int(os.getenv('LLM_MAX_CONCURRENCY', '2')))
|
| 109 |
-
self.max_queue = max(0, int(os.getenv('LLM_MAX_QUEUE', '8')))
|
| 110 |
-
self._condition = asyncio.Condition()
|
| 111 |
-
self._active = 0
|
| 112 |
-
self._queued = 0
|
| 113 |
-
|
| 114 |
-
async def reserve(self) -> bool:
|
| 115 |
-
async with self._condition:
|
| 116 |
-
if self._active + self._queued >= self.max_concurrency + self.max_queue:
|
| 117 |
-
return False
|
| 118 |
-
self._queued += 1
|
| 119 |
-
return True
|
| 120 |
-
|
| 121 |
-
async def acquire(self) -> None:
|
| 122 |
-
async with self._condition:
|
| 123 |
-
try:
|
| 124 |
-
while self._active >= self.max_concurrency:
|
| 125 |
-
await self._condition.wait()
|
| 126 |
-
self._queued = max(0, self._queued - 1)
|
| 127 |
-
self._active += 1
|
| 128 |
-
except BaseException:
|
| 129 |
-
self._queued = max(0, self._queued - 1)
|
| 130 |
-
self._condition.notify(1)
|
| 131 |
-
raise
|
| 132 |
-
|
| 133 |
-
async def cancel(self) -> None:
|
| 134 |
-
async with self._condition:
|
| 135 |
-
self._queued = max(0, self._queued - 1)
|
| 136 |
-
self._condition.notify(1)
|
| 137 |
-
|
| 138 |
-
async def release(self) -> None:
|
| 139 |
-
async with self._condition:
|
| 140 |
-
self._active = max(0, self._active - 1)
|
| 141 |
-
self._condition.notify(1)
|
| 142 |
-
|
| 143 |
-
async def snapshot(self) -> dict[str, int]:
|
| 144 |
-
async with self._condition:
|
| 145 |
-
return {
|
| 146 |
-
'active': self._active,
|
| 147 |
-
'queued': self._queued,
|
| 148 |
-
'max_concurrency': self.max_concurrency,
|
| 149 |
-
'max_queue': self.max_queue,
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
_LLM_ADMISSION = _LLMAdmission()
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
async def _record_phase(phase: str, duration_ms: float = 0.0,
|
| 157 |
-
outcome: str = 'ok', error_class: str | None = None) -> None:
|
| 158 |
-
try:
|
| 159 |
-
from .agent_telemetry import record_runtime_phase
|
| 160 |
-
await record_runtime_phase(phase, duration_ms, outcome, error_class)
|
| 161 |
-
except Exception:
|
| 162 |
-
pass
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def _queue_full_error() -> HTTPException:
|
| 166 |
-
return HTTPException(
|
| 167 |
-
status_code=429,
|
| 168 |
-
detail={
|
| 169 |
-
'error': 'llm_queue_full',
|
| 170 |
-
'message': 'Coda LLM satura. Riprova tra pochi secondi.',
|
| 171 |
-
'retry_after_seconds': 5,
|
| 172 |
-
},
|
| 173 |
-
headers={'Retry-After': '5'},
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
@router.get('/api/agent/queue-status')
|
| 178 |
-
async def agent_queue_status(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
| 179 |
-
return await _LLM_ADMISSION.snapshot()
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def _attach_byok_client(task_id: str, credentials: object) -> None:
|
| 183 |
-
"""Create a task-scoped LLM client without persisting or logging credentials."""
|
| 184 |
-
if credentials is None:
|
| 185 |
-
return
|
| 186 |
-
try:
|
| 187 |
-
runtime_config = credentials.as_runtime_config()
|
| 188 |
-
if not runtime_config:
|
| 189 |
-
return
|
| 190 |
-
from models.ai_client import AIClient
|
| 191 |
-
_task_ai_clients[task_id] = AIClient(byok_credentials=runtime_config)
|
| 192 |
-
except Exception as exc:
|
| 193 |
-
# Never include the payload or credential values in diagnostics.
|
| 194 |
-
_logger.warning("[agent] unable to initialise BYOK task client: %s", type(exc).__name__)
|
| 195 |
-
|
| 196 |
-
|
| 197 |
# ββ Deprecated run_loop βββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββ
|
| 198 |
|
| 199 |
@router.post('/run_loop', deprecated=True)
|
|
@@ -294,17 +188,10 @@ async def agent_run_stream(
|
|
| 294 |
body: ReasonLoopIn, request: Request,
|
| 295 |
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
|
| 296 |
):
|
| 297 |
-
await _record_phase('auth')
|
| 298 |
-
if not await _LLM_ADMISSION.reserve():
|
| 299 |
-
await _record_phase('queue', outcome='rejected', error_class='queue_full')
|
| 300 |
-
raise _queue_full_error()
|
| 301 |
-
await _record_phase('queue')
|
| 302 |
async def generate():
|
| 303 |
queue: asyncio.Queue = asyncio.Queue()
|
| 304 |
|
| 305 |
async def step_cb(step: dict) -> None:
|
| 306 |
-
if str(step.get('action', '')).startswith(('tool', 'executor:')):
|
| 307 |
-
await _record_phase('tool', outcome='ok')
|
| 308 |
await queue.put(step)
|
| 309 |
|
| 310 |
async def run_loop() -> None:
|
|
@@ -344,19 +231,16 @@ async def agent_run_stream(
|
|
| 344 |
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 345 |
if _neg_c:
|
| 346 |
context_str = f"[VINCOLI OPERATIVI APPRESI β NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
| 347 |
-
_provider_started = time.perf_counter()
|
| 348 |
result = await loop.run(
|
| 349 |
goal=body.goal, context=context_str,
|
| 350 |
max_steps=body.max_steps, on_step=step_cb,
|
| 351 |
session_id=getattr(body, "session_id", "") or "",
|
| 352 |
)
|
| 353 |
-
await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
|
| 354 |
await queue.put({
|
| 355 |
'__done__': True,
|
| 356 |
'result': result.get('output', ''),
|
| 357 |
'engine': result.get('engine', 'fallback'),
|
| 358 |
'success': result.get('success', False),
|
| 359 |
-
'response_json': _validated_response_json(result.get('output', '')),
|
| 360 |
})
|
| 361 |
except Exception as exc:
|
| 362 |
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
|
@@ -370,13 +254,7 @@ async def agent_run_stream(
|
|
| 370 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 371 |
await queue.put({'__error__': str(exc)})
|
| 372 |
|
| 373 |
-
|
| 374 |
-
await _LLM_ADMISSION.acquire()
|
| 375 |
-
try:
|
| 376 |
-
await run_loop()
|
| 377 |
-
finally:
|
| 378 |
-
await _LLM_ADMISSION.release()
|
| 379 |
-
task = asyncio.create_task(_admitted_run_loop())
|
| 380 |
task.add_done_callback(_log_task_exc) # BUG-CB-1
|
| 381 |
task_id = str(uuid.uuid4())
|
| 382 |
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
|
@@ -439,13 +317,7 @@ async def agent_run_stream(
|
|
| 439 |
_err_detail = _ss(item.get('error', ''))
|
| 440 |
_final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
|
| 441 |
else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
|
| 442 |
-
|
| 443 |
-
'type': 'task_done', 'taskId': task_id, 'result': _final_res,
|
| 444 |
-
'engine': item.get('engine', 'fallback'), 'success': item.get('success', False),
|
| 445 |
-
}
|
| 446 |
-
if item.get('response_json') is not None:
|
| 447 |
-
_done_payload['response_json'] = _sanitize_for_json(item['response_json'])
|
| 448 |
-
yield f"data: {json.dumps(_done_payload)}\n\n"
|
| 449 |
break
|
| 450 |
# S393 Priority 1: Narrative Streaming β arricchisce step_done con explanation
|
| 451 |
_NARR_QUICK = {
|
|
@@ -568,19 +440,7 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
|
|
| 568 |
'action': step_data.get('action', ''),
|
| 569 |
'output': str(step_data.get('output', ''))[:400], # S577: 200β400
|
| 570 |
})
|
| 571 |
-
|
| 572 |
-
raise _queue_full_error()
|
| 573 |
-
await _LLM_ADMISSION.acquire()
|
| 574 |
-
_provider_started = time.perf_counter()
|
| 575 |
-
try:
|
| 576 |
-
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
|
| 577 |
-
except Exception as exc:
|
| 578 |
-
await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000, 'error', type(exc).__name__)
|
| 579 |
-
raise
|
| 580 |
-
else:
|
| 581 |
-
await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
|
| 582 |
-
finally:
|
| 583 |
-
await _LLM_ADMISSION.release()
|
| 584 |
if isinstance(result, dict):
|
| 585 |
output_text = result.get('output', '') or ''
|
| 586 |
engine_used = result.get('engine', 'unknown')
|
|
@@ -589,7 +449,6 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
|
|
| 589 |
output_text = str(result)
|
| 590 |
engine_used = 'unknown'
|
| 591 |
errors_list = []
|
| 592 |
-
response_json = _validated_response_json(output_text)
|
| 593 |
return {
|
| 594 |
'ok': bool(output_text and output_text.strip()),
|
| 595 |
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
|
@@ -599,7 +458,6 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
|
|
| 599 |
'engine': engine_used,
|
| 600 |
'errors': errors_list,
|
| 601 |
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
| 602 |
-
**({'response_json': response_json} if response_json is not None else {}),
|
| 603 |
}
|
| 604 |
except Exception as e:
|
| 605 |
_logger.error("[reason/loop] Error: %s", e)
|
|
@@ -665,12 +523,12 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
|
|
| 665 |
'goal': goal,
|
| 666 |
'mode': mode,
|
| 667 |
'dispatch_id': _dispatch_id,
|
| 668 |
-
'metadata': {'workflow': 'agent-kernel.yml'},
|
| 669 |
},
|
| 670 |
priority='HIGH',
|
|
|
|
| 671 |
)).add_done_callback(_log_task_exc)
|
| 672 |
asyncio.create_task(_kernel.publish_event(
|
| 673 |
-
|
| 674 |
payload={'goal': goal[:200], 'mode': mode},
|
| 675 |
)).add_done_callback(_log_task_exc)
|
| 676 |
import httpx as _httpx
|
|
@@ -694,44 +552,8 @@ async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = De
|
|
| 694 |
|
| 695 |
# ββ Agent tasks (FASE 2.1 + S359 persistence) ββββββββββββββββββββββββββββββββββ
|
| 696 |
|
| 697 |
-
async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
|
| 698 |
-
"""
|
| 699 |
-
Versione interna di create_agent_task per uso da job_queue (GAP-1-fix).
|
| 700 |
-
Non richiede FastAPI body nΓ© dipendenze auth β chiamabile direttamente.
|
| 701 |
-
"""
|
| 702 |
-
_prune_agent_tasks()
|
| 703 |
-
if task_id in _agent_tasks:
|
| 704 |
-
return {"taskId": task_id, "status": _agent_tasks[task_id]["status"]}
|
| 705 |
-
created_at = int(time.time() * 1000)
|
| 706 |
-
_agent_tasks[task_id] = {
|
| 707 |
-
"id": task_id,
|
| 708 |
-
"status": "QUEUED",
|
| 709 |
-
"goal": goal,
|
| 710 |
-
"context": job.get("context", {}),
|
| 711 |
-
"max_steps": job.get("max_steps", 20),
|
| 712 |
-
"created_at": created_at,
|
| 713 |
-
"session_id": job.get("session_id", ""),
|
| 714 |
-
}
|
| 715 |
-
asyncio.create_task(
|
| 716 |
-
sb_upsert_task(task_id, goal, "QUEUED", job.get("max_steps", 20), job.get("context", {}), created_at)
|
| 717 |
-
).add_done_callback(_log_task_exc)
|
| 718 |
-
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 719 |
-
asyncio.create_task(_kernel.submit_task(
|
| 720 |
-
payload={
|
| 721 |
-
"task_id": task_id,
|
| 722 |
-
"goal": goal,
|
| 723 |
-
"max_steps": job.get("max_steps", 20),
|
| 724 |
-
"source": "job_queue",
|
| 725 |
-
"metadata": {"job_queue": True},
|
| 726 |
-
},
|
| 727 |
-
priority="NORMAL",
|
| 728 |
-
session_id=job.get("session_id", ""),
|
| 729 |
-
)).add_done_callback(_log_task_exc)
|
| 730 |
-
return {"taskId": task_id, "status": "QUEUED"}
|
| 731 |
-
|
| 732 |
-
|
| 733 |
@router.post('/api/agent/tasks')
|
| 734 |
-
async def create_agent_task(body: AgentTaskIn,
|
| 735 |
"""
|
| 736 |
Crea o recupera un task agent.
|
| 737 |
|
|
@@ -739,29 +561,11 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 739 |
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 740 |
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 741 |
"""
|
| 742 |
-
await _record_phase('auth')
|
| 743 |
_prune_agent_tasks()
|
| 744 |
-
|
| 745 |
-
if len(raw_idempotency_key) > 200:
|
| 746 |
-
raise HTTPException(400, detail={'error': 'invalid_idempotency_key'})
|
| 747 |
-
task_id = body.taskId or (
|
| 748 |
-
str(uuid.uuid5(uuid.NAMESPACE_URL, f'baida98-ai:{body.session_id or ""}:{raw_idempotency_key}'))
|
| 749 |
-
if raw_idempotency_key else str(uuid.uuid4())
|
| 750 |
-
)
|
| 751 |
-
_idempotency_claimed = False
|
| 752 |
-
if raw_idempotency_key and task_id not in _agent_tasks:
|
| 753 |
-
# Claim before the first await: concurrent retries in this worker see the
|
| 754 |
-
# same task immediately and cannot create a second provider loop/write.
|
| 755 |
-
_agent_tasks[task_id] = {
|
| 756 |
-
'id': task_id, 'status': 'CREATING',
|
| 757 |
-
'idempotency_key': raw_idempotency_key,
|
| 758 |
-
}
|
| 759 |
-
_idempotency_claimed = True
|
| 760 |
|
| 761 |
# Already in memory β return immediately (normal path, includes S358 reconnect)
|
| 762 |
-
if task_id in _agent_tasks
|
| 763 |
-
if task_id not in _task_ai_clients:
|
| 764 |
-
_attach_byok_client(task_id, body.provider_credentials)
|
| 765 |
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 766 |
|
| 767 |
# S359: try Supabase lazy restore (only hit network after backend restart)
|
|
@@ -771,12 +575,9 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 771 |
# Use context from the incoming request (not persisted to save space).
|
| 772 |
restored['context'] = body.context
|
| 773 |
_agent_tasks[task_id] = restored
|
| 774 |
-
_attach_byok_client(task_id, body.provider_credentials)
|
| 775 |
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 776 |
|
| 777 |
-
# Brand new task
|
| 778 |
-
# tool speculativo, pianificazione o chiamata al loop.
|
| 779 |
-
_tool_policy = build_task_tool_policy(body.goal)
|
| 780 |
created_at = int(time.time() * 1000)
|
| 781 |
_agent_tasks[task_id] = {
|
| 782 |
'id': task_id,
|
|
@@ -790,14 +591,10 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 790 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 791 |
'persona': body.persona, # P17-F5: expertise persona hint
|
| 792 |
'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ'')
|
| 793 |
-
'idempotency_key': raw_idempotency_key,
|
| 794 |
-
'forbid_tools': _tool_policy.forbid_tools,
|
| 795 |
-
'literal_response': _tool_policy.literal_response,
|
| 796 |
-
'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
|
| 797 |
}
|
| 798 |
-
#
|
| 799 |
-
#
|
| 800 |
-
|
| 801 |
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 802 |
if body.session_id:
|
| 803 |
_hctx = await sb_restore_handoff_context(body.session_id)
|
|
@@ -808,11 +605,9 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 808 |
asyncio.create_task(
|
| 809 |
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 810 |
).add_done_callback(_log_task_exc)
|
| 811 |
-
|
| 812 |
-
#
|
| 813 |
-
|
| 814 |
-
if not _tool_policy.forbid_tools:
|
| 815 |
-
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 816 |
# ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
|
| 817 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 818 |
asyncio.create_task(_kernel.submit_task(
|
|
@@ -822,13 +617,13 @@ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole
|
|
| 822 |
'max_steps': body.max_steps,
|
| 823 |
'persona': body.persona,
|
| 824 |
'source': 'agent_api',
|
| 825 |
-
'metadata': {'agent_api': True},
|
| 826 |
},
|
| 827 |
priority='NORMAL',
|
| 828 |
session_id=body.session_id,
|
|
|
|
| 829 |
)).add_done_callback(_log_task_exc)
|
| 830 |
asyncio.create_task(_kernel.publish_event(
|
| 831 |
-
|
| 832 |
payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
|
| 833 |
)).add_done_callback(_log_task_exc)
|
| 834 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
|
@@ -908,7 +703,6 @@ async def list_agent_tasks(limit: int = 50, status: str = '', role: AuthRole = D
|
|
| 908 |
async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 909 |
if task_id in _agent_tasks:
|
| 910 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 911 |
-
_task_ai_clients.pop(task_id, None)
|
| 912 |
reg = _loop_registry.get(task_id)
|
| 913 |
if reg and not reg.get('done'):
|
| 914 |
at = reg.get('asyncio_task')
|
|
@@ -959,7 +753,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 959 |
- Task era RUNNING β replay buffer parziale + evento task_interrupted.
|
| 960 |
- Task non trovato β prova sb_restore_task prima di 404.
|
| 961 |
"""
|
| 962 |
-
await _record_phase('auth')
|
| 963 |
# S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
|
| 964 |
if task_id not in _agent_tasks:
|
| 965 |
restored = await sb_restore_task(task_id)
|
|
@@ -970,17 +763,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 970 |
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 971 |
|
| 972 |
task = _agent_tasks[task_id]
|
| 973 |
-
# I task restaurati da persistenza potrebbero non contenere metadata runtime.
|
| 974 |
-
# Ricostruire la policy dal goal mantiene il resume fail-closed.
|
| 975 |
-
if (
|
| 976 |
-
"forbid_tools" not in task
|
| 977 |
-
or "literal_response" not in task
|
| 978 |
-
or "allow_local_csv_conversion" not in task
|
| 979 |
-
):
|
| 980 |
-
_restored_policy = build_task_tool_policy(task.get("goal", ""))
|
| 981 |
-
task["forbid_tools"] = _restored_policy.forbid_tools
|
| 982 |
-
task["literal_response"] = _restored_policy.literal_response
|
| 983 |
-
task["allow_local_csv_conversion"] = _restored_policy.allow_local_csv_conversion
|
| 984 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 985 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 986 |
|
|
@@ -989,19 +771,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 989 |
async def generate():
|
| 990 |
yield "retry: 3000\n\n"
|
| 991 |
|
| 992 |
-
# Contratto letterale: chiusura immediata prima di task_start, planner o
|
| 993 |
-
# tool. Γ il backstop per client SSE che non applicano il fast path UI.
|
| 994 |
-
_literal_response = task.get('literal_response')
|
| 995 |
-
if isinstance(_literal_response, str) and _literal_response:
|
| 996 |
-
_agent_tasks[task_id]['status'] = 'COMPLETED'
|
| 997 |
-
asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
|
| 998 |
-
literal_event = json.dumps(_sanitize_for_json({
|
| 999 |
-
'event': 'task_done', 'taskId': task_id, 'result': _literal_response,
|
| 1000 |
-
}))
|
| 1001 |
-
yield f"data: {literal_event}\n\n"
|
| 1002 |
-
yield "data: [DONE]\n\n"
|
| 1003 |
-
return
|
| 1004 |
-
|
| 1005 |
reg = _loop_registry.get(task_id)
|
| 1006 |
|
| 1007 |
is_done_reconnect = reg is not None and reg.get('done', False)
|
|
@@ -1043,7 +812,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1043 |
sb_events = await sb_get_events(task_id)
|
| 1044 |
if sb_events:
|
| 1045 |
task_status = task.get('status', 'UNKNOWN')
|
| 1046 |
-
terminal = task_status in ('
|
| 1047 |
# Replay buffer from resume point
|
| 1048 |
for evt_str in sb_events[_resume_from:]:
|
| 1049 |
yield evt_str
|
|
@@ -1096,18 +865,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1096 |
yield "data: [DONE]\n\n"
|
| 1097 |
return
|
| 1098 |
# ββ Case 3: nuova esecuzione ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1099 |
-
if not await _LLM_ADMISSION.reserve():
|
| 1100 |
-
await _record_phase('queue', outcome='rejected', error_class='queue_full')
|
| 1101 |
-
_agent_tasks[task_id]['status'] = 'RATE_LIMITED'
|
| 1102 |
-
_rate_limited = json.dumps(_sanitize_for_json({
|
| 1103 |
-
'event': 'task_error', 'taskId': task_id,
|
| 1104 |
-
'statusCode': 429, 'retryAfter': 5,
|
| 1105 |
-
'error': 'llm_queue_full',
|
| 1106 |
-
}))
|
| 1107 |
-
yield f'data: {_rate_limited}\n\n'
|
| 1108 |
-
yield 'data: [DONE]\n\n'
|
| 1109 |
-
return
|
| 1110 |
-
await _record_phase('queue')
|
| 1111 |
_prune_loop_registry()
|
| 1112 |
reg_entry: dict = {
|
| 1113 |
'asyncio_task': None,
|
|
@@ -1122,7 +879,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1122 |
def _sse(event: str, data: dict) -> None:
|
| 1123 |
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 1124 |
_ctr[0] += 1
|
| 1125 |
-
s = f"id: {_ctr[0]}\ndata: {json.dumps(
|
| 1126 |
# GAP-3-FIX: text_chunk bypass buffer β fanout diretto, no persist.
|
| 1127 |
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 1128 |
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
|
@@ -1150,26 +907,16 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1150 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1151 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1152 |
asyncio.create_task(_kernel.publish_event(
|
| 1153 |
-
|
| 1154 |
payload={'task_id': task_id, 'status': 'RUNNING'},
|
| 1155 |
)).add_done_callback(_log_task_exc)
|
| 1156 |
_prune_agent_tasks()
|
| 1157 |
|
| 1158 |
async def run_loop() -> None:
|
| 1159 |
try:
|
| 1160 |
-
# Contratto letterale: nessun provider, planner, tool, card o side effect.
|
| 1161 |
-
# Γ emesso direttamente nello stream affinchΓ© i client SSE non possano bypassarlo.
|
| 1162 |
-
_literal_response = task.get('literal_response')
|
| 1163 |
-
if isinstance(_literal_response, str) and _literal_response:
|
| 1164 |
-
_agent_tasks[task_id]['status'] = 'COMPLETED'
|
| 1165 |
-
asyncio.create_task(sb_update_status(task_id, 'COMPLETED')).add_done_callback(_log_task_exc)
|
| 1166 |
-
_sse('task_done', {'taskId': task_id, 'result': _literal_response})
|
| 1167 |
-
return
|
| 1168 |
-
|
| 1169 |
from agents.unified_loop import UnifiedAgentLoop
|
| 1170 |
-
#
|
| 1171 |
-
|
| 1172 |
-
client = _task_ai_clients.get(task_id) or _get_ai_client()
|
| 1173 |
try:
|
| 1174 |
from agents.critic import Critic
|
| 1175 |
from agents.response_verifier import ResponseVerifier
|
|
@@ -1202,24 +949,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1202 |
"Il frontend mostrerΓ automaticamente un pulsante 'Connetti' all'utente."
|
| 1203 |
)
|
| 1204 |
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
| 1205 |
-
# ARTIFACT-CONTRACT: le richieste di pagina/app richiedono un
|
| 1206 |
-
# artifact reale, non una risposta narrativa. Il frontend committa
|
| 1207 |
-
# solo file_written + vfs_sync_complete, quindi l'agente deve
|
| 1208 |
-
# scrivere e rileggere almeno un file prima di dichiarare successo.
|
| 1209 |
-
_artifact_goal = bool(re.search(
|
| 1210 |
-
r"\b(?:pagina|sito|website|webapp|app|mini[- ]app|ui|interfaccia)\b",
|
| 1211 |
-
task.get('goal', ''), re.IGNORECASE,
|
| 1212 |
-
))
|
| 1213 |
-
if _artifact_goal:
|
| 1214 |
-
_artifact_hint = (
|
| 1215 |
-
"[CONTRATTO ARTIFACT UI]\n"
|
| 1216 |
-
"Per questa richiesta devi creare davvero i file nel workspace usando write_file "
|
| 1217 |
-
"(non limitarti a proporre codice in chat). Dopo la scrittura, usa read_file o "
|
| 1218 |
-
"un controllo equivalente per verificare il contenuto. Dichiara completamento "
|
| 1219 |
-
"solo se la scrittura Γ¨ riuscita; in caso contrario segnala l'errore senza inventare "
|
| 1220 |
-
"un link o un'anteprima."
|
| 1221 |
-
)
|
| 1222 |
-
context_str = f"{_artifact_hint}\n\n{context_str}".strip()
|
| 1223 |
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 1224 |
# Bug: _resume_context era settato su task{} ma mai letto qui β context perduto su resume.
|
| 1225 |
_resume_ctx = task.get('_resume_context', '')
|
|
@@ -1290,49 +1019,21 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1290 |
_hctx = task.get("_handoff_context", "")
|
| 1291 |
if _hctx:
|
| 1292 |
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 1293 |
-
#
|
| 1294 |
-
|
| 1295 |
-
_is_byok_task = task_id in _task_ai_clients
|
| 1296 |
-
_persona_client = client if _is_byok_task else _get_persona_llm_client(_persona, client)
|
| 1297 |
-
_planner = _get_planner()
|
| 1298 |
-
if _is_byok_task:
|
| 1299 |
-
try:
|
| 1300 |
-
from agents.planner import Planner
|
| 1301 |
-
_planner = Planner(llm_client=client)
|
| 1302 |
-
except Exception as exc:
|
| 1303 |
-
_logger.warning("[agent] BYOK planner fallback: %s", type(exc).__name__)
|
| 1304 |
loop = UnifiedAgentLoop(
|
| 1305 |
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 1306 |
-
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=
|
| 1307 |
)
|
| 1308 |
step_idx = [0]
|
| 1309 |
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
| 1310 |
-
# P34/SYNC-1: i file arrivano nel frontend in staging; raccogliamo
|
| 1311 |
-
# soltanto quelli con contenuto così il commit atomico può avvenire
|
| 1312 |
-
# una sola volta dopo un task riuscito.
|
| 1313 |
-
_vfs_written_paths: set[str] = set()
|
| 1314 |
-
# Accumula soltanto i token realmente emessi per poter riconciliare
|
| 1315 |
-
# il testo in streaming con l'output autorevole del finalizer.
|
| 1316 |
-
_streamed_chunks: list[str] = []
|
| 1317 |
|
| 1318 |
async def step_cb(step_data: dict) -> None:
|
| 1319 |
step_idx[0] += 1
|
| 1320 |
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 1321 |
# S420: streaming token β emetti direttamente senza passare dal buffer step
|
| 1322 |
if _action == 'text_chunk':
|
| 1323 |
-
|
| 1324 |
-
if _token:
|
| 1325 |
-
_streamed_chunks.append(_token)
|
| 1326 |
-
_sse('text_chunk', {'taskId': task_id, 'token': _token})
|
| 1327 |
-
return
|
| 1328 |
-
# RECOV-P1: engineering_state event β forward projection to frontend
|
| 1329 |
-
if _action == 'engineering_state':
|
| 1330 |
-
_sse('engineering_state', {
|
| 1331 |
-
'taskId': task_id,
|
| 1332 |
-
'status': step_data.get('status'),
|
| 1333 |
-
'mode': step_data.get('mode'),
|
| 1334 |
-
'engineering_state': step_data.get('engineering_state'),
|
| 1335 |
-
})
|
| 1336 |
return
|
| 1337 |
|
| 1338 |
# S363-Blueprint: Narrative Streaming β explanation lookup for ALL step_done events
|
|
@@ -1442,26 +1143,10 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1442 |
step_data.get('output', '')[:500])
|
| 1443 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 1444 |
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 1445 |
-
# SYNC-1: includi content nel SSE event per file_written (β€60KB)
|
| 1446 |
-
#
|
| 1447 |
-
# inoltra soltanto l'URL HTTPS generato internamente, che il client
|
| 1448 |
-
# materializza come data URI nel proprio VFS prima del commit atomico.
|
| 1449 |
if _action == 'file_written' and step_data.get('content'):
|
| 1450 |
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
| 1451 |
-
_vfs_written_paths.add(str(_vfs_file)[:500])
|
| 1452 |
-
elif _action == 'file_written':
|
| 1453 |
-
_source_url = str(step_data.get('source_url') or '')
|
| 1454 |
-
_mime_type = str(step_data.get('mime_type') or '')
|
| 1455 |
-
_allowed_image_origins = (
|
| 1456 |
-
'https://image.pollinations.ai/',
|
| 1457 |
-
'https://media.pollinations.ai/',
|
| 1458 |
-
'https://gen.pollinations.ai/',
|
| 1459 |
-
)
|
| 1460 |
-
if (_source_url.startswith(_allowed_image_origins) and
|
| 1461 |
-
_mime_type in {'image/jpeg', 'image/png', 'image/webp'}):
|
| 1462 |
-
_vfs_evt['sourceUrl'] = _source_url[:2_000]
|
| 1463 |
-
_vfs_evt['mimeType'] = _mime_type
|
| 1464 |
-
_vfs_written_paths.add(str(_vfs_file)[:500])
|
| 1465 |
_sse('vfs_update', _vfs_evt)
|
| 1466 |
|
| 1467 |
# S363-UI: thought event β emitted when planner completes
|
|
@@ -1557,137 +1242,33 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1557 |
except Exception:
|
| 1558 |
pass # S364: skeleton injection is optional
|
| 1559 |
|
| 1560 |
-
_provider_started = time.perf_counter()
|
| 1561 |
result = await loop.run(
|
| 1562 |
goal=task['goal'],
|
| 1563 |
context=context_str,
|
| 1564 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1565 |
on_step=step_cb,
|
| 1566 |
session_id=task.get('session_id', '') or '',
|
| 1567 |
-
allow_tools=not bool(task.get('forbid_tools', False)),
|
| 1568 |
-
allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
|
| 1569 |
-
)
|
| 1570 |
-
await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
|
| 1571 |
-
# ARTIFACT-CONTRACT-FALLBACK: alcuni provider restituiscono codice HTML
|
| 1572 |
-
# nella risposta finale dopo aver narrato una scrittura, senza produrre
|
| 1573 |
-
# il tool event `file_written`. Non committiamo mai una falsa positivitΓ :
|
| 1574 |
-
# se il goal Γ¨ UI, proviamo solo a materializzare un documento HTML
|
| 1575 |
-
# completo giΓ presente nell'output, usando l'executor VFS autenticato.
|
| 1576 |
-
# Se non troviamo un documento completo o la scrittura fallisce, il task
|
| 1577 |
-
# resta senza artifact e il frontend non riceve alcun sync inventato.
|
| 1578 |
-
if _artifact_goal and not _vfs_written_paths and getattr(loop, 'executor', None):
|
| 1579 |
-
_artifact_output = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1580 |
-
_html_match = re.search(r'(?is)(<!doctype\s+html\b.*?</html>)', _artifact_output)
|
| 1581 |
-
if not _html_match:
|
| 1582 |
-
_html_match = re.search(r'(?is)(<html(?:\s[^>]*)?>.*?</html>)', _artifact_output)
|
| 1583 |
-
if _html_match:
|
| 1584 |
-
_artifact_content = _html_match.group(1).strip()
|
| 1585 |
-
_artifact_path = 'index.html'
|
| 1586 |
-
_path_match = re.search(r'(?i)(?:/|\b)([\w.-]+\.html)\b', _artifact_output)
|
| 1587 |
-
if _path_match:
|
| 1588 |
-
_artifact_path = _path_match.group(1)
|
| 1589 |
-
try:
|
| 1590 |
-
_write_result = await asyncio.wait_for(
|
| 1591 |
-
loop.executor.run_tool('write_file', {
|
| 1592 |
-
'path': _artifact_path,
|
| 1593 |
-
'content': _artifact_content,
|
| 1594 |
-
}),
|
| 1595 |
-
timeout=25.0,
|
| 1596 |
-
)
|
| 1597 |
-
_write_ok = not (isinstance(_write_result, dict) and _write_result.get('error'))
|
| 1598 |
-
if _write_ok:
|
| 1599 |
-
_vfs_written_paths.add(_artifact_path)
|
| 1600 |
-
await step_cb({
|
| 1601 |
-
'action': 'file_written', 'status': 'done',
|
| 1602 |
-
'path': _artifact_path, 'content': _artifact_content,
|
| 1603 |
-
'result': f'Artifact materializzato: {_artifact_path}',
|
| 1604 |
-
})
|
| 1605 |
-
_read_result = await asyncio.wait_for(
|
| 1606 |
-
loop.executor.run_tool('read_file', {'path': _artifact_path}),
|
| 1607 |
-
timeout=15.0,
|
| 1608 |
-
)
|
| 1609 |
-
_read_ok = not (isinstance(_read_result, dict) and _read_result.get('error'))
|
| 1610 |
-
await step_cb({
|
| 1611 |
-
'action': 'read_file',
|
| 1612 |
-
'status': 'done' if _read_ok else 'error',
|
| 1613 |
-
'path': _artifact_path,
|
| 1614 |
-
'result': f'Verifica artifact: {_artifact_path}' if _read_ok else str(_read_result)[:300],
|
| 1615 |
-
})
|
| 1616 |
-
except Exception as _artifact_exc:
|
| 1617 |
-
_logger.warning('[agent] artifact fallback failed: %s', type(_artifact_exc).__name__)
|
| 1618 |
-
|
| 1619 |
-
# Lifecycle separato: l'esecuzione primaria Γ¨ completa quando VFS e risultato
|
| 1620 |
-
# sono confermati; la verifica qualitΓ successiva non deve tenere il task RUNNING.
|
| 1621 |
-
_agent_tasks[task_id]['status'] = 'COMPLETED'
|
| 1622 |
-
_agent_tasks[task_id]['quality_status'] = (
|
| 1623 |
-
'QUALITY_CHECK_PENDING' if _run_quality_check else 'NOT_REQUIRED'
|
| 1624 |
)
|
| 1625 |
-
|
|
|
|
| 1626 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1627 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1628 |
asyncio.create_task(_kernel.publish_event(
|
| 1629 |
-
|
| 1630 |
payload={'task_id': task_id, 'status': 'SUCCESS'},
|
| 1631 |
)).add_done_callback(_log_task_exc)
|
| 1632 |
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1633 |
-
|
| 1634 |
-
task_id, _vfs_written_paths, result,
|
| 1635 |
-
)
|
| 1636 |
-
if _vfs_commit is not None:
|
| 1637 |
-
# P34: completa lβatomic swap frontend solo dopo che il loop ha
|
| 1638 |
-
# confermato il task. Su errore/cancellazione lo staging rimane
|
| 1639 |
-
# intenzionalmente non committato.
|
| 1640 |
-
_sse('vfs_sync_complete', _vfs_commit)
|
| 1641 |
-
if _run_quality_check:
|
| 1642 |
-
_sse('quality_check_pending', {
|
| 1643 |
-
'taskId': task_id,
|
| 1644 |
-
'status': 'QUALITY_CHECK_PENDING',
|
| 1645 |
-
'primaryStatus': 'COMPLETED',
|
| 1646 |
-
})
|
| 1647 |
-
_streamed_text = ''.join(_streamed_chunks)
|
| 1648 |
-
if _streamed_text and _result_text != _streamed_text:
|
| 1649 |
-
# Il finalizer puΓ² riparare/normalizzare la risposta dopo gli
|
| 1650 |
-
# ultimi chunk. Notifica esplicitamente la sostituzione così il
|
| 1651 |
-
# client non mostra un testo parziale o divergente.
|
| 1652 |
-
_sse('text_replace', {
|
| 1653 |
-
'taskId': task_id,
|
| 1654 |
-
'text': _result_text[:8000],
|
| 1655 |
-
'reason': 'authoritative_finalizer_output',
|
| 1656 |
-
})
|
| 1657 |
-
_sse('task_done', {
|
| 1658 |
-
'taskId': task_id,
|
| 1659 |
-
'result': _result_text[:8000],
|
| 1660 |
-
'status': 'COMPLETED',
|
| 1661 |
-
'qualityStatus': _agent_tasks[task_id].get('quality_status', 'NOT_REQUIRED'),
|
| 1662 |
-
})
|
| 1663 |
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1664 |
|
| 1665 |
-
#
|
| 1666 |
-
# primario del task e mai il commit VFS giΓ confermato.
|
| 1667 |
if _run_quality_check:
|
| 1668 |
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1669 |
-
if len(_qg_result) > 500 and _qg_result.count('```') >= 2:
|
| 1670 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1675 |
-
)
|
| 1676 |
-
_agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_DONE'
|
| 1677 |
-
_sse('quality_check_done', {
|
| 1678 |
-
'taskId': task_id,
|
| 1679 |
-
'status': 'QUALITY_CHECK_DONE',
|
| 1680 |
-
'primaryStatus': 'COMPLETED',
|
| 1681 |
-
})
|
| 1682 |
-
except Exception as _q_exc:
|
| 1683 |
-
_agent_tasks.get(task_id, {})['quality_status'] = 'QUALITY_CHECK_ERROR'
|
| 1684 |
-
_sse('quality_check_done', {
|
| 1685 |
-
'taskId': task_id,
|
| 1686 |
-
'status': 'QUALITY_CHECK_ERROR',
|
| 1687 |
-
'primaryStatus': 'COMPLETED',
|
| 1688 |
-
'error': type(_q_exc).__name__,
|
| 1689 |
-
})
|
| 1690 |
-
asyncio.create_task(_run_quality_background()).add_done_callback(_log_task_exc)
|
| 1691 |
|
| 1692 |
|
| 1693 |
except asyncio.CancelledError:
|
|
@@ -1696,14 +1277,14 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1696 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1697 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1698 |
asyncio.create_task(_kernel.publish_event(
|
| 1699 |
-
|
| 1700 |
payload={'task_id': task_id, 'status': 'CANCELLED'},
|
| 1701 |
)).add_done_callback(_log_task_exc)
|
| 1702 |
_sse('task_cancelled', {'taskId': task_id})
|
| 1703 |
|
| 1704 |
except (ImportError, ModuleNotFoundError):
|
| 1705 |
-
_agent_tasks[task_id]['status'] = '
|
| 1706 |
-
asyncio.create_task(sb_update_status(task_id, '
|
| 1707 |
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1708 |
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1709 |
f'Goal ricevuto: {task["goal"]}\n\n'
|
|
@@ -1717,7 +1298,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1717 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1718 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1719 |
asyncio.create_task(_kernel.publish_event(
|
| 1720 |
-
|
| 1721 |
payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
|
| 1722 |
)).add_done_callback(_log_task_exc)
|
| 1723 |
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
|
@@ -1725,9 +1306,6 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1725 |
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 1726 |
|
| 1727 |
finally:
|
| 1728 |
-
# Il task Γ¨ terminale: rimuove lβunico riferimento alle credenziali
|
| 1729 |
-
# BYOK, lasciando replay SSE e metadati senza segreti.
|
| 1730 |
-
_task_ai_clients.pop(task_id, None)
|
| 1731 |
reg_entry['done'] = True
|
| 1732 |
reg_entry['finished_at'] = time.time()
|
| 1733 |
for q in list(reg_entry['subscriber_queues']):
|
|
@@ -1736,13 +1314,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
|
|
| 1736 |
except Exception as _exc:
|
| 1737 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 1738 |
|
| 1739 |
-
|
| 1740 |
-
await _LLM_ADMISSION.acquire()
|
| 1741 |
-
try:
|
| 1742 |
-
await run_loop()
|
| 1743 |
-
finally:
|
| 1744 |
-
await _LLM_ADMISSION.release()
|
| 1745 |
-
reg_entry['asyncio_task'] = asyncio.create_task(_admitted_run_loop())
|
| 1746 |
reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
|
| 1747 |
|
| 1748 |
try:
|
|
@@ -1805,7 +1377,7 @@ async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Dep
|
|
| 1805 |
'extra': body.extra,
|
| 1806 |
'savedAt': int(time.time() * 1000),
|
| 1807 |
}
|
| 1808 |
-
asyncio.create_task(sb_save_checkpoint(task_id,
|
| 1809 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1810 |
|
| 1811 |
|
|
|
|
| 42 |
from fastapi import APIRouter, Depends, HTTPException, Request, Body
|
| 43 |
from fastapi.responses import StreamingResponse
|
| 44 |
from .auth_guard import require_role, AuthRole
|
| 45 |
+
from pydantic import BaseModel, field_validator
|
| 46 |
from typing import Literal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
from .state import (
|
| 48 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 49 |
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 50 |
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 51 |
ReasonLoopIn, AgentTaskIn,
|
| 52 |
+
write_ahead_task_created, # WRITE-AHEAD: persist immediato alla creazione task
|
| 53 |
)
|
| 54 |
from .speculative import fire_speculative_tools
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
from .quality_guardian import run_quality_check as _run_quality_check
|
| 57 |
except Exception:
|
|
|
|
| 88 |
router = APIRouter()
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
# ββ Deprecated run_loop βββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββ
|
| 92 |
|
| 93 |
@router.post('/run_loop', deprecated=True)
|
|
|
|
| 188 |
body: ReasonLoopIn, request: Request,
|
| 189 |
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
|
| 190 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
async def generate():
|
| 192 |
queue: asyncio.Queue = asyncio.Queue()
|
| 193 |
|
| 194 |
async def step_cb(step: dict) -> None:
|
|
|
|
|
|
|
| 195 |
await queue.put(step)
|
| 196 |
|
| 197 |
async def run_loop() -> None:
|
|
|
|
| 231 |
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 232 |
if _neg_c:
|
| 233 |
context_str = f"[VINCOLI OPERATIVI APPRESI β NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
|
|
|
| 234 |
result = await loop.run(
|
| 235 |
goal=body.goal, context=context_str,
|
| 236 |
max_steps=body.max_steps, on_step=step_cb,
|
| 237 |
session_id=getattr(body, "session_id", "") or "",
|
| 238 |
)
|
|
|
|
| 239 |
await queue.put({
|
| 240 |
'__done__': True,
|
| 241 |
'result': result.get('output', ''),
|
| 242 |
'engine': result.get('engine', 'fallback'),
|
| 243 |
'success': result.get('success', False),
|
|
|
|
| 244 |
})
|
| 245 |
except Exception as exc:
|
| 246 |
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
|
|
|
| 254 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 255 |
await queue.put({'__error__': str(exc)})
|
| 256 |
|
| 257 |
+
task = asyncio.create_task(run_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
task.add_done_callback(_log_task_exc) # BUG-CB-1
|
| 259 |
task_id = str(uuid.uuid4())
|
| 260 |
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
|
|
|
| 317 |
_err_detail = _ss(item.get('error', ''))
|
| 318 |
_final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
|
| 319 |
else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
|
| 320 |
+
yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _final_res, 'engine': item.get('engine', 'fallback'), 'success': item.get('success', False)})}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
break
|
| 322 |
# S393 Priority 1: Narrative Streaming β arricchisce step_done con explanation
|
| 323 |
_NARR_QUICK = {
|
|
|
|
| 440 |
'action': step_data.get('action', ''),
|
| 441 |
'output': str(step_data.get('output', ''))[:400], # S577: 200β400
|
| 442 |
})
|
| 443 |
+
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
if isinstance(result, dict):
|
| 445 |
output_text = result.get('output', '') or ''
|
| 446 |
engine_used = result.get('engine', 'unknown')
|
|
|
|
| 449 |
output_text = str(result)
|
| 450 |
engine_used = 'unknown'
|
| 451 |
errors_list = []
|
|
|
|
| 452 |
return {
|
| 453 |
'ok': bool(output_text and output_text.strip()),
|
| 454 |
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
|
|
|
| 458 |
'engine': engine_used,
|
| 459 |
'errors': errors_list,
|
| 460 |
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
|
|
|
| 461 |
}
|
| 462 |
except Exception as e:
|
| 463 |
_logger.error("[reason/loop] Error: %s", e)
|
|
|
|
| 523 |
'goal': goal,
|
| 524 |
'mode': mode,
|
| 525 |
'dispatch_id': _dispatch_id,
|
|
|
|
| 526 |
},
|
| 527 |
priority='HIGH',
|
| 528 |
+
metadata={'workflow': 'agent-kernel.yml'},
|
| 529 |
)).add_done_callback(_log_task_exc)
|
| 530 |
asyncio.create_task(_kernel.publish_event(
|
| 531 |
+
event_type='agent.kernel.dispatched',
|
| 532 |
payload={'goal': goal[:200], 'mode': mode},
|
| 533 |
)).add_done_callback(_log_task_exc)
|
| 534 |
import httpx as _httpx
|
|
|
|
| 552 |
|
| 553 |
# ββ Agent tasks (FASE 2.1 + S359 persistence) ββββββββββββββββββββββββββββββββββ
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
@router.post('/api/agent/tasks')
|
| 556 |
+
async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 557 |
"""
|
| 558 |
Crea o recupera un task agent.
|
| 559 |
|
|
|
|
| 561 |
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 562 |
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 563 |
"""
|
|
|
|
| 564 |
_prune_agent_tasks()
|
| 565 |
+
task_id = body.taskId or str(uuid.uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
# Already in memory β return immediately (normal path, includes S358 reconnect)
|
| 568 |
+
if task_id in _agent_tasks:
|
|
|
|
|
|
|
| 569 |
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 570 |
|
| 571 |
# S359: try Supabase lazy restore (only hit network after backend restart)
|
|
|
|
| 575 |
# Use context from the incoming request (not persisted to save space).
|
| 576 |
restored['context'] = body.context
|
| 577 |
_agent_tasks[task_id] = restored
|
|
|
|
| 578 |
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 579 |
|
| 580 |
+
# Brand new task
|
|
|
|
|
|
|
| 581 |
created_at = int(time.time() * 1000)
|
| 582 |
_agent_tasks[task_id] = {
|
| 583 |
'id': task_id,
|
|
|
|
| 591 |
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 592 |
'persona': body.persona, # P17-F5: expertise persona hint
|
| 593 |
'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ'')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
}
|
| 595 |
+
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 596 |
+
# periodico (15-60s). Finestra di perdita per la fase di creazione β zero.
|
| 597 |
+
asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 598 |
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 599 |
if body.session_id:
|
| 600 |
_hctx = await sb_restore_handoff_context(body.session_id)
|
|
|
|
| 605 |
asyncio.create_task(
|
| 606 |
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 607 |
).add_done_callback(_log_task_exc)
|
| 608 |
+
# S361: Speculative Tool Firing β pre-fires read-only tools in parallel
|
| 609 |
+
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 610 |
+
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
| 611 |
# ARCH-K2.2: registra il task nella Queue del Kernel e pubblica evento task.created
|
| 612 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 613 |
asyncio.create_task(_kernel.submit_task(
|
|
|
|
| 617 |
'max_steps': body.max_steps,
|
| 618 |
'persona': body.persona,
|
| 619 |
'source': 'agent_api',
|
|
|
|
| 620 |
},
|
| 621 |
priority='NORMAL',
|
| 622 |
session_id=body.session_id,
|
| 623 |
+
metadata={'agent_api': True},
|
| 624 |
)).add_done_callback(_log_task_exc)
|
| 625 |
asyncio.create_task(_kernel.publish_event(
|
| 626 |
+
event_type='task.created',
|
| 627 |
payload={'task_id': task_id, 'goal': body.goal[:200], 'status': 'QUEUED'},
|
| 628 |
)).add_done_callback(_log_task_exc)
|
| 629 |
return {'taskId': task_id, 'status': 'QUEUED'}
|
|
|
|
| 703 |
async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 704 |
if task_id in _agent_tasks:
|
| 705 |
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
|
|
|
| 706 |
reg = _loop_registry.get(task_id)
|
| 707 |
if reg and not reg.get('done'):
|
| 708 |
at = reg.get('asyncio_task')
|
|
|
|
| 753 |
- Task era RUNNING β replay buffer parziale + evento task_interrupted.
|
| 754 |
- Task non trovato β prova sb_restore_task prima di 404.
|
| 755 |
"""
|
|
|
|
| 756 |
# S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
|
| 757 |
if task_id not in _agent_tasks:
|
| 758 |
restored = await sb_restore_task(task_id)
|
|
|
|
| 763 |
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 764 |
|
| 765 |
task = _agent_tasks[task_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 767 |
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 768 |
|
|
|
|
| 771 |
async def generate():
|
| 772 |
yield "retry: 3000\n\n"
|
| 773 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
reg = _loop_registry.get(task_id)
|
| 775 |
|
| 776 |
is_done_reconnect = reg is not None and reg.get('done', False)
|
|
|
|
| 812 |
sb_events = await sb_get_events(task_id)
|
| 813 |
if sb_events:
|
| 814 |
task_status = task.get('status', 'UNKNOWN')
|
| 815 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 816 |
# Replay buffer from resume point
|
| 817 |
for evt_str in sb_events[_resume_from:]:
|
| 818 |
yield evt_str
|
|
|
|
| 865 |
yield "data: [DONE]\n\n"
|
| 866 |
return
|
| 867 |
# ββ Case 3: nuova esecuzione ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
_prune_loop_registry()
|
| 869 |
reg_entry: dict = {
|
| 870 |
'asyncio_task': None,
|
|
|
|
| 879 |
def _sse(event: str, data: dict) -> None:
|
| 880 |
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 881 |
_ctr[0] += 1
|
| 882 |
+
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 883 |
# GAP-3-FIX: text_chunk bypass buffer β fanout diretto, no persist.
|
| 884 |
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 885 |
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
|
|
|
| 907 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 908 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 909 |
asyncio.create_task(_kernel.publish_event(
|
| 910 |
+
event_type='task.running',
|
| 911 |
payload={'task_id': task_id, 'status': 'RUNNING'},
|
| 912 |
)).add_done_callback(_log_task_exc)
|
| 913 |
_prune_agent_tasks()
|
| 914 |
|
| 915 |
async def run_loop() -> None:
|
| 916 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
from agents.unified_loop import UnifiedAgentLoop
|
| 918 |
+
# S388: singleton β evita OpenAI() per ogni task
|
| 919 |
+
client = _get_ai_client()
|
|
|
|
| 920 |
try:
|
| 921 |
from agents.critic import Critic
|
| 922 |
from agents.response_verifier import ResponseVerifier
|
|
|
|
| 949 |
"Il frontend mostrerΓ automaticamente un pulsante 'Connetti' all'utente."
|
| 950 |
)
|
| 951 |
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 953 |
# Bug: _resume_context era settato su task{} ma mai letto qui β context perduto su resume.
|
| 954 |
_resume_ctx = task.get('_resume_context', '')
|
|
|
|
| 1019 |
_hctx = task.get("_handoff_context", "")
|
| 1020 |
if _hctx:
|
| 1021 |
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 1022 |
+
# P17-F5: route primary LLM to persona-appropriate client
|
| 1023 |
+
_persona_client = _get_persona_llm_client(_persona, client)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
loop = UnifiedAgentLoop(
|
| 1025 |
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 1026 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 1027 |
)
|
| 1028 |
step_idx = [0]
|
| 1029 |
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
|
| 1031 |
async def step_cb(step_data: dict) -> None:
|
| 1032 |
step_idx[0] += 1
|
| 1033 |
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 1034 |
# S420: streaming token β emetti direttamente senza passare dal buffer step
|
| 1035 |
if _action == 'text_chunk':
|
| 1036 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
return
|
| 1038 |
|
| 1039 |
# S363-Blueprint: Narrative Streaming β explanation lookup for ALL step_done events
|
|
|
|
| 1143 |
step_data.get('output', '')[:500])
|
| 1144 |
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 1145 |
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 1146 |
+
# SYNC-1: includi content nel SSE event per file_written (β€60KB)
|
| 1147 |
+
# Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
|
|
|
|
|
|
|
| 1148 |
if _action == 'file_written' and step_data.get('content'):
|
| 1149 |
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
_sse('vfs_update', _vfs_evt)
|
| 1151 |
|
| 1152 |
# S363-UI: thought event β emitted when planner completes
|
|
|
|
| 1242 |
except Exception:
|
| 1243 |
pass # S364: skeleton injection is optional
|
| 1244 |
|
|
|
|
| 1245 |
result = await loop.run(
|
| 1246 |
goal=task['goal'],
|
| 1247 |
context=context_str,
|
| 1248 |
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1249 |
on_step=step_cb,
|
| 1250 |
session_id=task.get('session_id', '') or '',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1251 |
)
|
| 1252 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1253 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1254 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1255 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1256 |
asyncio.create_task(_kernel.publish_event(
|
| 1257 |
+
event_type='task.completed',
|
| 1258 |
payload={'task_id': task_id, 'status': 'SUCCESS'},
|
| 1259 |
)).add_done_callback(_log_task_exc)
|
| 1260 |
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1261 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1263 |
|
| 1264 |
+
# S363: fire-and-forget quality check when code detected in output
|
|
|
|
| 1265 |
if _run_quality_check:
|
| 1266 |
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1267 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised β evita QG su snippet brevi
|
| 1268 |
+
asyncio.create_task(_run_quality_check(
|
| 1269 |
+
task_id, task['goal'], _qg_result,
|
| 1270 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1271 |
+
)).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1272 |
|
| 1273 |
|
| 1274 |
except asyncio.CancelledError:
|
|
|
|
| 1277 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1278 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1279 |
asyncio.create_task(_kernel.publish_event(
|
| 1280 |
+
event_type='task.cancelled',
|
| 1281 |
payload={'task_id': task_id, 'status': 'CANCELLED'},
|
| 1282 |
)).add_done_callback(_log_task_exc)
|
| 1283 |
_sse('task_cancelled', {'taskId': task_id})
|
| 1284 |
|
| 1285 |
except (ImportError, ModuleNotFoundError):
|
| 1286 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1287 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1288 |
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1289 |
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1290 |
f'Goal ricevuto: {task["goal"]}\n\n'
|
|
|
|
| 1298 |
# ARCH-K2.2: pubblica lifecycle event via Kernel
|
| 1299 |
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 1300 |
asyncio.create_task(_kernel.publish_event(
|
| 1301 |
+
event_type='task.failed',
|
| 1302 |
payload={'task_id': task_id, 'status': 'ERROR', 'error': str(err)[:500]},
|
| 1303 |
)).add_done_callback(_log_task_exc)
|
| 1304 |
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
|
|
|
| 1306 |
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 1307 |
|
| 1308 |
finally:
|
|
|
|
|
|
|
|
|
|
| 1309 |
reg_entry['done'] = True
|
| 1310 |
reg_entry['finished_at'] = time.time()
|
| 1311 |
for q in list(reg_entry['subscriber_queues']):
|
|
|
|
| 1314 |
except Exception as _exc:
|
| 1315 |
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 1316 |
|
| 1317 |
+
reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
|
| 1319 |
|
| 1320 |
try:
|
|
|
|
| 1377 |
'extra': body.extra,
|
| 1378 |
'savedAt': int(time.time() * 1000),
|
| 1379 |
}
|
| 1380 |
+
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 1381 |
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1382 |
|
| 1383 |
|
api/agent_checkpoint.py
DELETED
|
@@ -1,131 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
backend/api/agent_checkpoint.py β Simplified checkpoint endpoints (ARCH-K2.3)
|
| 3 |
-
|
| 4 |
-
Aggiunge alias /api/agent/checkpoint (senza task_id nella path) per uso diretto dal frontend:
|
| 5 |
-
GET /api/agent/checkpoint β lista tutti i checkpoint attivi in memoria
|
| 6 |
-
POST /api/agent/checkpoint β salva checkpoint (taskId opzionale nel body)
|
| 7 |
-
GET /api/agent/checkpoint/{task_id} β recupera checkpoint specifico
|
| 8 |
-
DELETE /api/agent/checkpoint/{task_id} β elimina checkpoint
|
| 9 |
-
|
| 10 |
-
I checkpoint per-task esistono giΓ su /api/agent/tasks/{id}/checkpoint (agent.py).
|
| 11 |
-
Questi alias sono piΓΉ comodi quando il frontend non ha un task_id esplicito
|
| 12 |
-
(es. salvataggio periodico dello stato dell'agente, resume dopo refresh).
|
| 13 |
-
|
| 14 |
-
ROUTING CF PAGES: /api/agent/* β HANDS (Space B) via HANDS_PATTERNS[0].
|
| 15 |
-
Nessuna modifica a [[catchall]].ts necessaria.
|
| 16 |
-
|
| 17 |
-
NOTA: Import da api.agent e api.persistence sono LAZY (dentro le funzioni)
|
| 18 |
-
per evitare import circolari β agent.py importa giΓ molti altri moduli.
|
| 19 |
-
"""
|
| 20 |
-
import time
|
| 21 |
-
import asyncio
|
| 22 |
-
import logging
|
| 23 |
-
from typing import Optional
|
| 24 |
-
|
| 25 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 26 |
-
from pydantic import BaseModel
|
| 27 |
-
|
| 28 |
-
from .auth_guard import require_role, AuthRole
|
| 29 |
-
|
| 30 |
-
_logger = logging.getLogger("api.agent_checkpoint")
|
| 31 |
-
|
| 32 |
-
router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class CheckpointBody(BaseModel):
|
| 36 |
-
taskId: Optional[str] = None # se omesso β usa "default"
|
| 37 |
-
step: int = 0
|
| 38 |
-
goal: str = ""
|
| 39 |
-
plan: list = []
|
| 40 |
-
logs: list[str] = []
|
| 41 |
-
artifacts: list[str] = []
|
| 42 |
-
retryCount: int = 0
|
| 43 |
-
extra: dict = {}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# ββ GET /api/agent/checkpoint βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
-
@router.get("/api/agent/checkpoint")
|
| 48 |
-
async def list_checkpoints_alias():
|
| 49 |
-
"""
|
| 50 |
-
Lista tutti i checkpoint attivi in memoria.
|
| 51 |
-
Alias leggero per /api/agent/checkpoints (agent.py).
|
| 52 |
-
"""
|
| 53 |
-
# Import lazy β evita circolaritΓ
|
| 54 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 55 |
-
|
| 56 |
-
_prune_checkpoints()
|
| 57 |
-
now = int(time.time() * 1000)
|
| 58 |
-
return {
|
| 59 |
-
"count": len(_task_checkpoints),
|
| 60 |
-
"checkpoints": [
|
| 61 |
-
{
|
| 62 |
-
"taskId": k,
|
| 63 |
-
"step": v.get("step", 0),
|
| 64 |
-
"goal": v.get("goal", "")[:300],
|
| 65 |
-
"age_ms": now - v.get("savedAt", now),
|
| 66 |
-
}
|
| 67 |
-
for k, v in _task_checkpoints.items()
|
| 68 |
-
],
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ββ POST /api/agent/checkpoint ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
-
@router.post("/api/agent/checkpoint")
|
| 74 |
-
async def save_checkpoint_alias(body: CheckpointBody):
|
| 75 |
-
"""
|
| 76 |
-
Salva un checkpoint. taskId opzionale: se omesso usa 'default'.
|
| 77 |
-
Replica la logica di /api/agent/tasks/{id}/checkpoint con Supabase persist.
|
| 78 |
-
"""
|
| 79 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 80 |
-
from api.persistence import sb_save_checkpoint # type: ignore[import]
|
| 81 |
-
|
| 82 |
-
_prune_checkpoints()
|
| 83 |
-
task_id = body.taskId or "default"
|
| 84 |
-
|
| 85 |
-
cp: dict = {
|
| 86 |
-
"taskId": task_id,
|
| 87 |
-
"step": body.step,
|
| 88 |
-
"goal": body.goal,
|
| 89 |
-
"plan": body.plan,
|
| 90 |
-
"logs": body.logs[-50:], # mantieni solo gli ultimi 50 log
|
| 91 |
-
"artifacts": body.artifacts,
|
| 92 |
-
"retryCount": body.retryCount,
|
| 93 |
-
"extra": body.extra,
|
| 94 |
-
"savedAt": int(time.time() * 1000),
|
| 95 |
-
}
|
| 96 |
-
_task_checkpoints[task_id] = cp
|
| 97 |
-
# Persist su Supabase β fire-and-forget (stesso pattern di agent.py)
|
| 98 |
-
asyncio.create_task(sb_save_checkpoint(task_id, body.step, cp))
|
| 99 |
-
return {"saved": True, "taskId": task_id, "step": body.step}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
# ββ GET /api/agent/checkpoint/{task_id} ββββββββββββββββββββββββββββββββββββββ
|
| 103 |
-
@router.get("/api/agent/checkpoint/{task_id}")
|
| 104 |
-
async def get_checkpoint_alias(task_id: str):
|
| 105 |
-
"""
|
| 106 |
-
Recupera il checkpoint per un task specifico.
|
| 107 |
-
Cerca prima in memoria (_task_checkpoints), poi su Supabase via sb_get_checkpoint.
|
| 108 |
-
"""
|
| 109 |
-
from api.agent import _task_checkpoints, _prune_checkpoints # type: ignore[import]
|
| 110 |
-
from api.persistence import sb_get_checkpoint # type: ignore[import]
|
| 111 |
-
|
| 112 |
-
_prune_checkpoints()
|
| 113 |
-
cp = _task_checkpoints.get(task_id)
|
| 114 |
-
if not cp:
|
| 115 |
-
cp = await sb_get_checkpoint(task_id)
|
| 116 |
-
if not cp:
|
| 117 |
-
raise HTTPException(
|
| 118 |
-
status_code=404,
|
| 119 |
-
detail={"error": "checkpoint_not_found", "taskId": task_id},
|
| 120 |
-
)
|
| 121 |
-
return cp
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
# ββ DELETE /api/agent/checkpoint/{task_id} βββββββββββββββββββββββββββββββββββ
|
| 125 |
-
@router.delete("/api/agent/checkpoint/{task_id}")
|
| 126 |
-
async def delete_checkpoint_alias(task_id: str):
|
| 127 |
-
"""Rimuove il checkpoint da memoria in-process (non elimina da Supabase)."""
|
| 128 |
-
from api.agent import _task_checkpoints # type: ignore[import]
|
| 129 |
-
|
| 130 |
-
_task_checkpoints.pop(task_id, None)
|
| 131 |
-
return {"deleted": task_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/agent_checkpoint_routes.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""agent_checkpoint_routes.py β Checkpoint, debug/timing, skill-stats, circuit-status.
|
| 2 |
+
|
| 3 |
+
Estratto da agent.py (split 2026-06-30).
|
| 4 |
+
Route coperte:
|
| 5 |
+
POST /api/agent/tasks/{task_id}/checkpoint
|
| 6 |
+
GET /api/agent/tasks/{task_id}/checkpoint
|
| 7 |
+
DELETE /api/agent/tasks/{task_id}/checkpoint
|
| 8 |
+
GET /api/agent/checkpoints
|
| 9 |
+
GET /debug/timing
|
| 10 |
+
GET /api/agent/skill-stats/{session_id}
|
| 11 |
+
GET /api/agent/skill-stats
|
| 12 |
+
GET /api/agent/circuit-status/{session_id}
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
import os, asyncio, json, uuid, time, re
|
| 16 |
+
import re as _re_persona
|
| 17 |
+
from fastapi import APIRouter, HTTPException, Request, Body
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from pydantic import BaseModel, field_validator
|
| 20 |
+
from typing import Literal
|
| 21 |
+
from .state import (
|
| 22 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 23 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 24 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 25 |
+
ReasonLoopIn, AgentTaskIn,
|
| 26 |
+
write_ahead_task_created,
|
| 27 |
+
)
|
| 28 |
+
from .speculative import fire_speculative_tools
|
| 29 |
+
try:
|
| 30 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 31 |
+
except Exception:
|
| 32 |
+
_run_quality_check = None
|
| 33 |
+
import logging
|
| 34 |
+
_logger = logging.getLogger("api.agent")
|
| 35 |
+
from .persistence import (
|
| 36 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 37 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 38 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 39 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
|
| 40 |
+
)
|
| 41 |
+
from ._agent_helpers import _RE_SURROGATES, _ss, _log_task_exc
|
| 42 |
+
try:
|
| 43 |
+
from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
|
| 44 |
+
except Exception:
|
| 45 |
+
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
|
| 46 |
+
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 47 |
+
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 48 |
+
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 49 |
+
|
| 50 |
+
router = APIRouter()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ Task checkpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
class CheckpointIn(BaseModel):
|
| 57 |
+
taskId: str
|
| 58 |
+
step: int
|
| 59 |
+
goal: str
|
| 60 |
+
plan: list[str] = []
|
| 61 |
+
logs: list[str] = []
|
| 62 |
+
artifacts: list[str] = []
|
| 63 |
+
retryCount: int = 0
|
| 64 |
+
extra: dict = {}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post('/api/agent/tasks/{task_id}/checkpoint')
|
| 68 |
+
async def save_checkpoint(task_id: str, body: CheckpointIn):
|
| 69 |
+
_prune_checkpoints()
|
| 70 |
+
_task_checkpoints[task_id] = {
|
| 71 |
+
'taskId': task_id,
|
| 72 |
+
'step': body.step,
|
| 73 |
+
'goal': body.goal,
|
| 74 |
+
'plan': body.plan,
|
| 75 |
+
'logs': body.logs[-50:],
|
| 76 |
+
'artifacts': body.artifacts,
|
| 77 |
+
'retryCount': body.retryCount,
|
| 78 |
+
'extra': body.extra,
|
| 79 |
+
'savedAt': int(time.time() * 1000),
|
| 80 |
+
}
|
| 81 |
+
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 82 |
+
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get('/api/agent/tasks/{task_id}/checkpoint')
|
| 86 |
+
async def get_checkpoint(task_id: str):
|
| 87 |
+
_prune_checkpoints()
|
| 88 |
+
cp = _task_checkpoints.get(task_id)
|
| 89 |
+
if not cp:
|
| 90 |
+
cp = await sb_get_checkpoint(task_id)
|
| 91 |
+
if not cp:
|
| 92 |
+
raise HTTPException(404, detail={'error': 'checkpoint_not_found', 'taskId': task_id})
|
| 93 |
+
return cp
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.delete('/api/agent/tasks/{task_id}/checkpoint')
|
| 97 |
+
async def delete_checkpoint(task_id: str):
|
| 98 |
+
_task_checkpoints.pop(task_id, None)
|
| 99 |
+
return {'deleted': task_id}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.get('/api/agent/checkpoints')
|
| 103 |
+
async def list_checkpoints():
|
| 104 |
+
_prune_checkpoints()
|
| 105 |
+
now = int(time.time() * 1000)
|
| 106 |
+
return {
|
| 107 |
+
'count': len(_task_checkpoints),
|
| 108 |
+
'checkpoints': [
|
| 109 |
+
{'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200β300
|
| 110 |
+
for k, v in _task_checkpoints.items()
|
| 111 |
+
],
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# βββ Sprint 5 ITEM 15: /debug/timing β telemetria timing + qualitΓ agente ββββ
|
| 116 |
+
# Usato da TelemetryDashboard.tsx (frontend) per la sezione "QualitΓ agente".
|
| 117 |
+
# Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualitΓ ).
|
| 118 |
+
# Non richiede auth β dati aggregati, nessun dato sensibile.
|
| 119 |
+
@router.get('/debug/timing')
|
| 120 |
+
async def get_debug_timing():
|
| 121 |
+
"""
|
| 122 |
+
Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
|
| 123 |
+
e contatori qualitΓ (goal_success, repair_success, tool_failure, req_engine).
|
| 124 |
+
Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
from api.state import _TIMING_STORE, _REPAIR_STATS
|
| 128 |
+
timing_stats: dict = {}
|
| 129 |
+
for label, samples in _TIMING_STORE.items():
|
| 130 |
+
if samples:
|
| 131 |
+
avg_val = round(sum(samples) / len(samples), 1)
|
| 132 |
+
else:
|
| 133 |
+
avg_val = None
|
| 134 |
+
timing_stats[label] = {"avg": avg_val, "count": len(samples)}
|
| 135 |
+
return {
|
| 136 |
+
"timing_stats": timing_stats,
|
| 137 |
+
"repair_stats": dict(_REPAIR_STATS),
|
| 138 |
+
}
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# βββ GAP-SKILL-SYNC: /api/agent/skill-stats β statistiche tool adattive ββββββ
|
| 144 |
+
# Espone i dati del SkillTracker (session-scoped success/fail per tool)
|
| 145 |
+
# al frontend per merge con skillRegistry Dexie β vista cross-runtime unificata.
|
| 146 |
+
@router.get('/api/agent/skill-stats/{session_id}')
|
| 147 |
+
async def get_skill_stats(session_id: str):
|
| 148 |
+
"""Success/fail rate + Wilson score per ogni tool nella sessione.
|
| 149 |
+
|
| 150 |
+
Il frontend usa questa API per arricchire i dati Dexie di skillRegistry.ts
|
| 151 |
+
con le stats backend: confidence reale (server-side) vs contatori browser-only.
|
| 152 |
+
"""
|
| 153 |
+
try:
|
| 154 |
+
from agents.skill_tracker import get_skill_tracker
|
| 155 |
+
return {
|
| 156 |
+
"session_id": session_id,
|
| 157 |
+
"stats": get_skill_tracker().get_stats(session_id),
|
| 158 |
+
}
|
| 159 |
+
except Exception as exc:
|
| 160 |
+
return {"session_id": session_id, "stats": {}, "error": str(exc)}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.get('/api/agent/skill-stats')
|
| 164 |
+
async def list_all_skill_sessions():
|
| 165 |
+
"""Debug: panoramica di tutte le sessioni SkillTracker attive (tool count, call count)."""
|
| 166 |
+
try:
|
| 167 |
+
from agents.skill_tracker import get_skill_tracker
|
| 168 |
+
return get_skill_tracker().get_all_sessions()
|
| 169 |
+
except Exception as exc:
|
| 170 |
+
return {"error": str(exc)}
|
| 171 |
+
|
| 172 |
+
# ββ /api/agent/circuit-status/{session_id} β circuit breaker live status ββββββ
|
| 173 |
+
# Espone per ogni tool tracciato in sessione: stato circuito, Wilson score,
|
| 174 |
+
# recovery calls effettuate β utile per debug e monitoring real-time.
|
| 175 |
+
@router.get('/api/agent/circuit-status/{session_id}')
|
| 176 |
+
async def get_circuit_status(session_id: str):
|
| 177 |
+
"""
|
| 178 |
+
Stato real-time del circuit breaker per ogni tool di una sessione.
|
| 179 |
+
|
| 180 |
+
Per ogni tool tracciato, classifica il circuito come:
|
| 181 |
+
- open β Wilson score < 0.15 AND total_count >= 3 AND tool ha fallback
|
| 182 |
+
(il tool viene bypassato β routing automatico ai fallback)
|
| 183 |
+
- closed β performance sufficiente o dati insufficienti per aprire il circuit
|
| 184 |
+
|
| 185 |
+
Campi per tool:
|
| 186 |
+
wilson_score: lower bound dell'intervallo di confidenza al 95% (0β1)
|
| 187 |
+
success_count: successi registrati nella sessione
|
| 188 |
+
fail_count: fallimenti registrati nella sessione
|
| 189 |
+
total_count: chiamate totali
|
| 190 |
+
success_rate: raw rate (NON usato dal circuit β solo informativo)
|
| 191 |
+
avg_latency_ms: latenza media (ms)
|
| 192 |
+
has_fallbacks: True se TOOL_REGISTRY definisce fallback per il tool
|
| 193 |
+
recovery_calls: quante volte il recovery credit ha concesso un tentativo
|
| 194 |
+
circuit_state: "open" | "closed" | "no_data" | "insufficient_data"
|
| 195 |
+
|
| 196 |
+
Thresholds (from executor.py):
|
| 197 |
+
circuit_open_threshold: 0.15 (Wilson score sotto cui il circuit si apre)
|
| 198 |
+
min_calls_for_circuit: 3 (chiamate minime prima che il circuit possa aprirsi)
|
| 199 |
+
recovery_interval: 5 (ogni N call con circuit open β recovery attempt)
|
| 200 |
+
"""
|
| 201 |
+
try:
|
| 202 |
+
from agents.skill_tracker import get_skill_tracker
|
| 203 |
+
from tools.registry import TOOL_REGISTRY
|
| 204 |
+
from api.state import _get_executor
|
| 205 |
+
from agents.executor import (
|
| 206 |
+
_CIRCUIT_OPEN_THRESHOLD,
|
| 207 |
+
_MIN_CALLS_FOR_CIRCUIT,
|
| 208 |
+
_RECOVERY_INTERVAL,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
stats = get_skill_tracker().get_stats(session_id)
|
| 212 |
+
|
| 213 |
+
# Recovery counts vivono nell'istanza Executor singleton
|
| 214 |
+
executor = _get_executor()
|
| 215 |
+
rec_counts: dict = {}
|
| 216 |
+
if executor is not None:
|
| 217 |
+
rec_counts = getattr(executor, '_circuit_recovery_counts', {})
|
| 218 |
+
|
| 219 |
+
circuits_open: list[dict] = []
|
| 220 |
+
circuits_closed: list[dict] = []
|
| 221 |
+
|
| 222 |
+
for tool_name, s in stats.items():
|
| 223 |
+
has_fallbacks = bool(TOOL_REGISTRY.get(tool_name, {}).get('fallbacks'))
|
| 224 |
+
recovery_calls = rec_counts.get(tool_name, 0)
|
| 225 |
+
|
| 226 |
+
# Replica logica _is_circuit_open() di executor.py
|
| 227 |
+
if s['total_count'] == 0:
|
| 228 |
+
state = 'no_data'
|
| 229 |
+
elif s['total_count'] < _MIN_CALLS_FOR_CIRCUIT:
|
| 230 |
+
state = 'insufficient_data'
|
| 231 |
+
elif s['wilson_score'] < _CIRCUIT_OPEN_THRESHOLD and has_fallbacks:
|
| 232 |
+
state = 'open'
|
| 233 |
+
else:
|
| 234 |
+
state = 'closed'
|
| 235 |
+
|
| 236 |
+
entry = {
|
| 237 |
+
'tool': tool_name,
|
| 238 |
+
'circuit_state': state,
|
| 239 |
+
'wilson_score': s['wilson_score'],
|
| 240 |
+
'success_count': s['success_count'],
|
| 241 |
+
'fail_count': s['fail_count'],
|
| 242 |
+
'total_count': s['total_count'],
|
| 243 |
+
'success_rate': s['success_rate'],
|
| 244 |
+
'avg_latency_ms': s['avg_latency_ms'],
|
| 245 |
+
'has_fallbacks': has_fallbacks,
|
| 246 |
+
'recovery_calls': recovery_calls,
|
| 247 |
+
}
|
| 248 |
+
if state == 'open':
|
| 249 |
+
circuits_open.append(entry)
|
| 250 |
+
else:
|
| 251 |
+
circuits_closed.append(entry)
|
| 252 |
+
|
| 253 |
+
# Ordina open per Wilson score asc (peggiori prima), closed per desc (migliori prima)
|
| 254 |
+
circuits_open.sort(key=lambda x: x['wilson_score'])
|
| 255 |
+
circuits_closed.sort(key=lambda x: x['wilson_score'], reverse=True)
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
'session_id': session_id,
|
| 259 |
+
'total_tools_tracked': len(stats),
|
| 260 |
+
'circuits_open_count': len(circuits_open),
|
| 261 |
+
'circuits_closed_count': len(circuits_closed),
|
| 262 |
+
'circuits_open': circuits_open,
|
| 263 |
+
'circuits_closed': circuits_closed,
|
| 264 |
+
'thresholds': {
|
| 265 |
+
'circuit_open_threshold': _CIRCUIT_OPEN_THRESHOLD,
|
| 266 |
+
'min_calls_for_circuit': _MIN_CALLS_FOR_CIRCUIT,
|
| 267 |
+
'recovery_interval': _RECOVERY_INTERVAL,
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
except Exception as exc:
|
| 271 |
+
return {
|
| 272 |
+
'session_id': session_id,
|
| 273 |
+
'total_tools_tracked': 0,
|
| 274 |
+
'circuits_open_count': 0,
|
| 275 |
+
'circuits_open': [],
|
| 276 |
+
'circuits_closed': [],
|
| 277 |
+
'error': str(exc),
|
| 278 |
+
}
|
| 279 |
+
|
api/agent_fsm.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/agent_fsm.py β AgentLoop come Macchina a Stati (ARCH-I4.5)
|
| 3 |
+
|
| 4 |
+
Rifactorizza il flusso dell'AgentLoop come FSM (Finite State Machine) esplicita.
|
| 5 |
+
Stati: IDLE β PLAN β THINK β TOOL β WAIT β OBSERVE β DECIDE β DONE | FAILED
|
| 6 |
+
|
| 7 |
+
Vantaggi rispetto all'implementazione implicita in agent.py:
|
| 8 |
+
- TestabilitΓ : ogni transizione Γ¨ una funzione pura
|
| 9 |
+
- OsservabilitΓ : stato corrente sempre visibile via API
|
| 10 |
+
- Idempotenza: ogni stato ha guard di entry/exit
|
| 11 |
+
- DebuggabilitΓ : history completa delle transizioni
|
| 12 |
+
|
| 13 |
+
Integrazione con agent.py:
|
| 14 |
+
- AgentFSM NON sostituisce agent.py (troppo rischio regressione)
|
| 15 |
+
- Viene usato come orchestratore esterno per nuovi task creati via Kernel
|
| 16 |
+
- agent.py esistente continua a funzionare invariato
|
| 17 |
+
|
| 18 |
+
ADR: S10 S16 S21 S27
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import asyncio
|
| 23 |
+
import logging
|
| 24 |
+
import time
|
| 25 |
+
import uuid
|
| 26 |
+
from enum import Enum
|
| 27 |
+
from typing import Any, Callable, Coroutine
|
| 28 |
+
|
| 29 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 30 |
+
from pydantic import BaseModel, Field
|
| 31 |
+
|
| 32 |
+
from .auth_guard import AuthRole, require_role
|
| 33 |
+
|
| 34 |
+
_logger = logging.getLogger("api.agent_fsm")
|
| 35 |
+
|
| 36 |
+
# ββ Guards ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
try:
|
| 38 |
+
from .brain_planner import planner as _planner, PlanRequest as _PlanReq
|
| 39 |
+
_PLANNER_AVAILABLE = True
|
| 40 |
+
except Exception:
|
| 41 |
+
_planner = None; _PlanReq = None; _PLANNER_AVAILABLE = False # type: ignore
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
from .tool_engine import tool_executor as _tool_exec, ToolExecuteRequest as _TExecReq
|
| 45 |
+
_TOOL_ENGINE_AVAILABLE = True
|
| 46 |
+
except Exception:
|
| 47 |
+
_tool_exec = None; _TExecReq = None; _TOOL_ENGINE_AVAILABLE = False # type: ignore
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
from .kernel import kernel as _kernel
|
| 51 |
+
_KERNEL_AVAILABLE = True
|
| 52 |
+
except Exception:
|
| 53 |
+
_kernel = None; _KERNEL_AVAILABLE = False # type: ignore
|
| 54 |
+
|
| 55 |
+
# ββ States ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
|
| 57 |
+
class AgentState(str, Enum):
|
| 58 |
+
IDLE = "idle" # in attesa di un goal
|
| 59 |
+
PLAN = "plan" # BrainPlanner genera WorkflowPlan
|
| 60 |
+
THINK = "think" # LLM reasoning: analisi contesto, scelta prossimo tool
|
| 61 |
+
TOOL = "tool" # esecuzione tool via ToolEngine
|
| 62 |
+
WAIT = "wait" # attesa risultato asincrono (polling)
|
| 63 |
+
OBSERVE = "observe" # elaborazione risultato tool, aggiornamento contesto
|
| 64 |
+
DECIDE = "decide" # decide se DONE, se re-THINK, o se FAILED
|
| 65 |
+
DONE = "done" # goal raggiunto
|
| 66 |
+
FAILED = "failed" # errore non recuperabile
|
| 67 |
+
|
| 68 |
+
# Transizioni valide: stato β [stati raggiungibili]
|
| 69 |
+
_TRANSITIONS: dict[AgentState, list[AgentState]] = {
|
| 70 |
+
AgentState.IDLE: [AgentState.PLAN],
|
| 71 |
+
AgentState.PLAN: [AgentState.THINK, AgentState.FAILED],
|
| 72 |
+
AgentState.THINK: [AgentState.TOOL, AgentState.DECIDE, AgentState.FAILED],
|
| 73 |
+
AgentState.TOOL: [AgentState.WAIT, AgentState.OBSERVE, AgentState.FAILED],
|
| 74 |
+
AgentState.WAIT: [AgentState.OBSERVE, AgentState.FAILED],
|
| 75 |
+
AgentState.OBSERVE: [AgentState.DECIDE, AgentState.FAILED],
|
| 76 |
+
AgentState.DECIDE: [AgentState.THINK, AgentState.DONE, AgentState.FAILED],
|
| 77 |
+
AgentState.DONE: [],
|
| 78 |
+
AgentState.FAILED: [],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ββ Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
+
|
| 83 |
+
class StateTransition(BaseModel):
|
| 84 |
+
from_state: AgentState
|
| 85 |
+
to_state: AgentState
|
| 86 |
+
reason: str = ""
|
| 87 |
+
ts: float = Field(default_factory=time.time)
|
| 88 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AgentContext(BaseModel):
|
| 92 |
+
"""Contesto condiviso tra tutti gli stati della FSM."""
|
| 93 |
+
session_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 94 |
+
goal: str = ""
|
| 95 |
+
plan_id: str | None = None
|
| 96 |
+
messages: list[dict] = Field(default_factory=list)
|
| 97 |
+
tool_results: list[dict] = Field(default_factory=list)
|
| 98 |
+
current_step: int = 0
|
| 99 |
+
max_steps: int = 10
|
| 100 |
+
last_tool: str | None = None
|
| 101 |
+
last_result: Any = None
|
| 102 |
+
error: str | None = None
|
| 103 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class FSMRunRequest(BaseModel):
|
| 107 |
+
goal: str
|
| 108 |
+
session_id: str | None = None
|
| 109 |
+
max_steps: int = 10
|
| 110 |
+
hints: list[str] = Field(default_factory=list,
|
| 111 |
+
description="Capability/tool suggeriti")
|
| 112 |
+
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class FSMStatus(BaseModel):
|
| 116 |
+
fsm_id: str
|
| 117 |
+
state: AgentState
|
| 118 |
+
context: AgentContext
|
| 119 |
+
history: list[StateTransition]
|
| 120 |
+
started_at: float
|
| 121 |
+
updated_at: float
|
| 122 |
+
done: bool
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ββ AgentFSM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
|
| 127 |
+
class AgentFSM:
|
| 128 |
+
"""
|
| 129 |
+
Istanza di FSM per un singolo run dell'agente.
|
| 130 |
+
Ogni run ha il proprio fsm_id, contesto e history.
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
def __init__(self, fsm_id: str, goal: str, max_steps: int = 10,
|
| 134 |
+
hints: list[str] | None = None) -> None:
|
| 135 |
+
self.fsm_id = fsm_id
|
| 136 |
+
self.state = AgentState.IDLE
|
| 137 |
+
self.context = AgentContext(goal=goal, max_steps=max_steps,
|
| 138 |
+
metadata={"hints": hints or []})
|
| 139 |
+
self.history: list[StateTransition] = []
|
| 140 |
+
self.started_at = time.time()
|
| 141 |
+
self.updated_at = time.time()
|
| 142 |
+
|
| 143 |
+
def can_transition(self, to: AgentState) -> bool:
|
| 144 |
+
return to in _TRANSITIONS.get(self.state, [])
|
| 145 |
+
|
| 146 |
+
def transition(self, to: AgentState, reason: str = "", metadata: dict | None = None) -> None:
|
| 147 |
+
if not self.can_transition(to):
|
| 148 |
+
raise ValueError(f"Transizione non valida: {self.state} β {to}")
|
| 149 |
+
t = StateTransition(from_state=self.state, to_state=to,
|
| 150 |
+
reason=reason, metadata=metadata or {})
|
| 151 |
+
self.history.append(t)
|
| 152 |
+
_logger.debug("[fsm:%s] %s β %s (%s)", self.fsm_id, self.state.value, to.value, reason)
|
| 153 |
+
self.state = to
|
| 154 |
+
self.updated_at = time.time()
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def done(self) -> bool:
|
| 158 |
+
return self.state in (AgentState.DONE, AgentState.FAILED)
|
| 159 |
+
|
| 160 |
+
def to_status(self) -> FSMStatus:
|
| 161 |
+
return FSMStatus(
|
| 162 |
+
fsm_id=self.fsm_id, state=self.state, context=self.context,
|
| 163 |
+
history=self.history, started_at=self.started_at,
|
| 164 |
+
updated_at=self.updated_at, done=self.done)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ββ AgentFSMRunner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
|
| 169 |
+
class AgentFSMRunner:
|
| 170 |
+
"""
|
| 171 |
+
Esegue la FSM step-by-step.
|
| 172 |
+
Ogni stato chiama il componente appropriato (Planner, LLM, ToolEngine).
|
| 173 |
+
"""
|
| 174 |
+
|
| 175 |
+
async def run(self, fsm: AgentFSM) -> FSMStatus:
|
| 176 |
+
"""Esegue la FSM fino a DONE o FAILED."""
|
| 177 |
+
try:
|
| 178 |
+
# IDLE β PLAN
|
| 179 |
+
fsm.transition(AgentState.PLAN, "starting run")
|
| 180 |
+
await self._state_plan(fsm)
|
| 181 |
+
|
| 182 |
+
while not fsm.done:
|
| 183 |
+
if fsm.context.current_step >= fsm.context.max_steps:
|
| 184 |
+
fsm.transition(AgentState.FAILED, f"max_steps={fsm.context.max_steps} raggiunto")
|
| 185 |
+
break
|
| 186 |
+
|
| 187 |
+
if fsm.state == AgentState.THINK:
|
| 188 |
+
await self._state_think(fsm)
|
| 189 |
+
elif fsm.state == AgentState.TOOL:
|
| 190 |
+
await self._state_tool(fsm)
|
| 191 |
+
elif fsm.state == AgentState.WAIT:
|
| 192 |
+
await self._state_wait(fsm)
|
| 193 |
+
elif fsm.state == AgentState.OBSERVE:
|
| 194 |
+
await self._state_observe(fsm)
|
| 195 |
+
elif fsm.state == AgentState.DECIDE:
|
| 196 |
+
await self._state_decide(fsm)
|
| 197 |
+
else:
|
| 198 |
+
fsm.transition(AgentState.FAILED, f"stato sconosciuto: {fsm.state}")
|
| 199 |
+
break
|
| 200 |
+
except Exception as exc:
|
| 201 |
+
_logger.error("[fsm:%s] unhandled error: %s", fsm.fsm_id, exc)
|
| 202 |
+
if not fsm.done:
|
| 203 |
+
fsm.context.error = str(exc)
|
| 204 |
+
try:
|
| 205 |
+
fsm.transition(AgentState.FAILED, f"unhandled: {exc}")
|
| 206 |
+
except Exception as _te:
|
| 207 |
+
_logger.debug("[fsm:%s] transitionβFAILED fallito: %s", fsm.fsm_id, _te)
|
| 208 |
+
return fsm.to_status()
|
| 209 |
+
|
| 210 |
+
async def _state_plan(self, fsm: AgentFSM) -> None:
|
| 211 |
+
"""PLAN: chiama BrainPlanner per generare il piano."""
|
| 212 |
+
if _PLANNER_AVAILABLE and _planner is not None and _PlanReq is not None:
|
| 213 |
+
try:
|
| 214 |
+
plan = await _planner.plan(_PlanReq(
|
| 215 |
+
goal = fsm.context.goal,
|
| 216 |
+
hints = fsm.context.metadata.get("hints", []),
|
| 217 |
+
max_steps = fsm.context.max_steps,
|
| 218 |
+
))
|
| 219 |
+
fsm.context.plan_id = plan.plan_id
|
| 220 |
+
# Carica capabilities come "tool queue" nel contesto
|
| 221 |
+
fsm.context.metadata["tool_queue"] = [
|
| 222 |
+
{"capability": s.capability, "payload": s.payload, "step_id": s.step_id}
|
| 223 |
+
for s in plan.steps
|
| 224 |
+
]
|
| 225 |
+
fsm.transition(AgentState.THINK, f"piano generato: {len(plan.steps)} step")
|
| 226 |
+
return
|
| 227 |
+
except Exception as exc:
|
| 228 |
+
_logger.warning("[fsm:%s] planner error: %s", fsm.fsm_id, exc)
|
| 229 |
+
# Fallback: THINK diretto senza piano
|
| 230 |
+
fsm.transition(AgentState.THINK, "planner non disponibile, THINK diretto")
|
| 231 |
+
|
| 232 |
+
async def _state_think(self, fsm: AgentFSM) -> None:
|
| 233 |
+
"""THINK: seleziona il prossimo tool da eseguire."""
|
| 234 |
+
tool_queue = fsm.context.metadata.get("tool_queue", [])
|
| 235 |
+
if not tool_queue:
|
| 236 |
+
fsm.transition(AgentState.DECIDE, "tool queue vuota β DECIDE")
|
| 237 |
+
return
|
| 238 |
+
next_tool = tool_queue.pop(0)
|
| 239 |
+
fsm.context.metadata["current_tool"] = next_tool
|
| 240 |
+
fsm.context.last_tool = next_tool.get("capability")
|
| 241 |
+
fsm.context.current_step += 1
|
| 242 |
+
fsm.transition(AgentState.TOOL, f"eseguo tool: {next_tool.get('capability')}")
|
| 243 |
+
|
| 244 |
+
async def _state_tool(self, fsm: AgentFSM) -> None:
|
| 245 |
+
"""TOOL: esegui il tool selezionato in THINK."""
|
| 246 |
+
current = fsm.context.metadata.get("current_tool", {})
|
| 247 |
+
cap = current.get("capability", "llm")
|
| 248 |
+
payload = current.get("payload", {})
|
| 249 |
+
|
| 250 |
+
if _TOOL_ENGINE_AVAILABLE and _tool_exec is not None and _TExecReq is not None:
|
| 251 |
+
try:
|
| 252 |
+
result = await _tool_exec.execute(_TExecReq(
|
| 253 |
+
tool_name = cap, payload = payload,
|
| 254 |
+
correlation_id = fsm.fsm_id,
|
| 255 |
+
))
|
| 256 |
+
fsm.context.last_result = result.model_dump()
|
| 257 |
+
fsm.context.tool_results.append({"tool": cap, "result": fsm.context.last_result})
|
| 258 |
+
fsm.transition(AgentState.OBSERVE, f"tool {cap} eseguito: {result.status}")
|
| 259 |
+
return
|
| 260 |
+
except Exception as exc:
|
| 261 |
+
fsm.context.error = str(exc)
|
| 262 |
+
_logger.warning("[fsm:%s] tool %s error: %s", fsm.fsm_id, cap, exc)
|
| 263 |
+
# Fallback: submit via Kernel
|
| 264 |
+
if _KERNEL_AVAILABLE and _kernel is not None:
|
| 265 |
+
result = await _kernel.submit_task({"capability": cap, **payload})
|
| 266 |
+
fsm.context.last_result = result.model_dump() if hasattr(result, "model_dump") else str(result)
|
| 267 |
+
fsm.context.tool_results.append({"tool": cap, "result": fsm.context.last_result})
|
| 268 |
+
fsm.transition(AgentState.OBSERVE, f"kernel submit: {cap}")
|
| 269 |
+
else:
|
| 270 |
+
fsm.transition(AgentState.OBSERVE, f"tool {cap} skipped (no executor)")
|
| 271 |
+
|
| 272 |
+
async def _state_wait(self, fsm: AgentFSM) -> None:
|
| 273 |
+
"""WAIT: attende risultato asincrono (polling con backoff)."""
|
| 274 |
+
await asyncio.sleep(1)
|
| 275 |
+
fsm.transition(AgentState.OBSERVE, "wait complete")
|
| 276 |
+
|
| 277 |
+
async def _state_observe(self, fsm: AgentFSM) -> None:
|
| 278 |
+
"""OBSERVE: elabora risultato, aggiorna messages."""
|
| 279 |
+
result = fsm.context.last_result
|
| 280 |
+
if result:
|
| 281 |
+
fsm.context.messages.append({
|
| 282 |
+
"role": "tool", "content": str(result)[:2000], # truncate
|
| 283 |
+
"tool": fsm.context.last_tool,
|
| 284 |
+
})
|
| 285 |
+
fsm.transition(AgentState.DECIDE, "osservazione completata")
|
| 286 |
+
|
| 287 |
+
async def _state_decide(self, fsm: AgentFSM) -> None:
|
| 288 |
+
"""DECIDE: goal raggiunto? Continua o termina."""
|
| 289 |
+
tool_queue = fsm.context.metadata.get("tool_queue", [])
|
| 290 |
+
if tool_queue:
|
| 291 |
+
fsm.transition(AgentState.THINK, f"coda non vuota: {len(tool_queue)} step rimanenti")
|
| 292 |
+
elif fsm.context.error:
|
| 293 |
+
fsm.transition(AgentState.FAILED, fsm.context.error)
|
| 294 |
+
else:
|
| 295 |
+
fsm.transition(AgentState.DONE, "tutti gli step completati")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ββ AgentFSMManager (registry delle FSM attive) βββββββββββββββββββββββββββββββββ
|
| 299 |
+
|
| 300 |
+
class AgentFSMManager:
|
| 301 |
+
def __init__(self) -> None:
|
| 302 |
+
self._instances: dict[str, AgentFSM] = {}
|
| 303 |
+
self._runner = AgentFSMRunner()
|
| 304 |
+
self._tasks: dict[str, asyncio.Task] = {}
|
| 305 |
+
self._lock = asyncio.Lock()
|
| 306 |
+
self._MAX_STORE = 200
|
| 307 |
+
|
| 308 |
+
async def start(self, req: FSMRunRequest) -> FSMStatus:
|
| 309 |
+
fsm_id = str(uuid.uuid4())
|
| 310 |
+
fsm = AgentFSM(fsm_id, req.goal, req.max_steps, req.hints)
|
| 311 |
+
if req.session_id:
|
| 312 |
+
fsm.context.session_id = req.session_id
|
| 313 |
+
async with self._lock:
|
| 314 |
+
self._instances[fsm_id] = fsm
|
| 315 |
+
if len(self._instances) > self._MAX_STORE:
|
| 316 |
+
oldest = sorted(self._instances, key=lambda k: self._instances[k].started_at)
|
| 317 |
+
for k in oldest[:10]:
|
| 318 |
+
self._instances.pop(k, None)
|
| 319 |
+
|
| 320 |
+
task = asyncio.create_task(self._runner.run(fsm))
|
| 321 |
+
self._tasks[fsm_id] = task
|
| 322 |
+
_logger.info("[fsm-manager] started fsm_id=%s goal=%s", fsm_id, req.goal[:60])
|
| 323 |
+
return fsm.to_status()
|
| 324 |
+
|
| 325 |
+
def get(self, fsm_id: str) -> FSMStatus | None:
|
| 326 |
+
fsm = self._instances.get(fsm_id)
|
| 327 |
+
return fsm.to_status() if fsm else None
|
| 328 |
+
|
| 329 |
+
def list_active(self, limit: int = 20) -> list[FSMStatus]:
|
| 330 |
+
all_fsm = sorted(self._instances.values(), key=lambda f: f.started_at, reverse=True)
|
| 331 |
+
return [f.to_status() for f in all_fsm[:limit]]
|
| 332 |
+
|
| 333 |
+
def status(self) -> dict:
|
| 334 |
+
instances = list(self._instances.values())
|
| 335 |
+
return {
|
| 336 |
+
"total": len(instances),
|
| 337 |
+
"running": sum(1 for f in instances if not f.done),
|
| 338 |
+
"done": sum(1 for f in instances if f.state == AgentState.DONE),
|
| 339 |
+
"failed": sum(1 for f in instances if f.state == AgentState.FAILED),
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ββ Singletons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 344 |
+
fsm_manager = AgentFSMManager()
|
| 345 |
+
|
| 346 |
+
# ββ HTTP Router ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 347 |
+
router = APIRouter(
|
| 348 |
+
prefix="/api/agent-fsm",
|
| 349 |
+
tags=["agent-fsm"],
|
| 350 |
+
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@router.post("/run", summary="Avvia un AgentFSM run (goal β DONE|FAILED in background)")
|
| 355 |
+
async def route_run(req: FSMRunRequest) -> FSMStatus:
|
| 356 |
+
return await fsm_manager.start(req)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@router.get("/runs/{fsm_id}", summary="Stato di un AgentFSM run")
|
| 360 |
+
async def route_get(fsm_id: str) -> FSMStatus:
|
| 361 |
+
status = fsm_manager.get(fsm_id)
|
| 362 |
+
if not status:
|
| 363 |
+
raise HTTPException(404, f"FSM '{fsm_id}' non trovata")
|
| 364 |
+
return status
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@router.get("/runs", summary="Lista AgentFSM runs attivi")
|
| 368 |
+
async def route_list(limit: int = 20) -> dict:
|
| 369 |
+
runs = fsm_manager.list_active(limit)
|
| 370 |
+
return {"count": len(runs), "runs": [r.model_dump() for r in runs]}
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@router.get("/status", summary="Stato aggregato AgentFSM manager")
|
| 374 |
+
async def route_status() -> dict:
|
| 375 |
+
return fsm_manager.status()
|
api/agent_loop_routes.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""agent_loop_routes.py β Route SSE legacy, persona helpers, reason/unified/loop, agent-kernel.
|
| 2 |
+
|
| 3 |
+
Estratto da agent.py (split 2026-06-30).
|
| 4 |
+
Route coperte:
|
| 5 |
+
POST /run_loop (deprecated 410)
|
| 6 |
+
POST /api/agent/run-stream (SSE legacy loop)
|
| 7 |
+
POST /api/reason/loop
|
| 8 |
+
POST /api/unified/loop
|
| 9 |
+
GET /api/agent-kernel/status
|
| 10 |
+
POST /api/agent-kernel/dispatch
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
import os, asyncio, json, uuid, time, re
|
| 14 |
+
import re as _re_persona
|
| 15 |
+
from fastapi import APIRouter, HTTPException, Request, Body
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
from fastapi.responses import StreamingResponse
|
| 18 |
+
from pydantic import BaseModel, field_validator
|
| 19 |
+
from typing import Literal
|
| 20 |
+
from .state import (
|
| 21 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 22 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 23 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 24 |
+
ReasonLoopIn, AgentTaskIn,
|
| 25 |
+
write_ahead_task_created,
|
| 26 |
+
)
|
| 27 |
+
from .speculative import fire_speculative_tools
|
| 28 |
+
try:
|
| 29 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 30 |
+
except Exception as _qg_err:
|
| 31 |
+
import logging as _qg_log; _qg_log.getLogger(__name__).warning("[routes] quality_guardian import failed: %s", _qg_err)
|
| 32 |
+
_run_quality_check = None
|
| 33 |
+
import logging
|
| 34 |
+
_logger = logging.getLogger("api.agent")
|
| 35 |
+
from .persistence import (
|
| 36 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 37 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 38 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 39 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
|
| 40 |
+
)
|
| 41 |
+
from ._agent_helpers import (
|
| 42 |
+
_RE_SURROGATES, _ss, _log_task_exc,
|
| 43 |
+
_PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
|
| 44 |
+
_build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
|
| 45 |
+
)
|
| 46 |
+
async def run_loop_removed():
|
| 47 |
+
"""S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=410,
|
| 50 |
+
detail={
|
| 51 |
+
"error": "Gone",
|
| 52 |
+
"message": "Endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream",
|
| 53 |
+
"migration": "/api/agent/tasks",
|
| 54 |
+
},
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ββ SSE run-stream ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
@router.post('/api/agent/run-stream')
|
| 61 |
+
async def agent_run_stream(body: ReasonLoopIn, request: Request):
|
| 62 |
+
# S-BENCH: auth guard β consistente con /api/exec e /api/execute-shell
|
| 63 |
+
_itok = os.getenv('INTERNAL_TOKEN', '')
|
| 64 |
+
if _itok and request.headers.get('X-Internal-Token') != _itok:
|
| 65 |
+
raise HTTPException(401, 'Unauthorized')
|
| 66 |
+
async def generate():
|
| 67 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 68 |
+
|
| 69 |
+
async def step_cb(step: dict) -> None:
|
| 70 |
+
await queue.put(step)
|
| 71 |
+
|
| 72 |
+
async def run_loop() -> None:
|
| 73 |
+
try:
|
| 74 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 75 |
+
# S388: usa singleton _get_ai_client() β nessuna re-istanziazione OpenAI() per request
|
| 76 |
+
client = _get_ai_client()
|
| 77 |
+
try:
|
| 78 |
+
from agents.critic import Critic
|
| 79 |
+
from agents.response_verifier import ResponseVerifier
|
| 80 |
+
_critic = Critic(llm_client=client)
|
| 81 |
+
_verifier = ResponseVerifier()
|
| 82 |
+
except Exception as _cv_err:
|
| 83 |
+
_logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
|
| 84 |
+
_critic = None
|
| 85 |
+
_verifier = None
|
| 86 |
+
# Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through)
|
| 87 |
+
_resume_ctx = getattr(body, '_resume_context', None)
|
| 88 |
+
_resume_max = getattr(body, '_resume_max_steps', None) or body.max_steps
|
| 89 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 90 |
+
# Bug-5-FIX: resume context iniettato DOPO che context_str Γ¨ definito (era NameError)
|
| 91 |
+
if _resume_ctx:
|
| 92 |
+
context_str = f"[RIPRESA AUTOMATICA]\n{_resume_ctx}\n\n{context_str}".strip()
|
| 93 |
+
|
| 94 |
+
loop = UnifiedAgentLoop(
|
| 95 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 96 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 97 |
+
)
|
| 98 |
+
# S456-X5: prepend project context (projectMemory.getContext() dal frontend)
|
| 99 |
+
if body.project_context:
|
| 100 |
+
context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
|
| 101 |
+
# S456-X4: inject top failure patterns appresi dal selfLearning frontend
|
| 102 |
+
if body.learning_hints:
|
| 103 |
+
# S591: learning_hints[:3]β[:5] β piΓΉ pattern appresi nel context
|
| 104 |
+
hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
|
| 105 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 106 |
+
# P35: vincoli negativi dal frontend (agentConstraints.ts β VFS /.agent/constraints.json)
|
| 107 |
+
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 108 |
+
if _neg_c:
|
| 109 |
+
context_str = f"[VINCOLI OPERATIVI APPRESI β NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
| 110 |
+
result = await loop.run(
|
| 111 |
+
goal=body.goal, context=context_str,
|
| 112 |
+
max_steps=body.max_steps, on_step=step_cb,
|
| 113 |
+
session_id=getattr(body, "session_id", "") or "",
|
| 114 |
+
)
|
| 115 |
+
await queue.put({
|
| 116 |
+
'__done__': True,
|
| 117 |
+
'result': result.get('output', ''),
|
| 118 |
+
'engine': result.get('engine', 'fallback'),
|
| 119 |
+
'success': result.get('success', False),
|
| 120 |
+
})
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
| 123 |
+
try:
|
| 124 |
+
from api.incident_registry import log_incident as _log_inc
|
| 125 |
+
asyncio.create_task(_log_inc(
|
| 126 |
+
task_id=body.goal[:32].replace(' ', '_'),
|
| 127 |
+
goal=body.goal, error=str(exc), source="agent",
|
| 128 |
+
)).add_done_callback(_log_task_exc)
|
| 129 |
+
except Exception as _exc:
|
| 130 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 131 |
+
await queue.put({'__error__': str(exc)})
|
| 132 |
+
|
| 133 |
+
task = asyncio.create_task(run_loop())
|
| 134 |
+
task_id = body.goal[:32].replace(' ', '_')
|
| 135 |
+
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
| 136 |
+
_run_stream_tasks[task_id] = {"task": task, "queue": queue}
|
| 137 |
+
yield "retry: 3000\n\n"
|
| 138 |
+
yield f"data: {json.dumps({'type': 'task_start', 'taskId': task_id})}\n\n"
|
| 139 |
+
|
| 140 |
+
# S386: fast-fail β se tutti i provider sono down (heartbeat lo sa giΓ ),
|
| 141 |
+
# non aspettare 120s di tentativi: rispondi subito con errore chiaro.
|
| 142 |
+
try:
|
| 143 |
+
from api.state import _heartbeat_state
|
| 144 |
+
_providers = _heartbeat_state.get("providers", [])
|
| 145 |
+
if _providers and not any(p.get("ok") for p in _providers):
|
| 146 |
+
task.cancel()
|
| 147 |
+
_names = ", ".join(p["name"] for p in _providers)
|
| 148 |
+
yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': 'system', 'abort_source': 'no_providers', 'error': f'Nessun provider AI disponibile ({_names})'})}\n\n" # MX18-ABORT: no providers β system abort
|
| 149 |
+
yield "data: [DONE]\n\n"
|
| 150 |
+
return
|
| 151 |
+
except Exception as _hb_err:
|
| 152 |
+
_logger.debug("[routes] heartbeat skip silenced: %s", type(_hb_err).__name__) # non inizializzato, prosegui normalmente
|
| 153 |
+
|
| 154 |
+
# S386: timeout ridotto 120β60s β risposta entro 1 minuto o errore esplicito
|
| 155 |
+
timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60'))
|
| 156 |
+
heartbeat_secs = 15.0
|
| 157 |
+
elapsed = 0.0
|
| 158 |
+
try:
|
| 159 |
+
while True:
|
| 160 |
+
try:
|
| 161 |
+
item = await asyncio.wait_for(queue.get(), timeout=heartbeat_secs)
|
| 162 |
+
elapsed = 0.0
|
| 163 |
+
except asyncio.TimeoutError:
|
| 164 |
+
elapsed += heartbeat_secs
|
| 165 |
+
if elapsed >= timeout_secs:
|
| 166 |
+
yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': 'timeout', 'abort_source': 'stream_timeout'})}\n\n" # MX18-ABORT: timeout β task_aborted
|
| 167 |
+
break
|
| 168 |
+
yield 'data: {"type":"ping"}\n\n'
|
| 169 |
+
continue
|
| 170 |
+
# ABORT-2: segnale abort dall'endpoint POST /api/agent/abort
|
| 171 |
+
if "__abort__" in item:
|
| 172 |
+
_ar = item.get('abort_reason', 'user_stop') # MX18-ABORT: dynamic reason
|
| 173 |
+
_src = item.get('abort_source', 'backend_abort_queue')
|
| 174 |
+
yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': _ar, 'abort_source': _src})}\n\n" # MX16+MX18-ABORT
|
| 175 |
+
break
|
| 176 |
+
if '__error__' in item:
|
| 177 |
+
yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': _ss(item['__error__'])})}\n\n"
|
| 178 |
+
break
|
| 179 |
+
# S420: streaming token β emetti subito al frontend senza accumulare
|
| 180 |
+
if item.get('action') == 'text_chunk':
|
| 181 |
+
yield f"data: {json.dumps({'type': 'text_chunk', 'token': _ss(item.get('token', '')), 'taskId': task_id})}\n\n"
|
| 182 |
+
continue
|
| 183 |
+
# S758-P4.1: tool_use β chip pre-esecuzione (agent_run_stream path)
|
| 184 |
+
_rs_act = item.get('action', '')
|
| 185 |
+
_rs_st = item.get('status', '')
|
| 186 |
+
if ((_rs_act == 'tool_start' and _rs_st == 'running') or
|
| 187 |
+
(_rs_act.startswith('executor:') and _rs_st == 'started')):
|
| 188 |
+
_rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
|
| 189 |
+
yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': item.get('title', _rs_tool.replace('_', ' ').capitalize())})}\n\n"
|
| 190 |
+
if '__done__' in item:
|
| 191 |
+
yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n"
|
| 192 |
+
break
|
| 193 |
+
# S393 Priority 1: Narrative Streaming β arricchisce step_done con explanation
|
| 194 |
+
_NARR_QUICK = {
|
| 195 |
+
'llm': 'Elaborazione risposta AI',
|
| 196 |
+
'direct_tools': 'Strumenti diretti',
|
| 197 |
+
'web_search': 'Ricerca web', 'get_weather': 'Dati meteo',
|
| 198 |
+
'read_page': 'Lettura pagina', 'calculate': 'Calcolo matematico',
|
| 199 |
+
'generate_image': 'Generazione immagine AI',
|
| 200 |
+
'execution_validator_fix': 'Auto-correzione codice (S393)',
|
| 201 |
+
'tool_governor_skip': 'Tool giΓ eseguito β risultato riutilizzato',
|
| 202 |
+
# S661: label narrative per tool aggiunti in S648-S659 β prima usavano
|
| 203 |
+
# _act_q.replace('_',' ').capitalize() β "Apply patch", "Call api" (generico)
|
| 204 |
+
'apply_patch': 'Applico patch al fileβ¦',
|
| 205 |
+
'call_api': 'Chiamo API RESTβ¦',
|
| 206 |
+
'send_email': 'Invio emailβ¦',
|
| 207 |
+
'create_pdf': 'Genero documento PDFβ¦',
|
| 208 |
+
'web_research': 'Ricerca multi-fonteβ¦',
|
| 209 |
+
'write_file': 'Scrivo fileβ¦',
|
| 210 |
+
'read_file': 'Leggo fileβ¦',
|
| 211 |
+
'execute_shell': 'Eseguo comando shellβ¦',
|
| 212 |
+
'analyze_image': 'Analizzo immagineβ¦',
|
| 213 |
+
'run_python': 'Eseguo Python (Pyodide)β¦',
|
| 214 |
+
# S-GAP1: narrative fasi strategiche
|
| 215 |
+
'plan': 'Analizzo la richiesta e preparo un piano di esecuzioneβ¦',
|
| 216 |
+
'reflective_debug': 'Ho incontrato un ostacolo β ricalcolo una strategia piΓΉ efficienteβ¦',
|
| 217 |
+
'fallback': 'Adotto un approccio alternativo per completare il taskβ¦',
|
| 218 |
+
'smolagents': 'Orchestro gli strumenti necessariβ¦',
|
| 219 |
+
}
|
| 220 |
+
_act_q = item.get('action', '')
|
| 221 |
+
if 'explanation' not in item:
|
| 222 |
+
item['explanation'] = _NARR_QUICK.get(_act_q, _act_q.replace('_', ' ').capitalize())
|
| 223 |
+
if 'title' not in item:
|
| 224 |
+
item['title'] = item['explanation']
|
| 225 |
+
|
| 226 |
+
# S403: SSE Visibility Guard β classifica ogni step event:
|
| 227 |
+
# "internal" β mai visibile (pipeline internals: planner, llm, reflection)
|
| 228 |
+
# "progress" β visibile come progress card (tool reali, auto-fix)
|
| 229 |
+
# "debug" β visibile solo in dev mode (direct_tools, fast_path)
|
| 230 |
+
# Il frontend filtra per visibility β solo "progress" mostrato all'utente.
|
| 231 |
+
_STEP_VISIBILITY: dict[str, str] = {
|
| 232 |
+
# Internal pipeline β never shown to user
|
| 233 |
+
'plan': 'progress', # S-GAP1
|
| 234 |
+
'llm': 'internal',
|
| 235 |
+
'smolagents': 'internal',
|
| 236 |
+
'fallback': 'progress', # S-GAP1
|
| 237 |
+
'reflective_debug': 'progress', # S-GAP1
|
| 238 |
+
'fast_path': 'internal',
|
| 239 |
+
'executor': 'internal',
|
| 240 |
+
# Progress β shown as step cards (user-visible)
|
| 241 |
+
'tool_start': 'progress',
|
| 242 |
+
'execution_validator_fix': 'progress',
|
| 243 |
+
'goal_verifier': 'progress',
|
| 244 |
+
'web_search': 'progress',
|
| 245 |
+
'get_weather': 'progress',
|
| 246 |
+
'read_page': 'progress',
|
| 247 |
+
'calculate': 'progress',
|
| 248 |
+
'generate_image': 'progress',
|
| 249 |
+
'run_python': 'progress',
|
| 250 |
+
'tool_governor_skip': 'progress',
|
| 251 |
+
# S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY β
|
| 252 |
+
# fallback rule: _act_q.startswith('tool_') era False per questi β
|
| 253 |
+
# classificati 'debug' β nascosti all'utente durante esecuzione.
|
| 254 |
+
'apply_patch': 'progress',
|
| 255 |
+
'call_api': 'progress',
|
| 256 |
+
'send_email': 'progress',
|
| 257 |
+
'create_pdf': 'progress',
|
| 258 |
+
'web_research': 'progress',
|
| 259 |
+
'write_file': 'progress',
|
| 260 |
+
'read_file': 'progress',
|
| 261 |
+
'execute_shell': 'progress',
|
| 262 |
+
'analyze_image': 'progress',
|
| 263 |
+
# Debug β shown only when devMode active
|
| 264 |
+
'direct_tools': 'debug',
|
| 265 |
+
# S-LOOP2: fase esecuzione avanzata β visibili come progress card
|
| 266 |
+
'reasoning_core': 'progress', # S-LOOP2: ReasoningCore multi-step
|
| 267 |
+
'browser_verifier': 'progress', # S-LOOP2: Browser Goal Verification live
|
| 268 |
+
}
|
| 269 |
+
# Fallback: azioni sconosciute con "tool_" prefix β progress; resto β debug
|
| 270 |
+
_vis = _STEP_VISIBILITY.get(_act_q)
|
| 271 |
+
if _vis is None:
|
| 272 |
+
_vis = 'progress' if _act_q.startswith('tool_') or _act_q.startswith('executor:') else 'debug'
|
| 273 |
+
item['visibility'] = _vis
|
| 274 |
+
|
| 275 |
+
yield f"data: {json.dumps({'type': 'step_done', 'step': item, 'taskId': task_id})}\n\n"
|
| 276 |
+
finally:
|
| 277 |
+
task.cancel()
|
| 278 |
+
# ABORT-3: cleanup registro β libera memoria e impedisce abort su task giΓ terminati
|
| 279 |
+
_run_stream_tasks.pop(task_id, None)
|
| 280 |
+
yield "data: [DONE]\n\n"
|
| 281 |
+
|
| 282 |
+
return StreamingResponse(generate(), media_type="text/event-stream",
|
| 283 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ββ Reason loop / Unified loop βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 287 |
+
|
| 288 |
+
@router.post('/api/reason/loop')
|
| 289 |
+
async def reason_loop(body: ReasonLoopIn):
|
| 290 |
+
try:
|
| 291 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 292 |
+
# S388: singleton β riusa il client giΓ inizializzato
|
| 293 |
+
client = _get_ai_client()
|
| 294 |
+
try:
|
| 295 |
+
from agents.critic import Critic
|
| 296 |
+
from agents.response_verifier import ResponseVerifier
|
| 297 |
+
_critic = Critic(llm_client=client)
|
| 298 |
+
_verifier = ResponseVerifier()
|
| 299 |
+
except Exception as _cv_err:
|
| 300 |
+
_logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err)
|
| 301 |
+
_critic = None
|
| 302 |
+
_verifier = None
|
| 303 |
+
loop = UnifiedAgentLoop(
|
| 304 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 305 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 306 |
+
)
|
| 307 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 308 |
+
# N-2-FIX: accumula step intermedi tramite on_step β inclusi nel response JSON per debug frontend
|
| 309 |
+
_steps_log: list[dict] = []
|
| 310 |
+
async def _on_step(step_data: dict) -> None:
|
| 311 |
+
_steps_log.append({
|
| 312 |
+
'action': step_data.get('action', ''),
|
| 313 |
+
'output': str(step_data.get('output', ''))[:400], # S577: 200β400
|
| 314 |
+
})
|
| 315 |
+
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
|
| 316 |
+
if isinstance(result, dict):
|
| 317 |
+
output_text = result.get('output', '') or result.get('answer', '') or ''
|
| 318 |
+
engine_used = result.get('engine', 'ambiguity-gate' if result.get('answer') else 'unknown')
|
| 319 |
+
errors_list = result.get('errors', [])
|
| 320 |
+
else:
|
| 321 |
+
output_text = str(result)
|
| 322 |
+
engine_used = 'unknown'
|
| 323 |
+
errors_list = []
|
| 324 |
+
return {
|
| 325 |
+
'ok': bool(output_text and output_text.strip()),
|
| 326 |
+
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
| 327 |
+
'output': output_text, # alias compat frontend
|
| 328 |
+
'result': output_text,
|
| 329 |
+
'source': 'backend_loop',
|
| 330 |
+
'engine': engine_used,
|
| 331 |
+
'errors': errors_list,
|
| 332 |
+
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
| 333 |
+
}
|
| 334 |
+
except Exception as e:
|
| 335 |
+
_logger.error("[reason/loop] Error: %s", e)
|
| 336 |
+
return {
|
| 337 |
+
'ok': False,
|
| 338 |
+
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 339 |
+
'source': 'fallback',
|
| 340 |
+
'steps': [],
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@router.post('/api/unified/loop')
|
| 345 |
+
async def unified_loop(body: ReasonLoopIn):
|
| 346 |
+
"""Alias di /api/reason/loop β compatibilitΓ con tutte le versioni frontend."""
|
| 347 |
+
return await reason_loop(body)
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# ββ Agent kernel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 351 |
+
|
| 352 |
+
@router.get('/api/agent-kernel/status')
|
| 353 |
+
async def agent_kernel_status():
|
| 354 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 355 |
+
return {
|
| 356 |
+
'dispatch_available': bool(gh_token),
|
| 357 |
+
'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
|
| 358 |
+
'mobile_url': 'https://github.com/Baida98/AI/actions',
|
| 359 |
+
'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN', 'NVIDIA_API_KEY'],
|
| 360 |
+
'usage': 'Vai su GitHub Actions β Agent Kernel β no PC β Run workflow β inserisci il goal',
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# S442-FIX3: modello Pydantic per agent_kernel_dispatch.
|
| 365 |
+
# Prima: body: dict grezzo β mode non validato, goal controllato solo dopo estrazione.
|
| 366 |
+
# Ora: validazione in ingresso β 422 chiaro invece di 500 a runtime.
|
| 367 |
+
class AgentKernelDispatchIn(BaseModel):
|
| 368 |
+
goal: str
|
| 369 |
+
mode: Literal["plan", "execute", "analyze"] = "plan"
|
| 370 |
+
|
| 371 |
+
@field_validator('goal', mode='before')
|
| 372 |
+
@classmethod
|
| 373 |
+
def validate_goal(cls, v: object) -> str:
|
| 374 |
+
if not isinstance(v, str) or not str(v).strip():
|
| 375 |
+
raise ValueError('goal must be a non-empty string')
|
| 376 |
+
return str(v).strip()
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@router.post('/api/agent-kernel/dispatch')
|
| 380 |
+
async def agent_kernel_dispatch(body: AgentKernelDispatchIn):
|
| 381 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 382 |
+
if not gh_token:
|
| 383 |
+
raise HTTPException(503, detail={
|
| 384 |
+
'error': 'no_github_token',
|
| 385 |
+
'message': 'GITHUB_TOKEN non configurato nel backend.',
|
| 386 |
+
})
|
| 387 |
+
goal = body.goal
|
| 388 |
+
mode = body.mode
|
| 389 |
+
import httpx as _httpx
|
| 390 |
+
try:
|
| 391 |
+
async with _httpx.AsyncClient(timeout=15) as _hc:
|
| 392 |
+
_resp = await _hc.post(
|
| 393 |
+
'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
|
| 394 |
+
json={'ref': 'main', 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'}},
|
| 395 |
+
headers={
|
| 396 |
+
'Authorization': f'Bearer {gh_token}',
|
| 397 |
+
'Accept': 'application/vnd.github+json',
|
| 398 |
+
'X-GitHub-Api-Version': '2022-11-28',
|
| 399 |
+
},
|
| 400 |
+
)
|
| 401 |
+
if _resp.status_code >= 400:
|
| 402 |
+
raise HTTPException(_resp.status_code, detail=_resp.text[:500])
|
| 403 |
+
return {'ok': True, 'status': _resp.status_code, 'goal': goal, 'mode': mode}
|
| 404 |
+
except _httpx.HTTPError as e:
|
| 405 |
+
raise HTTPException(502, detail=str(e)[:500])
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ββ Agent tasks (FASE 2.1 + S359 persistence) ββββββββββββββββββββββββββββββββββ
|
| 409 |
+
|
| 410 |
+
|
api/agent_memory.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 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).limit(500).execute()
|
| 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).limit(500).execute() # BUGFIX: LIMIT 500 β senza limit OOM su account grandi
|
| 66 |
entries = [
|
| 67 |
+
{'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
|
| 68 |
+
'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
for r in (data.data or [])
|
| 70 |
]
|
| 71 |
return {'entries': entries}
|
| 72 |
except Exception as e:
|
| 73 |
_logger.warning('[memory] Supabase list error: %s', e)
|
| 74 |
+
return {'entries': list(_mem_fallback.values())}
|
| 75 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
@router.get('/api/memory/agent/{key}')
|
| 78 |
async def get_agent_memory(key: str):
|
|
|
|
|
|
|
| 79 |
if _sb:
|
| 80 |
try:
|
| 81 |
data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
|
| 82 |
if data.data:
|
| 83 |
+
return {'value': data.data[0]['value']}
|
| 84 |
except Exception as e:
|
| 85 |
_logger.warning('[memory] Supabase get error: %s', e)
|
| 86 |
+
entry = _mem_fallback.get(key)
|
| 87 |
+
return {'value': entry['value'] if entry else None}
|
| 88 |
+
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
@router.post('/api/memory/agent')
|
| 91 |
async def set_agent_memory(entry: MemoryEntry):
|
|
|
|
| 94 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 95 |
'createdAt': entry.createdAt or now, 'updatedAt': entry.updatedAt or now,
|
| 96 |
}
|
| 97 |
+
# Sempre scrivi in fallback prima (garanzia immediata)
|
| 98 |
_mem_fallback[entry.key] = record
|
| 99 |
+
|
| 100 |
if _sb:
|
| 101 |
try:
|
| 102 |
_sb.table('agent_memory').upsert({
|
| 103 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
| 104 |
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 105 |
}, on_conflict='key').execute()
|
| 106 |
+
# GAP-MEM-FIX: Supabase disponibile β schedula riconciliazione fallback orfano
|
| 107 |
+
# (voci scritte solo in fallback durante downtime precedente)
|
| 108 |
if len(_mem_fallback) > 1:
|
| 109 |
asyncio.create_task(_reconcile_fallback())
|
| 110 |
except Exception as _e:
|
| 111 |
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
| 112 |
+
|
| 113 |
return {'ok': True, 'key': entry.key}
|
| 114 |
|
| 115 |
+
|
| 116 |
@router.delete('/api/memory/agent/{key}')
|
| 117 |
async def delete_agent_memory(key: str):
|
| 118 |
if _sb:
|
| 119 |
try:
|
| 120 |
_sb.table('agent_memory').delete().eq('key', key).execute()
|
| 121 |
except Exception as _exc:
|
| 122 |
+
_logger.debug("[agent_memory] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 123 |
_mem_fallback.pop(key, None)
|
| 124 |
return {'deleted': key}
|
api/agent_task_routes.py
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""agent_task_routes.py β Task CRUD: crea, lista, cancella, stato, stream SSE.
|
| 2 |
+
|
| 3 |
+
Estratto da agent.py (split 2026-06-30).
|
| 4 |
+
Route coperte:
|
| 5 |
+
POST /api/agent/tasks
|
| 6 |
+
GET /api/agent/tasks
|
| 7 |
+
DELETE /api/agent/tasks/{task_id}
|
| 8 |
+
GET /api/agent/tasks/{task_id}/status
|
| 9 |
+
GET /api/agent/tasks/{task_id}/stream (SSE principale, S359 persist)
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
import os, asyncio, json, uuid, time, re
|
| 13 |
+
import re as _re_persona
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Request, Body
|
| 15 |
+
from fastapi.responses import StreamingResponse
|
| 16 |
+
from pydantic import BaseModel, field_validator
|
| 17 |
+
from typing import Literal
|
| 18 |
+
from .state import (
|
| 19 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 20 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 21 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 22 |
+
ReasonLoopIn, AgentTaskIn,
|
| 23 |
+
write_ahead_task_created,
|
| 24 |
+
)
|
| 25 |
+
from .speculative import fire_speculative_tools
|
| 26 |
+
from .prewarm import fire_predictive_prewarm # S950
|
| 27 |
+
try:
|
| 28 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 29 |
+
except Exception:
|
| 30 |
+
_run_quality_check = None
|
| 31 |
+
import logging
|
| 32 |
+
_logger = logging.getLogger("api.agent")
|
| 33 |
+
from .persistence import (
|
| 34 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 35 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 36 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 37 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff,
|
| 38 |
+
)
|
| 39 |
+
from ._agent_helpers import (
|
| 40 |
+
_RE_SURROGATES, _ss, _log_task_exc,
|
| 41 |
+
_PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE,
|
| 42 |
+
_build_persona_kw_map, _classify_persona_server, _get_persona_llm_client,
|
| 43 |
+
)
|
| 44 |
+
router = APIRouter()
|
| 45 |
+
|
| 46 |
+
@router.post('/api/agent/tasks')
|
| 47 |
+
async def create_agent_task(body: AgentTaskIn):
|
| 48 |
+
"""
|
| 49 |
+
Crea o recupera un task agent.
|
| 50 |
+
|
| 51 |
+
S359: se task_id non Γ¨ in memoria ma esiste su Supabase (backend ha riavviato),
|
| 52 |
+
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 53 |
+
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 54 |
+
"""
|
| 55 |
+
_prune_agent_tasks()
|
| 56 |
+
task_id = body.taskId or str(uuid.uuid4())
|
| 57 |
+
|
| 58 |
+
# Already in memory β return immediately (normal path, includes S358 reconnect)
|
| 59 |
+
if task_id in _agent_tasks:
|
| 60 |
+
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 61 |
+
|
| 62 |
+
# S359: try Supabase lazy restore (only hit network after backend restart)
|
| 63 |
+
restored = await sb_restore_task(task_id)
|
| 64 |
+
if restored:
|
| 65 |
+
# Put restored metadata back into memory so stream_agent_task can use it.
|
| 66 |
+
# Use context from the incoming request (not persisted to save space).
|
| 67 |
+
restored['context'] = body.context
|
| 68 |
+
_agent_tasks[task_id] = restored
|
| 69 |
+
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 70 |
+
|
| 71 |
+
# Brand new task
|
| 72 |
+
created_at = int(time.time() * 1000)
|
| 73 |
+
_agent_tasks[task_id] = {
|
| 74 |
+
'id': task_id,
|
| 75 |
+
'status': 'QUEUED',
|
| 76 |
+
'goal': body.goal,
|
| 77 |
+
'context': body.context,
|
| 78 |
+
'max_steps': body.max_steps,
|
| 79 |
+
'created_at': created_at,
|
| 80 |
+
'project_context': body.project_context, # S456-X5
|
| 81 |
+
'learning_hints': body.learning_hints, # S456-X4
|
| 82 |
+
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 83 |
+
'persona': body.persona, # P17-F5: expertise persona hint
|
| 84 |
+
'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ'')
|
| 85 |
+
}
|
| 86 |
+
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 87 |
+
# periodico (15-60s). Finestra di perdita per la fase di creazione β zero.
|
| 88 |
+
asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 89 |
+
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 90 |
+
if body.session_id:
|
| 91 |
+
_hctx = await sb_restore_handoff_context(body.session_id)
|
| 92 |
+
if _hctx:
|
| 93 |
+
_agent_tasks[task_id]['_handoff_context'] = _hctx
|
| 94 |
+
asyncio.create_task(sb_delete_handoff(body.session_id)).add_done_callback(_log_task_exc)
|
| 95 |
+
# Persist asynchronously β never block the response
|
| 96 |
+
asyncio.create_task(
|
| 97 |
+
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 98 |
+
).add_done_callback(_log_task_exc)
|
| 99 |
+
# S361: Speculative Tool Firing β pre-fires read-only tools in parallel
|
| 100 |
+
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 101 |
+
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 102 |
+
# S950: Predictive Pre-warming cluster 4x4
|
| 103 |
+
fire_predictive_prewarm(body.goal)
|
| 104 |
+
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ββ S369: List agent tasks (in-memory + Supabase merge) βββββββββββββββββββββ
|
| 108 |
+
|
| 109 |
+
@router.get('/api/agent/tasks')
|
| 110 |
+
async def list_agent_tasks(limit: int = 50, status: str = ''):
|
| 111 |
+
"""
|
| 112 |
+
S369 β Lista tutti i task agent: unione di in-memory (_agent_tasks) e
|
| 113 |
+
Supabase (ultimi N task persistiti). In-memory ha sempre precedenza.
|
| 114 |
+
|
| 115 |
+
Query params:
|
| 116 |
+
limit β max task da Supabase (default 50, max 200)
|
| 117 |
+
status β filtra per status (es. RUNNING, SUCCESS, ERROR); vuoto = tutti
|
| 118 |
+
"""
|
| 119 |
+
_prune_agent_tasks()
|
| 120 |
+
now_ms = int(time.time() * 1000)
|
| 121 |
+
limit = min(max(limit, 1), 200)
|
| 122 |
+
|
| 123 |
+
# 1. Task in-memory (live)
|
| 124 |
+
mem_tasks = []
|
| 125 |
+
for tid, t in _agent_tasks.items():
|
| 126 |
+
reg = _loop_registry.get(tid)
|
| 127 |
+
is_live = reg is not None and not reg.get('done', True)
|
| 128 |
+
mem_tasks.append({
|
| 129 |
+
'taskId': tid,
|
| 130 |
+
'goal': (t.get('goal') or '')[:300], # S606: 200β300
|
| 131 |
+
'status': t.get('status', 'UNKNOWN'),
|
| 132 |
+
'maxSteps': t.get('max_steps', 8),
|
| 133 |
+
'createdAt': t.get('created_at', 0),
|
| 134 |
+
'ageMs': now_ms - t.get('created_at', now_ms),
|
| 135 |
+
'source': 'memory',
|
| 136 |
+
'isLive': is_live,
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
mem_ids = {t['taskId'] for t in mem_tasks}
|
| 140 |
+
|
| 141 |
+
# 2. Supabase recent tasks (only if Supabase available)
|
| 142 |
+
sb_tasks = []
|
| 143 |
+
try:
|
| 144 |
+
sb_rows = await sb_list_tasks(limit=limit, status_filter=status or None)
|
| 145 |
+
for r in sb_rows:
|
| 146 |
+
if r['task_id'] in mem_ids:
|
| 147 |
+
continue # already included from memory
|
| 148 |
+
sb_tasks.append({
|
| 149 |
+
'taskId': r['task_id'],
|
| 150 |
+
'goal': (r.get('goal') or '')[:300], # S606: 200β300
|
| 151 |
+
'status': r.get('status', 'UNKNOWN'),
|
| 152 |
+
'maxSteps': r.get('max_steps', 8),
|
| 153 |
+
'createdAt': r.get('created_at', 0),
|
| 154 |
+
'ageMs': now_ms - r.get('created_at', now_ms),
|
| 155 |
+
'source': 'supabase',
|
| 156 |
+
'isLive': False,
|
| 157 |
+
})
|
| 158 |
+
except Exception as _exc:
|
| 159 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 160 |
+
|
| 161 |
+
all_tasks = mem_tasks + sb_tasks
|
| 162 |
+
# Apply status filter to in-memory tasks too
|
| 163 |
+
if status:
|
| 164 |
+
all_tasks = [t for t in all_tasks if t['status'] == status.upper()]
|
| 165 |
+
|
| 166 |
+
# Sort by createdAt desc (newest first)
|
| 167 |
+
all_tasks.sort(key=lambda t: t['createdAt'], reverse=True)
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
'count': len(all_tasks),
|
| 171 |
+
'memory': len(mem_tasks),
|
| 172 |
+
'supabase': len(sb_tasks),
|
| 173 |
+
'tasks': all_tasks[:limit],
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.delete('/api/agent/tasks/{task_id}')
|
| 178 |
+
async def cancel_agent_task(task_id: str):
|
| 179 |
+
if task_id in _agent_tasks:
|
| 180 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 181 |
+
reg = _loop_registry.get(task_id)
|
| 182 |
+
if reg and not reg.get('done'):
|
| 183 |
+
at = reg.get('asyncio_task')
|
| 184 |
+
if at and not at.done():
|
| 185 |
+
at.cancel()
|
| 186 |
+
# Persist status + clean up events
|
| 187 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 188 |
+
asyncio.create_task(sb_delete_task_events(task_id)).add_done_callback(_log_task_exc)
|
| 189 |
+
# S361: clean speculative cache for cancelled task
|
| 190 |
+
try:
|
| 191 |
+
goal = _agent_tasks.get(task_id, {}).get('goal', '')
|
| 192 |
+
if goal:
|
| 193 |
+
from .speculative import purge_speculative
|
| 194 |
+
purge_speculative(goal)
|
| 195 |
+
except Exception as _exc:
|
| 196 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 197 |
+
return {'cancelled': task_id}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.get('/api/agent/tasks/{task_id}/status')
|
| 202 |
+
async def get_agent_task_status(task_id: str):
|
| 203 |
+
"""
|
| 204 |
+
Controlla lo stato di un task agent senza aprire un SSE stream.
|
| 205 |
+
Usato dal frontend per recovery al boot: verifica se un task in sospeso
|
| 206 |
+
e` ancora in esecuzione, completato, o scomparso dopo riavvio HF Space.
|
| 207 |
+
Returns: {taskId, status, goal, source: 'memory'|'supabase'|'not_found'}
|
| 208 |
+
"""
|
| 209 |
+
if task_id in _agent_tasks:
|
| 210 |
+
t = _agent_tasks[task_id]
|
| 211 |
+
return {'taskId': task_id, 'status': t.get('status', 'UNKNOWN'),
|
| 212 |
+
'goal': (t.get('goal') or '')[:300], 'source': 'memory'}
|
| 213 |
+
restored = await sb_restore_task(task_id)
|
| 214 |
+
if restored:
|
| 215 |
+
return {'taskId': task_id, 'status': restored.get('status', 'UNKNOWN'),
|
| 216 |
+
'goal': (restored.get('goal') or '')[:300], 'source': 'supabase'}
|
| 217 |
+
return {'taskId': task_id, 'status': 'NOT_FOUND', 'source': None}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@router.get('/api/agent/tasks/{task_id}/stream')
|
| 221 |
+
async def stream_agent_task(task_id: str, request: Request, resume: int = 0):
|
| 222 |
+
"""
|
| 223 |
+
SSE stream per un task agent.
|
| 224 |
+
|
| 225 |
+
S358: reconnect-safe via _loop_registry fanout (no re-run mentre il backend gira).
|
| 226 |
+
S359: lazy restore da Supabase dopo restart HF Space:
|
| 227 |
+
- Task SUCCESS/ERROR β replay event buffer da Supabase β chiusura immediata.
|
| 228 |
+
- Task era RUNNING β replay buffer parziale + evento task_interrupted.
|
| 229 |
+
- Task non trovato β prova sb_restore_task prima di 404.
|
| 230 |
+
"""
|
| 231 |
+
# S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
|
| 232 |
+
if task_id not in _agent_tasks:
|
| 233 |
+
restored = await sb_restore_task(task_id)
|
| 234 |
+
if restored:
|
| 235 |
+
restored['context'] = []
|
| 236 |
+
_agent_tasks[task_id] = restored
|
| 237 |
+
else:
|
| 238 |
+
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 239 |
+
|
| 240 |
+
task = _agent_tasks[task_id]
|
| 241 |
+
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 242 |
+
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 243 |
+
|
| 244 |
+
sub_q: asyncio.Queue[str | None] = asyncio.Queue()
|
| 245 |
+
|
| 246 |
+
async def generate():
|
| 247 |
+
yield "retry: 3000\n\n"
|
| 248 |
+
|
| 249 |
+
reg = _loop_registry.get(task_id)
|
| 250 |
+
|
| 251 |
+
is_done_reconnect = reg is not None and reg.get('done', False)
|
| 252 |
+
is_reconnect = reg is not None and not reg.get('done', False)
|
| 253 |
+
|
| 254 |
+
# ββ Case 1: loop giΓ finito in questa sessione β replay buffer in-memory ββ
|
| 255 |
+
if is_done_reconnect:
|
| 256 |
+
for evt_str in reg['event_buffer'][_resume_from:]:
|
| 257 |
+
yield evt_str
|
| 258 |
+
yield "data: [DONE]\n\n"
|
| 259 |
+
return
|
| 260 |
+
|
| 261 |
+
# ββ Case 2: loop attivo in questa sessione β reconnect SSE (S358) βββββββββ
|
| 262 |
+
if is_reconnect:
|
| 263 |
+
join_idx = len(reg['event_buffer'])
|
| 264 |
+
reg['subscriber_queues'].append(sub_q)
|
| 265 |
+
try:
|
| 266 |
+
for evt_str in reg['event_buffer'][_resume_from:join_idx]:
|
| 267 |
+
yield evt_str
|
| 268 |
+
while True:
|
| 269 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 270 |
+
break
|
| 271 |
+
try:
|
| 272 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 273 |
+
if item is None:
|
| 274 |
+
break
|
| 275 |
+
yield item
|
| 276 |
+
except asyncio.TimeoutError:
|
| 277 |
+
yield ': heartbeat\n\n'
|
| 278 |
+
finally:
|
| 279 |
+
try:
|
| 280 |
+
reg['subscriber_queues'].remove(sub_q)
|
| 281 |
+
except ValueError as _exc:
|
| 282 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 283 |
+
yield "data: [DONE]\n\n"
|
| 284 |
+
return
|
| 285 |
+
|
| 286 |
+
# ββ Case 2.5 (S359): backend riavviato β prova Supabase event buffer ββββββ
|
| 287 |
+
sb_events = await sb_get_events(task_id)
|
| 288 |
+
if sb_events:
|
| 289 |
+
task_status = task.get('status', 'UNKNOWN')
|
| 290 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 291 |
+
# Replay buffer from resume point
|
| 292 |
+
for evt_str in sb_events[_resume_from:]:
|
| 293 |
+
yield evt_str
|
| 294 |
+
if terminal:
|
| 295 |
+
# Task giΓ completato β niente da fare, client ha tutto
|
| 296 |
+
yield "data: [DONE]\n\n"
|
| 297 |
+
return
|
| 298 |
+
else:
|
| 299 |
+
# Task era in esecuzione quando il backend Γ¨ crashato β prova resume automatico
|
| 300 |
+
_cp_sb = _task_checkpoints.get(task_id) or await sb_get_checkpoint(task_id)
|
| 301 |
+
_can_resume = (
|
| 302 |
+
_cp_sb is not None and
|
| 303 |
+
len(_cp_sb.get('plan', [])) >= 1 and
|
| 304 |
+
len(_cp_sb.get('logs', [])) >= 2
|
| 305 |
+
)
|
| 306 |
+
if _can_resume:
|
| 307 |
+
# GAP-SYNC-FIX: usa _backend_steps se disponibili (context preciso per resume)
|
| 308 |
+
_bsteps = _cp_sb.get('_backend_steps', [])
|
| 309 |
+
if _bsteps:
|
| 310 |
+
_steps_text = '\n'.join(
|
| 311 |
+
f" Passo {s['step']}: {s['action']} β {s['result'][:80]}"
|
| 312 |
+
for s in _bsteps[-8:]
|
| 313 |
+
)
|
| 314 |
+
_rctx = (
|
| 315 |
+
f"[RESUME AUTOMATICO] Step giΓ completati dal backend:\n{_steps_text}\n"
|
| 316 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)+1} senza ripetere quelli giΓ eseguiti."
|
| 317 |
+
)
|
| 318 |
+
else:
|
| 319 |
+
# Fallback: context semantico (piano + log riassuntivi)
|
| 320 |
+
_rctx = (
|
| 321 |
+
f"Piano giΓ definito: {' | '.join((_cp_sb.get('plan') or [])[:5])}\n"
|
| 322 |
+
f"Log fin qui: {' | '.join((_cp_sb.get('logs') or [])[-5:])}\n"
|
| 323 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)} senza ripetere gli step giΓ fatti."
|
| 324 |
+
)
|
| 325 |
+
task['_resume_context'] = _rctx
|
| 326 |
+
task['_resume_max_steps'] = max(1, task.get('max_steps', 8) - _cp_sb.get('step', 0))
|
| 327 |
+
# Fall through a Case 3 β NON fare return
|
| 328 |
+
else:
|
| 329 |
+
# Nessun checkpoint utile β fallback onesto (comportamento precedente)
|
| 330 |
+
interrupted_evt = json.dumps({
|
| 331 |
+
'event': 'task_interrupted',
|
| 332 |
+
'taskId': task_id,
|
| 333 |
+
'reason': 'backend_restarted',
|
| 334 |
+
'message': 'Il backend si Γ¨ riavviato durante l\'esecuzione. '
|
| 335 |
+
'Premi "Riprova" per rieseguire il task.',
|
| 336 |
+
})
|
| 337 |
+
yield f"data: {interrupted_evt}\n\n"
|
| 338 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 339 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 340 |
+
yield "data: [DONE]\n\n"
|
| 341 |
+
return
|
| 342 |
+
# ββ Case 3: nuova esecuzione ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 343 |
+
_prune_loop_registry()
|
| 344 |
+
reg_entry: dict = {
|
| 345 |
+
'asyncio_task': None,
|
| 346 |
+
'event_buffer': [],
|
| 347 |
+
'subscriber_queues': [sub_q],
|
| 348 |
+
'done': False,
|
| 349 |
+
'finished_at': 0.0,
|
| 350 |
+
}
|
| 351 |
+
_loop_registry[task_id] = reg_entry
|
| 352 |
+
_ctr = [0]
|
| 353 |
+
|
| 354 |
+
def _sse(event: str, data: dict) -> None:
|
| 355 |
+
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 356 |
+
_ctr[0] += 1
|
| 357 |
+
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 358 |
+
# GAP-3-FIX: text_chunk bypass buffer β fanout diretto, no persist.
|
| 359 |
+
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 360 |
+
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
| 361 |
+
if event == 'text_chunk':
|
| 362 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 363 |
+
try:
|
| 364 |
+
q.put_nowait(s)
|
| 365 |
+
except Exception as _exc:
|
| 366 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 367 |
+
return
|
| 368 |
+
reg_entry['event_buffer'].append(s)
|
| 369 |
+
# N-5-FIX: cap buffer a 500 eventi β evita crescita illimitata su task lunghi
|
| 370 |
+
if len(reg_entry['event_buffer']) > 500:
|
| 371 |
+
reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
|
| 372 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 373 |
+
try:
|
| 374 |
+
q.put_nowait(s)
|
| 375 |
+
except Exception as _exc:
|
| 376 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 377 |
+
# S359: persist event asynchronously (fire-and-forget)
|
| 378 |
+
asyncio.create_task(sb_append_event(task_id, _ctr[0], s)).add_done_callback(_log_task_exc)
|
| 379 |
+
|
| 380 |
+
_agent_tasks[task_id]['status'] = 'RUNNING'
|
| 381 |
+
asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
|
| 382 |
+
_prune_agent_tasks()
|
| 383 |
+
|
| 384 |
+
async def run_loop() -> None:
|
| 385 |
+
try:
|
| 386 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 387 |
+
# S388: singleton β evita OpenAI() per ogni task
|
| 388 |
+
client = _get_ai_client()
|
| 389 |
+
try:
|
| 390 |
+
from agents.critic import Critic
|
| 391 |
+
from agents.response_verifier import ResponseVerifier
|
| 392 |
+
_critic = Critic(llm_client=client)
|
| 393 |
+
_verifier = ResponseVerifier()
|
| 394 |
+
except Exception:
|
| 395 |
+
_critic = None
|
| 396 |
+
_verifier = None
|
| 397 |
+
|
| 398 |
+
context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
|
| 399 |
+
# S456-X5/X4: inject project context + learning hints stored at task creation
|
| 400 |
+
_proj_ctx = task.get('project_context', '')
|
| 401 |
+
if _proj_ctx:
|
| 402 |
+
context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
|
| 403 |
+
_hints = task.get('learning_hints', [])
|
| 404 |
+
if _hints:
|
| 405 |
+
# S591: _hints[:3]β[:5] β piΓΉ pattern appresi nel context (task replay)
|
| 406 |
+
hints_str = "\n".join(f"- {h}" for h in _hints[:5])
|
| 407 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 408 |
+
# P16-F3: inject resume hint if task was promoted from queue at a specific step
|
| 409 |
+
_resume_step = task.get('resume_from_step')
|
| 410 |
+
if _resume_step:
|
| 411 |
+
context_str = f"[RIPRESA DA PASSO {_resume_step}] Riprendi dall'iterazione {_resume_step} del task.\n\n{context_str}".strip()
|
| 412 |
+
# P39-UX: Tocco Finale Manus β spiega all'agente come segnalare OAuth mancante
|
| 413 |
+
_connector_hint = (
|
| 414 |
+
"[CONNETTORI OAUTH]\n"
|
| 415 |
+
"Se durante il task hai bisogno di un accesso OAuth (GitHub, Google Calendar, Instagram)\n"
|
| 416 |
+
"ma non hai il token disponibile, includi nella tua risposta finale o parziale:\n"
|
| 417 |
+
" [CONNECTOR_NEEDED:github] oppure [CONNECTOR_NEEDED:google] oppure [CONNECTOR_NEEDED:instagram]\n"
|
| 418 |
+
"Il frontend mostrerΓ automaticamente un pulsante 'Connetti' all'utente."
|
| 419 |
+
)
|
| 420 |
+
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
| 421 |
+
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 422 |
+
# Bug: _resume_context era settato su task{} ma mai letto qui β context perduto su resume.
|
| 423 |
+
_resume_ctx = task.get('_resume_context', '')
|
| 424 |
+
if _resume_ctx:
|
| 425 |
+
context_str = f"{_resume_ctx}\n\n{context_str}".strip()
|
| 426 |
+
# P17-F5: inject Expertise Persona hint se specificato
|
| 427 |
+
_PERSONA_HINTS = {
|
| 428 |
+
"researcher": (
|
| 429 |
+
"[PERSONA: RICERCATORE ESPERTO]\n"
|
| 430 |
+
"- Priorizza sempre la ricerca web aggiornata prima di rispondere\n"
|
| 431 |
+
"- Cita fonti specifiche (URL, titolo, data) per ogni claim importante\n"
|
| 432 |
+
"- Struttura le risposte: Sommario β Dettaglio β Fonti\n"
|
| 433 |
+
"- Verifica incrociando piΓΉ fonti prima di concludere\n"
|
| 434 |
+
"- Strumenti preferiti: web_search, read_page, fetch_url, research"
|
| 435 |
+
),
|
| 436 |
+
"coder": (
|
| 437 |
+
"[PERSONA: SENIOR ENGINEER]\n"
|
| 438 |
+
"- Scrivi codice production-ready: tipizzato, documentato, con error handling\n"
|
| 439 |
+
"- Esegui il codice per verificare il funzionamento prima di rispondere\n"
|
| 440 |
+
"- Preferisci soluzioni robuste e testate su approcci creativi ma fragili\n"
|
| 441 |
+
"- Documenta funzioni e classi con docstring/JSDoc\n"
|
| 442 |
+
"- Strumenti preferiti: run_python, write_file, read_file, pip_install"
|
| 443 |
+
),
|
| 444 |
+
"architect": (
|
| 445 |
+
"[PERSONA: ARCHITECT]\n"
|
| 446 |
+
"- Priorizza analisi, design di sistema e decisioni strategiche\n"
|
| 447 |
+
"- Struttura l'architettura in componenti chiari e mantenibili\n"
|
| 448 |
+
"- Considera scalabilitΓ , manutenibilitΓ e trade-off tecnici\n"
|
| 449 |
+
"- Documenta le decisioni architetturali e il loro razionale"
|
| 450 |
+
),
|
| 451 |
+
"reasoner": (
|
| 452 |
+
"[PERSONA: RAGIONATORE STRATEGICO]\n"
|
| 453 |
+
"- Usa ragionamento step-by-step esplicito: mostra il processo di pensiero\n"
|
| 454 |
+
"- Analizza ogni prospettiva prima di concludere\n"
|
| 455 |
+
"- Struttura la risposta: Analisi β Pro/Contro β Raccomandazione\n"
|
| 456 |
+
"- Considera le implicazioni di lungo termine delle scelte"
|
| 457 |
+
),
|
| 458 |
+
"analyst": (
|
| 459 |
+
"[PERSONA: ANALISTA DATI]\n"
|
| 460 |
+
"- Usa Python per elaborare e analizzare dati quando disponibili\n"
|
| 461 |
+
"- Produci visualizzazioni chiare (grafici, tabelle) ove possibile\n"
|
| 462 |
+
"- Interpreta i risultati con rigore: distingui correlazione da causalitΓ \n"
|
| 463 |
+
"- Struttura i report: Executive Summary β Metodologia β Risultati β Conclusioni\n"
|
| 464 |
+
"- Strumenti preferiti: run_python, web_search, vision"
|
| 465 |
+
),
|
| 466 |
+
}
|
| 467 |
+
_persona = task.get('persona') or ''
|
| 468 |
+
# P17-F5-IMPROVED: server-side classification se persona vuota/auto
|
| 469 |
+
_persona_auto = False
|
| 470 |
+
if not _persona:
|
| 471 |
+
_persona = _classify_persona_server(task.get('goal', ''))
|
| 472 |
+
if _persona:
|
| 473 |
+
_persona_auto = True
|
| 474 |
+
task['persona'] = _persona # persist per history/resume
|
| 475 |
+
_persona_hint = _PERSONA_HINTS.get(_persona.lower().strip(), '')
|
| 476 |
+
if _persona_hint:
|
| 477 |
+
context_str = f"{_persona_hint}\n\n{context_str}".strip()
|
| 478 |
+
# P17-F5: emit persona_classified SSE event β UI badge feedback
|
| 479 |
+
if _persona:
|
| 480 |
+
_persona_conf = 0.85 if not _persona_auto else 0.78
|
| 481 |
+
_sse('persona_classified', {
|
| 482 |
+
'taskId': task_id,
|
| 483 |
+
'persona': _persona,
|
| 484 |
+
'confidence': _persona_conf,
|
| 485 |
+
'auto': _persona_auto,
|
| 486 |
+
})
|
| 487 |
+
# BG-4: inject cross-session handoff context if available
|
| 488 |
+
_hctx = task.get("_handoff_context", "")
|
| 489 |
+
if _hctx:
|
| 490 |
+
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 491 |
+
# P17-F5: route primary LLM to persona-appropriate client
|
| 492 |
+
_persona_client = _get_persona_llm_client(_persona, client)
|
| 493 |
+
loop = UnifiedAgentLoop(
|
| 494 |
+
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 495 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 496 |
+
)
|
| 497 |
+
step_idx = [0]
|
| 498 |
+
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
| 499 |
+
|
| 500 |
+
async def step_cb(step_data: dict) -> None:
|
| 501 |
+
step_idx[0] += 1
|
| 502 |
+
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 503 |
+
# S420: streaming token β emetti direttamente senza passare dal buffer step
|
| 504 |
+
if _action == 'text_chunk':
|
| 505 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
| 506 |
+
return
|
| 507 |
+
|
| 508 |
+
# S363-Blueprint: Narrative Streaming β explanation lookup for ALL step_done events
|
| 509 |
+
# S376: _STEP_NARRATIONS espanso β aggiunge 12 tool mancanti
|
| 510 |
+
# Il fallback `_action.replace('_', ' ').capitalize()` Γ¨ troppo generico
|
| 511 |
+
# per tool composti β narrativa esplicita migliora la UX del LiveStreamBlock
|
| 512 |
+
_STEP_NARRATIONS = {
|
| 513 |
+
'plan': 'Analisi del goal e creazione piano di azione',
|
| 514 |
+
'llm': 'Elaborazione risposta AI',
|
| 515 |
+
'fallback': 'Completamento task',
|
| 516 |
+
'smolagents': 'Esecuzione agente autonomo con strumenti',
|
| 517 |
+
'web_search': 'Cerco informazioni aggiornate sul web',
|
| 518 |
+
'read_page': 'Leggo il contenuto della pagina web',
|
| 519 |
+
'fetch_url': 'Recupero dati dall\'URL richiesto',
|
| 520 |
+
'fetch_url_content': 'Scarico il contenuto dell\'URL',
|
| 521 |
+
'run_code': 'Eseguo il codice nel sandbox',
|
| 522 |
+
'write_file': 'Scrivo il file nel progetto',
|
| 523 |
+
'read_file': 'Leggo il file dal VFS',
|
| 524 |
+
'delete_file': 'Rimuovo il file dal progetto',
|
| 525 |
+
'create_file': 'Creo il file nel progetto',
|
| 526 |
+
'list_files': 'Elenco i file del progetto',
|
| 527 |
+
'search_github': 'Cerco codice e repository su GitHub',
|
| 528 |
+
'search_github_code': 'Cerco snippet di codice su GitHub',
|
| 529 |
+
'search_wikipedia': 'Consulto Wikipedia per informazioni',
|
| 530 |
+
'get_weather': 'Recupero le previsioni meteo',
|
| 531 |
+
'get_news': 'Carico le ultime notizie',
|
| 532 |
+
'get_currency': 'Consulto il tasso di cambio',
|
| 533 |
+
'get_location': 'Rilevo la posizione geografica',
|
| 534 |
+
'calculate': 'Calcolo l\'espressione matematica',
|
| 535 |
+
'math_eval': 'Valuto l\'espressione matematica',
|
| 536 |
+
'generate_image': 'Genero l\'immagine con AI (Pollinations)',
|
| 537 |
+
'remember': 'Salvo informazioni in memoria',
|
| 538 |
+
'recall': 'Recupero informazioni dalla memoria',
|
| 539 |
+
'direct_tools': 'Utilizzo strumenti diretti',
|
| 540 |
+
'critic_retry': 'Auto-correzione risposta (Quality Gate)',
|
| 541 |
+
'execution_validator_fix': 'Auto-fix codice rilevato (ExecutionValidator)',
|
| 542 |
+
'__thinking__': 'Ragionamento interno in corso',
|
| 543 |
+
'__plan__': 'Pianificazione step successivo',
|
| 544 |
+
'__verify__': 'Verifica e validazione risposta',
|
| 545 |
+
'reflective_debug': 'Analisi root cause errore (Chain-of-Verification)',
|
| 546 |
+
'lint_result': 'Validazione sintattica file',
|
| 547 |
+
'lint_code': 'Analisi statica del codice',
|
| 548 |
+
'project_skeleton': 'Mappa aggiornata del progetto',
|
| 549 |
+
'tool_governor_skip': 'Tool giΓ eseguito β risultato riutilizzato',
|
| 550 |
+
'severity_retry': 'Retry adattivo per tipologia errore (S376)',
|
| 551 |
+
# S-LOOP2: narrations per fasi avanzate
|
| 552 |
+
'reasoning_core': 'Ragionamento multi-step (ReasoningCore attivo)',
|
| 553 |
+
'browser_verifier': 'Verifica app live in tempo reale (Playwright)',
|
| 554 |
+
}
|
| 555 |
+
_tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 556 |
+
_narration = _STEP_NARRATIONS.get(_tool_key_narr,
|
| 557 |
+
_action.replace('executor:', '').replace('_', ' ').capitalize())
|
| 558 |
+
# P16-B4: propaga 'truncated' dal loop (finish_reason==length) β frontend
|
| 559 |
+
_step_truncated = bool(step_data.get('truncated', False))
|
| 560 |
+
_sse('step_done', {
|
| 561 |
+
'taskId': task_id,
|
| 562 |
+
'step': {
|
| 563 |
+
'name': _action,
|
| 564 |
+
'index': step_idx[0],
|
| 565 |
+
'status': step_data.get('status', 'done'),
|
| 566 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:500],
|
| 567 |
+
'explanation': _narration, # S363-Blueprint: narrative field
|
| 568 |
+
'truncated': _step_truncated, # P16-B4: segnala max_tokens raggiunto
|
| 569 |
+
},
|
| 570 |
+
})
|
| 571 |
+
# P39-UX: rileva [CONNECTOR_NEEDED:provider] nel result β emetti SSE connector_needed
|
| 572 |
+
import re as _re_cn
|
| 573 |
+
_cn_result = str(step_data.get('result', step_data.get('output', '')))
|
| 574 |
+
_cn_matches = _re_cn.findall(r'\[CONNECTOR_NEEDED:([\w]+)\]', _cn_result)
|
| 575 |
+
for _cn_prov in _cn_matches:
|
| 576 |
+
_PROVIDER_LABELS = {'github': 'GitHub', 'google': 'Google Calendar', 'instagram': 'Instagram'}
|
| 577 |
+
_cn_label = _PROVIDER_LABELS.get(_cn_prov.lower(), _cn_prov.capitalize())
|
| 578 |
+
_sse('connector_needed', {
|
| 579 |
+
'taskId': task_id,
|
| 580 |
+
'provider': _cn_prov.lower(),
|
| 581 |
+
'label': _cn_label,
|
| 582 |
+
'message': f"Per completare il task ho bisogno di accedere a {_cn_label}. Connettiti con un tap.",
|
| 583 |
+
})
|
| 584 |
+
# GAP-SYNC-FIX: accumula step results per resume preciso (checkpoint backend-side)
|
| 585 |
+
_backend_steps.append({
|
| 586 |
+
'step': step_idx[0],
|
| 587 |
+
'action': _action,
|
| 588 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:150],
|
| 589 |
+
'ok': step_data.get('status', 'done') not in ('error', 'failed'),
|
| 590 |
+
})
|
| 591 |
+
# Ogni 2 step: persisti il log su Supabase (non saturare Supabase su loop lunghi)
|
| 592 |
+
if step_idx[0] % 2 == 0:
|
| 593 |
+
asyncio.create_task(
|
| 594 |
+
sb_save_checkpoint(task_id, step_idx[0], {
|
| 595 |
+
'_backend_steps': _backend_steps[-10:], # ultime 10 step
|
| 596 |
+
'step': step_idx[0],
|
| 597 |
+
})
|
| 598 |
+
).add_done_callback(_log_task_exc)
|
| 599 |
+
# TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
|
| 600 |
+
asyncio.create_task(_tg_step(task_id, _action, _narration)).add_done_callback(_log_task_exc)
|
| 601 |
+
# S362: emit vfs_update when a file operation is detected
|
| 602 |
+
# SYNC-1: file_written (da unified_loop GAP-1) incluso + content forwarding
|
| 603 |
+
_VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete', 'file_written')
|
| 604 |
+
if _action in _VFS_ACTIONS or step_data.get('file_path'):
|
| 605 |
+
# S581: 120β200 β path file spesso 120-200 chars
|
| 606 |
+
# S596: 200β400 β result/output puΓ² contenere path completo di progetto
|
| 607 |
+
# S604: 400β500 β parity con altri campi step
|
| 608 |
+
# SYNC-1: file_written porta path in 'path', non 'file_path'
|
| 609 |
+
_vfs_file = (step_data.get('path') or
|
| 610 |
+
step_data.get('file_path') or
|
| 611 |
+
step_data.get('result', '')[:500] or
|
| 612 |
+
step_data.get('output', '')[:500])
|
| 613 |
+
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 614 |
+
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 615 |
+
# SYNC-1: includi content nel SSE event per file_written (β€60KB)
|
| 616 |
+
# Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
|
| 617 |
+
if _action == 'file_written' and step_data.get('content'):
|
| 618 |
+
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
| 619 |
+
_sse('vfs_update', _vfs_evt)
|
| 620 |
+
|
| 621 |
+
# S363-UI: thought event β emitted when planner completes
|
| 622 |
+
if _action == 'plan' and step_data.get('status') == 'done':
|
| 623 |
+
_plan_obj = step_data.get('result', step_data.get('output', ''))
|
| 624 |
+
_thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280β400
|
| 625 |
+
if _thought:
|
| 626 |
+
_sse('thought', {'taskId': task_id, 'text': _thought,
|
| 627 |
+
'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
|
| 628 |
+
# S367: plan_update β structured subtask list for live plan tracking UI
|
| 629 |
+
if isinstance(_plan_obj, dict) and _plan_obj.get('subtasks'):
|
| 630 |
+
_sse('plan_update', {
|
| 631 |
+
'taskId': task_id,
|
| 632 |
+
'subtasks': [
|
| 633 |
+
{
|
| 634 |
+
'id': s.get('id', _si + 1),
|
| 635 |
+
'description': s.get('description', '')[:200], # S581: 80β200
|
| 636 |
+
'tool': s.get('tool', ''),
|
| 637 |
+
'status': 'pending',
|
| 638 |
+
}
|
| 639 |
+
for _si, s in enumerate(_plan_obj['subtasks'])
|
| 640 |
+
],
|
| 641 |
+
'goal': _plan_obj.get('goal', ''),
|
| 642 |
+
})
|
| 643 |
+
|
| 644 |
+
# S367: subtask_done β mark individual subtask complete for live checkbox update
|
| 645 |
+
if step_data.get('subtask_id') and step_data.get('status') == 'done':
|
| 646 |
+
_sse('plan_update', {
|
| 647 |
+
'taskId': task_id,
|
| 648 |
+
'subtask_done': step_data['subtask_id'],
|
| 649 |
+
})
|
| 650 |
+
|
| 651 |
+
# S363-UI: action event β tool execution phase
|
| 652 |
+
_TOOL_EXPLAINS_S363 = {
|
| 653 |
+
'web_search': 'Cerco informazioni in rete',
|
| 654 |
+
'get_weather': 'Recupero dati meteo',
|
| 655 |
+
'get_news': 'Carico notizie recenti',
|
| 656 |
+
'search_wikipedia': 'Consulto Wikipedia',
|
| 657 |
+
'fetch_url': 'Leggo la pagina web',
|
| 658 |
+
'search_github': 'Cerco su GitHub',
|
| 659 |
+
'run_code': 'Eseguo il codice',
|
| 660 |
+
'write_file': 'Scrivo il file',
|
| 661 |
+
'read_file': 'Leggo il file',
|
| 662 |
+
'direct_tools': 'Eseguo strumenti diretti',
|
| 663 |
+
}
|
| 664 |
+
_tool_key = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 665 |
+
if _action.startswith('executor:') or _tool_key in _TOOL_EXPLAINS_S363:
|
| 666 |
+
_sse('action', {
|
| 667 |
+
'taskId': task_id,
|
| 668 |
+
'log': _tool_key.upper().replace('_', ' ')[:30],
|
| 669 |
+
'explain': _TOOL_EXPLAINS_S363.get(_tool_key, f'Esecuzione: {_tool_key}'),
|
| 670 |
+
})
|
| 671 |
+
# S758-P4.1: tool_use β chip pre-esecuzione (stream_agent_task path)
|
| 672 |
+
_is_pre_exec = (
|
| 673 |
+
(_action == 'tool_start' and step_data.get('status') == 'running') or
|
| 674 |
+
(_action.startswith('executor:') and step_data.get('status') == 'started')
|
| 675 |
+
)
|
| 676 |
+
if _is_pre_exec:
|
| 677 |
+
_sse('tool_use', {
|
| 678 |
+
'taskId': task_id,
|
| 679 |
+
'tool': _tool_key,
|
| 680 |
+
'name': _tool_key,
|
| 681 |
+
'label': (step_data.get('title') or
|
| 682 |
+
_TOOL_EXPLAINS_S363.get(_tool_key,
|
| 683 |
+
_tool_key.replace('_', ' ').capitalize())),
|
| 684 |
+
'args': {},
|
| 685 |
+
})
|
| 686 |
+
# S758-P4.1: task_thinking β chip ragionamento LLM
|
| 687 |
+
if (_action in ('__thinking__', 'reflective_debug') and
|
| 688 |
+
step_data.get('status') in ('started', 'running', 'running_deep')):
|
| 689 |
+
_sse('task_thinking', {
|
| 690 |
+
'taskId': task_id,
|
| 691 |
+
'message': (step_data.get('explanation') or step_data.get('title') or
|
| 692 |
+
"Lβagente sta elaborandoβ¦"),
|
| 693 |
+
})
|
| 694 |
+
|
| 695 |
+
_sse('task_start', {'taskId': task_id, 'goal': task['goal']})
|
| 696 |
+
_task_started_ms = int(time.time() * 1000) # NOTIFY-BOT: elapsed tracking
|
| 697 |
+
asyncio.create_task(_tg_start(task_id, task['goal'])).add_done_callback(_log_task_exc)
|
| 698 |
+
_sse('step_start', {'taskId': task_id, 'step': {'name': 'Analisi goal', 'index': 0}})
|
| 699 |
+
|
| 700 |
+
# S364: inject project skeleton into context from VFS (Gap 4)
|
| 701 |
+
if task.get('conversation_id'):
|
| 702 |
+
try:
|
| 703 |
+
from api.project_manifest import build_manifest_from_vfs, get_skeleton
|
| 704 |
+
await asyncio.wait_for(
|
| 705 |
+
build_manifest_from_vfs(task['conversation_id']),
|
| 706 |
+
timeout=3.0,
|
| 707 |
+
)
|
| 708 |
+
_skeleton = await get_skeleton(task['conversation_id'])
|
| 709 |
+
if _skeleton:
|
| 710 |
+
context_str = (_skeleton + '\n\n' + context_str).strip()
|
| 711 |
+
except Exception:
|
| 712 |
+
pass # S364: skeleton injection is optional
|
| 713 |
+
|
| 714 |
+
result = await loop.run(
|
| 715 |
+
goal=task['goal'],
|
| 716 |
+
context=context_str,
|
| 717 |
+
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 718 |
+
on_step=step_cb,
|
| 719 |
+
session_id=task.get('session_id', '') or '',
|
| 720 |
+
)
|
| 721 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 722 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 723 |
+
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 724 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
| 725 |
+
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 726 |
+
|
| 727 |
+
# S363: fire-and-forget quality check when code detected in output
|
| 728 |
+
if _run_quality_check:
|
| 729 |
+
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 730 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised β evita QG su snippet brevi
|
| 731 |
+
asyncio.create_task(_run_quality_check(
|
| 732 |
+
task_id, task['goal'], _qg_result,
|
| 733 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 734 |
+
)).add_done_callback(_log_task_exc)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
except asyncio.CancelledError:
|
| 738 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 739 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 740 |
+
_sse('task_cancelled', {'taskId': task_id})
|
| 741 |
+
|
| 742 |
+
except (ImportError, ModuleNotFoundError):
|
| 743 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 744 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 745 |
+
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 746 |
+
_sse('task_done', {'taskId': task_id, 'result': (
|
| 747 |
+
f'Goal ricevuto: {task["goal"]}\n\n'
|
| 748 |
+
'Il backend non ha il modulo agents.unified_loop. '
|
| 749 |
+
'Configura HuggingFace Spaces con smolagents per l\'esecuzione autonoma.'
|
| 750 |
+
)})
|
| 751 |
+
|
| 752 |
+
except Exception as err:
|
| 753 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 754 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 755 |
+
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
| 756 |
+
_sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
|
| 757 |
+
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 758 |
+
|
| 759 |
+
finally:
|
| 760 |
+
reg_entry['done'] = True
|
| 761 |
+
reg_entry['finished_at'] = time.time()
|
| 762 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 763 |
+
try:
|
| 764 |
+
q.put_nowait(None)
|
| 765 |
+
except Exception as _exc:
|
| 766 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 767 |
+
|
| 768 |
+
reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
|
| 769 |
+
|
| 770 |
+
try:
|
| 771 |
+
while True:
|
| 772 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 773 |
+
at = reg_entry.get('asyncio_task')
|
| 774 |
+
if at and not at.done():
|
| 775 |
+
at.cancel()
|
| 776 |
+
break
|
| 777 |
+
try:
|
| 778 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 779 |
+
if item is None:
|
| 780 |
+
break
|
| 781 |
+
yield item
|
| 782 |
+
except asyncio.TimeoutError:
|
| 783 |
+
yield ': heartbeat\n\n'
|
| 784 |
+
finally:
|
| 785 |
+
try:
|
| 786 |
+
reg_entry['subscriber_queues'].remove(sub_q)
|
| 787 |
+
except ValueError as _exc:
|
| 788 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 789 |
+
|
| 790 |
+
yield "data: [DONE]\n\n"
|
| 791 |
+
|
| 792 |
+
return StreamingResponse(
|
| 793 |
+
generate(),
|
| 794 |
+
media_type='text/event-stream',
|
| 795 |
+
headers={
|
| 796 |
+
'Cache-Control': 'no-cache',
|
| 797 |
+
'X-Accel-Buffering': 'no',
|
| 798 |
+
}
|
| 799 |
+
)
|
| 800 |
+
|
api/agent_telemetry.py
CHANGED
|
@@ -34,52 +34,6 @@ _MAX_STORE = 500 # max entry totali prima del pruning
|
|
| 34 |
# lo stesso store e si sovrascriverebbero. asyncio.Lock() Γ¨ safe a livello di modulo
|
| 35 |
# in Python 3.10+ (non richiede event loop attivo all'init del modulo).
|
| 36 |
_store_lock = asyncio.Lock()
|
| 37 |
-
_RUNTIME_PHASES = ('auth', 'queue', 'provider', 'tool', 'persistence')
|
| 38 |
-
_RUNTIME_MAX_SAMPLES = 200
|
| 39 |
-
_runtime_lock = asyncio.Lock()
|
| 40 |
-
_runtime_store: dict[str, dict] = {
|
| 41 |
-
phase: {'samples_ms': [], 'ok': 0, 'errors': {}}
|
| 42 |
-
for phase in _RUNTIME_PHASES
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
async def record_runtime_phase(phase: str, duration_ms: float = 0.0,
|
| 47 |
-
outcome: str = 'ok', error_class: str | None = None) -> None:
|
| 48 |
-
if phase not in _runtime_store:
|
| 49 |
-
return
|
| 50 |
-
async with _runtime_lock:
|
| 51 |
-
bucket = _runtime_store[phase]
|
| 52 |
-
samples = bucket['samples_ms']
|
| 53 |
-
samples.append(max(0.0, round(float(duration_ms), 2)))
|
| 54 |
-
if len(samples) > _RUNTIME_MAX_SAMPLES:
|
| 55 |
-
del samples[:-_RUNTIME_MAX_SAMPLES]
|
| 56 |
-
if outcome == 'ok':
|
| 57 |
-
bucket['ok'] += 1
|
| 58 |
-
else:
|
| 59 |
-
key = error_class or outcome or 'unknown'
|
| 60 |
-
bucket['errors'][key] = bucket['errors'].get(key, 0) + 1
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _percentile(samples: list[float], percentile: float) -> float:
|
| 64 |
-
if not samples:
|
| 65 |
-
return 0.0
|
| 66 |
-
ordered = sorted(samples)
|
| 67 |
-
index = min(len(ordered) - 1, int(round((percentile / 100) * (len(ordered) - 1))))
|
| 68 |
-
return ordered[index]
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
async def runtime_snapshot() -> dict[str, dict]:
|
| 72 |
-
async with _runtime_lock:
|
| 73 |
-
return {
|
| 74 |
-
phase: {
|
| 75 |
-
'count': len(bucket['samples_ms']),
|
| 76 |
-
'ok': bucket['ok'],
|
| 77 |
-
'errors': dict(bucket['errors']),
|
| 78 |
-
'p50_ms': _percentile(bucket['samples_ms'], 50),
|
| 79 |
-
'p95_ms': _percentile(bucket['samples_ms'], 95),
|
| 80 |
-
}
|
| 81 |
-
for phase, bucket in _runtime_store.items()
|
| 82 |
-
}
|
| 83 |
|
| 84 |
# βββ Store helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
|
|
@@ -161,11 +115,6 @@ class TelemetrySyncBody(BaseModel):
|
|
| 161 |
|
| 162 |
# βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
|
| 164 |
-
@router.get("/api/agent-telemetry/runtime")
|
| 165 |
-
async def get_runtime_telemetry() -> JSONResponse:
|
| 166 |
-
return JSONResponse({'ok': True, 'phases': await runtime_snapshot(), 'server_ts': int(time.time() * 1000)})
|
| 167 |
-
|
| 168 |
-
|
| 169 |
@router.post("/api/agent-telemetry/sync")
|
| 170 |
async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
|
| 171 |
"""
|
|
|
|
| 34 |
# lo stesso store e si sovrascriverebbero. asyncio.Lock() Γ¨ safe a livello di modulo
|
| 35 |
# in Python 3.10+ (non richiede event loop attivo all'init del modulo).
|
| 36 |
_store_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# βββ Store helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
|
|
|
|
| 115 |
|
| 116 |
# βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
@router.post("/api/agent-telemetry/sync")
|
| 119 |
async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
|
| 120 |
"""
|
api/auth_guard.py
CHANGED
|
@@ -4,7 +4,7 @@ Definisce ruoli gerarchici + dependency FastAPI per proteggere gli endpoint.
|
|
| 4 |
|
| 5 |
Ruoli (IntEnum, gerarchici):
|
| 6 |
USER β nessuna auth (endpoint pubblici, chiamate frontend)
|
| 7 |
-
MACHINE β X-Internal-Token = INTERNAL_TOKEN
|
| 8 |
OPERATOR β X-Operator-Token header = OPERATOR_TOKEN env var (monitoring, trigger)
|
| 9 |
ADMIN β X-Admin-Token header = ADMIN_TOKEN env var (operazioni distruttive)
|
| 10 |
|
|
@@ -20,8 +20,7 @@ Utilizzo:
|
|
| 20 |
...
|
| 21 |
|
| 22 |
Token env vars (Railway):
|
| 23 |
-
INTERNAL_TOKEN β
|
| 24 |
-
MACHINE_TOKEN β canale machine dedicato per X-Machine-Token
|
| 25 |
OPERATOR_TOKEN β opzionale; se assente, endpoint OPERATOR bloccati
|
| 26 |
ADMIN_TOKEN β opzionale; se assente, endpoint ADMIN bloccati
|
| 27 |
|
|
@@ -34,7 +33,7 @@ from __future__ import annotations
|
|
| 34 |
import logging
|
| 35 |
import os
|
| 36 |
from enum import IntEnum
|
| 37 |
-
from typing import Optional
|
| 38 |
|
| 39 |
from fastapi import Depends, Header, HTTPException, Request
|
| 40 |
|
|
@@ -97,48 +96,19 @@ _RATE_LIMITS: dict[int, int] = {
|
|
| 97 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 98 |
_rate_store: dict[str, _col.deque] = {} # token_hash β deque di timestamps
|
| 99 |
|
| 100 |
-
# Lo store Γ¨ usato anche quando Redis non Γ¨ disponibile. Un client una tantum
|
| 101 |
-
# lasciava una deque vuota nel dict per l'intera vita del processo. Eseguiamo uno
|
| 102 |
-
# sweep ammortizzato: il lavoro resta O(1) per la quasi totalitΓ delle richieste
|
| 103 |
-
# e il numero di chiavi inattive rimane limitato al traffico tra due sweep.
|
| 104 |
-
_RATE_STORE_SWEEP_EVERY = 128
|
| 105 |
-
_rate_store_checks = 0
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def _prune_expired_rate_keys(now: float, window_s: float) -> None:
|
| 109 |
-
"""Rimuove bucket in-memory senza timestamp ancora nella finestra corrente."""
|
| 110 |
-
global _rate_store_checks
|
| 111 |
-
_rate_store_checks += 1
|
| 112 |
-
if _rate_store_checks % _RATE_STORE_SWEEP_EVERY:
|
| 113 |
-
return
|
| 114 |
-
|
| 115 |
-
window_start = now - window_s
|
| 116 |
-
stale_keys = [
|
| 117 |
-
stored_key
|
| 118 |
-
for stored_key, timestamps in _rate_store.items()
|
| 119 |
-
if not timestamps or timestamps[-1] < window_start
|
| 120 |
-
]
|
| 121 |
-
for stored_key in stale_keys:
|
| 122 |
-
_rate_store.pop(stored_key, None)
|
| 123 |
-
|
| 124 |
|
| 125 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 126 |
"""Chiave rate limiter: hash(role + discriminante) β non espone token nΓ© IP in chiaro.
|
| 127 |
|
| 128 |
-
USER usa
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
In assenza di IP attestato, MACHINE conserva il fallback per-token. OPERATOR
|
| 132 |
-
e ADMIN mantengono il bucket per-token.
|
| 133 |
"""
|
| 134 |
if role == 0:
|
| 135 |
-
# USER:
|
| 136 |
raw = f"0:{client_ip or 'unknown'}"
|
| 137 |
-
elif role == 1 and client_ip:
|
| 138 |
-
# MACHINE via proxy fidato: separa gli utenti dietro INTERNAL_TOKEN.
|
| 139 |
-
raw = f"1:{client_ip}"
|
| 140 |
else:
|
| 141 |
-
#
|
| 142 |
raw = f"{role}:{token_header or 'anonymous'}"
|
| 143 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 144 |
|
|
@@ -153,7 +123,6 @@ def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]
|
|
| 153 |
"""
|
| 154 |
now = _rl_time.monotonic()
|
| 155 |
window_start = now - window_s
|
| 156 |
-
_prune_expired_rate_keys(now, window_s)
|
| 157 |
|
| 158 |
if key not in _rate_store:
|
| 159 |
_rate_store[key] = _col.deque()
|
|
@@ -193,66 +162,6 @@ def _check_rate_limit(
|
|
| 193 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 194 |
|
| 195 |
|
| 196 |
-
async def require_supabase_user(request: Request) -> dict[str, Any]:
|
| 197 |
-
"""Valida il Bearer JWT tramite Supabase Auth e restituisce il profilo minimo.
|
| 198 |
-
|
| 199 |
-
La chiave Supabase resta server-side; il JWT arriva esclusivamente nell'header
|
| 200 |
-
Authorization del chiamante e non viene scritto nei log.
|
| 201 |
-
"""
|
| 202 |
-
import httpx
|
| 203 |
-
|
| 204 |
-
authorization = request.headers.get("Authorization", "")
|
| 205 |
-
if not authorization.lower().startswith("bearer "):
|
| 206 |
-
raise HTTPException(status_code=401, detail="Bearer token richiesto")
|
| 207 |
-
jwt = authorization[7:].strip()
|
| 208 |
-
if not jwt:
|
| 209 |
-
raise HTTPException(status_code=401, detail="Bearer token non valido")
|
| 210 |
-
|
| 211 |
-
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 212 |
-
api_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY", "")
|
| 213 |
-
if not supabase_url or not api_key:
|
| 214 |
-
raise HTTPException(status_code=503, detail="Autenticazione Supabase non configurata")
|
| 215 |
-
|
| 216 |
-
try:
|
| 217 |
-
async with httpx.AsyncClient(timeout=5) as client:
|
| 218 |
-
response = await client.get(
|
| 219 |
-
f"{supabase_url}/auth/v1/user",
|
| 220 |
-
headers={
|
| 221 |
-
"apikey": api_key,
|
| 222 |
-
"Authorization": f"Bearer {jwt}",
|
| 223 |
-
"Accept": "application/json",
|
| 224 |
-
},
|
| 225 |
-
)
|
| 226 |
-
except httpx.HTTPError as exc:
|
| 227 |
-
logger.warning("supabase user validation unavailable: %s", type(exc).__name__)
|
| 228 |
-
raise HTTPException(status_code=503, detail="Autenticazione temporaneamente non disponibile") from exc
|
| 229 |
-
|
| 230 |
-
if response.status_code != 200:
|
| 231 |
-
raise HTTPException(status_code=401, detail="Sessione Supabase non valida o scaduta")
|
| 232 |
-
try:
|
| 233 |
-
user = response.json()
|
| 234 |
-
except ValueError as exc:
|
| 235 |
-
raise HTTPException(status_code=401, detail="Risposta autenticazione non valida") from exc
|
| 236 |
-
if not isinstance(user, dict) or not user.get("id"):
|
| 237 |
-
raise HTTPException(status_code=401, detail="Utente Supabase non valido")
|
| 238 |
-
return user
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
async def require_admin_user(request: Request) -> dict[str, Any]:
|
| 242 |
-
"""Richiede un JWT Supabase con app_metadata.role=admin.
|
| 243 |
-
|
| 244 |
-
app_metadata Γ¨ server-controlled; user_metadata non viene mai considerato
|
| 245 |
-
per autorizzare lβarea amministrativa.
|
| 246 |
-
"""
|
| 247 |
-
user = await require_supabase_user(request)
|
| 248 |
-
app_metadata = user.get("app_metadata") or {}
|
| 249 |
-
roles = app_metadata.get("roles") or []
|
| 250 |
-
is_admin = app_metadata.get("role") == "admin" or "admin" in roles
|
| 251 |
-
if not is_admin:
|
| 252 |
-
raise HTTPException(status_code=403, detail="Membership amministrativa richiesta")
|
| 253 |
-
return user
|
| 254 |
-
|
| 255 |
-
|
| 256 |
class AuthRole(IntEnum):
|
| 257 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 258 |
USER = 0
|
|
@@ -267,7 +176,6 @@ def _get_token(env_var: str) -> str:
|
|
| 267 |
|
| 268 |
async def _resolve_role(
|
| 269 |
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 270 |
-
x_machine_token: Optional[str] = Header(None, alias="X-Machine-Token"),
|
| 271 |
x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
|
| 272 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 273 |
) -> AuthRole:
|
|
@@ -285,55 +193,16 @@ async def _resolve_role(
|
|
| 285 |
logger.debug("auth: OPERATOR role granted")
|
| 286 |
return AuthRole.OPERATOR
|
| 287 |
|
| 288 |
-
# MACHINE
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
if internal_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, internal_tok):
|
| 293 |
-
logger.debug("auth: MACHINE role granted via X-Internal-Token")
|
| 294 |
-
return AuthRole.MACHINE
|
| 295 |
-
|
| 296 |
-
machine_tok = _get_token("MACHINE_TOKEN")
|
| 297 |
-
if machine_tok and x_machine_token and _sec_comp.compare_digest(x_machine_token, machine_tok):
|
| 298 |
-
logger.debug("auth: MACHINE role granted via X-Machine-Token")
|
| 299 |
return AuthRole.MACHINE
|
| 300 |
|
| 301 |
# Nessun token valido β ruolo USER (minimo)
|
| 302 |
return AuthRole.USER
|
| 303 |
|
| 304 |
|
| 305 |
-
async def require_private_state_machine(
|
| 306 |
-
request: 'Request',
|
| 307 |
-
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 308 |
-
) -> AuthRole:
|
| 309 |
-
"""Autorizza esclusivamente il proxy Pages dello stato privato.
|
| 310 |
-
|
| 311 |
-
Usa un token dedicato per non ruotare o esporre ``INTERNAL_TOKEN``, da cui
|
| 312 |
-
dipendono le integrazioni legacy del master B. Il token non conferisce un
|
| 313 |
-
ruolo piΓΉ ampio del canale MACHINE e resta soggetto allo stesso rate limit.
|
| 314 |
-
"""
|
| 315 |
-
import secrets as _sec_comp
|
| 316 |
-
private_token = _get_token("PRIVATE_STATE_INTERNAL_TOKEN")
|
| 317 |
-
if not private_token:
|
| 318 |
-
raise HTTPException(status_code=503, detail="Canale stato privato non configurato")
|
| 319 |
-
if not x_internal_token or not _sec_comp.compare_digest(x_internal_token, private_token):
|
| 320 |
-
raise HTTPException(status_code=403, detail="Permessi insufficienti per lo stato privato")
|
| 321 |
-
|
| 322 |
-
client_ip = (
|
| 323 |
-
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
| 324 |
-
or request.headers.get('X-Real-IP', '')
|
| 325 |
-
or (request.client.host if request.client else None)
|
| 326 |
-
) or None
|
| 327 |
-
allowed, retry_after = _check_rate_limit(int(AuthRole.MACHINE), x_internal_token, client_ip)
|
| 328 |
-
if not allowed:
|
| 329 |
-
raise HTTPException(
|
| 330 |
-
status_code=429,
|
| 331 |
-
detail="Rate limit stato privato superato",
|
| 332 |
-
headers={'Retry-After': str(retry_after)},
|
| 333 |
-
)
|
| 334 |
-
return AuthRole.MACHINE
|
| 335 |
-
|
| 336 |
-
|
| 337 |
def require_role(min_role: AuthRole):
|
| 338 |
"""
|
| 339 |
FastAPI Depends factory per autorizzazione granulare.
|
|
@@ -354,8 +223,7 @@ def require_role(min_role: AuthRole):
|
|
| 354 |
_token_hdr = (
|
| 355 |
request.headers.get('X-Admin-Token') or
|
| 356 |
request.headers.get('X-Operator-Token') or
|
| 357 |
-
request.headers.get('X-Internal-Token')
|
| 358 |
-
request.headers.get('X-Machine-Token')
|
| 359 |
)
|
| 360 |
# GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy β X-Forwarded-For)
|
| 361 |
_client_ip: str | None = (
|
|
|
|
| 4 |
|
| 5 |
Ruoli (IntEnum, gerarchici):
|
| 6 |
USER β nessuna auth (endpoint pubblici, chiamate frontend)
|
| 7 |
+
MACHINE β X-Internal-Token header = INTERNAL_TOKEN env var (backendβbackend)
|
| 8 |
OPERATOR β X-Operator-Token header = OPERATOR_TOKEN env var (monitoring, trigger)
|
| 9 |
ADMIN β X-Admin-Token header = ADMIN_TOKEN env var (operazioni distruttive)
|
| 10 |
|
|
|
|
| 20 |
...
|
| 21 |
|
| 22 |
Token env vars (Railway):
|
| 23 |
+
INTERNAL_TOKEN β giΓ usato in main.py (generato al boot se assente)
|
|
|
|
| 24 |
OPERATOR_TOKEN β opzionale; se assente, endpoint OPERATOR bloccati
|
| 25 |
ADMIN_TOKEN β opzionale; se assente, endpoint ADMIN bloccati
|
| 26 |
|
|
|
|
| 33 |
import logging
|
| 34 |
import os
|
| 35 |
from enum import IntEnum
|
| 36 |
+
from typing import Optional
|
| 37 |
|
| 38 |
from fastapi import Depends, Header, HTTPException, Request
|
| 39 |
|
|
|
|
| 96 |
_RATE_WINDOW_S = 60 # finestra sliding 60s
|
| 97 |
_rate_store: dict[str, _col.deque] = {} # token_hash β deque di timestamps
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
def _rate_key(role: int, token_header: str | None, client_ip: str | None = None) -> str:
|
| 101 |
"""Chiave rate limiter: hash(role + discriminante) β non espone token nΓ© IP in chiaro.
|
| 102 |
|
| 103 |
+
GAP-AUTH-FIX: USER (role=0) usa client_ip come discriminante β bucket per IP,
|
| 104 |
+
non bucket globale condiviso. Previene DoS a costo zero (un client svuota tutti).
|
| 105 |
+
Ruoli autenticati (MACHINE/OPERATOR/ADMIN) continuano a usare il token hash.
|
|
|
|
|
|
|
| 106 |
"""
|
| 107 |
if role == 0:
|
| 108 |
+
# USER: discrimina per IP β ogni client ha il proprio bucket
|
| 109 |
raw = f"0:{client_ip or 'unknown'}"
|
|
|
|
|
|
|
|
|
|
| 110 |
else:
|
| 111 |
+
# Ruoli autenticati: discrimina per token (piΓΉ preciso dell'IP)
|
| 112 |
raw = f"{role}:{token_header or 'anonymous'}"
|
| 113 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 114 |
|
|
|
|
| 123 |
"""
|
| 124 |
now = _rl_time.monotonic()
|
| 125 |
window_start = now - window_s
|
|
|
|
| 126 |
|
| 127 |
if key not in _rate_store:
|
| 128 |
_rate_store[key] = _col.deque()
|
|
|
|
| 162 |
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 163 |
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
class AuthRole(IntEnum):
|
| 166 |
"""Gerarchia ruoli: USER < MACHINE < OPERATOR < ADMIN."""
|
| 167 |
USER = 0
|
|
|
|
| 176 |
|
| 177 |
async def _resolve_role(
|
| 178 |
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
|
|
|
| 179 |
x_operator_token: Optional[str] = Header(None, alias="X-Operator-Token"),
|
| 180 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 181 |
) -> AuthRole:
|
|
|
|
| 193 |
logger.debug("auth: OPERATOR role granted")
|
| 194 |
return AuthRole.OPERATOR
|
| 195 |
|
| 196 |
+
# MACHINE (INTERNAL_TOKEN, giΓ generato al boot da main.py)
|
| 197 |
+
int_tok = _get_token("INTERNAL_TOKEN")
|
| 198 |
+
if int_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, int_tok):
|
| 199 |
+
logger.debug("auth: MACHINE role granted")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return AuthRole.MACHINE
|
| 201 |
|
| 202 |
# Nessun token valido β ruolo USER (minimo)
|
| 203 |
return AuthRole.USER
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def require_role(min_role: AuthRole):
|
| 207 |
"""
|
| 208 |
FastAPI Depends factory per autorizzazione granulare.
|
|
|
|
| 223 |
_token_hdr = (
|
| 224 |
request.headers.get('X-Admin-Token') or
|
| 225 |
request.headers.get('X-Operator-Token') or
|
| 226 |
+
request.headers.get('X-Internal-Token')
|
|
|
|
| 227 |
)
|
| 228 |
# GAP-AUTH-FIX: estrai IP reale (Railway/HF dietro proxy β X-Forwarded-For)
|
| 229 |
_client_ip: str | None = (
|
api/auth_managed.py
CHANGED
|
@@ -11,7 +11,7 @@ I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
|
|
| 11 |
|
| 12 |
Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) β richiede cryptography>=42.
|
| 13 |
"""
|
| 14 |
-
import os, time, secrets, json, logging, asyncio
|
| 15 |
from typing import Optional
|
| 16 |
from fastapi import APIRouter, Depends, Request, HTTPException
|
| 17 |
from .auth_guard import require_role, AuthRole
|
|
@@ -21,29 +21,6 @@ import httpx
|
|
| 21 |
_logger = logging.getLogger('api.auth_managed')
|
| 22 |
router = APIRouter()
|
| 23 |
|
| 24 |
-
_DIAGNOSTIC_SECRET_NAMES = (
|
| 25 |
-
'ADMIN_DIAGNOSTICS_TOKEN', 'INTERNAL_TOKEN', 'PUBLIC_API_TOKEN',
|
| 26 |
-
'PRIVATE_STATE_INTERNAL_TOKEN', 'SUPABASE_SERVICE_ROLE_KEY',
|
| 27 |
-
'SUPABASE_SERVICE_ROLE_KEY_B', 'GROQ_API_KEY', 'HF_TOKEN',
|
| 28 |
-
'GEMINI_API_KEY', 'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
|
| 29 |
-
'OPENROUTER_API_KEY', 'OPENROUTER_API_KEY_B', 'OPENROUTER_API_KEY_C',
|
| 30 |
-
'OPENROUTER_PROFILES_JSON', 'NVIDIA_API_KEY', 'NVIDIA_API_KEY_B',
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def _secret_fingerprint(name: str) -> dict[str, object]:
|
| 35 |
-
value = os.getenv(name, '').strip()
|
| 36 |
-
return {
|
| 37 |
-
'configured': bool(value),
|
| 38 |
-
'length': len(value),
|
| 39 |
-
'sha256': hashlib.sha256(value.encode('utf-8')).hexdigest()[:16] if value else None,
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
@router.get('/api/admin/secret-fingerprint')
|
| 44 |
-
async def secret_fingerprint(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
| 45 |
-
return {'secrets': {name: _secret_fingerprint(name) for name in _DIAGNOSTIC_SECRET_NAMES}}
|
| 46 |
-
|
| 47 |
# ββ Fernet encryption setup ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
# Salt statico pubblico: accettabile per chiave macchina (non password utente).
|
| 49 |
# Il VAULT_KEY Γ¨ il segreto; il salt previene rainbow-table cross-application.
|
|
|
|
| 11 |
|
| 12 |
Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) β richiede cryptography>=42.
|
| 13 |
"""
|
| 14 |
+
import os, time, secrets, json, logging, asyncio
|
| 15 |
from typing import Optional
|
| 16 |
from fastapi import APIRouter, Depends, Request, HTTPException
|
| 17 |
from .auth_guard import require_role, AuthRole
|
|
|
|
| 21 |
_logger = logging.getLogger('api.auth_managed')
|
| 22 |
router = APIRouter()
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# ββ Fernet encryption setup ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
# Salt statico pubblico: accettabile per chiave macchina (non password utente).
|
| 26 |
# Il VAULT_KEY Γ¨ il segreto; il salt previene rainbow-table cross-application.
|
api/background_tasks.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
| 1 |
-
"""Supervision utilities for long-lived background asyncio tasks."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import asyncio
|
| 5 |
-
import logging
|
| 6 |
-
from collections.abc import Awaitable
|
| 7 |
-
from typing import Any
|
| 8 |
-
|
| 9 |
-
_logger = logging.getLogger("agente_ai.background_tasks")
|
| 10 |
-
_tasks: dict[str, asyncio.Task[Any]] = {}
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def spawn_background_task(coro: Awaitable[Any], *, name: str) -> asyncio.Task[Any]:
|
| 14 |
-
"""Start one named background task and retain it for lifecycle shutdown.
|
| 15 |
-
|
| 16 |
-
A live task with the same name is reused. The passed coroutine is closed in
|
| 17 |
-
that case so duplicate startup calls do not leak an un-awaited coroutine.
|
| 18 |
-
"""
|
| 19 |
-
current = _tasks.get(name)
|
| 20 |
-
if current is not None and not current.done():
|
| 21 |
-
close = getattr(coro, "close", None)
|
| 22 |
-
if close is not None:
|
| 23 |
-
close()
|
| 24 |
-
return current
|
| 25 |
-
|
| 26 |
-
task = asyncio.create_task(coro, name=name)
|
| 27 |
-
_tasks[name] = task
|
| 28 |
-
|
| 29 |
-
def _report(task_result: asyncio.Task[Any]) -> None:
|
| 30 |
-
if task_result.cancelled():
|
| 31 |
-
return
|
| 32 |
-
try:
|
| 33 |
-
error = task_result.exception()
|
| 34 |
-
except asyncio.CancelledError:
|
| 35 |
-
return
|
| 36 |
-
if error is not None:
|
| 37 |
-
_logger.error("background task %s failed: %s", name, error, exc_info=error)
|
| 38 |
-
|
| 39 |
-
task.add_done_callback(_report)
|
| 40 |
-
return task
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
async def shutdown_background_tasks() -> None:
|
| 44 |
-
"""Cancel and await all supervised background tasks."""
|
| 45 |
-
tasks = [task for task in _tasks.values() if not task.done()]
|
| 46 |
-
for task in tasks:
|
| 47 |
-
task.cancel()
|
| 48 |
-
if tasks:
|
| 49 |
-
await asyncio.gather(*tasks, return_exceptions=True)
|
| 50 |
-
_tasks.clear()
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
__all__ = ["spawn_background_task", "shutdown_background_tasks"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/benchmark.py
CHANGED
|
@@ -373,7 +373,7 @@ async def run_benchmark(
|
|
| 373 |
#
|
| 374 |
# Per ogni categoria agente (DA / ORCH / MC / REC):
|
| 375 |
# 1. Inietta la context rule via UnifiedLoopPrompts._pick_context_rules()
|
| 376 |
-
# 2. Chiama il LLM (ARCHITECT =
|
| 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/benchmarks_hub.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/benchmarks_hub.py β Gap 3: router per i 3 benchmark standalone
|
| 3 |
+
|
| 4 |
+
Espone come endpoint HTTP le classi benchmark finora orfane:
|
| 5 |
+
POST /api/debug/benchmark/advanced β AdvancedBenchmark (concorrenza)
|
| 6 |
+
POST /api/debug/benchmark/fabric β FabricBenchmark (token rotation)
|
| 7 |
+
POST /api/debug/benchmark/extended β ExtendedBenchmark (multi-scenario)
|
| 8 |
+
|
| 9 |
+
Tutti usano mock interni (asyncio.sleep + dict simulati) β sicuri da eseguire
|
| 10 |
+
in produzione senza side-effect su provider reali.
|
| 11 |
+
"""
|
| 12 |
+
import asyncio, logging, time
|
| 13 |
+
from fastapi import APIRouter
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
_logger = logging.getLogger("api.benchmarks_hub")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/api/debug/benchmark/advanced")
|
| 21 |
+
async def run_advanced() -> JSONResponse:
|
| 22 |
+
"""Stress test concorrenza: 3 scenari Γ N task simulati."""
|
| 23 |
+
t0 = time.monotonic()
|
| 24 |
+
try:
|
| 25 |
+
from api.advanced_complex_benchmark import AdvancedBenchmark
|
| 26 |
+
b = AdvancedBenchmark()
|
| 27 |
+
await b.setup()
|
| 28 |
+
await asyncio.gather(
|
| 29 |
+
b.run_complex_task("complex_reasoning", "reasoning", count=5),
|
| 30 |
+
b.run_complex_task("data_heavy_sync", "memory", count=5),
|
| 31 |
+
b.run_complex_task("multi_provider_chain","compute", count=5),
|
| 32 |
+
)
|
| 33 |
+
elapsed = round((time.monotonic() - t0) * 1000)
|
| 34 |
+
total = b.metrics["successes"] + b.metrics["failures"]
|
| 35 |
+
return JSONResponse({
|
| 36 |
+
"ok": True,
|
| 37 |
+
"type": "advanced",
|
| 38 |
+
"elapsed_ms": elapsed,
|
| 39 |
+
"success_rate": round(b.metrics["successes"] / max(1, total) * 100, 1),
|
| 40 |
+
"total_calls": total,
|
| 41 |
+
"successes": b.metrics["successes"],
|
| 42 |
+
"failures": b.metrics["failures"],
|
| 43 |
+
"latencies": {
|
| 44 |
+
k: {"avg_ms": round(sum(v) / len(v), 1), "max_ms": round(max(v), 1), "n": len(v)}
|
| 45 |
+
for k, v in b.metrics.items() if isinstance(v, list) and v
|
| 46 |
+
},
|
| 47 |
+
})
|
| 48 |
+
except Exception as exc:
|
| 49 |
+
_logger.exception("[advanced_bench]")
|
| 50 |
+
return JSONResponse({"ok": False, "type": "advanced", "error": str(exc)}, status_code=500)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.post("/api/debug/benchmark/fabric")
|
| 54 |
+
async def run_fabric() -> JSONResponse:
|
| 55 |
+
"""Benchmark rotazione token e fallback provider."""
|
| 56 |
+
t0 = time.monotonic()
|
| 57 |
+
try:
|
| 58 |
+
from api.fabric_benchmark import FabricBenchmark
|
| 59 |
+
b = FabricBenchmark()
|
| 60 |
+
await b.setup_simulated_environment()
|
| 61 |
+
await b.run_benchmark()
|
| 62 |
+
elapsed = round((time.monotonic() - t0) * 1000)
|
| 63 |
+
lats = b.stats.get("latencies", [])
|
| 64 |
+
return JSONResponse({
|
| 65 |
+
"ok": True,
|
| 66 |
+
"type": "fabric",
|
| 67 |
+
"elapsed_ms": elapsed,
|
| 68 |
+
"rotations": b.stats.get("rotations", 0),
|
| 69 |
+
"fallbacks": b.stats.get("fallbacks", 0),
|
| 70 |
+
"errors": len(b.stats.get("errors", [])),
|
| 71 |
+
"avg_latency_ms": round(sum(lats) / len(lats), 1) if lats else None,
|
| 72 |
+
})
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
_logger.exception("[fabric_bench]")
|
| 75 |
+
return JSONResponse({"ok": False, "type": "fabric", "error": str(exc)}, status_code=500)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post("/api/debug/benchmark/extended")
|
| 79 |
+
async def run_extended() -> JSONResponse:
|
| 80 |
+
"""Multi-scenario: normal load + cascading failure + oracle stress."""
|
| 81 |
+
t0 = time.monotonic()
|
| 82 |
+
try:
|
| 83 |
+
from api.extended_benchmark import ExtendedBenchmark
|
| 84 |
+
b = ExtendedBenchmark()
|
| 85 |
+
await b.setup()
|
| 86 |
+
await asyncio.gather(
|
| 87 |
+
b.scenario_normal_load(),
|
| 88 |
+
b.scenario_cascading_failure(),
|
| 89 |
+
b.scenario_oracle_stress(),
|
| 90 |
+
)
|
| 91 |
+
elapsed = round((time.monotonic() - t0) * 1000)
|
| 92 |
+
return JSONResponse({
|
| 93 |
+
"ok": True,
|
| 94 |
+
"type": "extended",
|
| 95 |
+
"elapsed_ms": elapsed,
|
| 96 |
+
"scenarios": b.results,
|
| 97 |
+
})
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
_logger.exception("[extended_bench]")
|
| 100 |
+
return JSONResponse({"ok": False, "type": "extended", "error": str(exc)}, status_code=500)
|
api/bootstrap_tools.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/bootstrap_tools.py β Inizializza e registra i tool predefiniti all'avvio.
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from .tool_engine import tool_registry, ToolDescriptor
|
| 6 |
+
|
| 7 |
+
_logger = logging.getLogger("api.bootstrap_tools")
|
| 8 |
+
|
| 9 |
+
async def bootstrap_all_tools():
|
| 10 |
+
"""
|
| 11 |
+
Registra i tool fondamentali nel ToolEngine.
|
| 12 |
+
Questi tool diventano capability disponibili per il Brain.
|
| 13 |
+
"""
|
| 14 |
+
tools = [
|
| 15 |
+
ToolDescriptor(
|
| 16 |
+
name="web_search",
|
| 17 |
+
description="Cerca informazioni sul web in tempo reale",
|
| 18 |
+
provider_id="hf-space-1",
|
| 19 |
+
input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
|
| 20 |
+
tags=["search", "web"]
|
| 21 |
+
),
|
| 22 |
+
ToolDescriptor(
|
| 23 |
+
name="python_interpreter",
|
| 24 |
+
description="Esegue codice Python in una sandbox sicura",
|
| 25 |
+
provider_id="oracle-cloud-vm-01",
|
| 26 |
+
input_schema={"type": "object", "properties": {"code": {"type": "string"}}},
|
| 27 |
+
tags=["code", "sandbox"]
|
| 28 |
+
),
|
| 29 |
+
ToolDescriptor(
|
| 30 |
+
name="vision_analysis",
|
| 31 |
+
description="Analizza immagini e screenshot tramite OCR e modelli Vision",
|
| 32 |
+
provider_id="hf-space-2",
|
| 33 |
+
input_schema={"type": "object", "properties": {"image_url": {"type": "string"}}},
|
| 34 |
+
tags=["vision", "ocr"]
|
| 35 |
+
)
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
for tool in tools:
|
| 39 |
+
try:
|
| 40 |
+
await tool_registry.register(tool, force=True)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
_logger.error(f"Errore registrazione tool {tool.name}: {e}")
|
| 43 |
+
|
| 44 |
+
_logger.info(f"Bootstrap completato: {len(tools)} tool registrati.")
|
api/brain_planner.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/brain_planner.py β Brain Planner (ARCH-I4.1)
|
| 3 |
+
|
| 4 |
+
Componente Planner del Brain: reasoning, planning, decomposition, reflection.
|
| 5 |
+
Produce WorkflowPlan (lista ordinata di PlanStep) senza sapere come eseguirli.
|
| 6 |
+
|
| 7 |
+
Il Brain Γ¨ ora separato in:
|
| 8 |
+
BrainPlanner β "cosa fare e in che ordine" (questo file)
|
| 9 |
+
BrainExecutor β "come eseguire lo stato" (brain_executor.py)
|
| 10 |
+
|
| 11 |
+
Flusso:
|
| 12 |
+
Kernel.submit_task(payload, capability="plan") β BrainPlanner.plan(goal)
|
| 13 |
+
β WorkflowPlan(steps=[PlanStep,...])
|
| 14 |
+
β BrainExecutor.execute_plan(plan) β [Kernel.submit_task per ogni step]
|
| 15 |
+
|
| 16 |
+
Invarianti ADR:
|
| 17 |
+
S4: Brain non conosce l'infrastruttura
|
| 18 |
+
S9: Planner ignora come Executor esegue
|
| 19 |
+
S21: Brain dipende solo dal Kernel
|
| 20 |
+
S27: ogni piano tracciato via plan_id
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import asyncio
|
| 25 |
+
import logging
|
| 26 |
+
import time
|
| 27 |
+
import uuid
|
| 28 |
+
from typing import Any, Literal
|
| 29 |
+
|
| 30 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 31 |
+
from pydantic import BaseModel, Field
|
| 32 |
+
|
| 33 |
+
from .auth_guard import AuthRole, require_role
|
| 34 |
+
|
| 35 |
+
_logger = logging.getLogger("api.brain_planner")
|
| 36 |
+
|
| 37 |
+
# ββ Resolver guard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
try:
|
| 39 |
+
from .capability_resolver import resolver as _resolver, ResolveRequest as _RReq
|
| 40 |
+
_RESOLVER_AVAILABLE = True
|
| 41 |
+
except Exception:
|
| 42 |
+
_resolver = None # type: ignore[assignment]
|
| 43 |
+
_RESOLVER_AVAILABLE = False
|
| 44 |
+
|
| 45 |
+
# ββ Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
class PlanStep(BaseModel):
|
| 48 |
+
"""Singolo step del piano β capability + payload + dipendenze."""
|
| 49 |
+
step_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 50 |
+
capability: str = Field(..., description="Capability richiesta per questo step")
|
| 51 |
+
description: str = Field("", description="Descrizione human-readable")
|
| 52 |
+
payload: dict[str, Any] = Field(default_factory=dict)
|
| 53 |
+
depends_on: list[str] = Field(default_factory=list,
|
| 54 |
+
description="step_id degli step da completare prima")
|
| 55 |
+
timeout_s: int = Field(60)
|
| 56 |
+
retry_max: int = Field(2)
|
| 57 |
+
optional: bool = Field(False, description="Se True, fallimento non blocca il piano")
|
| 58 |
+
requires_gpu: bool = Field(False)
|
| 59 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class WorkflowPlan(BaseModel):
|
| 63 |
+
"""Piano di esecuzione prodotto dal Planner β input per il Workflow Engine."""
|
| 64 |
+
plan_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 65 |
+
goal: str = Field(..., description="Obiettivo originale")
|
| 66 |
+
strategy: Literal["sequential", "parallel", "dag"] = Field(
|
| 67 |
+
"sequential",
|
| 68 |
+
description="sequential=step uno alla volta, "
|
| 69 |
+
"parallel=tutti in parallelo, "
|
| 70 |
+
"dag=dipendenze esplicite tra step")
|
| 71 |
+
steps: list[PlanStep] = Field(default_factory=list)
|
| 72 |
+
context: dict[str, Any] = Field(default_factory=dict,
|
| 73 |
+
description="Contesto condiviso tra gli step")
|
| 74 |
+
created_at: float = Field(default_factory=time.time)
|
| 75 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 76 |
+
|
| 77 |
+
def validate_dag(self) -> list[str]:
|
| 78 |
+
"""Verifica che il DAG non abbia cicli. Ritorna errori (lista vuota = ok)."""
|
| 79 |
+
step_ids = {s.step_id for s in self.steps}
|
| 80 |
+
errors = []
|
| 81 |
+
for step in self.steps:
|
| 82 |
+
for dep in step.depends_on:
|
| 83 |
+
if dep not in step_ids:
|
| 84 |
+
errors.append(f"Step {step.step_id}: dipende da {dep} che non esiste nel piano")
|
| 85 |
+
return errors
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class PlanRequest(BaseModel):
|
| 89 |
+
goal: str = Field(..., description="Obiettivo da pianificare")
|
| 90 |
+
context: dict[str, Any] = Field(default_factory=dict)
|
| 91 |
+
strategy: Literal["sequential", "parallel", "dag"] = "sequential"
|
| 92 |
+
max_steps: int = Field(10, description="Numero massimo di step nel piano")
|
| 93 |
+
session_id: str | None = None
|
| 94 |
+
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 95 |
+
hints: list[str] = Field(default_factory=list,
|
| 96 |
+
description="Capability suggerite dal caller")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class ReflectRequest(BaseModel):
|
| 100 |
+
"""Richiesta di reflection su un piano eseguito (per migliorare piani futuri)."""
|
| 101 |
+
plan_id: str
|
| 102 |
+
outcome: Literal["success", "partial", "failure"]
|
| 103 |
+
failed_steps: list[str] = Field(default_factory=list)
|
| 104 |
+
notes: str = ""
|
| 105 |
+
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ββ BrainPlanner singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
|
| 110 |
+
class BrainPlanner:
|
| 111 |
+
"""
|
| 112 |
+
Planner del Brain: data un goal, produce un WorkflowPlan.
|
| 113 |
+
|
| 114 |
+
Strategia di planning (senza LLM, rule-based per ora):
|
| 115 |
+
1. Decomposizione goal β capability necessarie (via Resolver + hints)
|
| 116 |
+
2. Ordinamento step (sequenziale per default, DAG se dipendenze esplicite)
|
| 117 |
+
3. Validazione piano (no cicli, capabilities disponibili)
|
| 118 |
+
4. Reflection store (memorizza piani e outcome per futuro miglioramento)
|
| 119 |
+
|
| 120 |
+
Nota: la versione LLM-based (reasoning con modello) sarΓ ARCH-I4.1 Phase 2.
|
| 121 |
+
Questa implementazione Γ¨ rule-based e serve a definire il contratto.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def __init__(self) -> None:
|
| 125 |
+
self._plans: dict[str, WorkflowPlan] = {} # plan_id β plan
|
| 126 |
+
self._reflections: list[dict[str, Any]] = [] # storia reflection
|
| 127 |
+
self._lock = asyncio.Lock()
|
| 128 |
+
|
| 129 |
+
# ββ Plan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
|
| 131 |
+
async def plan(self, req: PlanRequest) -> WorkflowPlan:
|
| 132 |
+
"""
|
| 133 |
+
Produce un WorkflowPlan dal goal.
|
| 134 |
+
|
| 135 |
+
Step logic (rule-based):
|
| 136 |
+
- Se hints presenti: crea uno step per ogni hint
|
| 137 |
+
- Altrimenti: step singolo con capability "llm" (Brain decide runtime)
|
| 138 |
+
- Verifica che ogni capability sia risolvibile dal Resolver
|
| 139 |
+
"""
|
| 140 |
+
capabilities = req.hints if req.hints else ["llm"]
|
| 141 |
+
# Verifica capabilities via Resolver
|
| 142 |
+
verified_caps = []
|
| 143 |
+
for cap in capabilities[:req.max_steps]:
|
| 144 |
+
if _RESOLVER_AVAILABLE and _resolver is not None:
|
| 145 |
+
if _resolver.can_resolve(cap):
|
| 146 |
+
verified_caps.append(cap)
|
| 147 |
+
else:
|
| 148 |
+
_logger.warning("[planner] capability '%s' non risolvibile β esclusa dal piano", cap)
|
| 149 |
+
else:
|
| 150 |
+
verified_caps.append(cap) # no resolver: trust hints
|
| 151 |
+
|
| 152 |
+
if not verified_caps:
|
| 153 |
+
verified_caps = ["llm"] # fallback sempre
|
| 154 |
+
|
| 155 |
+
# Costruisci steps
|
| 156 |
+
steps = []
|
| 157 |
+
prev_id: str | None = None
|
| 158 |
+
for cap in verified_caps:
|
| 159 |
+
step = PlanStep(
|
| 160 |
+
capability = cap,
|
| 161 |
+
description = f"Esegui capability '{cap}' per goal: {req.goal[:80]}",
|
| 162 |
+
payload = {"goal": req.goal, **req.context},
|
| 163 |
+
depends_on = [prev_id] if (req.strategy == "sequential" and prev_id) else [],
|
| 164 |
+
timeout_s = 120,
|
| 165 |
+
)
|
| 166 |
+
steps.append(step)
|
| 167 |
+
prev_id = step.step_id
|
| 168 |
+
|
| 169 |
+
plan = WorkflowPlan(
|
| 170 |
+
goal = req.goal,
|
| 171 |
+
strategy = req.strategy,
|
| 172 |
+
steps = steps,
|
| 173 |
+
context = req.context,
|
| 174 |
+
metadata = {"session_id": req.session_id, "correlation_id": req.correlation_id},
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Validate DAG
|
| 178 |
+
errors = plan.validate_dag()
|
| 179 |
+
if errors:
|
| 180 |
+
raise ValueError("Piano DAG non valido: " + "; ".join(errors))
|
| 181 |
+
|
| 182 |
+
async with self._lock:
|
| 183 |
+
self._plans[plan.plan_id] = plan
|
| 184 |
+
_logger.info("[planner] plan created plan_id=%s goal=%s steps=%d strategy=%s",
|
| 185 |
+
plan.plan_id, req.goal[:40], len(steps), req.strategy)
|
| 186 |
+
return plan
|
| 187 |
+
|
| 188 |
+
def get_plan(self, plan_id: str) -> WorkflowPlan | None:
|
| 189 |
+
return self._plans.get(plan_id)
|
| 190 |
+
|
| 191 |
+
# ββ Reflect βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
|
| 193 |
+
async def reflect(self, req: ReflectRequest) -> dict:
|
| 194 |
+
"""Registra l'outcome di un piano β base per miglioramento futuro."""
|
| 195 |
+
plan = self._plans.get(req.plan_id)
|
| 196 |
+
entry = {
|
| 197 |
+
"plan_id": req.plan_id,
|
| 198 |
+
"goal": plan.goal if plan else "unknown",
|
| 199 |
+
"outcome": req.outcome,
|
| 200 |
+
"failed_steps": req.failed_steps,
|
| 201 |
+
"notes": req.notes,
|
| 202 |
+
"ts": time.time(),
|
| 203 |
+
}
|
| 204 |
+
async with self._lock:
|
| 205 |
+
self._reflections.append(entry)
|
| 206 |
+
if len(self._reflections) > 500: # cap
|
| 207 |
+
self._reflections = self._reflections[-500:]
|
| 208 |
+
_logger.info("[planner] reflect plan=%s outcome=%s", req.plan_id, req.outcome)
|
| 209 |
+
return {"reflected": True, "plan_id": req.plan_id, "outcome": req.outcome}
|
| 210 |
+
|
| 211 |
+
def status(self) -> dict:
|
| 212 |
+
return {
|
| 213 |
+
"total_plans": len(self._plans),
|
| 214 |
+
"total_reflections": len(self._reflections),
|
| 215 |
+
"resolver_available": _RESOLVER_AVAILABLE,
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ββ Singleton βββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 220 |
+
planner = BrainPlanner()
|
| 221 |
+
|
| 222 |
+
# ββ HTTP Router ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
router = APIRouter(
|
| 224 |
+
prefix="/api/brain",
|
| 225 |
+
tags=["brain-planner"],
|
| 226 |
+
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@router.post("/plan", summary="Pianifica un goal β WorkflowPlan")
|
| 231 |
+
async def route_plan(req: PlanRequest) -> WorkflowPlan:
|
| 232 |
+
try:
|
| 233 |
+
return await planner.plan(req)
|
| 234 |
+
except ValueError as exc:
|
| 235 |
+
raise HTTPException(400, str(exc))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@router.get("/plan/{plan_id}", summary="Recupera un piano esistente")
|
| 239 |
+
async def route_get_plan(plan_id: str) -> WorkflowPlan:
|
| 240 |
+
p = planner.get_plan(plan_id)
|
| 241 |
+
if not p:
|
| 242 |
+
raise HTTPException(404, f"Piano '{plan_id}' non trovato")
|
| 243 |
+
return p
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.post("/reflect", summary="Reflection su un piano eseguito")
|
| 247 |
+
async def route_reflect(req: ReflectRequest) -> dict:
|
| 248 |
+
return await planner.reflect(req)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@router.get("/planner/status", summary="Stato del BrainPlanner")
|
| 252 |
+
async def route_status() -> dict:
|
| 253 |
+
return planner.status()
|
api/browser.py
CHANGED
|
@@ -260,16 +260,16 @@ def _trim_ax_tree(node: dict, depth: int) -> dict:
|
|
| 260 |
Mantiene: role, name, description, value, checked, expanded, required.
|
| 261 |
Scarta: proprietΓ interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
|
| 262 |
"""
|
| 263 |
-
KEEP = frozenset({
|
| 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 |
|
|
@@ -713,47 +713,6 @@ async def verify_goal_browser(
|
|
| 713 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 714 |
|
| 715 |
|
| 716 |
-
# βββ _take_screenshot (internal helper) ββββββββββββββββββββββββββββββββββββββ
|
| 717 |
-
|
| 718 |
-
async def _take_screenshot(
|
| 719 |
-
url: str,
|
| 720 |
-
mobile: bool = False,
|
| 721 |
-
width: int = 1280,
|
| 722 |
-
height: int = 800,
|
| 723 |
-
wait_ms: int = 1500,
|
| 724 |
-
) -> dict:
|
| 725 |
-
"""
|
| 726 |
-
Wrapper interno per screenshot Playwright headless. (GAP-6-fix)
|
| 727 |
-
Usato da gemini_vision.py senza passare per la route HTTP.
|
| 728 |
-
Ritorna: {"ok": bool, "screenshot_b64": str, "title": str, "url": str}
|
| 729 |
-
"""
|
| 730 |
-
if not _safe_url(url):
|
| 731 |
-
return {"ok": False, "error": "URL non consentita", "screenshot_b64": "", "title": url, "url": url}
|
| 732 |
-
async with _browser_lock:
|
| 733 |
-
try:
|
| 734 |
-
from playwright.async_api import async_playwright
|
| 735 |
-
async with async_playwright() as pw:
|
| 736 |
-
browser = await pw.chromium.launch(headless=True, args=_LAUNCH_ARGS)
|
| 737 |
-
ctx = await _make_context(browser, width, height, mobile)
|
| 738 |
-
page = await ctx.new_page()
|
| 739 |
-
try:
|
| 740 |
-
await _goto_with_networkidle(page, url, GOTO_TIMEOUT)
|
| 741 |
-
await _dismiss_cookie_banner(page)
|
| 742 |
-
await page.wait_for_timeout(wait_ms)
|
| 743 |
-
png = await page.screenshot(type="png", full_page=False)
|
| 744 |
-
title = await page.title()
|
| 745 |
-
png_b64 = base64.b64encode(png).decode()
|
| 746 |
-
asyncio.create_task(_try_persist_screenshot(url, png_b64, title))
|
| 747 |
-
return {"ok": True, "screenshot_b64": png_b64, "title": title, "url": page.url}
|
| 748 |
-
except Exception as _e:
|
| 749 |
-
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 750 |
-
finally:
|
| 751 |
-
await ctx.close()
|
| 752 |
-
await browser.close()
|
| 753 |
-
except Exception as _e:
|
| 754 |
-
return {"ok": False, "error": str(_e)[:500], "screenshot_b64": "", "title": url, "url": url}
|
| 755 |
-
|
| 756 |
-
|
| 757 |
# βββ /screenshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 758 |
|
| 759 |
@router.post("/screenshot", response_model=BrowserResult)
|
|
|
|
| 260 |
Mantiene: role, name, description, value, checked, expanded, required.
|
| 261 |
Scarta: proprietΓ interne Playwright (nodeId, backendDOMNodeId, ignoredReasons).
|
| 262 |
"""
|
| 263 |
+
KEEP = frozenset({role, name, description, value, checked,
|
| 264 |
+
expanded, required, haspopup, level, pressed,
|
| 265 |
+
selected, multiselectable, orientation})
|
| 266 |
result: dict = {k: v for k, v in node.items() if k in KEEP and v not in (None, False, )}
|
| 267 |
+
if depth > 0 and node.get(children):
|
| 268 |
+
trimmed = [_trim_ax_tree(c, depth - 1) for c in node[children]]
|
| 269 |
# Filtra nodi completamente vuoti (solo role senza nome nΓ© figli)
|
| 270 |
+
trimmed = [c for c in trimmed if len(c) > 1 or c.get(children)]
|
| 271 |
if trimmed:
|
| 272 |
+
result[children] = trimmed
|
| 273 |
return result
|
| 274 |
|
| 275 |
|
|
|
|
| 713 |
return {"ok": False, "overall": "UNKNOWN", "per_criterion": per_criterion, "error": str(_e)[:300]} # S588
|
| 714 |
|
| 715 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
# βββ /screenshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 717 |
|
| 718 |
@router.post("/screenshot", response_model=BrowserResult)
|
api/cache_endpoints.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_endpoints.py β Endpoint API per Gestione Cache
|
| 3 |
+
|
| 4 |
+
Endpoint:
|
| 5 |
+
GET /api/cache/stats β Statistiche cache
|
| 6 |
+
POST /api/cache/invalidate β Invalida un entry
|
| 7 |
+
GET /api/cache/health β Health check cache
|
| 8 |
+
POST /api/cache/reset-stats β Resetta statistiche
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, HTTPException
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
from typing import Optional
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
# Import cache manager
|
| 17 |
+
try:
|
| 18 |
+
from backend.api.cache_manager import (
|
| 19 |
+
get_cache_stats,
|
| 20 |
+
reset_cache_stats,
|
| 21 |
+
invalidate_cache,
|
| 22 |
+
CacheStrategy,
|
| 23 |
+
CACHE_ENABLED,
|
| 24 |
+
)
|
| 25 |
+
except ImportError:
|
| 26 |
+
from cache_manager import (
|
| 27 |
+
get_cache_stats,
|
| 28 |
+
reset_cache_stats,
|
| 29 |
+
invalidate_cache,
|
| 30 |
+
CacheStrategy,
|
| 31 |
+
CACHE_ENABLED,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
_logger = logging.getLogger("cache_endpoints")
|
| 35 |
+
|
| 36 |
+
router = APIRouter(prefix="/api/cache", tags=["cache"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββ Modelli Pydantic ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
class InvalidateCacheRequest(BaseModel):
|
| 41 |
+
strategy: str # "query", "memory", "embedding", "conversation", "analytics"
|
| 42 |
+
identifier: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class CacheStatsResponse(BaseModel):
|
| 46 |
+
enabled: bool
|
| 47 |
+
hits: int
|
| 48 |
+
misses: int
|
| 49 |
+
sets: int
|
| 50 |
+
deletes: int
|
| 51 |
+
evictions: int
|
| 52 |
+
hit_rate_percent: float
|
| 53 |
+
total_requests: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CacheHealthResponse(BaseModel):
|
| 57 |
+
ok: bool
|
| 58 |
+
cache_enabled: bool
|
| 59 |
+
message: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ββ Endpoint: Statistiche Cache ββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
@router.get("/stats", response_model=CacheStatsResponse)
|
| 64 |
+
async def cache_stats():
|
| 65 |
+
"""Ritorna le statistiche del cache layer."""
|
| 66 |
+
try:
|
| 67 |
+
stats = get_cache_stats()
|
| 68 |
+
return CacheStatsResponse(**stats)
|
| 69 |
+
except Exception as exc:
|
| 70 |
+
_logger.error(f"Error fetching cache stats: {exc}")
|
| 71 |
+
raise HTTPException(500, "Error fetching cache stats")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ββ Endpoint: Invalida Cache ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
@router.post("/invalidate")
|
| 76 |
+
async def invalidate_cache_entry(req: InvalidateCacheRequest):
|
| 77 |
+
"""Invalida un entry specifico dalla cache."""
|
| 78 |
+
try:
|
| 79 |
+
# Valida strategy
|
| 80 |
+
try:
|
| 81 |
+
strategy = CacheStrategy(req.strategy)
|
| 82 |
+
except ValueError:
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
400,
|
| 85 |
+
f"Invalid strategy. Must be one of: {', '.join([s.value for s in CacheStrategy])}",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Invalida
|
| 89 |
+
success = await invalidate_cache(strategy, req.identifier)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"ok": success,
|
| 93 |
+
"strategy": req.strategy,
|
| 94 |
+
"identifier": req.identifier,
|
| 95 |
+
"message": "Cache entry invalidated" if success else "Failed to invalidate cache entry",
|
| 96 |
+
}
|
| 97 |
+
except HTTPException:
|
| 98 |
+
raise
|
| 99 |
+
except Exception as exc:
|
| 100 |
+
_logger.error(f"Error invalidating cache: {exc}")
|
| 101 |
+
raise HTTPException(500, "Error invalidating cache")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ββ Endpoint: Health Check ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
@router.get("/health", response_model=CacheHealthResponse)
|
| 106 |
+
async def cache_health():
|
| 107 |
+
"""Health check per il cache layer."""
|
| 108 |
+
try:
|
| 109 |
+
stats = get_cache_stats()
|
| 110 |
+
|
| 111 |
+
return CacheHealthResponse(
|
| 112 |
+
ok=True,
|
| 113 |
+
cache_enabled=CACHE_ENABLED,
|
| 114 |
+
message=f"Cache layer operational. Hit rate: {stats.get('hit_rate_percent', 0):.1f}%",
|
| 115 |
+
)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
_logger.error(f"Cache health check failed: {exc}")
|
| 118 |
+
return CacheHealthResponse(
|
| 119 |
+
ok=False,
|
| 120 |
+
cache_enabled=CACHE_ENABLED,
|
| 121 |
+
message=f"Cache health check failed: {str(exc)}",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ββ Endpoint: Reset Statistiche βββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
@router.post("/reset-stats")
|
| 127 |
+
async def reset_stats():
|
| 128 |
+
"""Resetta le statistiche del cache."""
|
| 129 |
+
try:
|
| 130 |
+
reset_cache_stats()
|
| 131 |
+
return {
|
| 132 |
+
"ok": True,
|
| 133 |
+
"message": "Cache statistics reset",
|
| 134 |
+
}
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
_logger.error(f"Error resetting cache stats: {exc}")
|
| 137 |
+
raise HTTPException(500, "Error resetting cache stats")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ββ Endpoint: Info Cache ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
@router.get("/info")
|
| 142 |
+
async def cache_info():
|
| 143 |
+
"""Ritorna informazioni sulla configurazione del cache."""
|
| 144 |
+
try:
|
| 145 |
+
from cache_manager import (
|
| 146 |
+
CACHE_ENABLED,
|
| 147 |
+
CACHE_TTL_DEFAULT,
|
| 148 |
+
CACHE_MAX_SIZE,
|
| 149 |
+
CACHE_TTL_BY_STRATEGY,
|
| 150 |
+
SUPABASE_A_URL,
|
| 151 |
+
SUPABASE_B_URL,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"enabled": CACHE_ENABLED,
|
| 156 |
+
"ttl_default_seconds": CACHE_TTL_DEFAULT,
|
| 157 |
+
"max_size": CACHE_MAX_SIZE,
|
| 158 |
+
"ttl_by_strategy": {k.value: v for k, v in CACHE_TTL_BY_STRATEGY.items()},
|
| 159 |
+
"supabase_a_configured": bool(SUPABASE_A_URL),
|
| 160 |
+
"supabase_b_configured": bool(SUPABASE_B_URL),
|
| 161 |
+
}
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
_logger.error(f"Error fetching cache info: {exc}")
|
| 164 |
+
raise HTTPException(500, "Error fetching cache info")
|