Spaces:
Running
Running
sync: 149 file da Baida98/AI@37832425 (2026-07-01 10:26 UTC)
Browse files- REBUILD_TRIGGER +1 -0
- agents/context_manager.py +15 -183
- agents/executor.py +27 -87
- agents/unified_loop.py +21 -19
- api/_agent_helpers.py +0 -1
- api/agent_checkpoint_routes.py +1 -3
- api/agent_loop_routes.py +10 -8
- api/hf_storage.py +2 -0
- api/job_queue.py +201 -17
- api/providers.py +4 -3
- api/state.py +20 -7
- api/state_sync.py +105 -17
- main.py +4 -2
- models/ai_client.py +2 -2
REBUILD_TRIGGER
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
rebuild
|
agents/context_manager.py
CHANGED
|
@@ -18,8 +18,11 @@ from __future__ import annotations
|
|
| 18 |
import asyncio
|
| 19 |
import hashlib
|
| 20 |
import re
|
|
|
|
| 21 |
from typing import Any
|
| 22 |
|
|
|
|
|
|
|
| 23 |
_FUNC_RE = re.compile(
|
| 24 |
r'^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()',
|
| 25 |
re.MULTILINE)
|
|
@@ -50,89 +53,66 @@ _RANK_ENTRY_STEMS = {'main', 'index', 'app', '__init__', 'config', 'settings', '
|
|
| 50 |
_CAMEL_SPLIT_RE = re.compile(r'([a-z])([A-Z])')
|
| 51 |
|
| 52 |
# ββ FIX-SYN-EXPAND: tabella sinonimi tecnici ITβEN (15 cluster) βββββββββββββββ
|
| 53 |
-
# Struttura: ogni entry Γ¨ un frozenset di termini equivalenti.
|
| 54 |
-
# _expand_tokens() aggiunge tutti i sinonimi di ogni token del goal prima del matching.
|
| 55 |
-
# Scelta design: sinonimi statici (zero LLM, zero latency) coprono l'80% dei task reali.
|
| 56 |
-
# I cluster coprono i domini piΓΉ frequenti nello sviluppo software.
|
| 57 |
_SYN_CLUSTERS: list[frozenset[str]] = [
|
| 58 |
-
# Auth / Sicurezza
|
| 59 |
frozenset({'auth', 'autenticazione', 'authentication', 'login', 'signin',
|
| 60 |
'guard', 'middleware', 'jwt', 'token', 'session', 'oauth',
|
| 61 |
'passport', 'credential', 'permission', 'role', 'accesso'}),
|
| 62 |
-
# Pagamenti
|
| 63 |
frozenset({'payment', 'pagamento', 'stripe', 'checkout', 'invoice',
|
| 64 |
'billing', 'subscription', 'abbonamento', 'fattura', 'webhook',
|
| 65 |
'price', 'plan', 'tier'}),
|
| 66 |
-
# Database / ORM
|
| 67 |
frozenset({'database', 'db', 'schema', 'model', 'migration', 'migrazione',
|
| 68 |
'orm', 'repository', 'query', 'drizzle', 'prisma', 'postgres',
|
| 69 |
'sqlite', 'mysql', 'table', 'tabella', 'record'}),
|
| 70 |
-
# API / Network
|
| 71 |
frozenset({'api', 'endpoint', 'route', 'rotta', 'router', 'server',
|
| 72 |
'request', 'response', 'richiesta', 'risposta', 'http',
|
| 73 |
'rest', 'graphql', 'fetch', 'axios', 'client'}),
|
| 74 |
-
# UI / Frontend
|
| 75 |
frozenset({'component', 'componente', 'ui', 'interface', 'interfaccia',
|
| 76 |
'button', 'form', 'modal', 'layout', 'page', 'pagina',
|
| 77 |
'style', 'css', 'theme', 'tema', 'render', 'view'}),
|
| 78 |
-
# State Management
|
| 79 |
frozenset({'state', 'stato', 'store', 'redux', 'zustand', 'context',
|
| 80 |
'provider', 'hook', 'reducer', 'action', 'dispatch',
|
| 81 |
'observable', 'signal', 'reactive'}),
|
| 82 |
-
# File / Storage
|
| 83 |
frozenset({'file', 'upload', 'caricamento', 'storage', 'bucket',
|
| 84 |
'download', 'attachment', 'allegato', 'blob', 'stream',
|
| 85 |
'filesystem', 'directory', 'path', 'percorso'}),
|
| 86 |
-
# Testing
|
| 87 |
frozenset({'test', 'testing', 'spec', 'unit', 'integration', 'e2e',
|
| 88 |
'mock', 'stub', 'fixture', 'assert', 'expect', 'coverage',
|
| 89 |
'vitest', 'jest', 'pytest'}),
|
| 90 |
-
# Build / Deploy
|
| 91 |
frozenset({'build', 'deploy', 'deployment', 'bundle', 'webpack', 'vite',
|
| 92 |
'esbuild', 'compile', 'dist', 'production', 'staging',
|
| 93 |
'pipeline', 'ci', 'cd', 'docker', 'container'}),
|
| 94 |
-
# Email / Notifiche
|
| 95 |
frozenset({'email', 'mail', 'smtp', 'notification', 'notifica', 'alert',
|
| 96 |
'push', 'telegram', 'slack', 'webhook', 'message', 'messaggio',
|
| 97 |
'sendgrid', 'resend', 'mailer'}),
|
| 98 |
-
# AI / ML
|
| 99 |
frozenset({'ai', 'llm', 'model', 'prompt', 'embedding', 'rag',
|
| 100 |
'vector', 'semantic', 'chat', 'completion', 'inference',
|
| 101 |
'openai', 'gemini', 'groq', 'anthropic', 'agent', 'agente'}),
|
| 102 |
-
# Errori / Debug
|
| 103 |
frozenset({'error', 'errore', 'exception', 'eccezione', 'bug', 'fix',
|
| 104 |
'debug', 'log', 'logging', 'trace', 'stack', 'crash',
|
| 105 |
'fallback', 'retry', 'recover', 'handler', 'catch'}),
|
| 106 |
-
# Configurazione
|
| 107 |
frozenset({'config', 'configurazione', 'configuration', 'settings',
|
| 108 |
'impostazioni', 'env', 'environment', 'variable', 'variabile',
|
| 109 |
'secret', 'segreto', 'dotenv', 'constant', 'costante'}),
|
| 110 |
-
# Performance / Cache
|
| 111 |
frozenset({'cache', 'performance', 'performanza', 'speed', 'velocitΓ ',
|
| 112 |
'optimize', 'ottimizzazione', 'lazy', 'memo', 'debounce',
|
| 113 |
'throttle', 'batch', 'compress', 'compressione'}),
|
| 114 |
-
# Sicurezza / Validazione
|
| 115 |
frozenset({'validation', 'validazione', 'validate', 'sanitize',
|
| 116 |
'sanitizzazione', 'schema', 'zod', 'yup', 'joi',
|
| 117 |
'csrf', 'xss', 'injection', 'escape', 'secure'}),
|
| 118 |
-
# Monitoring / Observability (B-GAP-D: cluster mancante β task metriche/dashboard non rankati)
|
| 119 |
frozenset({'metrics', 'metric', 'monitoring', 'monitoraggio', 'observability',
|
| 120 |
'prometheus', 'grafana', 'dashboard', 'telemetry', 'telemetria',
|
| 121 |
'tracing', 'trace', 'health', 'healthcheck', 'uptime', 'alerting',
|
| 122 |
'datadog', 'sentry', 'newrelic', 'audit', 'report'}),
|
| 123 |
-
# Scheduling / Background Jobs (B-GAP-D: cluster mancante β task cron/queue/worker)
|
| 124 |
frozenset({'cron', 'scheduler', 'pianificatore', 'schedule', 'queue', 'coda',
|
| 125 |
'worker', 'job', 'background', 'celery', 'bull', 'bullmq',
|
| 126 |
'agenda', 'delayed', 'periodic', 'retry', 'backoff', 'redis',
|
| 127 |
'task', 'processo', 'process', 'daemon'}),
|
| 128 |
-
# WebSocket / Realtime (B-GAP-D: cluster mancante β task ws/sse/pubsub)
|
| 129 |
frozenset({'websocket', 'ws', 'socket', 'socketio', 'realtime', 'real_time',
|
| 130 |
'sse', 'server_sent', 'pubsub', 'publish', 'subscribe', 'broadcast',
|
| 131 |
'channel', 'canale', 'room', 'event', 'listener', 'emitter',
|
| 132 |
'live', 'push', 'poll', 'long_polling', 'signalr', 'liveview'}),
|
| 133 |
]
|
| 134 |
|
| 135 |
-
# Indice inverso: token β frozenset di sinonimi (costruito una volta a import)
|
| 136 |
_SYN_INDEX: dict[str, frozenset[str]] = {}
|
| 137 |
for _cluster in _SYN_CLUSTERS:
|
| 138 |
for _term in _cluster:
|
|
@@ -140,20 +120,6 @@ for _cluster in _SYN_CLUSTERS:
|
|
| 140 |
|
| 141 |
|
| 142 |
def _expand_tokens(tokens: list[str]) -> list[str]:
|
| 143 |
-
"""
|
| 144 |
-
FIX-SYN-EXPAND: espande ogni token del goal con i sinonimi IT/EN del suo cluster.
|
| 145 |
-
|
| 146 |
-
Esempio:
|
| 147 |
-
["autenticazione", "aggiungi"] β ["autenticazione", "aggiungi",
|
| 148 |
-
"auth", "login", "guard", "middleware", "jwt", ...]
|
| 149 |
-
|
| 150 |
-
Garanzie:
|
| 151 |
-
- Ordine stabile: token originali prima, sinonimi dopo (preserva prioritΓ )
|
| 152 |
-
- Nessun duplicato (usa set interno)
|
| 153 |
-
- Nessun token < 3 chars, nessuna stopword aggiunta
|
| 154 |
-
- Zero latency (<0.1ms per 20 token), zero LLM calls
|
| 155 |
-
- Mai rilancia eccezioni
|
| 156 |
-
"""
|
| 157 |
try:
|
| 158 |
seen: set[str] = set(tokens)
|
| 159 |
expanded = list(tokens)
|
|
@@ -165,25 +131,18 @@ def _expand_tokens(tokens: list[str]) -> list[str]:
|
|
| 165 |
seen.add(syn)
|
| 166 |
expanded.append(syn)
|
| 167 |
return expanded
|
| 168 |
-
except Exception:
|
|
|
|
| 169 |
return tokens
|
| 170 |
|
| 171 |
|
| 172 |
def _split_camel_snake(text: str) -> list[str]:
|
| 173 |
-
"""
|
| 174 |
-
Spezza camelCase/PascalCase/snake_case in token lowercase (min 3 chars).
|
| 175 |
-
|
| 176 |
-
Esempi:
|
| 177 |
-
"contextManager" β ["context", "manager"]
|
| 178 |
-
"rank_files_by_relevance" β ["rank", "files", "relevance"]
|
| 179 |
-
"UnifiedAgentLoop" β ["unified", "agent", "loop"]
|
| 180 |
-
Usato per fuzzy prefix bonus in rank_files_by_relevance.
|
| 181 |
-
"""
|
| 182 |
try:
|
| 183 |
snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text)
|
| 184 |
parts = re.split(r'[_\-./]', snake)
|
| 185 |
return [p.lower() for p in parts if len(p) >= 3]
|
| 186 |
-
except Exception:
|
|
|
|
| 187 |
return []
|
| 188 |
|
| 189 |
|
|
@@ -193,27 +152,6 @@ def rank_files_by_relevance(
|
|
| 193 |
k: int = 5,
|
| 194 |
min_score: float = 0.0,
|
| 195 |
) -> list[str]:
|
| 196 |
-
"""
|
| 197 |
-
FIX-SKEL-RAG + FIX-SYN-EXPAND: Seleziona i top-K file piΓΉ rilevanti per il goal.
|
| 198 |
-
|
| 199 |
-
Score composito (normalizzato su max(len(base_tokens), 1)):
|
| 200 |
-
path_hits * 2.0 β keyword del goal (espansi) nel path
|
| 201 |
-
symbol_hits * 1.5 β keyword nei nomi funzione/classe (skeleton RAG)
|
| 202 |
-
content_hits * 1.0 β keyword nei primi 600 chars del contenuto
|
| 203 |
-
prefix_bonus * 0.4 β goal token Γ¨ prefisso di un split-token path/symbol (fuzzy)
|
| 204 |
-
entry_boost +0.15 β file entry-point/config noti
|
| 205 |
-
lang_boost +0.20 β il linguaggio del file Γ¨ nel goal
|
| 206 |
-
|
| 207 |
-
FIX-SYN-EXPAND:
|
| 208 |
-
- I token del goal vengono espansi con sinonimi IT/EN prima del matching.
|
| 209 |
-
- Normalizzazione su len(base_tokens) originali (non espansi) per evitare score
|
| 210 |
-
inflazionati su file che matchano solo sinonimi lontani.
|
| 211 |
-
- "autenticazione" β matcha authGuard.ts, middleware.ts, jwt.ts anche senza
|
| 212 |
-
keyword nel path β copertura semantica senza embeddings.
|
| 213 |
-
|
| 214 |
-
Ritorna lista di path ordinata score-desc (top-K, score > min_score).
|
| 215 |
-
Mai rilancia eccezioni β fallback ai primi K file non ranked.
|
| 216 |
-
"""
|
| 217 |
if not all_files or not goal:
|
| 218 |
return []
|
| 219 |
try:
|
|
@@ -225,11 +163,8 @@ def rank_files_by_relevance(
|
|
| 225 |
if not base_tokens:
|
| 226 |
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 227 |
|
| 228 |
-
# FIX-SYN-EXPAND: espandi con sinonimi tecnici IT/EN
|
| 229 |
tokens = _expand_tokens(base_tokens)
|
| 230 |
-
|
| 231 |
goal_lower = goal.lower()
|
| 232 |
-
# Normalizzatore: usa len(base_tokens) non len(tokens) per evitare score inflazionati
|
| 233 |
n = max(len(base_tokens), 1)
|
| 234 |
scores: list[tuple[float, str]] = []
|
| 235 |
|
|
@@ -243,49 +178,43 @@ def rank_files_by_relevance(
|
|
| 243 |
path_lower = path.lower()
|
| 244 |
content_lower = content.lower()
|
| 245 |
|
| 246 |
-
# FIX-SKEL-RAG: estrai firme funzione/classe
|
| 247 |
sigs = _extract_signatures(content, lang)
|
| 248 |
symbols_lower = ' '.join(s.split(':', 1)[-1].lower() for s in sigs)
|
| 249 |
|
| 250 |
-
# Score primario β matching su token espansi
|
| 251 |
path_hits = sum(1 for t in tokens if t in path_lower)
|
| 252 |
symbol_hits = sum(1 for t in tokens if t in symbols_lower)
|
| 253 |
content_hits = sum(1 for t in tokens if t in content_lower)
|
| 254 |
score = (path_hits * 2.0 + symbol_hits * 1.5 + content_hits) / n
|
| 255 |
|
| 256 |
-
# Fuzzy prefix bonus (su token base, non espansi β evita falsi positivi)
|
| 257 |
filename_stem = re.sub(r'\.[^.]+$', '', path_lower.rsplit('/', 1)[-1])
|
| 258 |
split_path = _split_camel_snake(filename_stem)
|
| 259 |
split_syms = [t for s in sigs for t in _split_camel_snake(s.split(':', 1)[-1])]
|
| 260 |
all_split = split_path + split_syms
|
| 261 |
prefix_hits = sum(
|
| 262 |
-
1 for gt in base_tokens
|
| 263 |
for st in all_split
|
| 264 |
if st != gt and st.startswith(gt)
|
| 265 |
)
|
| 266 |
if prefix_hits:
|
| 267 |
score += (prefix_hits * 0.4) / n
|
| 268 |
|
| 269 |
-
# Entry-point boost
|
| 270 |
if filename_stem in _RANK_ENTRY_STEMS:
|
| 271 |
score += 0.15
|
| 272 |
|
| 273 |
-
# Language boost
|
| 274 |
if lang and lang in goal_lower:
|
| 275 |
score += 0.20
|
| 276 |
|
| 277 |
if score > min_score:
|
| 278 |
scores.append((score, path))
|
| 279 |
|
| 280 |
-
# Ordinamento stabile: score desc, poi path asc
|
| 281 |
scores.sort(key=lambda x: (-x[0], x[1]))
|
| 282 |
return [p for _, p in scores[:k] if p]
|
| 283 |
-
except Exception:
|
|
|
|
| 284 |
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 285 |
|
| 286 |
|
| 287 |
def _extract_signatures(content: str, language: str) -> list[str]:
|
| 288 |
-
"""Estrae nomi di funzioni/classi per lo skeleton."""
|
| 289 |
try:
|
| 290 |
lang = (language or '').lower()
|
| 291 |
sigs: list[str] = []
|
|
@@ -302,12 +231,12 @@ def _extract_signatures(content: str, language: str) -> list[str]:
|
|
| 302 |
for m in _PY_CLS_RE.finditer(content):
|
| 303 |
sigs.append(f'class:{m.group(1)}')
|
| 304 |
return sigs[:15]
|
| 305 |
-
except Exception:
|
|
|
|
| 306 |
return []
|
| 307 |
|
| 308 |
|
| 309 |
def build_file_skeleton(path: str, content: str, language: str) -> str:
|
| 310 |
-
"""Costruisce una riga skeleton per un singolo file."""
|
| 311 |
sigs = _extract_signatures(content, language)
|
| 312 |
line_count = content.count('\n') + 1
|
| 313 |
sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)'
|
|
@@ -315,11 +244,6 @@ def build_file_skeleton(path: str, content: str, language: str) -> str:
|
|
| 315 |
|
| 316 |
|
| 317 |
async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
| 318 |
-
"""
|
| 319 |
-
Costruisce lo skeleton compatto da una lista di file VFS.
|
| 320 |
-
Ogni dict ha: path, content, language.
|
| 321 |
-
Ritorna stringa multiriga per iniezione nel contesto agente.
|
| 322 |
-
"""
|
| 323 |
if not files:
|
| 324 |
return ''
|
| 325 |
try:
|
|
@@ -330,98 +254,6 @@ async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
|
| 330 |
language = f.get('language', '') or ''
|
| 331 |
lines.append(build_file_skeleton(path, content, language))
|
| 332 |
return '\n'.join(lines)
|
| 333 |
-
except Exception:
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
async def compress_cold_file(path: str, content: str, language: str,
|
| 338 |
-
tester_llm: Any | None = None) -> str:
|
| 339 |
-
"""
|
| 340 |
-
Comprime un file 'cold' al suo riepilogo essenziale.
|
| 341 |
-
Usa Groq-8b via CONTEXT role per velocitΓ . Fallback a skeleton.
|
| 342 |
-
Max 8s. Mai rilancia eccezioni.
|
| 343 |
-
"""
|
| 344 |
-
# S573: hash() Γ¨ PYTHONHASHSEED-salted β chiave diversa a ogni restart HF Space
|
| 345 |
-
# β nessun riuso della cache tra restart. hashlib.sha256 Γ¨ stabile e deterministica.
|
| 346 |
-
_h = hashlib.sha256(content[:500].encode("utf-8", errors="replace")).hexdigest()[:16]
|
| 347 |
-
cache_key = f'{path}:{_h}'
|
| 348 |
-
if cache_key in _SUMMARY_CACHE:
|
| 349 |
-
return _SUMMARY_CACHE[cache_key]
|
| 350 |
-
|
| 351 |
-
skeleton = build_file_skeleton(path, content, language)
|
| 352 |
-
|
| 353 |
-
if tester_llm and len(content) > 500:
|
| 354 |
-
try:
|
| 355 |
-
msgs = [
|
| 356 |
-
{"role": "system", "content":
|
| 357 |
-
"Riassumi il file in max 2 righe: scopo, symbols chiave, deps. "
|
| 358 |
-
"Solo facts. Formato: [SCOPO] | [SYMBOLS] | [DEPS]"},
|
| 359 |
-
{"role": "user", "content":
|
| 360 |
-
f"File: {path}\n```{language}\n{content[:2000]}\n```"},
|
| 361 |
-
]
|
| 362 |
-
summary = await asyncio.wait_for(
|
| 363 |
-
# S587: 120β200 β formato [SCOPO]|[SYMBOLS]|[DEPS] puΓ² superare 120 tok
|
| 364 |
-
tester_llm.chat(msgs, temperature=0.0, max_tokens=200),
|
| 365 |
-
timeout=7.0,
|
| 366 |
-
)
|
| 367 |
-
if summary and not summary.startswith('[LLM'):
|
| 368 |
-
result = f' {path}: {summary[:300]}' # S604: 180β300 β summary file LLM spesso 2-3 righe
|
| 369 |
-
if len(_SUMMARY_CACHE) >= _MAX_SUMMARY_CACHE:
|
| 370 |
-
oldest = next(iter(_SUMMARY_CACHE))
|
| 371 |
-
del _SUMMARY_CACHE[oldest]
|
| 372 |
-
_SUMMARY_CACHE[cache_key] = result
|
| 373 |
-
return result
|
| 374 |
-
except Exception:
|
| 375 |
-
pass # S364: fallback a skeleton
|
| 376 |
-
|
| 377 |
-
return skeleton
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
async def get_context_for_goal(
|
| 381 |
-
goal: str,
|
| 382 |
-
active_files: list[str],
|
| 383 |
-
all_files: list[dict[str, Any]],
|
| 384 |
-
tester_llm: Any | None = None,
|
| 385 |
-
top_k: int = 5,
|
| 386 |
-
) -> str:
|
| 387 |
-
"""
|
| 388 |
-
Contesto intelligente per l'agente:
|
| 389 |
-
- File in active_files: full content (max 1500 chars ciascuno)
|
| 390 |
-
- Altri file: skeleton compatto
|
| 391 |
-
- Output max: ~4000 chars
|
| 392 |
-
|
| 393 |
-
S752-A + FIX-SKEL-RAG + FIX-SYN-EXPAND: se active_files Γ¨ vuoto o None, usa
|
| 394 |
-
rank_files_by_relevance() (con synonym expansion) per selezionare i top_k file
|
| 395 |
-
piΓΉ rilevanti per il goal. File con score == 0 esclusi automaticamente.
|
| 396 |
-
"""
|
| 397 |
-
if not all_files:
|
| 398 |
-
return ''
|
| 399 |
-
try:
|
| 400 |
-
if not active_files and goal:
|
| 401 |
-
active_files = rank_files_by_relevance(goal, all_files, k=top_k)
|
| 402 |
-
|
| 403 |
-
active_set = set(active_files)
|
| 404 |
-
parts: list[str] = []
|
| 405 |
-
budget = 4000
|
| 406 |
-
|
| 407 |
-
for f in all_files:
|
| 408 |
-
path = f.get('path', '')
|
| 409 |
-
if path not in active_set:
|
| 410 |
-
continue
|
| 411 |
-
content = (f.get('content', '') or '')[:1500]
|
| 412 |
-
language = f.get('language', '') or ''
|
| 413 |
-
chunk = f'[ACTIVE FILE: {path}]\n```{language}\n{content}\n```'
|
| 414 |
-
parts.append(chunk)
|
| 415 |
-
budget -= len(chunk)
|
| 416 |
-
if budget <= 0:
|
| 417 |
-
break
|
| 418 |
-
|
| 419 |
-
cold_files = [f for f in all_files if f.get('path', '') not in active_set]
|
| 420 |
-
if cold_files and budget > 500:
|
| 421 |
-
skeleton = await build_project_skeleton(cold_files)
|
| 422 |
-
if skeleton:
|
| 423 |
-
parts.append(skeleton)
|
| 424 |
-
|
| 425 |
-
return '\n\n'.join(parts) if parts else ''
|
| 426 |
-
except Exception:
|
| 427 |
-
return ''
|
|
|
|
| 18 |
import asyncio
|
| 19 |
import hashlib
|
| 20 |
import re
|
| 21 |
+
import logging
|
| 22 |
from typing import Any
|
| 23 |
|
| 24 |
+
_logger = logging.getLogger("agente_ai.context_manager")
|
| 25 |
+
|
| 26 |
_FUNC_RE = re.compile(
|
| 27 |
r'^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=\s*(?:async\s*)?\()',
|
| 28 |
re.MULTILINE)
|
|
|
|
| 53 |
_CAMEL_SPLIT_RE = re.compile(r'([a-z])([A-Z])')
|
| 54 |
|
| 55 |
# ββ FIX-SYN-EXPAND: tabella sinonimi tecnici ITβEN (15 cluster) βββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
_SYN_CLUSTERS: list[frozenset[str]] = [
|
|
|
|
| 57 |
frozenset({'auth', 'autenticazione', 'authentication', 'login', 'signin',
|
| 58 |
'guard', 'middleware', 'jwt', 'token', 'session', 'oauth',
|
| 59 |
'passport', 'credential', 'permission', 'role', 'accesso'}),
|
|
|
|
| 60 |
frozenset({'payment', 'pagamento', 'stripe', 'checkout', 'invoice',
|
| 61 |
'billing', 'subscription', 'abbonamento', 'fattura', 'webhook',
|
| 62 |
'price', 'plan', 'tier'}),
|
|
|
|
| 63 |
frozenset({'database', 'db', 'schema', 'model', 'migration', 'migrazione',
|
| 64 |
'orm', 'repository', 'query', 'drizzle', 'prisma', 'postgres',
|
| 65 |
'sqlite', 'mysql', 'table', 'tabella', 'record'}),
|
|
|
|
| 66 |
frozenset({'api', 'endpoint', 'route', 'rotta', 'router', 'server',
|
| 67 |
'request', 'response', 'richiesta', 'risposta', 'http',
|
| 68 |
'rest', 'graphql', 'fetch', 'axios', 'client'}),
|
|
|
|
| 69 |
frozenset({'component', 'componente', 'ui', 'interface', 'interfaccia',
|
| 70 |
'button', 'form', 'modal', 'layout', 'page', 'pagina',
|
| 71 |
'style', 'css', 'theme', 'tema', 'render', 'view'}),
|
|
|
|
| 72 |
frozenset({'state', 'stato', 'store', 'redux', 'zustand', 'context',
|
| 73 |
'provider', 'hook', 'reducer', 'action', 'dispatch',
|
| 74 |
'observable', 'signal', 'reactive'}),
|
|
|
|
| 75 |
frozenset({'file', 'upload', 'caricamento', 'storage', 'bucket',
|
| 76 |
'download', 'attachment', 'allegato', 'blob', 'stream',
|
| 77 |
'filesystem', 'directory', 'path', 'percorso'}),
|
|
|
|
| 78 |
frozenset({'test', 'testing', 'spec', 'unit', 'integration', 'e2e',
|
| 79 |
'mock', 'stub', 'fixture', 'assert', 'expect', 'coverage',
|
| 80 |
'vitest', 'jest', 'pytest'}),
|
|
|
|
| 81 |
frozenset({'build', 'deploy', 'deployment', 'bundle', 'webpack', 'vite',
|
| 82 |
'esbuild', 'compile', 'dist', 'production', 'staging',
|
| 83 |
'pipeline', 'ci', 'cd', 'docker', 'container'}),
|
|
|
|
| 84 |
frozenset({'email', 'mail', 'smtp', 'notification', 'notifica', 'alert',
|
| 85 |
'push', 'telegram', 'slack', 'webhook', 'message', 'messaggio',
|
| 86 |
'sendgrid', 'resend', 'mailer'}),
|
|
|
|
| 87 |
frozenset({'ai', 'llm', 'model', 'prompt', 'embedding', 'rag',
|
| 88 |
'vector', 'semantic', 'chat', 'completion', 'inference',
|
| 89 |
'openai', 'gemini', 'groq', 'anthropic', 'agent', 'agente'}),
|
|
|
|
| 90 |
frozenset({'error', 'errore', 'exception', 'eccezione', 'bug', 'fix',
|
| 91 |
'debug', 'log', 'logging', 'trace', 'stack', 'crash',
|
| 92 |
'fallback', 'retry', 'recover', 'handler', 'catch'}),
|
|
|
|
| 93 |
frozenset({'config', 'configurazione', 'configuration', 'settings',
|
| 94 |
'impostazioni', 'env', 'environment', 'variable', 'variabile',
|
| 95 |
'secret', 'segreto', 'dotenv', 'constant', 'costante'}),
|
|
|
|
| 96 |
frozenset({'cache', 'performance', 'performanza', 'speed', 'velocitΓ ',
|
| 97 |
'optimize', 'ottimizzazione', 'lazy', 'memo', 'debounce',
|
| 98 |
'throttle', 'batch', 'compress', 'compressione'}),
|
|
|
|
| 99 |
frozenset({'validation', 'validazione', 'validate', 'sanitize',
|
| 100 |
'sanitizzazione', 'schema', 'zod', 'yup', 'joi',
|
| 101 |
'csrf', 'xss', 'injection', 'escape', 'secure'}),
|
|
|
|
| 102 |
frozenset({'metrics', 'metric', 'monitoring', 'monitoraggio', 'observability',
|
| 103 |
'prometheus', 'grafana', 'dashboard', 'telemetry', 'telemetria',
|
| 104 |
'tracing', 'trace', 'health', 'healthcheck', 'uptime', 'alerting',
|
| 105 |
'datadog', 'sentry', 'newrelic', 'audit', 'report'}),
|
|
|
|
| 106 |
frozenset({'cron', 'scheduler', 'pianificatore', 'schedule', 'queue', 'coda',
|
| 107 |
'worker', 'job', 'background', 'celery', 'bull', 'bullmq',
|
| 108 |
'agenda', 'delayed', 'periodic', 'retry', 'backoff', 'redis',
|
| 109 |
'task', 'processo', 'process', 'daemon'}),
|
|
|
|
| 110 |
frozenset({'websocket', 'ws', 'socket', 'socketio', 'realtime', 'real_time',
|
| 111 |
'sse', 'server_sent', 'pubsub', 'publish', 'subscribe', 'broadcast',
|
| 112 |
'channel', 'canale', 'room', 'event', 'listener', 'emitter',
|
| 113 |
'live', 'push', 'poll', 'long_polling', 'signalr', 'liveview'}),
|
| 114 |
]
|
| 115 |
|
|
|
|
| 116 |
_SYN_INDEX: dict[str, frozenset[str]] = {}
|
| 117 |
for _cluster in _SYN_CLUSTERS:
|
| 118 |
for _term in _cluster:
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def _expand_tokens(tokens: list[str]) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
seen: set[str] = set(tokens)
|
| 125 |
expanded = list(tokens)
|
|
|
|
| 131 |
seen.add(syn)
|
| 132 |
expanded.append(syn)
|
| 133 |
return expanded
|
| 134 |
+
except Exception as e:
|
| 135 |
+
_logger.debug("[context_manager] _expand_tokens failed: %s", e)
|
| 136 |
return tokens
|
| 137 |
|
| 138 |
|
| 139 |
def _split_camel_snake(text: str) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
try:
|
| 141 |
snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text)
|
| 142 |
parts = re.split(r'[_\-./]', snake)
|
| 143 |
return [p.lower() for p in parts if len(p) >= 3]
|
| 144 |
+
except Exception as e:
|
| 145 |
+
_logger.debug("[context_manager] _split_camel_snake failed: %s", e)
|
| 146 |
return []
|
| 147 |
|
| 148 |
|
|
|
|
| 152 |
k: int = 5,
|
| 153 |
min_score: float = 0.0,
|
| 154 |
) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
if not all_files or not goal:
|
| 156 |
return []
|
| 157 |
try:
|
|
|
|
| 163 |
if not base_tokens:
|
| 164 |
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 165 |
|
|
|
|
| 166 |
tokens = _expand_tokens(base_tokens)
|
|
|
|
| 167 |
goal_lower = goal.lower()
|
|
|
|
| 168 |
n = max(len(base_tokens), 1)
|
| 169 |
scores: list[tuple[float, str]] = []
|
| 170 |
|
|
|
|
| 178 |
path_lower = path.lower()
|
| 179 |
content_lower = content.lower()
|
| 180 |
|
|
|
|
| 181 |
sigs = _extract_signatures(content, lang)
|
| 182 |
symbols_lower = ' '.join(s.split(':', 1)[-1].lower() for s in sigs)
|
| 183 |
|
|
|
|
| 184 |
path_hits = sum(1 for t in tokens if t in path_lower)
|
| 185 |
symbol_hits = sum(1 for t in tokens if t in symbols_lower)
|
| 186 |
content_hits = sum(1 for t in tokens if t in content_lower)
|
| 187 |
score = (path_hits * 2.0 + symbol_hits * 1.5 + content_hits) / n
|
| 188 |
|
|
|
|
| 189 |
filename_stem = re.sub(r'\.[^.]+$', '', path_lower.rsplit('/', 1)[-1])
|
| 190 |
split_path = _split_camel_snake(filename_stem)
|
| 191 |
split_syms = [t for s in sigs for t in _split_camel_snake(s.split(':', 1)[-1])]
|
| 192 |
all_split = split_path + split_syms
|
| 193 |
prefix_hits = sum(
|
| 194 |
+
1 for gt in base_tokens
|
| 195 |
for st in all_split
|
| 196 |
if st != gt and st.startswith(gt)
|
| 197 |
)
|
| 198 |
if prefix_hits:
|
| 199 |
score += (prefix_hits * 0.4) / n
|
| 200 |
|
|
|
|
| 201 |
if filename_stem in _RANK_ENTRY_STEMS:
|
| 202 |
score += 0.15
|
| 203 |
|
|
|
|
| 204 |
if lang and lang in goal_lower:
|
| 205 |
score += 0.20
|
| 206 |
|
| 207 |
if score > min_score:
|
| 208 |
scores.append((score, path))
|
| 209 |
|
|
|
|
| 210 |
scores.sort(key=lambda x: (-x[0], x[1]))
|
| 211 |
return [p for _, p in scores[:k] if p]
|
| 212 |
+
except Exception as e:
|
| 213 |
+
_logger.error("[context_manager] rank_files_by_relevance critical failure: %s", e)
|
| 214 |
return [f.get('path', '') for f in all_files[:k] if f.get('path')]
|
| 215 |
|
| 216 |
|
| 217 |
def _extract_signatures(content: str, language: str) -> list[str]:
|
|
|
|
| 218 |
try:
|
| 219 |
lang = (language or '').lower()
|
| 220 |
sigs: list[str] = []
|
|
|
|
| 231 |
for m in _PY_CLS_RE.finditer(content):
|
| 232 |
sigs.append(f'class:{m.group(1)}')
|
| 233 |
return sigs[:15]
|
| 234 |
+
except Exception as e:
|
| 235 |
+
_logger.debug("[context_manager] _extract_signatures failed: %s", e)
|
| 236 |
return []
|
| 237 |
|
| 238 |
|
| 239 |
def build_file_skeleton(path: str, content: str, language: str) -> str:
|
|
|
|
| 240 |
sigs = _extract_signatures(content, language)
|
| 241 |
line_count = content.count('\n') + 1
|
| 242 |
sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)'
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if not files:
|
| 248 |
return ''
|
| 249 |
try:
|
|
|
|
| 254 |
language = f.get('language', '') or ''
|
| 255 |
lines.append(build_file_skeleton(path, content, language))
|
| 256 |
return '\n'.join(lines)
|
| 257 |
+
except Exception as e:
|
| 258 |
+
_logger.error("[context_manager] build_project_skeleton failed: %s", e)
|
| 259 |
+
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
agents/executor.py
CHANGED
|
@@ -25,14 +25,11 @@ _MIN_CALLS_FOR_CIRCUIT = 3 # minimo di chiamate prima che il circuit possa
|
|
| 25 |
_RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open β tenta il tool primario
|
| 26 |
|
| 27 |
# βββ S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker βββββββββββββββββββββββββ
|
| 28 |
-
# Sliding window (last 5 durations) per tool β calcola P90 adattivo.
|
| 29 |
-
# Strategia iPhone: rete variabile β se tool Γ¨ stato lento di recente,
|
| 30 |
-
# aumenta timeout; se Γ¨ stato veloce, non sprecare tempo.
|
| 31 |
class _AdaptiveTimeoutTracker:
|
| 32 |
"""Tracked P90 per-tool timeout con sliding window di 5 call."""
|
| 33 |
_WINDOW = 5
|
| 34 |
-
_MIN = 4.0 # mai sotto 4s
|
| 35 |
-
_MAX = 55.0 # mai sopra 55s
|
| 36 |
_MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo
|
| 37 |
|
| 38 |
def __init__(self) -> None:
|
|
@@ -44,10 +41,9 @@ class _AdaptiveTimeoutTracker:
|
|
| 44 |
self._times[tool_name].append(elapsed)
|
| 45 |
|
| 46 |
def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
|
| 47 |
-
"""Ritorna timeout adattivo: P90 * 1.5 se dati sufficienti, else base."""
|
| 48 |
times = self._times.get(tool_name)
|
| 49 |
if not times or len(times) < 2:
|
| 50 |
-
return base_timeout
|
| 51 |
sorted_t = sorted(times)
|
| 52 |
p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
|
| 53 |
adaptive = sorted_t[p90_idx] * self._MULTIPLIER
|
|
@@ -56,17 +52,15 @@ class _AdaptiveTimeoutTracker:
|
|
| 56 |
_timeout_tracker = _AdaptiveTimeoutTracker()
|
| 57 |
|
| 58 |
|
| 59 |
-
# βββ Helper: ottieni session_id dal ContextVar (impostato da unified_loop.py) β
|
| 60 |
def _get_session_id() -> str:
|
| 61 |
try:
|
| 62 |
from tools.registry import _agent_session_id_var
|
| 63 |
return _agent_session_id_var.get()
|
| 64 |
-
except Exception:
|
|
|
|
| 65 |
return "default"
|
| 66 |
|
| 67 |
|
| 68 |
-
# βββ Executor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
-
|
| 70 |
class Executor:
|
| 71 |
def __init__(
|
| 72 |
self,
|
|
@@ -77,33 +71,21 @@ class Executor:
|
|
| 77 |
self.llm = llm_client or AIClient()
|
| 78 |
self.memory = memory
|
| 79 |
self.max_retries = max_retries
|
| 80 |
-
# GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
|
| 81 |
self._circuit_recovery_counts: dict[str, int] = {}
|
| 82 |
|
| 83 |
-
# Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
|
| 84 |
@classmethod
|
| 85 |
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 86 |
return cls(memory=memory, max_retries=max_retries)
|
| 87 |
|
| 88 |
-
# ββ Circuit breaker helper ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
-
|
| 90 |
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
| 91 |
-
"""True se il circuit breaker deve aprirsi per questo tool in questa sessione.
|
| 92 |
-
|
| 93 |
-
Condizioni (tutte necessarie):
|
| 94 |
-
1. Wilson score < CIRCUIT_OPEN_THRESHOLD (0.15)
|
| 95 |
-
2. >= MIN_CALLS_FOR_CIRCUIT (3) chiamate nella sessione
|
| 96 |
-
3. Il tool ha fallback disponibili in TOOL_REGISTRY
|
| 97 |
-
Recovery credit: ogni RECOVERY_INTERVAL chiamate, il circuit si chiude
|
| 98 |
-
temporaneamente per un tentativo di recovery.
|
| 99 |
-
"""
|
| 100 |
tool = TOOL_REGISTRY.get(tool_name, {})
|
| 101 |
if not tool.get("fallbacks"):
|
| 102 |
-
return False
|
| 103 |
try:
|
| 104 |
from agents.skill_tracker import get_skill_tracker
|
| 105 |
stats = get_skill_tracker().get_stats(session_id).get(tool_name)
|
| 106 |
-
except Exception:
|
|
|
|
| 107 |
return False
|
| 108 |
if not stats:
|
| 109 |
return False
|
|
@@ -111,19 +93,13 @@ class Executor:
|
|
| 111 |
return False
|
| 112 |
if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
|
| 113 |
return False
|
| 114 |
-
# Recovery credit: conta le chiamate e apri una finestra ogni RECOVERY_INTERVAL
|
| 115 |
count = self._circuit_recovery_counts.get(tool_name, 0) + 1
|
| 116 |
self._circuit_recovery_counts[tool_name] = count
|
| 117 |
if count % _RECOVERY_INTERVAL == 0:
|
| 118 |
-
_logger.info(
|
| 119 |
-
|
| 120 |
-
tool_name, count,
|
| 121 |
-
)
|
| 122 |
-
return False # consenti un tentativo di recovery
|
| 123 |
return True
|
| 124 |
|
| 125 |
-
# ββ Fallback execution βββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββ
|
| 126 |
-
|
| 127 |
async def _try_fallbacks(
|
| 128 |
self,
|
| 129 |
primary_name: str,
|
|
@@ -131,11 +107,6 @@ class Executor:
|
|
| 131 |
timeout: float,
|
| 132 |
session_id: str,
|
| 133 |
) -> "dict | None":
|
| 134 |
-
"""Tenta i fallback definiti in TOOL_REGISTRY ordinati per Wilson score.
|
| 135 |
-
|
| 136 |
-
Registra ogni tentativo nel skill_tracker sotto il nome del fallback.
|
| 137 |
-
Ritorna il primo risultato con successo, o None se tutti falliscono.
|
| 138 |
-
"""
|
| 139 |
tool = TOOL_REGISTRY.get(primary_name, {})
|
| 140 |
fallbacks = tool.get("fallbacks", [])
|
| 141 |
if not fallbacks:
|
|
@@ -144,28 +115,25 @@ class Executor:
|
|
| 144 |
try:
|
| 145 |
from agents.skill_tracker import get_skill_tracker
|
| 146 |
sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
|
| 147 |
-
except Exception:
|
| 148 |
-
|
|
|
|
| 149 |
|
| 150 |
for fb_name in sorted_fbs:
|
| 151 |
fb_tool = TOOL_REGISTRY.get(fb_name)
|
| 152 |
if not fb_tool or not fb_tool.get("_fn"):
|
| 153 |
continue
|
| 154 |
-
_logger.info(
|
| 155 |
-
"[executor] %s fallita β provo fallback %s (Wilson-sorted)",
|
| 156 |
-
primary_name, fb_name,
|
| 157 |
-
)
|
| 158 |
try:
|
| 159 |
_t0 = _time_mod.monotonic()
|
| 160 |
_fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
|
| 161 |
result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
|
| 162 |
_timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
|
| 163 |
-
# Registra il successo del fallback nel skill_tracker
|
| 164 |
try:
|
| 165 |
from agents.skill_tracker import get_skill_tracker
|
| 166 |
get_skill_tracker().record(session_id, fb_name, True)
|
| 167 |
-
except Exception:
|
| 168 |
-
|
| 169 |
return {
|
| 170 |
"success": True,
|
| 171 |
"tool": fb_name,
|
|
@@ -179,19 +147,17 @@ class Executor:
|
|
| 179 |
try:
|
| 180 |
from agents.skill_tracker import get_skill_tracker
|
| 181 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 182 |
-
except Exception:
|
| 183 |
-
|
| 184 |
except Exception as fb_exc:
|
| 185 |
_logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
|
| 186 |
try:
|
| 187 |
from agents.skill_tracker import get_skill_tracker
|
| 188 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 189 |
-
except Exception:
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
return None # tutti i fallback hanno fallito
|
| 193 |
|
| 194 |
-
|
| 195 |
|
| 196 |
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 197 |
tool = TOOL_REGISTRY.get(tool_name)
|
|
@@ -204,24 +170,13 @@ class Executor:
|
|
| 204 |
|
| 205 |
session_id = _get_session_id()
|
| 206 |
|
| 207 |
-
# ββ GAP-SKILL-SYNC v2: circuit breaker pre-check ββββββββββββββββββββββ
|
| 208 |
-
# Se il tool ha un Wilson score molto basso (< 0.15) con >= 3 dati in sessione,
|
| 209 |
-
# bypassa il tool e vai direttamente al miglior fallback disponibile.
|
| 210 |
if self._is_circuit_open(tool_name, session_id):
|
| 211 |
-
_logger.info(
|
| 212 |
-
"[executor] circuit OPEN per %s β routing diretto a fallback (Wilson < %.2f)",
|
| 213 |
-
tool_name, _CIRCUIT_OPEN_THRESHOLD,
|
| 214 |
-
)
|
| 215 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 216 |
if fb_result:
|
| 217 |
return fb_result
|
| 218 |
-
|
| 219 |
-
_logger.warning(
|
| 220 |
-
"[executor] tutti i fallback di %s hanno fallito β provo comunque il tool primario",
|
| 221 |
-
tool_name,
|
| 222 |
-
)
|
| 223 |
|
| 224 |
-
# ββ Esecuzione normale con retry ββββββββββββββββββββββββββββββββββββββ
|
| 225 |
fn = tool.get("_fn")
|
| 226 |
if fn is None:
|
| 227 |
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
|
@@ -229,31 +184,21 @@ class Executor:
|
|
| 229 |
last_error: str = "max_retries"
|
| 230 |
for attempt in range(self.max_retries + 1):
|
| 231 |
try:
|
| 232 |
-
# S-ORCH-8GAP FIX-GAP2: usa timeout adattivo basato su P90 ultime 5 chiamate
|
| 233 |
_adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
|
| 234 |
_t0 = _time_mod.monotonic()
|
| 235 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 236 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 237 |
if self.memory:
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
str(result)[:500],
|
| 243 |
-
True,
|
| 244 |
-
)
|
| 245 |
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
| 246 |
|
| 247 |
except asyncio.TimeoutError:
|
| 248 |
-
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
| 249 |
_timeout_tracker.record(tool_name, timeout * 1.2)
|
| 250 |
last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
|
| 251 |
if attempt == self.max_retries:
|
| 252 |
-
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 253 |
-
_logger.info(
|
| 254 |
-
"[executor] %s timeout definitivo β provo fallback Wilson-sorted",
|
| 255 |
-
tool_name,
|
| 256 |
-
)
|
| 257 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 258 |
if fb_result:
|
| 259 |
return fb_result
|
|
@@ -263,11 +208,6 @@ class Executor:
|
|
| 263 |
except Exception as e:
|
| 264 |
last_error = str(e)
|
| 265 |
if attempt == self.max_retries:
|
| 266 |
-
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 267 |
-
_logger.info(
|
| 268 |
-
"[executor] %s errore definitivo (%s) β provo fallback Wilson-sorted",
|
| 269 |
-
tool_name, last_error[:60],
|
| 270 |
-
)
|
| 271 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 272 |
if fb_result:
|
| 273 |
return fb_result
|
|
|
|
| 25 |
_RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open β tenta il tool primario
|
| 26 |
|
| 27 |
# βββ S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker βββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 28 |
class _AdaptiveTimeoutTracker:
|
| 29 |
"""Tracked P90 per-tool timeout con sliding window di 5 call."""
|
| 30 |
_WINDOW = 5
|
| 31 |
+
_MIN = 4.0 # mai sotto 4s
|
| 32 |
+
_MAX = 55.0 # mai sopra 55s
|
| 33 |
_MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo
|
| 34 |
|
| 35 |
def __init__(self) -> None:
|
|
|
|
| 41 |
self._times[tool_name].append(elapsed)
|
| 42 |
|
| 43 |
def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
|
|
|
|
| 44 |
times = self._times.get(tool_name)
|
| 45 |
if not times or len(times) < 2:
|
| 46 |
+
return base_timeout
|
| 47 |
sorted_t = sorted(times)
|
| 48 |
p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
|
| 49 |
adaptive = sorted_t[p90_idx] * self._MULTIPLIER
|
|
|
|
| 52 |
_timeout_tracker = _AdaptiveTimeoutTracker()
|
| 53 |
|
| 54 |
|
|
|
|
| 55 |
def _get_session_id() -> str:
|
| 56 |
try:
|
| 57 |
from tools.registry import _agent_session_id_var
|
| 58 |
return _agent_session_id_var.get()
|
| 59 |
+
except Exception as e:
|
| 60 |
+
_logger.debug("[executor] _get_session_id failed: %s", e)
|
| 61 |
return "default"
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
| 64 |
class Executor:
|
| 65 |
def __init__(
|
| 66 |
self,
|
|
|
|
| 71 |
self.llm = llm_client or AIClient()
|
| 72 |
self.memory = memory
|
| 73 |
self.max_retries = max_retries
|
|
|
|
| 74 |
self._circuit_recovery_counts: dict[str, int] = {}
|
| 75 |
|
|
|
|
| 76 |
@classmethod
|
| 77 |
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 78 |
return cls(memory=memory, max_retries=max_retries)
|
| 79 |
|
|
|
|
|
|
|
| 80 |
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
tool = TOOL_REGISTRY.get(tool_name, {})
|
| 82 |
if not tool.get("fallbacks"):
|
| 83 |
+
return False
|
| 84 |
try:
|
| 85 |
from agents.skill_tracker import get_skill_tracker
|
| 86 |
stats = get_skill_tracker().get_stats(session_id).get(tool_name)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
_logger.debug("[executor] _is_circuit_open failed to get skill tracker: %s", e)
|
| 89 |
return False
|
| 90 |
if not stats:
|
| 91 |
return False
|
|
|
|
| 93 |
return False
|
| 94 |
if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
|
| 95 |
return False
|
|
|
|
| 96 |
count = self._circuit_recovery_counts.get(tool_name, 0) + 1
|
| 97 |
self._circuit_recovery_counts[tool_name] = count
|
| 98 |
if count % _RECOVERY_INTERVAL == 0:
|
| 99 |
+
_logger.info("[executor] recovery credit: riprovo %s (circuit call #%d)", tool_name, count)
|
| 100 |
+
return False
|
|
|
|
|
|
|
|
|
|
| 101 |
return True
|
| 102 |
|
|
|
|
|
|
|
| 103 |
async def _try_fallbacks(
|
| 104 |
self,
|
| 105 |
primary_name: str,
|
|
|
|
| 107 |
timeout: float,
|
| 108 |
session_id: str,
|
| 109 |
) -> "dict | None":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
tool = TOOL_REGISTRY.get(primary_name, {})
|
| 111 |
fallbacks = tool.get("fallbacks", [])
|
| 112 |
if not fallbacks:
|
|
|
|
| 115 |
try:
|
| 116 |
from agents.skill_tracker import get_skill_tracker
|
| 117 |
sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
_logger.debug("[executor] _try_fallbacks failed to sort: %s", e)
|
| 120 |
+
sorted_fbs = fallbacks
|
| 121 |
|
| 122 |
for fb_name in sorted_fbs:
|
| 123 |
fb_tool = TOOL_REGISTRY.get(fb_name)
|
| 124 |
if not fb_tool or not fb_tool.get("_fn"):
|
| 125 |
continue
|
| 126 |
+
_logger.info("[executor] %s fallita β provo fallback %s", primary_name, fb_name)
|
|
|
|
|
|
|
|
|
|
| 127 |
try:
|
| 128 |
_t0 = _time_mod.monotonic()
|
| 129 |
_fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
|
| 130 |
result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
|
| 131 |
_timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
|
|
|
|
| 132 |
try:
|
| 133 |
from agents.skill_tracker import get_skill_tracker
|
| 134 |
get_skill_tracker().record(session_id, fb_name, True)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
_logger.debug("[executor] record success failed: %s", e)
|
| 137 |
return {
|
| 138 |
"success": True,
|
| 139 |
"tool": fb_name,
|
|
|
|
| 147 |
try:
|
| 148 |
from agents.skill_tracker import get_skill_tracker
|
| 149 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 150 |
+
except Exception as e:
|
| 151 |
+
_logger.debug("[executor] record timeout failed: %s", e)
|
| 152 |
except Exception as fb_exc:
|
| 153 |
_logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
|
| 154 |
try:
|
| 155 |
from agents.skill_tracker import get_skill_tracker
|
| 156 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
_logger.debug("[executor] record error failed: %s", e)
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
return None
|
| 161 |
|
| 162 |
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 163 |
tool = TOOL_REGISTRY.get(tool_name)
|
|
|
|
| 170 |
|
| 171 |
session_id = _get_session_id()
|
| 172 |
|
|
|
|
|
|
|
|
|
|
| 173 |
if self._is_circuit_open(tool_name, session_id):
|
| 174 |
+
_logger.info("[executor] circuit OPEN per %s β routing a fallback", tool_name)
|
|
|
|
|
|
|
|
|
|
| 175 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 176 |
if fb_result:
|
| 177 |
return fb_result
|
| 178 |
+
_logger.warning("[executor] tutti i fallback di %s falliti β provo primario", tool_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
|
|
|
| 180 |
fn = tool.get("_fn")
|
| 181 |
if fn is None:
|
| 182 |
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
|
|
|
| 184 |
last_error: str = "max_retries"
|
| 185 |
for attempt in range(self.max_retries + 1):
|
| 186 |
try:
|
|
|
|
| 187 |
_adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
|
| 188 |
_t0 = _time_mod.monotonic()
|
| 189 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 190 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 191 |
if self.memory:
|
| 192 |
+
try:
|
| 193 |
+
await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:500]}", str(result)[:500], True)
|
| 194 |
+
except Exception as e:
|
| 195 |
+
_logger.debug("[executor] memory save failed: %s", e)
|
|
|
|
|
|
|
|
|
|
| 196 |
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
| 197 |
|
| 198 |
except asyncio.TimeoutError:
|
|
|
|
| 199 |
_timeout_tracker.record(tool_name, timeout * 1.2)
|
| 200 |
last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
|
| 201 |
if attempt == self.max_retries:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 203 |
if fb_result:
|
| 204 |
return fb_result
|
|
|
|
| 208 |
except Exception as e:
|
| 209 |
last_error = str(e)
|
| 210 |
if attempt == self.max_retries:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 212 |
if fb_result:
|
| 213 |
return fb_result
|
agents/unified_loop.py
CHANGED
|
@@ -105,10 +105,11 @@ async def _read_bb_upstash(session_id: str) -> str:
|
|
| 105 |
_key = _e.get("key", "")
|
| 106 |
_val = str(_e.get("value", ""))[:200]
|
| 107 |
_out.append(f"- [{_agid}] {_key}: {_val}")
|
| 108 |
-
except Exception:
|
| 109 |
-
|
| 110 |
return ("SCOPERTE CRITICHE DAI DELEGATI:\n" + "\n".join(_out)) if _out else ""
|
| 111 |
-
except Exception:
|
|
|
|
| 112 |
return ""
|
| 113 |
|
| 114 |
# ββ Nuovi mixin estratti (split 2026-06-30) ββββββββββββββββββββββββββββββββββ
|
|
@@ -196,7 +197,8 @@ class UnifiedAgentLoop(
|
|
| 196 |
try:
|
| 197 |
from tools.registry import _agent_session_id_var as _sid_var
|
| 198 |
_sid_token = _sid_var.set(self._run_task_id)
|
| 199 |
-
except Exception:
|
|
|
|
| 200 |
_sid_token = None # fallback silente Γ’ΒΒ registry usa default "agent_default"
|
| 201 |
|
| 202 |
# S750-GAP-B: pre-warm sandbox backend-exec Γ’ΒΒ POST /api/session in background.
|
|
@@ -269,8 +271,8 @@ class UnifiedAgentLoop(
|
|
| 269 |
"explanation": _bl_reason[:200],
|
| 270 |
}))
|
| 271 |
# Fail-open: logghiamo e proseguiamo β non blocchiamo task legittimi
|
| 272 |
-
except Exception:
|
| 273 |
-
|
| 274 |
|
| 275 |
# P29-B1: gate ambiguitΓ strutturale β _is_goal_ambiguous() era P28-B2 dead code (mai chiamata).
|
| 276 |
# Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing.
|
|
@@ -314,7 +316,7 @@ class UnifiedAgentLoop(
|
|
| 314 |
_r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 315 |
if _sid_token is not None:
|
| 316 |
try: _sid_var.reset(_sid_token)
|
| 317 |
-
except Exception:
|
| 318 |
return _r_amb
|
| 319 |
|
| 320 |
# P29-R1: borderline ambiguity gate β goal con verbo ma oggetto pronominale vago.
|
|
@@ -431,7 +433,7 @@ class UnifiedAgentLoop(
|
|
| 431 |
_r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 432 |
if _sid_token is not None:
|
| 433 |
try: _sid_var.reset(_sid_token)
|
| 434 |
-
except Exception:
|
| 435 |
return _r_bl
|
| 436 |
|
| 437 |
|
|
@@ -451,7 +453,7 @@ class UnifiedAgentLoop(
|
|
| 451 |
# S749-D: reset ContextVar
|
| 452 |
if _sid_token is not None:
|
| 453 |
try: _sid_var.reset(_sid_token)
|
| 454 |
-
except Exception:
|
| 455 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 456 |
if self._session_files:
|
| 457 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
@@ -475,7 +477,7 @@ class UnifiedAgentLoop(
|
|
| 475 |
# S749-D: reset ContextVar
|
| 476 |
if _sid_token is not None:
|
| 477 |
try: _sid_var.reset(_sid_token)
|
| 478 |
-
except Exception:
|
| 479 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 480 |
if self._session_files:
|
| 481 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
@@ -492,14 +494,14 @@ class UnifiedAgentLoop(
|
|
| 492 |
try:
|
| 493 |
from api.state import record_timing as _rtcB5
|
| 494 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 495 |
-
except Exception:
|
| 496 |
-
|
| 497 |
_r = await self._run_fallback(state, on_step)
|
| 498 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 499 |
_r["effective_max_steps"] = state.max_steps
|
| 500 |
if _sid_token is not None:
|
| 501 |
try: _sid_var.reset(_sid_token)
|
| 502 |
-
except Exception:
|
| 503 |
if self._session_files:
|
| 504 |
asyncio.ensure_future(self._vfs_git_backup())
|
| 505 |
return _r
|
|
@@ -555,12 +557,12 @@ class UnifiedAgentLoop(
|
|
| 555 |
try:
|
| 556 |
from api.state import increment_stat as _p36_stat
|
| 557 |
_p36_stat("p36_fast_path_hit")
|
| 558 |
-
except Exception:
|
| 559 |
-
|
| 560 |
_logger.info("P36 fast-path: python_analyze in %dms", _p36_ms)
|
| 561 |
if _sid_token is not None:
|
| 562 |
try: _sid_var.reset(_sid_token)
|
| 563 |
-
except Exception:
|
| 564 |
return {
|
| 565 |
"success": True,
|
| 566 |
"answer": _p36_answer,
|
|
@@ -605,7 +607,7 @@ class UnifiedAgentLoop(
|
|
| 605 |
# S749-D: reset ContextVar
|
| 606 |
if _sid_token is not None:
|
| 607 |
try: _sid_var.reset(_sid_token)
|
| 608 |
-
except Exception:
|
| 609 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 610 |
if self._session_files:
|
| 611 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
@@ -648,7 +650,7 @@ class UnifiedAgentLoop(
|
|
| 648 |
# S749-D: reset ContextVar
|
| 649 |
if _sid_token is not None:
|
| 650 |
try: _sid_var.reset(_sid_token)
|
| 651 |
-
except Exception:
|
| 652 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 653 |
if self._session_files:
|
| 654 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
@@ -683,4 +685,4 @@ class UnifiedAgentLoop(
|
|
| 683 |
# S749-D: reset ContextVar prima di uscire Γ’ΒΒ libera la sandbox per il GC
|
| 684 |
if _sid_token is not None:
|
| 685 |
try: _sid_var.reset(_sid_token)
|
| 686 |
-
except Exception:
|
|
|
|
| 105 |
_key = _e.get("key", "")
|
| 106 |
_val = str(_e.get("value", ""))[:200]
|
| 107 |
_out.append(f"- [{_agid}] {_key}: {_val}")
|
| 108 |
+
except Exception as _d_err:
|
| 109 |
+
_logger.debug("[unified_loop] delegate parse silenced: %s", type(_d_err).__name__)
|
| 110 |
return ("SCOPERTE CRITICHE DAI DELEGATI:\n" + "\n".join(_out)) if _out else ""
|
| 111 |
+
except Exception as _d_outer_err:
|
| 112 |
+
_logger.debug("[unified_loop] _get_delegate_findings silenced: %s", type(_d_outer_err).__name__)
|
| 113 |
return ""
|
| 114 |
|
| 115 |
# ββ Nuovi mixin estratti (split 2026-06-30) ββββββββββββββββββββββββββββββββββ
|
|
|
|
| 197 |
try:
|
| 198 |
from tools.registry import _agent_session_id_var as _sid_var
|
| 199 |
_sid_token = _sid_var.set(self._run_task_id)
|
| 200 |
+
except Exception as _imp_err:
|
| 201 |
+
_logger.debug("[unified_loop] sid_var import silenced: %s", type(_imp_err).__name__)
|
| 202 |
_sid_token = None # fallback silente Γ’ΒΒ registry usa default "agent_default"
|
| 203 |
|
| 204 |
# S750-GAP-B: pre-warm sandbox backend-exec Γ’ΒΒ POST /api/session in background.
|
|
|
|
| 271 |
"explanation": _bl_reason[:200],
|
| 272 |
}))
|
| 273 |
# Fail-open: logghiamo e proseguiamo β non blocchiamo task legittimi
|
| 274 |
+
except Exception as _dm_err:
|
| 275 |
+
_logger.debug("[unified_loop] decision_memory check silenced: %s", type(_dm_err).__name__) # non disponibile β continua normalmente
|
| 276 |
|
| 277 |
# P29-B1: gate ambiguitΓ strutturale β _is_goal_ambiguous() era P28-B2 dead code (mai chiamata).
|
| 278 |
# Zero LLM, <0.1ms. Lingua-aware via self._run_lang (P28-B1). Fires dopo blacklist e prima del routing.
|
|
|
|
| 316 |
_r_amb = {"answer": _amb_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 317 |
if _sid_token is not None:
|
| 318 |
try: _sid_var.reset(_sid_token)
|
| 319 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 320 |
return _r_amb
|
| 321 |
|
| 322 |
# P29-R1: borderline ambiguity gate β goal con verbo ma oggetto pronominale vago.
|
|
|
|
| 433 |
_r_bl = {"answer": _bl_answer, "timing_ms": 0, "effective_max_steps": state.max_steps}
|
| 434 |
if _sid_token is not None:
|
| 435 |
try: _sid_var.reset(_sid_token)
|
| 436 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 437 |
return _r_bl
|
| 438 |
|
| 439 |
|
|
|
|
| 453 |
# S749-D: reset ContextVar
|
| 454 |
if _sid_token is not None:
|
| 455 |
try: _sid_var.reset(_sid_token)
|
| 456 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 457 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 458 |
if self._session_files:
|
| 459 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
|
|
| 477 |
# S749-D: reset ContextVar
|
| 478 |
if _sid_token is not None:
|
| 479 |
try: _sid_var.reset(_sid_token)
|
| 480 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 481 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 482 |
if self._session_files:
|
| 483 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
|
|
| 494 |
try:
|
| 495 |
from api.state import record_timing as _rtcB5
|
| 496 |
_rtcB5("classify_ms", (_time.monotonic() - _t0_classify) * 1000)
|
| 497 |
+
except Exception as _rt_err:
|
| 498 |
+
_logger.debug("[unified_loop] record_timing silenced: %s", type(_rt_err).__name__)
|
| 499 |
_r = await self._run_fallback(state, on_step)
|
| 500 |
_r["timing_ms"] = int((_time.monotonic() - _t_run) * 1000)
|
| 501 |
_r["effective_max_steps"] = state.max_steps
|
| 502 |
if _sid_token is not None:
|
| 503 |
try: _sid_var.reset(_sid_token)
|
| 504 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 505 |
if self._session_files:
|
| 506 |
asyncio.ensure_future(self._vfs_git_backup())
|
| 507 |
return _r
|
|
|
|
| 557 |
try:
|
| 558 |
from api.state import increment_stat as _p36_stat
|
| 559 |
_p36_stat("p36_fast_path_hit")
|
| 560 |
+
except Exception as _is_err:
|
| 561 |
+
_logger.debug("[unified_loop] increment_stat P36 silenced: %s", type(_is_err).__name__)
|
| 562 |
_logger.info("P36 fast-path: python_analyze in %dms", _p36_ms)
|
| 563 |
if _sid_token is not None:
|
| 564 |
try: _sid_var.reset(_sid_token)
|
| 565 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 566 |
return {
|
| 567 |
"success": True,
|
| 568 |
"answer": _p36_answer,
|
|
|
|
| 607 |
# S749-D: reset ContextVar
|
| 608 |
if _sid_token is not None:
|
| 609 |
try: _sid_var.reset(_sid_token)
|
| 610 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 611 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 612 |
if self._session_files:
|
| 613 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
|
|
| 650 |
# S749-D: reset ContextVar
|
| 651 |
if _sid_token is not None:
|
| 652 |
try: _sid_var.reset(_sid_token)
|
| 653 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
| 654 |
# GAP-NEW-4: schedule VFS backup se ci sono file scritti nella sessione
|
| 655 |
if self._session_files:
|
| 656 |
asyncio.ensure_future(self._vfs_git_backup())
|
|
|
|
| 685 |
# S749-D: reset ContextVar prima di uscire Γ’ΒΒ libera la sandbox per il GC
|
| 686 |
if _sid_token is not None:
|
| 687 |
try: _sid_var.reset(_sid_token)
|
| 688 |
+
except Exception as _sv_err: _logger.debug("[unified_loop] sid_var.reset silenced: %s", type(_sv_err).__name__)
|
api/_agent_helpers.py
CHANGED
|
@@ -42,7 +42,6 @@ except Exception:
|
|
| 42 |
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 43 |
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 44 |
|
| 45 |
-
from fastapi import APIRouter
|
| 46 |
router = APIRouter()
|
| 47 |
|
| 48 |
@router.post('/run_loop', deprecated=True)
|
|
|
|
| 42 |
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 43 |
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 44 |
|
|
|
|
| 45 |
router = APIRouter()
|
| 46 |
|
| 47 |
@router.post('/run_loop', deprecated=True)
|
api/agent_checkpoint_routes.py
CHANGED
|
@@ -49,9 +49,6 @@ except Exception:
|
|
| 49 |
|
| 50 |
router = APIRouter()
|
| 51 |
|
| 52 |
-
'Connection': 'keep-alive',
|
| 53 |
-
},
|
| 54 |
-
)
|
| 55 |
|
| 56 |
|
| 57 |
# ββ Task checkpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -279,3 +276,4 @@ async def get_circuit_status(session_id: str):
|
|
| 279 |
'circuits_closed': [],
|
| 280 |
'error': str(exc),
|
| 281 |
}
|
|
|
|
|
|
| 49 |
|
| 50 |
router = APIRouter()
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
# ββ Task checkpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 276 |
'circuits_closed': [],
|
| 277 |
'error': str(exc),
|
| 278 |
}
|
| 279 |
+
|
api/agent_loop_routes.py
CHANGED
|
@@ -12,7 +12,8 @@ Route coperte:
|
|
| 12 |
from __future__ import annotations
|
| 13 |
import os, asyncio, json, uuid, time, re
|
| 14 |
import re as _re_persona
|
| 15 |
-
from fastapi import APIRouter
|
|
|
|
| 16 |
from fastapi.responses import StreamingResponse
|
| 17 |
from pydantic import BaseModel, field_validator
|
| 18 |
from typing import Literal
|
|
@@ -26,7 +27,8 @@ from .state import (
|
|
| 26 |
from .speculative import fire_speculative_tools
|
| 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")
|
|
@@ -55,8 +57,6 @@ async def run_loop_removed():
|
|
| 55 |
|
| 56 |
# ββ SSE run-stream ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
|
| 58 |
-
router = APIRouter()
|
| 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
|
|
@@ -79,7 +79,8 @@ async def agent_run_stream(body: ReasonLoopIn, request: Request):
|
|
| 79 |
from agents.response_verifier import ResponseVerifier
|
| 80 |
_critic = Critic(llm_client=client)
|
| 81 |
_verifier = ResponseVerifier()
|
| 82 |
-
except Exception:
|
|
|
|
| 83 |
_critic = None
|
| 84 |
_verifier = None
|
| 85 |
# Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through)
|
|
@@ -147,8 +148,8 @@ async def agent_run_stream(body: ReasonLoopIn, request: Request):
|
|
| 147 |
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
|
| 148 |
yield "data: [DONE]\n\n"
|
| 149 |
return
|
| 150 |
-
except Exception:
|
| 151 |
-
|
| 152 |
|
| 153 |
# S386: timeout ridotto 120β60s β risposta entro 1 minuto o errore esplicito
|
| 154 |
timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60'))
|
|
@@ -295,7 +296,8 @@ async def reason_loop(body: ReasonLoopIn):
|
|
| 295 |
from agents.response_verifier import ResponseVerifier
|
| 296 |
_critic = Critic(llm_client=client)
|
| 297 |
_verifier = ResponseVerifier()
|
| 298 |
-
except Exception:
|
|
|
|
| 299 |
_critic = None
|
| 300 |
_verifier = None
|
| 301 |
loop = UnifiedAgentLoop(
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
import os, asyncio, json, uuid, time, re
|
| 14 |
import re as _re_persona
|
| 15 |
+
from fastapi import APIRouter
|
| 16 |
+
router = APIRouter(), HTTPException, Request, Body
|
| 17 |
from fastapi.responses import StreamingResponse
|
| 18 |
from pydantic import BaseModel, field_validator
|
| 19 |
from typing import Literal
|
|
|
|
| 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")
|
|
|
|
| 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
|
|
|
|
| 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)
|
|
|
|
| 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'))
|
|
|
|
| 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(
|
api/hf_storage.py
CHANGED
|
@@ -18,6 +18,7 @@ import httpx
|
|
| 18 |
import asyncio
|
| 19 |
import logging
|
| 20 |
from typing import Any, Optional
|
|
|
|
| 21 |
|
| 22 |
_logger = logging.getLogger("api.hf_storage")
|
| 23 |
|
|
@@ -91,3 +92,4 @@ def hf_fire_and_forget(
|
|
| 91 |
loop.create_task(hf_append_record(dataset_path, record, repo_id))
|
| 92 |
except Exception:
|
| 93 |
pass # Fire-and-forget: mai propagare eccezioni al caller
|
|
|
|
|
|
| 18 |
import asyncio
|
| 19 |
import logging
|
| 20 |
from typing import Any, Optional
|
| 21 |
+
from .state import get_env_secret
|
| 22 |
|
| 23 |
_logger = logging.getLogger("api.hf_storage")
|
| 24 |
|
|
|
|
| 92 |
loop.create_task(hf_append_record(dataset_path, record, repo_id))
|
| 93 |
except Exception:
|
| 94 |
pass # Fire-and-forget: mai propagare eccezioni al caller
|
| 95 |
+
|
api/job_queue.py
CHANGED
|
@@ -3,6 +3,7 @@ import json
|
|
| 3 |
import time
|
| 4 |
import asyncio
|
| 5 |
import logging
|
|
|
|
| 6 |
from typing import Optional, List, Dict, Any
|
| 7 |
from fastapi import APIRouter, HTTPException, Request
|
| 8 |
from pydantic import BaseModel
|
|
@@ -69,8 +70,8 @@ async def publish_load_metrics():
|
|
| 69 |
data = {
|
| 70 |
"role": _SPACE_ROLE,
|
| 71 |
"ts": int(time.time() * 1000),
|
| 72 |
-
"active_tasks": len(asyncio.all_tasks()) - 5,
|
| 73 |
-
"cpu": 0,
|
| 74 |
"mem": 0
|
| 75 |
}
|
| 76 |
await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
|
|
@@ -85,31 +86,154 @@ async def _load_publisher_loop():
|
|
| 85 |
await asyncio.sleep(30)
|
| 86 |
|
| 87 |
async def _hands_consumer_loop():
|
| 88 |
-
"""Loop consumer per nodi
|
| 89 |
_logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
|
|
|
|
|
|
|
|
|
|
| 90 |
while True:
|
| 91 |
try:
|
| 92 |
-
#
|
| 93 |
await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
|
| 94 |
-
|
| 95 |
-
#
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
except Exception as e:
|
| 99 |
-
_logger.
|
| 100 |
-
|
| 101 |
|
| 102 |
async def start_job_queue_consumer() -> None:
|
| 103 |
"""Punto di ingresso per main.py _on_startup()."""
|
| 104 |
if not _redis_ok():
|
| 105 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 106 |
return
|
| 107 |
-
|
| 108 |
-
asyncio.create_task(_load_publisher_loop())
|
| 109 |
-
|
| 110 |
# Grid Orchestrator: Consumer specializzati (HANDS, MEMORY, AUDIT)
|
| 111 |
if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
|
| 112 |
-
asyncio.create_task(_hands_consumer_loop())
|
| 113 |
else:
|
| 114 |
_logger.info("[jq] SPACE_ROLE=%s β consumer non avviato (solo load publisher)", _SPACE_ROLE)
|
| 115 |
|
|
@@ -131,11 +255,71 @@ async def jq_load(role: str):
|
|
| 131 |
res = await _rcmd(["GET", _K_LOAD(role)])
|
| 132 |
if not res or not res.get("result"):
|
| 133 |
raise HTTPException(404, f"Metriche {role} non disponibili")
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
@router.post("/submit")
|
| 137 |
async def jq_submit(job: JobPayload, request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 139 |
raise HTTPException(401, "Unauthorized")
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import time
|
| 4 |
import asyncio
|
| 5 |
import logging
|
| 6 |
+
import uuid
|
| 7 |
from typing import Optional, List, Dict, Any
|
| 8 |
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
from pydantic import BaseModel
|
|
|
|
| 70 |
data = {
|
| 71 |
"role": _SPACE_ROLE,
|
| 72 |
"ts": int(time.time() * 1000),
|
| 73 |
+
"active_tasks": len(asyncio.all_tasks()) - 5, # Stima approssimativa
|
| 74 |
+
"cpu": 0, # Placeholder per metrics reali
|
| 75 |
"mem": 0
|
| 76 |
}
|
| 77 |
await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
|
|
|
|
| 86 |
await asyncio.sleep(30)
|
| 87 |
|
| 88 |
async def _hands_consumer_loop():
|
| 89 |
+
"""Loop consumer reale per nodi distribuiti (S42)."""
|
| 90 |
_logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
|
| 91 |
+
_consecutive_errors = 0
|
| 92 |
+
_MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β pausa 30s
|
| 93 |
+
|
| 94 |
while True:
|
| 95 |
try:
|
| 96 |
+
# 1. Heartbeat consumer
|
| 97 |
await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
|
| 98 |
+
|
| 99 |
+
# 2. Prelievo job (RPOP per coda FIFO)
|
| 100 |
+
res = await _rcmd(["RPOP", _K_PENDING])
|
| 101 |
+
if res and res.get("result"):
|
| 102 |
+
job_raw = res["result"]
|
| 103 |
+
try:
|
| 104 |
+
job_data = json.loads(job_raw)
|
| 105 |
+
except json.JSONDecodeError as _jd_err:
|
| 106 |
+
_logger.error("[jq] JSON malformato in coda: %s | raw: %.120s", _jd_err, job_raw)
|
| 107 |
+
continue
|
| 108 |
+
task_id = job_data.get("taskId", str(uuid.uuid4()))
|
| 109 |
+
goal = job_data.get("goal", "")
|
| 110 |
+
context_raw = job_data.get("context", {})
|
| 111 |
+
_logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
|
| 112 |
+
|
| 113 |
+
# Normalizza context β stringa
|
| 114 |
+
if isinstance(context_raw, dict):
|
| 115 |
+
context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
|
| 116 |
+
elif isinstance(context_raw, str):
|
| 117 |
+
context_str = context_raw
|
| 118 |
+
else:
|
| 119 |
+
context_str = ""
|
| 120 |
+
|
| 121 |
+
# S429 β Esecuzione REALE via UnifiedAgentLoop
|
| 122 |
+
result_payload: Dict[str, Any] = {}
|
| 123 |
+
try:
|
| 124 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 125 |
+
from .state import (
|
| 126 |
+
_get_ai_client, _get_mem_manager_async,
|
| 127 |
+
_get_executor, _get_planner,
|
| 128 |
+
)
|
| 129 |
+
ai_client = _get_ai_client()
|
| 130 |
+
try:
|
| 131 |
+
from agents.critic import Critic
|
| 132 |
+
from agents.response_verifier import ResponseVerifier
|
| 133 |
+
_critic = Critic(llm_client=ai_client)
|
| 134 |
+
_verifier = ResponseVerifier()
|
| 135 |
+
except Exception:
|
| 136 |
+
_critic = None
|
| 137 |
+
_verifier = None
|
| 138 |
+
|
| 139 |
+
# Accumula step per tracciabilitΓ nel risultato Redis
|
| 140 |
+
_steps: list = []
|
| 141 |
+
|
| 142 |
+
async def _on_step(step: dict) -> None:
|
| 143 |
+
_steps.append({
|
| 144 |
+
"action": step.get("action", ""),
|
| 145 |
+
"output": str(step.get("output", ""))[:200],
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
loop_inst = UnifiedAgentLoop(
|
| 149 |
+
llm_client=ai_client,
|
| 150 |
+
critic=_critic,
|
| 151 |
+
verifier=_verifier,
|
| 152 |
+
memory=await _get_mem_manager_async(),
|
| 153 |
+
executor=_get_executor(),
|
| 154 |
+
planner=_get_planner(),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# max_steps: clampato 5β16, scalato per prioritΓ job
|
| 158 |
+
_priority = int(job_data.get("priority", 1))
|
| 159 |
+
_max_steps = max(5, min(4 + _priority * 2, 16))
|
| 160 |
+
|
| 161 |
+
raw_result = await loop_inst.run(
|
| 162 |
+
goal=goal,
|
| 163 |
+
context=context_str,
|
| 164 |
+
max_steps=_max_steps,
|
| 165 |
+
on_step=_on_step,
|
| 166 |
+
session_id=task_id,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
|
| 170 |
+
success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
|
| 171 |
+
engine = raw_result.get("engine", "unknown") if isinstance(raw_result, dict) else "unknown"
|
| 172 |
+
|
| 173 |
+
result_payload = {
|
| 174 |
+
"taskId": task_id,
|
| 175 |
+
"status": "completed" if success else "failed",
|
| 176 |
+
"worker": _SPACE_ROLE,
|
| 177 |
+
"output": output[:4000],
|
| 178 |
+
"engine": engine,
|
| 179 |
+
"success": success,
|
| 180 |
+
"steps": len(_steps),
|
| 181 |
+
"ts": int(time.time() * 1000),
|
| 182 |
+
}
|
| 183 |
+
_consecutive_errors = 0
|
| 184 |
+
_logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
|
| 185 |
+
|
| 186 |
+
except (ImportError, ModuleNotFoundError) as imp_err:
|
| 187 |
+
# UnifiedAgentLoop non disponibile su questo nodo
|
| 188 |
+
_logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
|
| 189 |
+
result_payload = {
|
| 190 |
+
"taskId": task_id,
|
| 191 |
+
"status": "unavailable",
|
| 192 |
+
"worker": _SPACE_ROLE,
|
| 193 |
+
"error": f"UnifiedAgentLoop non disponibile su nodo {_SPACE_ROLE}: {imp_err}",
|
| 194 |
+
"ts": int(time.time() * 1000),
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
except Exception as exec_err:
|
| 198 |
+
_consecutive_errors += 1
|
| 199 |
+
_logger.error("[jq] job execution error task=%s: %s", task_id, exec_err)
|
| 200 |
+
result_payload = {
|
| 201 |
+
"taskId": task_id,
|
| 202 |
+
"status": "error",
|
| 203 |
+
"worker": _SPACE_ROLE,
|
| 204 |
+
"error": str(exec_err)[:500],
|
| 205 |
+
"ts": int(time.time() * 1000),
|
| 206 |
+
}
|
| 207 |
+
# INV-R1: 3 errori consecutivi infra β pausa 30s, log esplicito
|
| 208 |
+
if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
| 209 |
+
_logger.error(
|
| 210 |
+
"[jq] INV-R1: %d errori consecutivi β pausa 30s (nodo: %s)",
|
| 211 |
+
_consecutive_errors, _SPACE_ROLE,
|
| 212 |
+
)
|
| 213 |
+
await asyncio.sleep(30)
|
| 214 |
+
|
| 215 |
+
# 3. Salva risultato su Redis (TTL 1h) β Tool Success Contract S429
|
| 216 |
+
await _rcmd(["SET", _K_RESULT(task_id), json.dumps(result_payload), "EX", "3600"])
|
| 217 |
+
_logger.info("[jq] risultato Redis: %s β %s", task_id, result_payload.get("status"))
|
| 218 |
+
|
| 219 |
+
else:
|
| 220 |
+
# Coda vuota β reset errori consecutivi, polling leggero via REST
|
| 221 |
+
_consecutive_errors = 0
|
| 222 |
+
await asyncio.sleep(2)
|
| 223 |
+
|
| 224 |
except Exception as e:
|
| 225 |
+
_logger.error("[jq] consumer loop error: %s", e)
|
| 226 |
+
await asyncio.sleep(5)
|
| 227 |
|
| 228 |
async def start_job_queue_consumer() -> None:
|
| 229 |
"""Punto di ingresso per main.py _on_startup()."""
|
| 230 |
if not _redis_ok():
|
| 231 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 232 |
return
|
| 233 |
+
_bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
|
|
|
|
|
|
|
| 234 |
# Grid Orchestrator: Consumer specializzati (HANDS, MEMORY, AUDIT)
|
| 235 |
if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
|
| 236 |
+
_bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
|
| 237 |
else:
|
| 238 |
_logger.info("[jq] SPACE_ROLE=%s β consumer non avviato (solo load publisher)", _SPACE_ROLE)
|
| 239 |
|
|
|
|
| 255 |
res = await _rcmd(["GET", _K_LOAD(role)])
|
| 256 |
if not res or not res.get("result"):
|
| 257 |
raise HTTPException(404, f"Metriche {role} non disponibili")
|
| 258 |
+
try:
|
| 259 |
+
return json.loads(res["result"])
|
| 260 |
+
except json.JSONDecodeError as _je:
|
| 261 |
+
raise HTTPException(500, f"Metriche Redis corrotte per {role}: {_je}")
|
| 262 |
|
| 263 |
@router.post("/submit")
|
| 264 |
async def jq_submit(job: JobPayload, request: Request):
|
| 265 |
+
"""
|
| 266 |
+
S42 β Grid Orchestrator: sottomissione job reale su Redis.
|
| 267 |
+
Inserisce il job in coda LPUSH (FIFO con RPOP dal consumer).
|
| 268 |
+
Restituisce taskId e lunghezza coda corrente.
|
| 269 |
+
"""
|
| 270 |
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 271 |
raise HTTPException(401, "Unauthorized")
|
| 272 |
+
|
| 273 |
+
if not _redis_ok():
|
| 274 |
+
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 275 |
+
|
| 276 |
+
# Normalizza taskId: usa quello fornito o genera uno nuovo
|
| 277 |
+
task_id = job.taskId if job.taskId else str(uuid.uuid4())
|
| 278 |
+
|
| 279 |
+
job_payload = {
|
| 280 |
+
"taskId": task_id,
|
| 281 |
+
"goal": job.goal,
|
| 282 |
+
"context": job.context or {},
|
| 283 |
+
"priority": job.priority,
|
| 284 |
+
"submittedAt": int(time.time() * 1000),
|
| 285 |
+
"submittedBy": _SPACE_ROLE,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
# LPUSH β consumer usa RPOP (FIFO)
|
| 289 |
+
res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
|
| 290 |
+
if res is None:
|
| 291 |
+
raise HTTPException(503, "Errore Redis durante la sottomissione del job")
|
| 292 |
+
|
| 293 |
+
queue_len = int(res.get("result", 0)) if res else 0
|
| 294 |
+
|
| 295 |
+
# Segnale di wake per consumer (TTL breve)
|
| 296 |
+
await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
|
| 297 |
+
|
| 298 |
+
_logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
|
| 299 |
+
return {
|
| 300 |
+
"taskId": task_id,
|
| 301 |
+
"status": "queued",
|
| 302 |
+
"queueLength": queue_len,
|
| 303 |
+
"ts": int(time.time() * 1000),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
@router.get("/result/{task_id}")
|
| 307 |
+
async def jq_result(task_id: str, request: Request):
|
| 308 |
+
"""
|
| 309 |
+
S429 β Tool Success Contract: recupero risultato job per taskId.
|
| 310 |
+
Restituisce il risultato se disponibile, altrimenti pending.
|
| 311 |
+
"""
|
| 312 |
+
if _INTERNAL_TOKEN and request.headers.get("X-Internal-Token") != _INTERNAL_TOKEN:
|
| 313 |
+
raise HTTPException(401, "Unauthorized")
|
| 314 |
+
|
| 315 |
+
if not _redis_ok():
|
| 316 |
+
raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
|
| 317 |
+
|
| 318 |
+
res = await _rcmd(["GET", _K_RESULT(task_id)])
|
| 319 |
+
if not res or not res.get("result"):
|
| 320 |
+
return {"taskId": task_id, "status": "pending", "ts": int(time.time() * 1000)}
|
| 321 |
+
try:
|
| 322 |
+
return json.loads(res["result"])
|
| 323 |
+
except json.JSONDecodeError as _je:
|
| 324 |
+
_logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
|
| 325 |
+
raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
|
api/providers.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""backend/api/providers.py β Health, tools, status, AI health, heartbeat (S354)."""
|
| 2 |
import os, asyncio, time, logging
|
| 3 |
from fastapi import APIRouter, Request
|
| 4 |
-
from .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS
|
| 5 |
|
| 6 |
router = APIRouter()
|
| 7 |
_logger = logging.getLogger('agente_ai')
|
|
@@ -151,7 +151,7 @@ async def list_tools():
|
|
| 151 |
@router.get('/api/status')
|
| 152 |
async def status(request: Request):
|
| 153 |
# security-fix: richiede X-Internal-Token β endpoint espone env vars
|
| 154 |
-
_tok =
|
| 155 |
if _tok and request.headers.get('X-Internal-Token', '') != _tok:
|
| 156 |
from fastapi import HTTPException as _HTTPEx
|
| 157 |
raise _HTTPEx(401, 'Unauthorized')
|
|
@@ -569,7 +569,7 @@ async def auth_ping(
|
|
| 569 |
(CF Worker aggiunge il token β role MACHINE se i due token coincidono)
|
| 570 |
"""
|
| 571 |
import os as _os
|
| 572 |
-
server_token =
|
| 573 |
|
| 574 |
# Leggi header sia dal parametro sia dall'oggetto request (FastAPI puΓ² passare entrambi)
|
| 575 |
hdr_token = x_internal_token
|
|
@@ -611,3 +611,4 @@ async def status_ping():
|
|
| 611 |
'supabase': _sb is not None,
|
| 612 |
'backend': 'HuggingFace Spaces / Railway',
|
| 613 |
}
|
|
|
|
|
|
| 1 |
"""backend/api/providers.py β Health, tools, status, AI health, heartbeat (S354)."""
|
| 2 |
import os, asyncio, time, logging
|
| 3 |
from fastapi import APIRouter, Request
|
| 4 |
+
from .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS, get_env_secret
|
| 5 |
|
| 6 |
router = APIRouter()
|
| 7 |
_logger = logging.getLogger('agente_ai')
|
|
|
|
| 151 |
@router.get('/api/status')
|
| 152 |
async def status(request: Request):
|
| 153 |
# security-fix: richiede X-Internal-Token β endpoint espone env vars
|
| 154 |
+
_tok = get_env_secret('INTERNAL_TOKEN')
|
| 155 |
if _tok and request.headers.get('X-Internal-Token', '') != _tok:
|
| 156 |
from fastapi import HTTPException as _HTTPEx
|
| 157 |
raise _HTTPEx(401, 'Unauthorized')
|
|
|
|
| 569 |
(CF Worker aggiunge il token β role MACHINE se i due token coincidono)
|
| 570 |
"""
|
| 571 |
import os as _os
|
| 572 |
+
server_token = _get_env_secret('INTERNAL_TOKEN').strip()
|
| 573 |
|
| 574 |
# Leggi header sia dal parametro sia dall'oggetto request (FastAPI puΓ² passare entrambi)
|
| 575 |
hdr_token = x_internal_token
|
|
|
|
| 611 |
'supabase': _sb is not None,
|
| 612 |
'backend': 'HuggingFace Spaces / Railway',
|
| 613 |
}
|
| 614 |
+
|
api/state.py
CHANGED
|
@@ -5,6 +5,10 @@ Contains: Supabase client, in-memory stores, singleton getters, shared Pydantic
|
|
| 5 |
TTL constants, prune helpers. Extracted from main.py β zero behaviour change.
|
| 6 |
"""
|
| 7 |
import os, time, asyncio as _asyncio_mod, json as _json, re as _re
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from typing import Optional, Any
|
| 9 |
from fastapi import HTTPException
|
| 10 |
from pydantic import BaseModel, field_validator, model_validator
|
|
@@ -31,8 +35,8 @@ def safe_json_dumps(obj: object, *, ensure_ascii: bool = False, **kw) -> str:
|
|
| 31 |
_sb: Any = None
|
| 32 |
_sb2: Any = None
|
| 33 |
try:
|
| 34 |
-
_SUPA_URL =
|
| 35 |
-
_SUPA_KEY =
|
| 36 |
if _SUPA_URL and _SUPA_KEY:
|
| 37 |
from supabase import create_client
|
| 38 |
_sb = create_client(_SUPA_URL, _SUPA_KEY)
|
|
@@ -150,7 +154,11 @@ async def restore_agent_tasks_from_snap() -> int:
|
|
| 150 |
)
|
| 151 |
if not res or not res.data:
|
| 152 |
return 0
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
n = 0
|
| 155 |
for task_id, data in snap.items():
|
| 156 |
if task_id not in _agent_tasks and isinstance(data, dict):
|
|
@@ -343,13 +351,15 @@ async def _get_mem_manager_async() -> Any:
|
|
| 343 |
_mem_manager = MemoryManager()
|
| 344 |
await _mem_manager.init()
|
| 345 |
_mem_manager_inited = True
|
| 346 |
-
except Exception:
|
|
|
|
| 347 |
_mem_manager = None
|
| 348 |
elif not _mem_manager_inited:
|
| 349 |
try:
|
| 350 |
await _mem_manager.init()
|
| 351 |
_mem_manager_inited = True
|
| 352 |
-
except Exception:
|
|
|
|
| 353 |
_mem_manager_inited = True # evita retry infiniti
|
| 354 |
return _mem_manager
|
| 355 |
|
|
@@ -387,7 +397,8 @@ def _get_mem_manager() -> Any:
|
|
| 387 |
# Chiamata in contesto sync (es. import-time) β init rimandato al primo call async.
|
| 388 |
# _mem_manager_inited resta False β verrΓ ritentato al prossimo call in loop.
|
| 389 |
_logger.debug("[state] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 390 |
-
except Exception:
|
|
|
|
| 391 |
_mem_manager = None
|
| 392 |
return _mem_manager
|
| 393 |
|
|
@@ -403,7 +414,8 @@ def _get_executor() -> Any:
|
|
| 403 |
try:
|
| 404 |
from agents.executor import Executor
|
| 405 |
_executor = Executor(memory=_get_mem_manager())
|
| 406 |
-
except Exception:
|
|
|
|
| 407 |
_executor = None
|
| 408 |
return _executor
|
| 409 |
|
|
@@ -576,3 +588,4 @@ class AgentTaskIn(BaseModel):
|
|
| 576 |
if not isinstance(v, list):
|
| 577 |
return []
|
| 578 |
return [str(h)[:300] for h in v[:5]] # S606: 200β300
|
|
|
|
|
|
| 5 |
TTL constants, prune helpers. Extracted from main.py β zero behaviour change.
|
| 6 |
"""
|
| 7 |
import os, time, asyncio as _asyncio_mod, json as _json, re as _re
|
| 8 |
+
|
| 9 |
+
def get_env_secret(name: str, default: str = "") -> str:
|
| 10 |
+
"""Getter resiliente: prova SECRET_name prima di name per evitare collisioni HF."""
|
| 11 |
+
return os.getenv(f"SECRET_{name}") or os.getenv(name) or default
|
| 12 |
from typing import Optional, Any
|
| 13 |
from fastapi import HTTPException
|
| 14 |
from pydantic import BaseModel, field_validator, model_validator
|
|
|
|
| 35 |
_sb: Any = None
|
| 36 |
_sb2: Any = None
|
| 37 |
try:
|
| 38 |
+
_SUPA_URL = get_env_secret('SUPABASE_URL')
|
| 39 |
+
_SUPA_KEY = get_env_secret('SUPABASE_KEY') or get_env_secret('SUPABASE_ANON_KEY')
|
| 40 |
if _SUPA_URL and _SUPA_KEY:
|
| 41 |
from supabase import create_client
|
| 42 |
_sb = create_client(_SUPA_URL, _SUPA_KEY)
|
|
|
|
| 154 |
)
|
| 155 |
if not res or not res.data:
|
| 156 |
return 0
|
| 157 |
+
try:
|
| 158 |
+
snap: dict = _json.loads(res.data["value"])
|
| 159 |
+
except _json.JSONDecodeError as _snap_jd:
|
| 160 |
+
_logger.warning("BOOT: GAP-STATE snapshot JSON corrotto: %s", _snap_jd)
|
| 161 |
+
return 0
|
| 162 |
n = 0
|
| 163 |
for task_id, data in snap.items():
|
| 164 |
if task_id not in _agent_tasks and isinstance(data, dict):
|
|
|
|
| 351 |
_mem_manager = MemoryManager()
|
| 352 |
await _mem_manager.init()
|
| 353 |
_mem_manager_inited = True
|
| 354 |
+
except Exception as _mm_err:
|
| 355 |
+
_logger.warning("[state] MemoryManager.init failed (1st): %s", _mm_err)
|
| 356 |
_mem_manager = None
|
| 357 |
elif not _mem_manager_inited:
|
| 358 |
try:
|
| 359 |
await _mem_manager.init()
|
| 360 |
_mem_manager_inited = True
|
| 361 |
+
except Exception as _mm2_err:
|
| 362 |
+
_logger.warning("[state] MemoryManager.init failed (2nd, no retry): %s", _mm2_err)
|
| 363 |
_mem_manager_inited = True # evita retry infiniti
|
| 364 |
return _mem_manager
|
| 365 |
|
|
|
|
| 397 |
# Chiamata in contesto sync (es. import-time) β init rimandato al primo call async.
|
| 398 |
# _mem_manager_inited resta False β verrΓ ritentato al prossimo call in loop.
|
| 399 |
_logger.debug("[state] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 400 |
+
except Exception as _mm3_err:
|
| 401 |
+
_logger.warning("[state] MemoryManager get failed: %s", _mm3_err)
|
| 402 |
_mem_manager = None
|
| 403 |
return _mem_manager
|
| 404 |
|
|
|
|
| 414 |
try:
|
| 415 |
from agents.executor import Executor
|
| 416 |
_executor = Executor(memory=_get_mem_manager())
|
| 417 |
+
except Exception as _ex_err:
|
| 418 |
+
_logger.warning("[state] Executor init failed: %s", _ex_err)
|
| 419 |
_executor = None
|
| 420 |
return _executor
|
| 421 |
|
|
|
|
| 588 |
if not isinstance(v, list):
|
| 589 |
return []
|
| 590 |
return [str(h)[:300] for h in v[:5]] # S606: 200β300
|
| 591 |
+
|
api/state_sync.py
CHANGED
|
@@ -1,32 +1,49 @@
|
|
| 1 |
-
|
| 2 |
-
api/state_sync.py β S901: Real-Time State Synchronizer (UltraVSS Beta Strategy)
|
| 3 |
-
|
| 4 |
-
Router FastAPI per la sincronizzazione dello stato tramite WebSocket.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 8 |
-
from typing import List, Dict, Any
|
| 9 |
import logging
|
| 10 |
|
| 11 |
_logger = logging.getLogger("api.state_sync")
|
| 12 |
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class ConnectionManager:
|
| 15 |
def __init__(self):
|
| 16 |
self.active_connections: Dict[str, List[WebSocket]] = {}
|
| 17 |
self.room_states: Dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
async def connect(self, websocket: WebSocket, room_id: str):
|
| 20 |
await websocket.accept()
|
| 21 |
if room_id not in self.active_connections:
|
| 22 |
self.active_connections[room_id] = []
|
| 23 |
self.active_connections[room_id].append(websocket)
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def disconnect(self, websocket: WebSocket, room_id: str):
|
| 32 |
if room_id in self.active_connections:
|
|
@@ -35,25 +52,96 @@ class ConnectionManager:
|
|
| 35 |
if not self.active_connections[room_id]:
|
| 36 |
del self.active_connections[room_id]
|
| 37 |
|
| 38 |
-
async def broadcast(self, message: dict, room_id: str, exclude: WebSocket = None):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
if room_id not in self.active_connections:
|
| 40 |
return
|
| 41 |
-
|
|
|
|
| 42 |
if message.get("type") == "UPDATE_STATE":
|
| 43 |
self.room_states[room_id] = message.get("payload")
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
for connection in self.active_connections[room_id]:
|
| 46 |
if connection != exclude:
|
| 47 |
try:
|
| 48 |
await connection.send_json(message)
|
| 49 |
-
except Exception as
|
| 50 |
-
_logger.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
manager = ConnectionManager()
|
| 53 |
|
| 54 |
@router.websocket("/ws/{room_id}")
|
| 55 |
async def websocket_endpoint(websocket: WebSocket, room_id: str):
|
| 56 |
await manager.connect(websocket, room_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
try:
|
| 58 |
while True:
|
| 59 |
data = await websocket.receive_json()
|
|
|
|
| 1 |
+
import os, json, asyncio, httpx, time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
import logging
|
| 5 |
|
| 6 |
_logger = logging.getLogger("api.state_sync")
|
| 7 |
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
| 8 |
|
| 9 |
+
# ββ Helper Redis (Upstash REST) ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
async def _rcmd(cmd: list) -> Optional[dict]:
|
| 11 |
+
url = os.getenv("UPSTASH_REDIS_REST_URL")
|
| 12 |
+
tok = os.getenv("UPSTASH_REDIS_REST_TOKEN")
|
| 13 |
+
if not url or not tok: return None
|
| 14 |
+
try:
|
| 15 |
+
async with httpx.AsyncClient() as client:
|
| 16 |
+
r = await client.post(url, headers={"Authorization": f"Bearer {tok}"}, json=cmd, timeout=5.0)
|
| 17 |
+
return r.json()
|
| 18 |
+
except Exception as e:
|
| 19 |
+
_logger.error("[sync] redis error: %s", e)
|
| 20 |
+
return None
|
| 21 |
+
|
| 22 |
+
# ββ Polling interval per cross-node sync (secondi) ββββββββββββββββββββββββββ
|
| 23 |
+
_PUBSUB_POLL_INTERVAL = float(os.getenv("SYNC_POLL_INTERVAL", "3"))
|
| 24 |
+
|
| 25 |
class ConnectionManager:
|
| 26 |
def __init__(self):
|
| 27 |
self.active_connections: Dict[str, List[WebSocket]] = {}
|
| 28 |
self.room_states: Dict[str, Any] = {}
|
| 29 |
+
# Cursore per polling: ultimo timestamp letto per ogni room
|
| 30 |
+
self._last_seen: Dict[str, int] = {}
|
| 31 |
+
self._pubsub_task: Optional[asyncio.Task] = None
|
| 32 |
|
| 33 |
async def connect(self, websocket: WebSocket, room_id: str):
|
| 34 |
await websocket.accept()
|
| 35 |
if room_id not in self.active_connections:
|
| 36 |
self.active_connections[room_id] = []
|
| 37 |
self.active_connections[room_id].append(websocket)
|
| 38 |
+
|
| 39 |
+
# S901: Recupero stato globale da Redis
|
| 40 |
+
res = await _rcmd(["GET", f"sync:state:{room_id}"])
|
| 41 |
+
if res and res.get("result"):
|
| 42 |
+
try:
|
| 43 |
+
state = json.loads(res["result"])
|
| 44 |
+
except json.JSONDecodeError:
|
| 45 |
+
state = {}
|
| 46 |
+
await websocket.send_json({"type": "SYNC_STATE", "payload": state})
|
| 47 |
|
| 48 |
def disconnect(self, websocket: WebSocket, room_id: str):
|
| 49 |
if room_id in self.active_connections:
|
|
|
|
| 52 |
if not self.active_connections[room_id]:
|
| 53 |
del self.active_connections[room_id]
|
| 54 |
|
| 55 |
+
async def broadcast(self, message: dict, room_id: str, exclude: WebSocket = None, remote: bool = False):
|
| 56 |
+
"""
|
| 57 |
+
Broadcast locale e remoto (via Redis).
|
| 58 |
+
remote=True indica che il messaggio arriva da Pub/Sub, quindi non ri-pubblicare.
|
| 59 |
+
"""
|
| 60 |
if room_id not in self.active_connections:
|
| 61 |
return
|
| 62 |
+
|
| 63 |
+
# Aggiornamento stato locale/globale
|
| 64 |
if message.get("type") == "UPDATE_STATE":
|
| 65 |
self.room_states[room_id] = message.get("payload")
|
| 66 |
+
if not remote:
|
| 67 |
+
# Persistenza stato su Redis (TTL 1h)
|
| 68 |
+
await _rcmd(["SET", f"sync:state:{room_id}", json.dumps(message.get("payload")), "EX", "3600"])
|
| 69 |
|
| 70 |
+
# Pubblicazione su Redis per altri nodi (lista circolare con timestamp)
|
| 71 |
+
if not remote:
|
| 72 |
+
envelope = {
|
| 73 |
+
"ts": int(time.time() * 1000),
|
| 74 |
+
"room": room_id,
|
| 75 |
+
"msg": message,
|
| 76 |
+
}
|
| 77 |
+
await _rcmd(["LPUSH", f"sync:events:{room_id}", json.dumps(envelope)])
|
| 78 |
+
# Mantieni solo gli ultimi 50 eventi per room (evita memory leak Redis)
|
| 79 |
+
await _rcmd(["LTRIM", f"sync:events:{room_id}", "0", "49"])
|
| 80 |
+
|
| 81 |
+
# Invio ai client connessi a QUESTA istanza
|
| 82 |
for connection in self.active_connections[room_id]:
|
| 83 |
if connection != exclude:
|
| 84 |
try:
|
| 85 |
await connection.send_json(message)
|
| 86 |
+
except Exception as _sj_err:
|
| 87 |
+
_logger.debug("[sync] send_json silenced (WS dead?): %s", type(_sj_err).__name__)
|
| 88 |
+
|
| 89 |
+
async def start_pubsub_listener(self):
|
| 90 |
+
"""
|
| 91 |
+
S901 Fix: Polling leggero su Redis per sincronizzazione cross-nodo.
|
| 92 |
+
Upstash REST non supporta SUBSCRIBE persistente; usiamo LRANGE + cursore timestamp.
|
| 93 |
+
Intervallo configurabile via SYNC_POLL_INTERVAL (default: 3s).
|
| 94 |
+
"""
|
| 95 |
+
_logger.info("[sync] pubsub polling listener avviato (interval=%.1fs)", _PUBSUB_POLL_INTERVAL)
|
| 96 |
+
while True:
|
| 97 |
+
try:
|
| 98 |
+
# Itera su tutte le room attive
|
| 99 |
+
for room_id in list(self.active_connections.keys()):
|
| 100 |
+
if not self.active_connections.get(room_id):
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
res = await _rcmd(["LRANGE", f"sync:events:{room_id}", "0", "9"])
|
| 104 |
+
if not res or not res.get("result"):
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
events: list = res["result"]
|
| 108 |
+
last_seen = self._last_seen.get(room_id, 0)
|
| 109 |
+
new_events = []
|
| 110 |
+
|
| 111 |
+
for raw in events:
|
| 112 |
+
try:
|
| 113 |
+
envelope = json.loads(raw)
|
| 114 |
+
ts = envelope.get("ts", 0)
|
| 115 |
+
if ts > last_seen:
|
| 116 |
+
new_events.append(envelope)
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
+
|
| 120 |
+
if new_events:
|
| 121 |
+
# Ordina per timestamp crescente
|
| 122 |
+
new_events.sort(key=lambda e: e.get("ts", 0))
|
| 123 |
+
for envelope in new_events:
|
| 124 |
+
msg = envelope.get("msg", {})
|
| 125 |
+
# Broadcast locale senza ri-pubblicare su Redis (remote=True)
|
| 126 |
+
await self.broadcast(msg, room_id, remote=True)
|
| 127 |
+
# Aggiorna cursore
|
| 128 |
+
self._last_seen[room_id] = new_events[-1].get("ts", last_seen)
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
_logger.error("[sync] pubsub polling error: %s", e)
|
| 132 |
+
|
| 133 |
+
await asyncio.sleep(_PUBSUB_POLL_INTERVAL)
|
| 134 |
|
| 135 |
manager = ConnectionManager()
|
| 136 |
|
| 137 |
@router.websocket("/ws/{room_id}")
|
| 138 |
async def websocket_endpoint(websocket: WebSocket, room_id: str):
|
| 139 |
await manager.connect(websocket, room_id)
|
| 140 |
+
|
| 141 |
+
# Avvia il listener di polling se non giΓ attivo
|
| 142 |
+
if manager._pubsub_task is None or manager._pubsub_task.done():
|
| 143 |
+
manager._pubsub_task = asyncio.create_task(manager.start_pubsub_listener())
|
| 144 |
+
|
| 145 |
try:
|
| 146 |
while True:
|
| 147 |
data = await websocket.receive_json()
|
main.py
CHANGED
|
@@ -24,7 +24,8 @@ from fastapi.responses import JSONResponse
|
|
| 24 |
|
| 25 |
# Gap 2.2: Structured JSON logging β setup PRIMA di qualsiasi altro import
|
| 26 |
from api.structured_log import setup_structured_logging as _setup_structured_log, router as _logs_router
|
| 27 |
-
from api.integrity_manager import router as _integrity_router
|
|
|
|
| 28 |
_setup_structured_log()
|
| 29 |
import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...')
|
| 30 |
|
|
@@ -33,7 +34,7 @@ _logger = logging.getLogger('agente_ai')
|
|
| 33 |
|
| 34 |
# S274-SEC3: INTERNAL_TOKEN β genera casuale al boot se non configurato.
|
| 35 |
_GENERATED_TOKEN = _secrets_mod.token_hex(32)
|
| 36 |
-
if not
|
| 37 |
os.environ['INTERNAL_TOKEN'] = _GENERATED_TOKEN
|
| 38 |
_logger.critical('BOOT: INTERNAL_TOKEN not set β ephemeral token generato per questa sessione.')
|
| 39 |
_logger.critical('BOOT: Ogni restart cambia il token β CF Worker riceve 401 finchΓ© il secret non Γ¨ aggiornato!')
|
|
@@ -430,3 +431,4 @@ else:
|
|
| 430 |
_logger.warning('BOOT: no frontend at %s', _STATIC_DIR)
|
| 431 |
|
| 432 |
_logger.info('BOOT: main.py v%s ready β %s routes registered β', app.version, len(app.routes))
|
|
|
|
|
|
| 24 |
|
| 25 |
# Gap 2.2: Structured JSON logging β setup PRIMA di qualsiasi altro import
|
| 26 |
from api.structured_log import setup_structured_logging as _setup_structured_log, router as _logs_router
|
| 27 |
+
from api.integrity_manager import router as _integrity_router
|
| 28 |
+
from api.state import get_env_secret # P41
|
| 29 |
_setup_structured_log()
|
| 30 |
import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...')
|
| 31 |
|
|
|
|
| 34 |
|
| 35 |
# S274-SEC3: INTERNAL_TOKEN β genera casuale al boot se non configurato.
|
| 36 |
_GENERATED_TOKEN = _secrets_mod.token_hex(32)
|
| 37 |
+
if not get_env_secret('INTERNAL_TOKEN'):
|
| 38 |
os.environ['INTERNAL_TOKEN'] = _GENERATED_TOKEN
|
| 39 |
_logger.critical('BOOT: INTERNAL_TOKEN not set β ephemeral token generato per questa sessione.')
|
| 40 |
_logger.critical('BOOT: Ogni restart cambia il token β CF Worker riceve 401 finchΓ© il secret non Γ¨ aggiornato!')
|
|
|
|
| 431 |
_logger.warning('BOOT: no frontend at %s', _STATIC_DIR)
|
| 432 |
|
| 433 |
_logger.info('BOOT: main.py v%s ready β %s routes registered β', app.version, len(app.routes))
|
| 434 |
+
|
models/ai_client.py
CHANGED
|
@@ -484,8 +484,8 @@ class AIClient:
|
|
| 484 |
# RF-3: attivo se CF_API_TOKEN + CF_ACCOUNT_ID entrambi presenti.
|
| 485 |
# Endpoint OpenAI-compatible: /accounts/{id}/ai/v1
|
| 486 |
# Free: 10K req/giorno, nessun account separato β usa il token Cloudflare.
|
| 487 |
-
cf_token = os.getenv("CF_API_TOKEN")
|
| 488 |
-
cf_account = os.getenv("CF_ACCOUNT_ID")
|
| 489 |
if cf_token and cf_account and not os.getenv("DISABLE_CF_PROVIDER"):
|
| 490 |
providers.append(ProviderConfig(
|
| 491 |
name="cloudflare",
|
|
|
|
| 484 |
# RF-3: attivo se CF_API_TOKEN + CF_ACCOUNT_ID entrambi presenti.
|
| 485 |
# Endpoint OpenAI-compatible: /accounts/{id}/ai/v1
|
| 486 |
# Free: 10K req/giorno, nessun account separato β usa il token Cloudflare.
|
| 487 |
+
cf_token = os.getenv("CF_API_TOKEN") or os.getenv("CLOUDFLARE_API_TOKEN")
|
| 488 |
+
cf_account = os.getenv("CF_ACCOUNT_ID") or os.getenv("CLOUDFLARE_ACCOUNT_ID")
|
| 489 |
if cf_token and cf_account and not os.getenv("DISABLE_CF_PROVIDER"):
|
| 490 |
providers.append(ProviderConfig(
|
| 491 |
name="cloudflare",
|