Spaces:
Running
Running
sync: 125 file da Baida98/AI@4bad3bd3 (2026-07-10 21:37 UTC)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- agents/backend_antiregress.py +1 -2
- agents/context_manager.py +183 -15
- agents/executor.py +130 -27
- agents/goal_drift_detector.py +6 -41
- agents/goal_verifier.py +9 -9
- agents/planner.py +2 -2
- agents/reasoning_core.py +121 -44
- agents/unified_loop.py +0 -0
- agents/unified_loop_helpers.py +1 -1
- agents/unified_loop_llm.py +3 -0
- agents/unified_loop_prompts.py +45 -31
- agents/unified_loop_tools.py +4 -35
- agents/unified_loop_types.py +0 -4
- api/agent.py +1513 -13
- api/agent_memory.py +8 -53
- api/agent_telemetry.py +3 -2
- api/auth_guard.py +37 -42
- api/auth_managed.py +181 -42
- api/benchmark.py +5 -4
- api/blackboard.py +10 -27
- api/browser.py +11 -11
- api/coding.py +4 -3
- api/conversations.py +3 -2
- api/daemon_status.py +3 -2
- api/database.py +15 -7
- api/decision_memory.py +3 -9
- api/deploy.py +29 -21
- api/email.py +87 -18
- api/exec.py +61 -38
- api/files.py +10 -37
- api/gemini_vision.py +4 -3
- api/incident_registry.py +3 -9
- api/integrity_manager.py +3 -2
- api/job_queue.py +418 -295
- api/llm_cache.py +3 -2
- api/mcp.py +3 -2
- api/persistence.py +0 -15
- api/providers.py +427 -195
- api/quality_guardian.py +2 -2
- api/research.py +6 -10
- api/scheduler.py +97 -329
- api/search.py +8 -16
- api/skills.py +92 -0
- api/state.py +39 -32
- api/structured_log.py +3 -2
- api/telegram_webhook.py +0 -0
- api/telemetry.py +3 -2
- api/terminal.py +3 -1
- api/vault.py +18 -64
- api/vision.py +3 -2
agents/backend_antiregress.py
CHANGED
|
@@ -5,8 +5,7 @@
|
|
| 5 |
# 1. Import injection β nuove dipendenze esterne non presenti nell'originale
|
| 6 |
# 2. Code rewrite β output ha drasticamente meno classi/def dell'originale
|
| 7 |
#
|
| 8 |
-
# Chiamato dentro il loop _llm_try di
|
| 9 |
-
# Post-split 2026-06-30: il loop LLM risiede in unified_loop_fallback.py, non in unified_loop.py.
|
| 10 |
# Non bloccante: qualsiasi eccezione interna viene silenziata dal caller.
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 5 |
# 1. Import injection β nuove dipendenze esterne non presenti nell'originale
|
| 6 |
# 2. Code rewrite β output ha drasticamente meno classi/def dell'originale
|
| 7 |
#
|
| 8 |
+
# Chiamato dentro il loop _llm_try di unified_loop.py prima del `break`.
|
|
|
|
| 9 |
# Non bloccante: qualsiasi eccezione interna viene silenziata dal caller.
|
| 10 |
|
| 11 |
from __future__ import annotations
|
agents/context_manager.py
CHANGED
|
@@ -18,11 +18,8 @@ from __future__ import annotations
|
|
| 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,66 +50,89 @@ _RANK_ENTRY_STEMS = {'main', 'index', 'app', '__init__', 'config', 'settings', '
|
|
| 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,6 +140,20 @@ for _cluster in _SYN_CLUSTERS:
|
|
| 120 |
|
| 121 |
|
| 122 |
def _expand_tokens(tokens: list[str]) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
seen: set[str] = set(tokens)
|
| 125 |
expanded = list(tokens)
|
|
@@ -131,18 +165,25 @@ def _expand_tokens(tokens: list[str]) -> list[str]:
|
|
| 131 |
seen.add(syn)
|
| 132 |
expanded.append(syn)
|
| 133 |
return expanded
|
| 134 |
-
except Exception
|
| 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
|
| 145 |
-
_logger.debug("[context_manager] _split_camel_snake failed: %s", e)
|
| 146 |
return []
|
| 147 |
|
| 148 |
|
|
@@ -152,6 +193,27 @@ def rank_files_by_relevance(
|
|
| 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,8 +225,11 @@ def rank_files_by_relevance(
|
|
| 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,43 +243,49 @@ def rank_files_by_relevance(
|
|
| 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
|
| 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,12 +302,12 @@ def _extract_signatures(content: str, language: str) -> 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
|
| 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,6 +315,11 @@ def build_file_skeleton(path: str, content: str, language: str) -> str:
|
|
| 244 |
|
| 245 |
|
| 246 |
async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if not files:
|
| 248 |
return ''
|
| 249 |
try:
|
|
@@ -254,6 +330,98 @@ async def build_project_skeleton(files: list[dict[str, Any]]) -> str:
|
|
| 254 |
language = f.get('language', '') or ''
|
| 255 |
lines.append(build_file_skeleton(path, content, language))
|
| 256 |
return '\n'.join(lines)
|
| 257 |
-
except Exception
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
_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 |
|
| 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 |
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 |
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 |
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 |
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 # usa base_tokens: fuzzy su originali
|
| 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 |
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 |
|
| 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 |
language = f.get('language', '') or ''
|
| 331 |
lines.append(build_file_skeleton(path, content, language))
|
| 332 |
return '\n'.join(lines)
|
| 333 |
+
except Exception:
|
| 334 |
+
return ''
|
| 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 ''
|
agents/executor.py
CHANGED
|
@@ -17,6 +17,20 @@ from models.ai_client import AIClient
|
|
| 17 |
from memory.manager import MemoryManager
|
| 18 |
from tools.registry import TOOL_REGISTRY
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
_logger = logging.getLogger("agente_ai.executor")
|
| 21 |
|
| 22 |
# βββ Costanti circuit breaker ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -25,11 +39,14 @@ _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 |
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,9 +58,10 @@ class _AdaptiveTimeoutTracker:
|
|
| 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
|
|
@@ -51,16 +69,24 @@ class _AdaptiveTimeoutTracker:
|
|
| 51 |
|
| 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
|
| 60 |
-
_logger.debug("[executor] _get_session_id failed: %s", e)
|
| 61 |
return "default"
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
| 64 |
class Executor:
|
| 65 |
def __init__(
|
| 66 |
self,
|
|
@@ -71,21 +97,33 @@ class Executor:
|
|
| 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
|
| 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,13 +131,19 @@ class Executor:
|
|
| 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(
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
| 101 |
return True
|
| 102 |
|
|
|
|
|
|
|
| 103 |
async def _try_fallbacks(
|
| 104 |
self,
|
| 105 |
primary_name: str,
|
|
@@ -107,6 +151,11 @@ class Executor:
|
|
| 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,25 +164,28 @@ class Executor:
|
|
| 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
|
| 119 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 136 |
-
_logger.debug("[executor]
|
| 137 |
return {
|
| 138 |
"success": True,
|
| 139 |
"tool": fb_name,
|
|
@@ -147,17 +199,19 @@ class Executor:
|
|
| 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
|
| 151 |
-
_logger.debug("[executor]
|
| 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
|
| 158 |
-
_logger.debug("[executor]
|
| 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,35 +224,79 @@ class Executor:
|
|
| 170 |
|
| 171 |
session_id = _get_session_id()
|
| 172 |
|
|
|
|
|
|
|
|
|
|
| 173 |
if self._is_circuit_open(tool_name, session_id):
|
| 174 |
-
_logger.info(
|
|
|
|
|
|
|
|
|
|
| 175 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 176 |
if fb_result:
|
| 177 |
return fb_result
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
|
|
|
| 180 |
fn = tool.get("_fn")
|
| 181 |
if fn is None:
|
| 182 |
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
| 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,6 +306,11 @@ class Executor:
|
|
| 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
|
|
|
|
| 17 |
from memory.manager import MemoryManager
|
| 18 |
from tools.registry import TOOL_REGISTRY
|
| 19 |
|
| 20 |
+
# P17-B1: pre-esecuzione syntax check β fail-open se ast_check non disponibile
|
| 21 |
+
try:
|
| 22 |
+
from tools.ast_check import check_code_syntax as _check_syntax
|
| 23 |
+
_CHECK_SYNTAX_AVAILABLE = True
|
| 24 |
+
except ImportError:
|
| 25 |
+
_CHECK_SYNTAX_AVAILABLE = False
|
| 26 |
+
def _check_syntax(code: str, lang: str): # type: ignore[misc]
|
| 27 |
+
class _Ok:
|
| 28 |
+
ok = True
|
| 29 |
+
error = None
|
| 30 |
+
line = None
|
| 31 |
+
col = None
|
| 32 |
+
return _Ok()
|
| 33 |
+
|
| 34 |
_logger = logging.getLogger("agente_ai.executor")
|
| 35 |
|
| 36 |
# βββ Costanti circuit breaker ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 39 |
_RECOVERY_INTERVAL = 5 # ogni N chiamate con circuit open β tenta il tool primario
|
| 40 |
|
| 41 |
# βββ S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker βββββββββββββββββββββββββ
|
| 42 |
+
# Sliding window (last 5 durations) per tool β calcola P90 adattivo.
|
| 43 |
+
# Strategia iPhone: rete variabile β se tool Γ¨ stato lento di recente,
|
| 44 |
+
# aumenta timeout; se Γ¨ stato veloce, non sprecare tempo.
|
| 45 |
class _AdaptiveTimeoutTracker:
|
| 46 |
"""Tracked P90 per-tool timeout con sliding window di 5 call."""
|
| 47 |
_WINDOW = 5
|
| 48 |
+
_MIN = 4.0 # mai sotto 4s β tool veloci non vanno sotto
|
| 49 |
+
_MAX = 55.0 # mai sopra 55s β iPhone connection timeout ~60s
|
| 50 |
_MULTIPLIER = 1.5 # P90 * 1.5 = headroom conservativo
|
| 51 |
|
| 52 |
def __init__(self) -> None:
|
|
|
|
| 58 |
self._times[tool_name].append(elapsed)
|
| 59 |
|
| 60 |
def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
|
| 61 |
+
"""Ritorna timeout adattivo: P90 * 1.5 se dati sufficienti, else base."""
|
| 62 |
times = self._times.get(tool_name)
|
| 63 |
if not times or len(times) < 2:
|
| 64 |
+
return base_timeout # dati insufficienti β usa base invariato
|
| 65 |
sorted_t = sorted(times)
|
| 66 |
p90_idx = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
|
| 67 |
adaptive = sorted_t[p90_idx] * self._MULTIPLIER
|
|
|
|
| 69 |
|
| 70 |
_timeout_tracker = _AdaptiveTimeoutTracker()
|
| 71 |
|
| 72 |
+
# P17-B1: mapping tool_name β (argomento_codice, linguaggio) per syntax check
|
| 73 |
+
_CODE_EXEC_TOOLS: dict[str, tuple[str, str]] = {
|
| 74 |
+
"run_python": ("code", "python"),
|
| 75 |
+
"run_code": ("code", "python"),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
|
| 79 |
+
# βββ Helper: ottieni session_id dal ContextVar (impostato da unified_loop.py) β
|
| 80 |
def _get_session_id() -> str:
|
| 81 |
try:
|
| 82 |
from tools.registry import _agent_session_id_var
|
| 83 |
return _agent_session_id_var.get()
|
| 84 |
+
except Exception:
|
|
|
|
| 85 |
return "default"
|
| 86 |
|
| 87 |
|
| 88 |
+
# βββ Executor ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
class Executor:
|
| 91 |
def __init__(
|
| 92 |
self,
|
|
|
|
| 97 |
self.llm = llm_client or AIClient()
|
| 98 |
self.memory = memory
|
| 99 |
self.max_retries = max_retries
|
| 100 |
+
# GAP-SKILL-SYNC v2: contatore chiamate per recovery credit (per-tool)
|
| 101 |
self._circuit_recovery_counts: dict[str, int] = {}
|
| 102 |
|
| 103 |
+
# Backward-compat: vecchia firma aveva ollama=OllamaClient, memory=MemoryManager
|
| 104 |
@classmethod
|
| 105 |
def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
|
| 106 |
return cls(memory=memory, max_retries=max_retries)
|
| 107 |
|
| 108 |
+
# ββ Circuit breaker helper ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
+
|
| 110 |
def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
|
| 111 |
+
"""True se il circuit breaker deve aprirsi per questo tool in questa sessione.
|
| 112 |
+
|
| 113 |
+
Condizioni (tutte necessarie):
|
| 114 |
+
1. Wilson score < CIRCUIT_OPEN_THRESHOLD (0.15)
|
| 115 |
+
2. >= MIN_CALLS_FOR_CIRCUIT (3) chiamate nella sessione
|
| 116 |
+
3. Il tool ha fallback disponibili in TOOL_REGISTRY
|
| 117 |
+
Recovery credit: ogni RECOVERY_INTERVAL chiamate, il circuit si chiude
|
| 118 |
+
temporaneamente per un tentativo di recovery.
|
| 119 |
+
"""
|
| 120 |
tool = TOOL_REGISTRY.get(tool_name, {})
|
| 121 |
if not tool.get("fallbacks"):
|
| 122 |
+
return False # senza fallback il circuit non puΓ² aprirsi
|
| 123 |
try:
|
| 124 |
from agents.skill_tracker import get_skill_tracker
|
| 125 |
stats = get_skill_tracker().get_stats(session_id).get(tool_name)
|
| 126 |
+
except Exception:
|
|
|
|
| 127 |
return False
|
| 128 |
if not stats:
|
| 129 |
return False
|
|
|
|
| 131 |
return False
|
| 132 |
if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
|
| 133 |
return False
|
| 134 |
+
# Recovery credit: conta le chiamate e apri una finestra ogni RECOVERY_INTERVAL
|
| 135 |
count = self._circuit_recovery_counts.get(tool_name, 0) + 1
|
| 136 |
self._circuit_recovery_counts[tool_name] = count
|
| 137 |
if count % _RECOVERY_INTERVAL == 0:
|
| 138 |
+
_logger.info(
|
| 139 |
+
"[executor] recovery credit: riprovo %s (circuit call #%d)",
|
| 140 |
+
tool_name, count,
|
| 141 |
+
)
|
| 142 |
+
return False # consenti un tentativo di recovery
|
| 143 |
return True
|
| 144 |
|
| 145 |
+
# ββ Fallback execution ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 146 |
+
|
| 147 |
async def _try_fallbacks(
|
| 148 |
self,
|
| 149 |
primary_name: str,
|
|
|
|
| 151 |
timeout: float,
|
| 152 |
session_id: str,
|
| 153 |
) -> "dict | None":
|
| 154 |
+
"""Tenta i fallback definiti in TOOL_REGISTRY ordinati per Wilson score.
|
| 155 |
+
|
| 156 |
+
Registra ogni tentativo nel skill_tracker sotto il nome del fallback.
|
| 157 |
+
Ritorna il primo risultato con successo, o None se tutti falliscono.
|
| 158 |
+
"""
|
| 159 |
tool = TOOL_REGISTRY.get(primary_name, {})
|
| 160 |
fallbacks = tool.get("fallbacks", [])
|
| 161 |
if not fallbacks:
|
|
|
|
| 164 |
try:
|
| 165 |
from agents.skill_tracker import get_skill_tracker
|
| 166 |
sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
|
| 167 |
+
except Exception:
|
| 168 |
+
sorted_fbs = fallbacks # ordinamento originale come fallback del fallback
|
|
|
|
| 169 |
|
| 170 |
for fb_name in sorted_fbs:
|
| 171 |
fb_tool = TOOL_REGISTRY.get(fb_name)
|
| 172 |
if not fb_tool or not fb_tool.get("_fn"):
|
| 173 |
continue
|
| 174 |
+
_logger.info(
|
| 175 |
+
"[executor] %s fallita β provo fallback %s (Wilson-sorted)",
|
| 176 |
+
primary_name, fb_name,
|
| 177 |
+
)
|
| 178 |
try:
|
| 179 |
_t0 = _time_mod.monotonic()
|
| 180 |
_fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
|
| 181 |
result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
|
| 182 |
_timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
|
| 183 |
+
# Registra il successo del fallback nel skill_tracker
|
| 184 |
try:
|
| 185 |
from agents.skill_tracker import get_skill_tracker
|
| 186 |
get_skill_tracker().record(session_id, fb_name, True)
|
| 187 |
+
except Exception as _skt_err:
|
| 188 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 189 |
return {
|
| 190 |
"success": True,
|
| 191 |
"tool": fb_name,
|
|
|
|
| 199 |
try:
|
| 200 |
from agents.skill_tracker import get_skill_tracker
|
| 201 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 202 |
+
except Exception as _skt_err:
|
| 203 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 204 |
except Exception as fb_exc:
|
| 205 |
_logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
|
| 206 |
try:
|
| 207 |
from agents.skill_tracker import get_skill_tracker
|
| 208 |
get_skill_tracker().record(session_id, fb_name, False)
|
| 209 |
+
except Exception as _skt_err:
|
| 210 |
+
_logger.debug("[executor] skill_tracker silenced: %s", _skt_err) # BUG-SILENT-EXC
|
| 211 |
|
| 212 |
+
return None # tutti i fallback hanno fallito
|
| 213 |
+
|
| 214 |
+
# ββ run_tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 215 |
|
| 216 |
async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 217 |
tool = TOOL_REGISTRY.get(tool_name)
|
|
|
|
| 224 |
|
| 225 |
session_id = _get_session_id()
|
| 226 |
|
| 227 |
+
# ββ GAP-SKILL-SYNC v2: circuit breaker pre-check ββββββββββββββββββββββ
|
| 228 |
+
# Se il tool ha un Wilson score molto basso (< 0.15) con >= 3 dati in sessione,
|
| 229 |
+
# bypassa il tool e vai direttamente al miglior fallback disponibile.
|
| 230 |
if self._is_circuit_open(tool_name, session_id):
|
| 231 |
+
_logger.info(
|
| 232 |
+
"[executor] circuit OPEN per %s β routing diretto a fallback (Wilson < %.2f)",
|
| 233 |
+
tool_name, _CIRCUIT_OPEN_THRESHOLD,
|
| 234 |
+
)
|
| 235 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 236 |
if fb_result:
|
| 237 |
return fb_result
|
| 238 |
+
# Tutti i fallback falliti: procedi con il tool primario (ultima spiaggia)
|
| 239 |
+
_logger.warning(
|
| 240 |
+
"[executor] tutti i fallback di %s hanno fallito β provo comunque il tool primario",
|
| 241 |
+
tool_name,
|
| 242 |
+
)
|
| 243 |
|
| 244 |
+
# ββ Esecuzione normale con retry ββββββββββββββββββββββββββββββββββββββ
|
| 245 |
fn = tool.get("_fn")
|
| 246 |
if fn is None:
|
| 247 |
return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
|
| 248 |
|
| 249 |
+
# P17-B1: syntax check pre-esecuzione β intercetta SyntaxError prima che il
|
| 250 |
+
# backend-exec spreci un round-trip su codice giΓ rotto. Fail-open: tool non in
|
| 251 |
+
# mappa, ast_check non importato, o codice vuoto β nessun blocco.
|
| 252 |
+
if tool_name in _CODE_EXEC_TOOLS:
|
| 253 |
+
_code_arg, _code_lang = _CODE_EXEC_TOOLS[tool_name]
|
| 254 |
+
_raw_code = inputs.get(_code_arg, "")
|
| 255 |
+
if isinstance(_raw_code, str) and _raw_code.strip():
|
| 256 |
+
_syn = _check_syntax(_raw_code, _code_lang)
|
| 257 |
+
if not _syn.ok:
|
| 258 |
+
_logger.warning(
|
| 259 |
+
"[executor] P17-B1 syntax check failed per %s: %s",
|
| 260 |
+
tool_name, _syn.error,
|
| 261 |
+
)
|
| 262 |
+
return {
|
| 263 |
+
"success": False,
|
| 264 |
+
"error": (
|
| 265 |
+
f"SyntaxError pre-esecuzione [{_code_lang}]: {_syn.error}"
|
| 266 |
+
+ (f" β riga {_syn.line}" if _syn.line else "")
|
| 267 |
+
),
|
| 268 |
+
"output": None,
|
| 269 |
+
"syntax_check_failed": True,
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
last_error: str = "max_retries"
|
| 273 |
for attempt in range(self.max_retries + 1):
|
| 274 |
try:
|
| 275 |
+
# S-ORCH-8GAP FIX-GAP2: usa timeout adattivo basato su P90 ultime 5 chiamate
|
| 276 |
_adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
|
| 277 |
_t0 = _time_mod.monotonic()
|
| 278 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 279 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 280 |
if self.memory:
|
| 281 |
+
# S577βS600: inputs 100β500 β parity con altri handler
|
| 282 |
+
await self.memory.save_episode(
|
| 283 |
+
"tool",
|
| 284 |
+
f"{tool_name}: {str(inputs)[:500]}",
|
| 285 |
+
str(result)[:500],
|
| 286 |
+
True,
|
| 287 |
+
)
|
| 288 |
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
| 289 |
|
| 290 |
except asyncio.TimeoutError:
|
| 291 |
+
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
| 292 |
_timeout_tracker.record(tool_name, timeout * 1.2)
|
| 293 |
last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
|
| 294 |
if attempt == self.max_retries:
|
| 295 |
+
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 296 |
+
_logger.info(
|
| 297 |
+
"[executor] %s timeout definitivo β provo fallback Wilson-sorted",
|
| 298 |
+
tool_name,
|
| 299 |
+
)
|
| 300 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 301 |
if fb_result:
|
| 302 |
return fb_result
|
|
|
|
| 306 |
except Exception as e:
|
| 307 |
last_error = str(e)
|
| 308 |
if attempt == self.max_retries:
|
| 309 |
+
# Ultima chance: prova i fallback ordinati per Wilson score
|
| 310 |
+
_logger.info(
|
| 311 |
+
"[executor] %s errore definitivo (%s) β provo fallback Wilson-sorted",
|
| 312 |
+
tool_name, last_error[:60],
|
| 313 |
+
)
|
| 314 |
fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
|
| 315 |
if fb_result:
|
| 316 |
return fb_result
|
agents/goal_drift_detector.py
CHANGED
|
@@ -7,10 +7,6 @@ che il loop principale usa per iniettare una micro-guida correttiva.
|
|
| 7 |
|
| 8 |
Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM.
|
| 9 |
Zero overhead su task senza drift (guard rapido in should_check_drift).
|
| 10 |
-
|
| 11 |
-
GAP-DRIFT-THRESHOLD-FIXED fix: threshold dinamica basata sul numero di subtask
|
| 12 |
-
completati β previene falsi positivi su task multi-fase (es. "installa dipendenze"
|
| 13 |
-
come primo subtask di "crea componente React" β overlap = 0% β falso positivo).
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
@@ -22,16 +18,8 @@ _logger = logging.getLogger("agente_ai.goal_drift")
|
|
| 22 |
|
| 23 |
# ββ Costanti ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati
|
| 25 |
-
DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% β drift
|
| 26 |
-
_MIN_EXEC_DONE: int =
|
| 27 |
-
# Permette almeno 4 subtask di setup/infra prima
|
| 28 |
-
# di valutare il drift semantico.
|
| 29 |
-
|
| 30 |
-
# GAP-DRIFT-THRESHOLD-FIXED: threshold dinamica per task giovani.
|
| 31 |
-
# Nei primi _EARLY_EXEC_DONE subtask usiamo una soglia molto bassa (0.05 = 5% overlap)
|
| 32 |
-
# invece di 0.25 β solo drift estremi vengono rilevati in fase di setup.
|
| 33 |
-
_EARLY_EXEC_DONE: int = 6 # "fase giovane" = < 6 subtask completati
|
| 34 |
-
_EARLY_THRESHOLD: float = 0.05 # soglia permissiva per fase giovane (5% vs 25%)
|
| 35 |
|
| 36 |
_STOP_WORDS = frozenset({
|
| 37 |
# italiano
|
|
@@ -90,9 +78,8 @@ def should_check_drift(step_count: int, last_check: int) -> bool:
|
|
| 90 |
"""
|
| 91 |
True se Γ¨ ora di eseguire un drift check.
|
| 92 |
|
| 93 |
-
GAP-DRIFT-THRESHOLD-FIXED fix: _MIN_EXEC_DONE alzato a 4 (era 2).
|
| 94 |
Controlla solo se:
|
| 95 |
-
- step_count >= _MIN_EXEC_DONE (almeno
|
| 96 |
- step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step)
|
| 97 |
"""
|
| 98 |
return (
|
|
@@ -101,21 +88,6 @@ def should_check_drift(step_count: int, last_check: int) -> bool:
|
|
| 101 |
)
|
| 102 |
|
| 103 |
|
| 104 |
-
def _effective_threshold(step_count: int) -> float:
|
| 105 |
-
"""GAP-DRIFT-THRESHOLD-FIXED: threshold dinamica basata sul numero di subtask.
|
| 106 |
-
|
| 107 |
-
Fase giovane (< _EARLY_EXEC_DONE subtask): threshold permissiva (5%).
|
| 108 |
-
Fase matura (>= _EARLY_EXEC_DONE subtask): threshold standard (25%).
|
| 109 |
-
|
| 110 |
-
Motivazione: i primi subtask di un task multi-fase sono spesso setup/infra
|
| 111 |
-
(installazione dipendenze, creazione directory, init config) con keyword
|
| 112 |
-
molto diverse dal goal semantico β falsi positivi con threshold fissa 25%.
|
| 113 |
-
"""
|
| 114 |
-
if step_count < _EARLY_EXEC_DONE:
|
| 115 |
-
return _EARLY_THRESHOLD
|
| 116 |
-
return DRIFT_OVERLAP_THRESHOLD
|
| 117 |
-
|
| 118 |
-
|
| 119 |
def detect_drift(
|
| 120 |
goal: str,
|
| 121 |
exec_done: list[str],
|
|
@@ -156,23 +128,16 @@ def detect_drift(
|
|
| 156 |
score = compute_drift_score(goal, exec_done)
|
| 157 |
out["score"] = round(score, 3)
|
| 158 |
|
| 159 |
-
|
| 160 |
-
effective_thr = _effective_threshold(step_count)
|
| 161 |
-
|
| 162 |
-
if score > (1.0 - effective_thr):
|
| 163 |
out["drifted"] = True
|
| 164 |
goal_kws = _extract_keywords(goal)
|
| 165 |
exec_kws = _extract_keywords(" ".join(exec_done))
|
| 166 |
missing = sorted(goal_kws - exec_kws)[:5]
|
| 167 |
out["reason"] = (
|
| 168 |
-
f"score={score:.2f}
|
| 169 |
-
f"keyword goal assenti nell'output: {missing}"
|
| 170 |
)
|
| 171 |
_logger.info("COG-5 drift rilevato: %s", out["reason"])
|
| 172 |
else:
|
| 173 |
-
_logger.debug(
|
| 174 |
-
"COG-5 no drift: score=%.2f threshold=%.2f step=%d",
|
| 175 |
-
score, effective_thr, step_count,
|
| 176 |
-
)
|
| 177 |
|
| 178 |
return out
|
|
|
|
| 7 |
|
| 8 |
Tutto sincrono e non-blocking: nessun I/O, nessuna chiamata LLM.
|
| 9 |
Zero overhead su task senza drift (guard rapido in should_check_drift).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 18 |
|
| 19 |
# ββ Costanti ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
DRIFT_CHECK_EVERY_N: int = 3 # check ogni 3 subtask completati
|
| 21 |
+
DRIFT_OVERLAP_THRESHOLD: float = 0.25 # keyword overlap < 25% β drift
|
| 22 |
+
_MIN_EXEC_DONE: int = 2 # non controlla prima di 2 subtask completati
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
_STOP_WORDS = frozenset({
|
| 25 |
# italiano
|
|
|
|
| 78 |
"""
|
| 79 |
True se Γ¨ ora di eseguire un drift check.
|
| 80 |
|
|
|
|
| 81 |
Controlla solo se:
|
| 82 |
+
- step_count >= _MIN_EXEC_DONE (almeno 2 subtask completati)
|
| 83 |
- step_count - last_check >= DRIFT_CHECK_EVERY_N (ogni 3 step)
|
| 84 |
"""
|
| 85 |
return (
|
|
|
|
| 88 |
)
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def detect_drift(
|
| 92 |
goal: str,
|
| 93 |
exec_done: list[str],
|
|
|
|
| 128 |
score = compute_drift_score(goal, exec_done)
|
| 129 |
out["score"] = round(score, 3)
|
| 130 |
|
| 131 |
+
if score > (1.0 - DRIFT_OVERLAP_THRESHOLD):
|
|
|
|
|
|
|
|
|
|
| 132 |
out["drifted"] = True
|
| 133 |
goal_kws = _extract_keywords(goal)
|
| 134 |
exec_kws = _extract_keywords(" ".join(exec_done))
|
| 135 |
missing = sorted(goal_kws - exec_kws)[:5]
|
| 136 |
out["reason"] = (
|
| 137 |
+
f"score={score:.2f}, keyword goal assenti nell'output: {missing}"
|
|
|
|
| 138 |
)
|
| 139 |
_logger.info("COG-5 drift rilevato: %s", out["reason"])
|
| 140 |
else:
|
| 141 |
+
_logger.debug("COG-5 no drift: score=%.2f step=%d", score, step_count)
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
return out
|
agents/goal_verifier.py
CHANGED
|
@@ -193,18 +193,18 @@ class GoalVerifier:
|
|
| 193 |
|
| 194 |
@classmethod
|
| 195 |
def is_code_goal(cls, goal: str) -> bool:
|
| 196 |
-
return bool(cls._CODE_RE.search(goal[:500]))
|
| 197 |
|
| 198 |
@classmethod
|
| 199 |
def adaptive_threshold(cls, goal: str) -> float:
|
| 200 |
g = goal.strip()
|
| 201 |
if _SIMPLE_RE.match(g):
|
| 202 |
return 0.28
|
| 203 |
-
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 204 |
return 0.25
|
| 205 |
-
if _COMPLEX_CODE_RE.search(g[:500]):
|
| 206 |
return 0.55
|
| 207 |
-
if cls._CODE_RE.search(g[:500]):
|
| 208 |
return 0.42
|
| 209 |
return RETRY_THRESHOLD
|
| 210 |
|
|
@@ -221,7 +221,7 @@ class GoalVerifier:
|
|
| 221 |
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 222 |
]
|
| 223 |
try:
|
| 224 |
-
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
|
| 225 |
if not raw or raw.startswith("[LLM"):
|
| 226 |
return self._default_ok()
|
| 227 |
return self._parse(raw)
|
|
@@ -258,7 +258,7 @@ class GoalVerifier:
|
|
| 258 |
per_req[req_id] = GoalVerificationStatus.UNKNOWN
|
| 259 |
continue
|
| 260 |
|
| 261 |
-
criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
|
| 262 |
check_prompt = (
|
| 263 |
f"Requisito: {req_name}\n"
|
| 264 |
f"Criteri:\n{criteria_text}\n\n"
|
|
@@ -287,9 +287,9 @@ class GoalVerifier:
|
|
| 287 |
score = (n_pass / n_known) if n_known > 0 else 0.5
|
| 288 |
|
| 289 |
overall_pass = score >= threshold and not failed_reqs
|
| 290 |
-
hint = "; ".join(failed_hints[:4]) if failed_hints else ""
|
| 291 |
if failed_reqs:
|
| 292 |
-
hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
|
| 293 |
|
| 294 |
status = (
|
| 295 |
GoalVerificationStatus.PASS if overall_pass
|
|
@@ -300,7 +300,7 @@ class GoalVerifier:
|
|
| 300 |
return GoalVerifyResult(
|
| 301 |
goal_met = overall_pass,
|
| 302 |
coverage_score = round(score, 3),
|
| 303 |
-
missing_items = failed_reqs[:5],
|
| 304 |
repair_hint = hint[:MAX_HINT_CHARS],
|
| 305 |
verification_status = status,
|
| 306 |
)
|
|
|
|
| 193 |
|
| 194 |
@classmethod
|
| 195 |
def is_code_goal(cls, goal: str) -> bool:
|
| 196 |
+
return bool(cls._CODE_RE.search(goal[:500]))
|
| 197 |
|
| 198 |
@classmethod
|
| 199 |
def adaptive_threshold(cls, goal: str) -> float:
|
| 200 |
g = goal.strip()
|
| 201 |
if _SIMPLE_RE.match(g):
|
| 202 |
return 0.28
|
| 203 |
+
if cls._EXPLANATION_RE.search(g[:500]) and not cls._CODE_RE.search(g[:500]):
|
| 204 |
return 0.25
|
| 205 |
+
if _COMPLEX_CODE_RE.search(g[:500]):
|
| 206 |
return 0.55
|
| 207 |
+
if cls._CODE_RE.search(g[:500]):
|
| 208 |
return 0.42
|
| 209 |
return RETRY_THRESHOLD
|
| 210 |
|
|
|
|
| 221 |
{"role": "user", "content": f"GOAL: {goal_short}\n\nRISPOSTA:\n{ans_short}"},
|
| 222 |
]
|
| 223 |
try:
|
| 224 |
+
raw = await self.llm.chat(msgs, temperature=0.0, max_tokens=200)
|
| 225 |
if not raw or raw.startswith("[LLM"):
|
| 226 |
return self._default_ok()
|
| 227 |
return self._parse(raw)
|
|
|
|
| 258 |
per_req[req_id] = GoalVerificationStatus.UNKNOWN
|
| 259 |
continue
|
| 260 |
|
| 261 |
+
criteria_text = "\n".join(f"- {c}" for c in criteria[:5])
|
| 262 |
check_prompt = (
|
| 263 |
f"Requisito: {req_name}\n"
|
| 264 |
f"Criteri:\n{criteria_text}\n\n"
|
|
|
|
| 287 |
score = (n_pass / n_known) if n_known > 0 else 0.5
|
| 288 |
|
| 289 |
overall_pass = score >= threshold and not failed_reqs
|
| 290 |
+
hint = "; ".join(failed_hints[:4]) if failed_hints else ""
|
| 291 |
if failed_reqs:
|
| 292 |
+
hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}"
|
| 293 |
|
| 294 |
status = (
|
| 295 |
GoalVerificationStatus.PASS if overall_pass
|
|
|
|
| 300 |
return GoalVerifyResult(
|
| 301 |
goal_met = overall_pass,
|
| 302 |
coverage_score = round(score, 3),
|
| 303 |
+
missing_items = failed_reqs[:5],
|
| 304 |
repair_hint = hint[:MAX_HINT_CHARS],
|
| 305 |
verification_status = status,
|
| 306 |
)
|
agents/planner.py
CHANGED
|
@@ -221,7 +221,7 @@ class Planner:
|
|
| 221 |
{"role": "user", "content": f"Obiettivo: {goal}"},
|
| 222 |
]
|
| 223 |
if context:
|
| 224 |
-
ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
|
| 225 |
msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 226 |
return msgs
|
| 227 |
|
|
@@ -258,7 +258,7 @@ class Planner:
|
|
| 258 |
plan = _parse_plan(raw)
|
| 259 |
if plan:
|
| 260 |
plan["_speculative"] = True
|
| 261 |
-
plan["_raw"] = raw[:400]
|
| 262 |
return plan
|
| 263 |
except Exception:
|
| 264 |
return None
|
|
|
|
| 221 |
{"role": "user", "content": f"Obiettivo: {goal}"},
|
| 222 |
]
|
| 223 |
if context:
|
| 224 |
+
ctx_str = "\n".join(m.get("content", "")[:500] for m in context[-5:])
|
| 225 |
msgs[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
|
| 226 |
return msgs
|
| 227 |
|
|
|
|
| 258 |
plan = _parse_plan(raw)
|
| 259 |
if plan:
|
| 260 |
plan["_speculative"] = True
|
| 261 |
+
plan["_raw"] = raw[:400]
|
| 262 |
return plan
|
| 263 |
except Exception:
|
| 264 |
return None
|
agents/reasoning_core.py
CHANGED
|
@@ -64,6 +64,8 @@ Return:
|
|
| 64 |
CONTEXT:
|
| 65 |
{repo_context}
|
| 66 |
"""
|
|
|
|
|
|
|
| 67 |
try:
|
| 68 |
return await asyncio.wait_for(
|
| 69 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
|
@@ -87,6 +89,7 @@ Decide:
|
|
| 87 |
- impact
|
| 88 |
- risk level
|
| 89 |
"""
|
|
|
|
| 90 |
try:
|
| 91 |
return await asyncio.wait_for(
|
| 92 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
|
|
@@ -105,6 +108,7 @@ Return:
|
|
| 105 |
- root cause
|
| 106 |
- fix strategy
|
| 107 |
"""
|
|
|
|
| 108 |
try:
|
| 109 |
return await asyncio.wait_for(
|
| 110 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
|
|
@@ -115,6 +119,8 @@ Return:
|
|
| 115 |
|
| 116 |
# ββ Prompt builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 117 |
def _build_prompt(self, state: ReasoningState) -> str:
|
|
|
|
|
|
|
| 118 |
if state.errors:
|
| 119 |
import re as _re_err
|
| 120 |
_err_all = state.errors
|
|
@@ -137,7 +143,7 @@ STATO:
|
|
| 137 |
- goal: {state.goal}
|
| 138 |
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 139 |
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 140 |
-
- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300
|
| 141 |
- errors: {errors_str}
|
| 142 |
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 143 |
|
|
@@ -149,8 +155,16 @@ Rispondi SOLO con JSON valido:
|
|
| 149 |
"reason": "perchΓ© questa azione?",
|
| 150 |
"confidence": 0.0-1.0
|
| 151 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
"""
|
| 153 |
|
|
|
|
| 154 |
_ctx_section = ""
|
| 155 |
if state.project_files:
|
| 156 |
try:
|
|
@@ -166,6 +180,9 @@ Rispondi SOLO con JSON valido:
|
|
| 166 |
if f.get("path") in _top_paths
|
| 167 |
]
|
| 168 |
if _skels:
|
|
|
|
|
|
|
|
|
|
| 169 |
_goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set()
|
| 170 |
if _goal_kw_ctx:
|
| 171 |
_skels.sort(
|
|
@@ -173,6 +190,8 @@ Rispondi SOLO con JSON valido:
|
|
| 173 |
reverse=True,
|
| 174 |
)
|
| 175 |
_ctx_raw = "\n".join(_skels)
|
|
|
|
|
|
|
| 176 |
if len(_ctx_raw) > 6000:
|
| 177 |
import re as _re_sk
|
| 178 |
_sig_lines = _re_sk.findall(
|
|
@@ -180,22 +199,26 @@ Rispondi SOLO con JSON valido:
|
|
| 180 |
r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}',
|
| 181 |
_ctx_raw, _re_sk.MULTILINE
|
| 182 |
)
|
| 183 |
-
_ctx_smart =
|
| 184 |
if len(_ctx_smart) >= 500:
|
| 185 |
_ctx_raw = (
|
| 186 |
-
f
|
| 187 |
+ _ctx_smart[:10000]
|
| 188 |
)
|
| 189 |
else:
|
| 190 |
-
_ctx_raw = _ctx_raw[:6000] +
|
| 191 |
_ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw
|
| 192 |
except Exception:
|
| 193 |
-
pass
|
| 194 |
|
| 195 |
return _base_prompt + _ctx_section
|
| 196 |
|
| 197 |
@staticmethod
|
| 198 |
def _extract_json(raw: str) -> str | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
depth = 0
|
| 200 |
start = -1
|
| 201 |
for i, ch in enumerate(raw):
|
|
@@ -229,28 +252,12 @@ Rispondi SOLO con JSON valido:
|
|
| 229 |
return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
|
| 230 |
|
| 231 |
prompt = self._build_prompt(state)
|
| 232 |
-
# S42: Speculative Decoding Multi-Nodo
|
| 233 |
-
# Lanciamo 3 generazioni parallele con temperature e prompt diversi
|
| 234 |
try:
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
self.llm.chat([{"role": "user", "content": prompt
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
_logger.info("SPECULATIVE: Avviate 3 generazioni parallele")
|
| 242 |
-
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 243 |
-
|
| 244 |
-
# Verificatore (Node D logic): Seleziona il risultato piΓΉ coerente o il primo valido
|
| 245 |
-
valid_results = [r for r in raw_results if isinstance(r, str) and r.strip()]
|
| 246 |
-
|
| 247 |
-
if not valid_results:
|
| 248 |
-
raise Exception("Nessun risultato valido dai nodi speculativi")
|
| 249 |
-
|
| 250 |
-
# Per ora scegliamo il primo (BRAIN), ma potremmo implementare un ranker
|
| 251 |
-
raw = valid_results[0]
|
| 252 |
-
_logger.info("SPECULATIVE: Risposta selezionata tra %d varianti", len(valid_results))
|
| 253 |
-
|
| 254 |
return self._parse(raw)
|
| 255 |
except asyncio.TimeoutError:
|
| 256 |
return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3)
|
|
@@ -269,7 +276,7 @@ Rispondi SOLO con JSON valido:
|
|
| 269 |
await on_step({
|
| 270 |
"loop": state.loop_count,
|
| 271 |
"action": decision.action,
|
| 272 |
-
"reason": decision.reason
|
| 273 |
"confidence": decision.confidence
|
| 274 |
})
|
| 275 |
|
|
@@ -277,13 +284,11 @@ Rispondi SOLO con JSON valido:
|
|
| 277 |
break
|
| 278 |
|
| 279 |
elif decision.action == "analyze":
|
| 280 |
-
|
| 281 |
-
state.world_model = (_wm_raw or '')[:600] # S593: world_model 400->600
|
| 282 |
results.append({"action": "analyze", "output": "World model built"})
|
| 283 |
-
|
| 284 |
elif decision.action == "strategy":
|
| 285 |
-
|
| 286 |
-
state.strategy = (_strat_raw or '')[:600] # S593: strategy 400->600
|
| 287 |
results.append({"action": "strategy", "output": state.strategy})
|
| 288 |
|
| 289 |
elif decision.action == "plan" and self.planner:
|
|
@@ -294,6 +299,7 @@ Rispondi SOLO con JSON valido:
|
|
| 294 |
|
| 295 |
elif decision.action == "fix":
|
| 296 |
if decision.patch:
|
|
|
|
| 297 |
if self.executor:
|
| 298 |
res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
|
| 299 |
state.last_result = str(res.get("output", ""))
|
|
@@ -305,6 +311,7 @@ Rispondi SOLO con JSON valido:
|
|
| 305 |
results.append({"action": "error_analysis", "output": error_analysis})
|
| 306 |
|
| 307 |
elif decision.action == "continue":
|
|
|
|
| 308 |
if decision.steps:
|
| 309 |
try:
|
| 310 |
_step_prompt = decision.steps[0]
|
|
@@ -321,32 +328,102 @@ Rispondi SOLO con JSON valido:
|
|
| 321 |
state.completed_steps.append(decision.steps[0])
|
| 322 |
results.append({"action": "continue", "steps": decision.steps})
|
| 323 |
|
|
|
|
| 324 |
if self.critic and state.last_result and decision.action != "analyze":
|
| 325 |
critique = await self.critic.evaluate(goal, state.last_result)
|
| 326 |
if critique.get("needs_retry"):
|
| 327 |
-
state.errors.extend(critique.get("issues", []))
|
| 328 |
|
| 329 |
state.loop_count += 1
|
| 330 |
|
| 331 |
return {
|
| 332 |
"goal": goal,
|
| 333 |
"loops": state.loop_count,
|
|
|
|
| 334 |
"results": results,
|
| 335 |
-
"final_state":
|
|
|
|
|
|
|
|
|
|
| 336 |
}
|
| 337 |
|
| 338 |
-
async def run_loop_to_answer(self, goal: str,
|
| 339 |
-
|
|
|
|
|
|
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
| 343 |
"""
|
| 344 |
try:
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
except Exception:
|
| 351 |
return ""
|
| 352 |
-
|
|
|
|
| 64 |
CONTEXT:
|
| 65 |
{repo_context}
|
| 66 |
"""
|
| 67 |
+
# S665: wrap con asyncio.wait_for β analyze_project usava await self.llm.chat() senza timeout
|
| 68 |
+
# β hang indefinito se il provider non risponde. Timeout 45s = STREAM_TIMEOUT (ai_client.py).
|
| 69 |
try:
|
| 70 |
return await asyncio.wait_for(
|
| 71 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
|
|
|
| 89 |
- impact
|
| 90 |
- risk level
|
| 91 |
"""
|
| 92 |
+
# S665: timeout anche per develop_strategy
|
| 93 |
try:
|
| 94 |
return await asyncio.wait_for(
|
| 95 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3),
|
|
|
|
| 108 |
- root cause
|
| 109 |
- fix strategy
|
| 110 |
"""
|
| 111 |
+
# S665: timeout anche per analyze_error
|
| 112 |
try:
|
| 113 |
return await asyncio.wait_for(
|
| 114 |
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1),
|
|
|
|
| 119 |
|
| 120 |
# ββ Prompt builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
def _build_prompt(self, state: ReasoningState) -> str:
|
| 122 |
+
# S590: errors[-3:]β[-5:] β piΓΉ errori nel contesto per diagnosi piΓΉ accurata
|
| 123 |
+
# BUG-2: raggruppa errori per tipo + ultimi 5 dettagliati β diagnosi piΓΉ accurata
|
| 124 |
if state.errors:
|
| 125 |
import re as _re_err
|
| 126 |
_err_all = state.errors
|
|
|
|
| 143 |
- goal: {state.goal}
|
| 144 |
- world_model: {'Presente' if state.world_model else 'Mancante'}
|
| 145 |
- strategy: {'Definita' if state.strategy else 'Da definire'}
|
| 146 |
+
- last_result: {state.last_result[:500] if state.last_result else 'vuoto'} # S592: 300β500
|
| 147 |
- errors: {errors_str}
|
| 148 |
- loop_count: {state.loop_count}/{self.MAX_LOOPS}
|
| 149 |
|
|
|
|
| 155 |
"reason": "perchΓ© questa azione?",
|
| 156 |
"confidence": 0.0-1.0
|
| 157 |
}}
|
| 158 |
+
|
| 159 |
+
Regole:
|
| 160 |
+
1. Se manca world_model -> "analyze"
|
| 161 |
+
2. Se manca strategy -> "strategy"
|
| 162 |
+
3. Se strategy c'Γ¨ ma serve piano -> "plan"
|
| 163 |
+
4. Se ci sono errori -> "fix"
|
| 164 |
+
5. Se tutto ok -> "continue" o "stop" se finito.
|
| 165 |
"""
|
| 166 |
|
| 167 |
+
# GAP-2: Deep Context β inietta skeleton dei file rilevanti per ragionamento multi-file
|
| 168 |
_ctx_section = ""
|
| 169 |
if state.project_files:
|
| 170 |
try:
|
|
|
|
| 180 |
if f.get("path") in _top_paths
|
| 181 |
]
|
| 182 |
if _skels:
|
| 183 |
+
# P25-B1: ordina i blocchi skeleton per overlap keyword col goal prima di troncare.
|
| 184 |
+
# Zero LLM, zero latenza β stessa logica word-overlap di episodic.py.
|
| 185 |
+
# Garantisce che i blocchi piΓΉ rilevanti per il goal finiscano PRIMA del taglio.
|
| 186 |
_goal_kw_ctx = set(re.findall(r'\w{4,}', state.goal.lower())) if hasattr(state, 'goal') else set()
|
| 187 |
if _goal_kw_ctx:
|
| 188 |
_skels.sort(
|
|
|
|
| 190 |
reverse=True,
|
| 191 |
)
|
| 192 |
_ctx_raw = "\n".join(_skels)
|
| 193 |
+
# S780-SMART: Smart Chunking β estrae firme funzioni/classi invece di troncare.
|
| 194 |
+
# BUG-SKEL fix: evita allucinazioni su funzioni mancanti nei file complessi.
|
| 195 |
if len(_ctx_raw) > 6000:
|
| 196 |
import re as _re_sk
|
| 197 |
_sig_lines = _re_sk.findall(
|
|
|
|
| 199 |
r'(?:function|const|class)\s+\w|function\s+\w)[^\n]{0,200}',
|
| 200 |
_ctx_raw, _re_sk.MULTILINE
|
| 201 |
)
|
| 202 |
+
_ctx_smart = '\n'.join(_sig_lines)
|
| 203 |
if len(_ctx_smart) >= 500:
|
| 204 |
_ctx_raw = (
|
| 205 |
+
f'[SMART CHUNK β {len(_skels)} file β solo firme estratte]\n'
|
| 206 |
+ _ctx_smart[:10000]
|
| 207 |
)
|
| 208 |
else:
|
| 209 |
+
_ctx_raw = _ctx_raw[:6000] + '\nβ¦ [troncato β usa file_search per dettagli]'
|
| 210 |
_ctx_section = "\n\nFILE RILEVANTI (skeleton per ragionamento):\n" + _ctx_raw
|
| 211 |
except Exception:
|
| 212 |
+
pass # non-fatal β degradazione graceful senza deep context
|
| 213 |
|
| 214 |
return _base_prompt + _ctx_section
|
| 215 |
|
| 216 |
@staticmethod
|
| 217 |
def _extract_json(raw: str) -> str | None:
|
| 218 |
+
"""P16-B3: depth-counting bilanciato β sostituisce regex greedy r'{[\s\S]+}'
|
| 219 |
+
che su JSON nested (es. patch con oggetti interni) estraeva dal primo { all'ULTIMO }
|
| 220 |
+
producendo JSON malformato β action='continue' per default β agente in loop.
|
| 221 |
+
Pattern identico a safeJsonParse.ts giΓ in produzione sul frontend."""
|
| 222 |
depth = 0
|
| 223 |
start = -1
|
| 224 |
for i, ch in enumerate(raw):
|
|
|
|
| 252 |
return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
|
| 253 |
|
| 254 |
prompt = self._build_prompt(state)
|
|
|
|
|
|
|
| 255 |
try:
|
| 256 |
+
# S750-GAP-D: asyncio.wait_for β evita hang se LLM provider non risponde
|
| 257 |
+
raw = await asyncio.wait_for(
|
| 258 |
+
self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2),
|
| 259 |
+
timeout=30.0,
|
| 260 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
return self._parse(raw)
|
| 262 |
except asyncio.TimeoutError:
|
| 263 |
return ReasoningResult(action="continue", steps=[], reason="decide(): LLM timeout 30s", confidence=0.3)
|
|
|
|
| 276 |
await on_step({
|
| 277 |
"loop": state.loop_count,
|
| 278 |
"action": decision.action,
|
| 279 |
+
"reason": decision.reason,
|
| 280 |
"confidence": decision.confidence
|
| 281 |
})
|
| 282 |
|
|
|
|
| 284 |
break
|
| 285 |
|
| 286 |
elif decision.action == "analyze":
|
| 287 |
+
state.world_model = await self.analyze_project(context or goal)
|
|
|
|
| 288 |
results.append({"action": "analyze", "output": "World model built"})
|
| 289 |
+
|
| 290 |
elif decision.action == "strategy":
|
| 291 |
+
state.strategy = await self.develop_strategy(state)
|
|
|
|
| 292 |
results.append({"action": "strategy", "output": state.strategy})
|
| 293 |
|
| 294 |
elif decision.action == "plan" and self.planner:
|
|
|
|
| 299 |
|
| 300 |
elif decision.action == "fix":
|
| 301 |
if decision.patch:
|
| 302 |
+
# Se c'Γ¨ una patch, l'executor la applica
|
| 303 |
if self.executor:
|
| 304 |
res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
|
| 305 |
state.last_result = str(res.get("output", ""))
|
|
|
|
| 311 |
results.append({"action": "error_analysis", "output": error_analysis})
|
| 312 |
|
| 313 |
elif decision.action == "continue":
|
| 314 |
+
# S575: direct_response non esiste nel TOOL_REGISTRY β usa LLM diretto
|
| 315 |
if decision.steps:
|
| 316 |
try:
|
| 317 |
_step_prompt = decision.steps[0]
|
|
|
|
| 328 |
state.completed_steps.append(decision.steps[0])
|
| 329 |
results.append({"action": "continue", "steps": decision.steps})
|
| 330 |
|
| 331 |
+
# Auto-debug check con Critic
|
| 332 |
if self.critic and state.last_result and decision.action != "analyze":
|
| 333 |
critique = await self.critic.evaluate(goal, state.last_result)
|
| 334 |
if critique.get("needs_retry"):
|
| 335 |
+
state.errors.extend(critique.get("issues", []))
|
| 336 |
|
| 337 |
state.loop_count += 1
|
| 338 |
|
| 339 |
return {
|
| 340 |
"goal": goal,
|
| 341 |
"loops": state.loop_count,
|
| 342 |
+
"success": len(state.errors) == 0,
|
| 343 |
"results": results,
|
| 344 |
+
"final_state": {
|
| 345 |
+
"has_world_model": state.world_model is not None,
|
| 346 |
+
"has_strategy": state.strategy is not None
|
| 347 |
+
}
|
| 348 |
}
|
| 349 |
|
| 350 |
+
async def run_loop_to_answer(self, goal: str, context: str = "",
|
| 351 |
+
on_step=None, max_loops: int = 8,
|
| 352 |
+
project_files: Optional[List[Dict[str, Any]]] = None) -> str:
|
| 353 |
+
"""S575: Versione di run_loop che ritorna una stringa risposta sintetizzata.
|
| 354 |
|
| 355 |
+
Usata dal gate in UnifiedAgentLoop quando tok_budget >= 6144 e subtask >= 3.
|
| 356 |
+
Limite max_loops=8 (S701: era 5) β piΓΉ iterazioni per task profondi.
|
| 357 |
+
Output: stringa di risultati aggregati da passare come contesto extra al LLM finale.
|
| 358 |
+
Mai solleva eccezioni.
|
| 359 |
"""
|
| 360 |
try:
|
| 361 |
+
# GAP-2: deep context β inietta i file VFS nella ReasoningState per rank_files_by_relevance()
|
| 362 |
+
state = ReasoningState(goal=goal, context=context, project_files=project_files)
|
| 363 |
+
parts: List[str] = []
|
| 364 |
+
loop_cap = min(max_loops, self.MAX_LOOPS)
|
| 365 |
+
|
| 366 |
+
while state.loop_count < loop_cap:
|
| 367 |
+
try:
|
| 368 |
+
decision = await self.decide(state)
|
| 369 |
+
except Exception:
|
| 370 |
+
break
|
| 371 |
+
|
| 372 |
+
if on_step:
|
| 373 |
+
try:
|
| 374 |
+
import asyncio as _aio
|
| 375 |
+
coro = on_step({
|
| 376 |
+
"loop": state.loop_count,
|
| 377 |
+
"action": f"reasoning:{decision.action}",
|
| 378 |
+
"reason": decision.reason[:200] if decision.reason else "", # S578: 120β200
|
| 379 |
+
"confidence": decision.confidence,
|
| 380 |
+
})
|
| 381 |
+
if _aio.iscoroutine(coro):
|
| 382 |
+
await coro
|
| 383 |
+
except Exception as _exc:
|
| 384 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 385 |
+
|
| 386 |
+
if decision.action == "stop" or decision.confidence < self.MIN_CONFIDENCE:
|
| 387 |
+
break
|
| 388 |
+
|
| 389 |
+
elif decision.action == "analyze":
|
| 390 |
+
try:
|
| 391 |
+
state.world_model = await self.analyze_project(context or goal)
|
| 392 |
+
# S593: 400β600 β world_model spesso multi-paragrafo
|
| 393 |
+
parts.append(f"[ANALISI PROGETTO]: {(state.world_model or '')[:600]}")
|
| 394 |
+
except Exception as _exc:
|
| 395 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 396 |
+
|
| 397 |
+
elif decision.action == "strategy":
|
| 398 |
+
try:
|
| 399 |
+
state.strategy = await self.develop_strategy(state)
|
| 400 |
+
# S593: 400β600 β strategy spesso multi-step
|
| 401 |
+
parts.append(f"[STRATEGIA]: {(state.strategy or '')[:600]}")
|
| 402 |
+
except Exception as _exc:
|
| 403 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 404 |
+
|
| 405 |
+
elif decision.action in ("plan", "continue", "fix"):
|
| 406 |
+
# Esegui passo diretto via LLM
|
| 407 |
+
step_desc = (decision.steps[0] if decision.steps
|
| 408 |
+
else decision.reason or goal)
|
| 409 |
+
try:
|
| 410 |
+
_ans = await self.llm.chat(
|
| 411 |
+
[{"role": "system", "content":
|
| 412 |
+
"Sei un assistente tecnico esperto. "
|
| 413 |
+
"Svolgi il passo richiesto in modo preciso e conciso."},
|
| 414 |
+
{"role": "user", "content":
|
| 415 |
+
f"Goal complessivo: {goal}\n\nPasso: {step_desc}"}],
|
| 416 |
+
temperature=0.2, max_tokens=512,
|
| 417 |
+
)
|
| 418 |
+
if _ans and not _ans.startswith("[LLM"):
|
| 419 |
+
parts.append(f"[PASSO {state.loop_count+1}]: {_ans[:600]}")
|
| 420 |
+
state.last_result = _ans
|
| 421 |
+
state.completed_steps.append(step_desc)
|
| 422 |
+
except Exception as _exc:
|
| 423 |
+
_logger.debug("[reasoning_core] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 424 |
+
|
| 425 |
+
state.loop_count += 1
|
| 426 |
+
|
| 427 |
+
return "\n\n".join(parts) if parts else ""
|
| 428 |
except Exception:
|
| 429 |
return ""
|
|
|
agents/unified_loop.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
agents/unified_loop_helpers.py
CHANGED
|
@@ -27,7 +27,7 @@ import logging
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi β zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
-
from agents.unified_loop_types import StepCallback, UnifiedLoopState
|
| 31 |
|
| 32 |
|
| 33 |
class HelpersMixin:
|
|
|
|
| 27 |
_logger = logging.getLogger("agents.unified_loop_helpers")
|
| 28 |
|
| 29 |
# Import tipi condivisi β zero circular (unified_loop_types ha solo stdlib)
|
| 30 |
+
from agents.unified_loop_types import StepCallback, UnifiedLoopState
|
| 31 |
|
| 32 |
|
| 33 |
class HelpersMixin:
|
agents/unified_loop_llm.py
CHANGED
|
@@ -88,6 +88,9 @@ class LLMSelectionMixin:
|
|
| 88 |
elif 'cerebras' in _gen_prov:
|
| 89 |
# Generatore Cerebras β Verifier Gemini
|
| 90 |
self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
|
|
|
|
|
|
|
|
|
|
| 91 |
else:
|
| 92 |
# OpenRouter / SambaNova / altri β Verifier Groq CODER come default cross-model
|
| 93 |
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
|
|
|
| 88 |
elif 'cerebras' in _gen_prov:
|
| 89 |
# Generatore Cerebras β Verifier Gemini
|
| 90 |
self._verifier_llm = _RRv.get_client(_Rolev.RESEARCHER)
|
| 91 |
+
elif 'nvidia' in _gen_prov:
|
| 92 |
+
# Generatore NVIDIA β Verifier Groq CODER (per diversificare)
|
| 93 |
+
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
| 94 |
else:
|
| 95 |
# OpenRouter / SambaNova / altri β Verifier Groq CODER come default cross-model
|
| 96 |
self._verifier_llm = _RRv.get_client(_Rolev.CODER)
|
agents/unified_loop_prompts.py
CHANGED
|
@@ -59,7 +59,10 @@ class PromptBuilderMixin:
|
|
| 59 |
"Questi modelli/versioni NON ESISTONO β non citarli mai. "
|
| 60 |
"Se non sei certo della versione esatta β ometti il numero o scrivi 'versione corrente'. "
|
| 61 |
"Per la tua identitΓ : di' solo 'Sono un agente AI' senza inventare versioni, parametri o architettura.\n"
|
| 62 |
-
"14.
|
|
|
|
|
|
|
|
|
|
| 63 |
"usa il tool generate_image con il prompt desiderato. "
|
| 64 |
"Il tool usa FLUX.1-schnell (backend HF Space) β Pollinations fallback automatico. "
|
| 65 |
"Restituisce URL diretto visualizzabile nel browser come link cliccabile. "
|
|
@@ -67,7 +70,7 @@ class PromptBuilderMixin:
|
|
| 67 |
"https://image.pollinations.ai/prompt/{PROMPT_URL_ENCODED}?width=512&height=512&nologo=true\n"
|
| 68 |
"MAI inventare URL fake tipo example.com, placeholder.com, via.placeholder.com. "
|
| 69 |
"L'URL Pollinations funziona nel browser come fallback.\n"
|
| 70 |
-
"
|
| 71 |
"(files_created, directory, framework), presenta il risultato come: "
|
| 72 |
"(a) conferma breve 'Progetto X creato in /path/', "
|
| 73 |
"(b) elenco file con descrizione 1-riga per ciascuno, "
|
|
@@ -254,9 +257,43 @@ class PromptBuilderMixin:
|
|
| 254 |
"β’ Struttura obbligatoria: ## Premesse β ## Ragionamento β ## Conclusione β ## Verifica\n"
|
| 255 |
"β’ Aggiungi sempre la confidence: [ALTA/MEDIA/BASSA] con motivazione\n"
|
| 256 |
),
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
-
|
| 260 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 261 |
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
| 262 |
# causa troncamento silenzioso β le regole non vengono mai lette.
|
|
@@ -1219,37 +1256,14 @@ class PromptBuilderMixin:
|
|
| 1219 |
return None, goal
|
| 1220 |
|
| 1221 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1222 |
-
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto.
|
| 1223 |
-
S-BENCH-PRIORITY (RX-LIVE-01): le regole benchmark (DA/RS) vengono iniettate
|
| 1224 |
-
per prime β garantite nell'output anche se 3 regole generiche le precedono nella lista.
|
| 1225 |
-
Root cause fix: max-3 cut-off tagliava S-BENCH-DA/RS (posizione ~960/864 su 1494 righe).
|
| 1226 |
-
"""
|
| 1227 |
goal_lower = goal.lower()
|
| 1228 |
matched: list[str] = []
|
| 1229 |
-
|
| 1230 |
-
# S-BENCH-PRIORITY: benchmark-specific rules β always inject first
|
| 1231 |
-
# Lookup_keys = sottoinsieme unico che identifica la regola nella lista
|
| 1232 |
-
_BENCH_PRIORITY: list[tuple[list[str], str]] = [
|
| 1233 |
-
# DA: "vendite mensili:" + "valore anomalo fuori scala" β ultra-specifici
|
| 1234 |
-
(["vendite mensili:", "copia la struttura, sostituisci", "valore anomalo fuori scala"],
|
| 1235 |
-
"vendite mensili:"),
|
| 1236 |
-
# RS: "coprire:" + "solutions architect" β mai in prompt utente normali
|
| 1237 |
-
(["coprire:", "message queue per use case", "solutions architect"],
|
| 1238 |
-
"coprire:"),
|
| 1239 |
-
]
|
| 1240 |
-
for trigger_keys, lookup_key in _BENCH_PRIORITY:
|
| 1241 |
-
if any(k in goal_lower for k in trigger_keys):
|
| 1242 |
-
rule = next((r for ps, r in self._CONTEXT_RULES if lookup_key in ps), None)
|
| 1243 |
-
if rule and rule not in matched:
|
| 1244 |
-
matched.append(rule)
|
| 1245 |
-
|
| 1246 |
-
# Regole generali: riempi fino a max 3
|
| 1247 |
for patterns, rule in self._CONTEXT_RULES:
|
|
|
|
|
|
|
| 1248 |
if len(matched) >= 3:
|
| 1249 |
break
|
| 1250 |
-
if rule not in matched and any(p in goal_lower for p in patterns):
|
| 1251 |
-
matched.append(rule)
|
| 1252 |
-
|
| 1253 |
if not matched:
|
| 1254 |
return ""
|
| 1255 |
return "\n\nβ‘ REGOLE SPECIFICHE PER QUESTO TASK:\n" + "\n".join(f"β’ {r}" for r in matched)
|
|
@@ -1363,7 +1377,7 @@ class PromptBuilderMixin:
|
|
| 1363 |
# Anche: content scoring (keyword nel head del file, +1 per match vs +2 path).
|
| 1364 |
_goal_hint = (getattr(state, 'goal', '') or '')[:300]
|
| 1365 |
_step_hint = (
|
| 1366 |
-
(_goal_hint + ' ' + tool_results[:
|
| 1367 |
if tool_results
|
| 1368 |
else _goal_hint.lower()
|
| 1369 |
)
|
|
|
|
| 59 |
"Questi modelli/versioni NON ESISTONO β non citarli mai. "
|
| 60 |
"Se non sei certo della versione esatta β ometti il numero o scrivi 'versione corrente'. "
|
| 61 |
"Per la tua identitΓ : di' solo 'Sono un agente AI' senza inventare versioni, parametri o architettura.\n"
|
| 62 |
+
"14. CONTESTO ESTESO (S-CTX-1M): Quando usi modelli NVIDIA con contesto 1M+ (es. `deepseek-v4-flash`), "
|
| 63 |
+
"sfrutta SEMPRE l'intera finestra di contesto per analisi approfondite, riassunti di documenti lunghi, "
|
| 64 |
+
"e per mantenere lo stato conversazionale su task complessi. NON riassumere prematuramente o ignorare parti del contesto.\n"
|
| 65 |
+
"15. GENERAZIONE IMMAGINI (S276): Se ti viene chiesto di generare un'immagine AI, "
|
| 66 |
"usa il tool generate_image con il prompt desiderato. "
|
| 67 |
"Il tool usa FLUX.1-schnell (backend HF Space) β Pollinations fallback automatico. "
|
| 68 |
"Restituisce URL diretto visualizzabile nel browser come link cliccabile. "
|
|
|
|
| 70 |
"https://image.pollinations.ai/prompt/{PROMPT_URL_ENCODED}?width=512&height=512&nologo=true\n"
|
| 71 |
"MAI inventare URL fake tipo example.com, placeholder.com, via.placeholder.com. "
|
| 72 |
"L'URL Pollinations funziona nel browser come fallback.\n"
|
| 73 |
+
"16. SCAFFOLD PROGETTO: Quando i tool_results contengono output scaffold_project "
|
| 74 |
"(files_created, directory, framework), presenta il risultato come: "
|
| 75 |
"(a) conferma breve 'Progetto X creato in /path/', "
|
| 76 |
"(b) elenco file con descrizione 1-riga per ciascuno, "
|
|
|
|
| 257 |
"β’ Struttura obbligatoria: ## Premesse β ## Ragionamento β ## Conclusione β ## Verifica\n"
|
| 258 |
"β’ Aggiungi sempre la confidence: [ALTA/MEDIA/BASSA] con motivazione\n"
|
| 259 |
),
|
| 260 |
+
"ANALYST": (
|
| 261 |
+
"=== MODALITΓ ANALYST (P17-F5) ===\n"
|
| 262 |
+
"Sei un analista dati e business senior. In questa sessione:\n"
|
| 263 |
+
"β’ Ogni insight supportato da numeri, trend o comparazioni\n"
|
| 264 |
+
"β’ Framework: SWOT, Pareto, funnel, cohort, time-series\n"
|
| 265 |
+
"β’ Distingui: correlazione β causalitΓ \n"
|
| 266 |
+
"β’ Per ogni metrica: definizione, formula, limiti interpretativi\n"
|
| 267 |
+
"β’ Identifica outlier, stagionalitΓ , anomalie con contesto\n"
|
| 268 |
+
"β’ Struttura: ## Dati β ## Pattern β ## Insight β ## Azioni\n"
|
| 269 |
+
"β’ Aggiungi: 'Confidence: [ALTA/MEDIA/BASSA] β N={sample_size}'\n"
|
| 270 |
+
),
|
| 271 |
+
"ARCHITECT": (
|
| 272 |
+
"=== MODALITΓ ARCHITECT (P17-F5) ===\n"
|
| 273 |
+
"Sei un software architect senior. In questa sessione:\n"
|
| 274 |
+
"β’ Ogni decisione architetturale con trade-off espliciti\n"
|
| 275 |
+
"β’ Valuta: scalabilitΓ , maintainability, testabilitΓ , costo operativo\n"
|
| 276 |
+
"β’ Pattern noti (CQRS, Event Sourcing, Saga) solo se giustificati\n"
|
| 277 |
+
"β’ Disegna ASCII art o pseudo-UML per ogni sistema\n"
|
| 278 |
+
"β’ Per ogni componente: responsabilitΓ , interfaccia, dipendenze\n"
|
| 279 |
+
"β’ Considera: failure modes, recovery, observability, deployment\n"
|
| 280 |
+
"β’ Struttura: ## Contesto β ## Opzioni(3) β ## Raccomandazione β ## Rischi\n"
|
| 281 |
+
"β’ Aggiungi: 'Tech Debt Score: [BASSO/MEDIO/ALTO]' con motivazione\n"
|
| 282 |
+
),
|
| 283 |
+
"WRITER": (
|
| 284 |
+
"=== MODALITΓ WRITER (P17-F5) ===\n"
|
| 285 |
+
"Sei un content writer e copywriter esperto. In questa sessione:\n"
|
| 286 |
+
"β’ Adatta tono e vocabolario al pubblico target specificato\n"
|
| 287 |
+
"β’ Usa tecniche: AIDA, PAS, storytelling, social proof\n"
|
| 288 |
+
"β’ Ottimizza leggibilitΓ : frasi brevi, paragrafi 3-4 righe\n"
|
| 289 |
+
"β’ Per copy digitale: CTA chiaro, hook above-the-fold\n"
|
| 290 |
+
"β’ Proponi sempre 2-3 varianti A/B per headline e CTA critici\n"
|
| 291 |
+
"β’ Struttura: ## Pubblico β ## Messaggio chiave β ## Testo β ## Varianti\n"
|
| 292 |
+
"β’ Aggiungi: 'Readability: Flesch~[score] | Tono: [formale/informale]'\n"
|
| 293 |
+
),
|
| 294 |
+
}
|
| 295 |
|
| 296 |
+
# ββ S200: Context-aware rule injection ββββββββββββββββββββββββββββββββββββββ
|
| 297 |
# Seleziona solo le regole rilevanti per il task corrente.
|
| 298 |
# Con llama-3.1-8b-instant (8K context), mettere tutto nel system prompt
|
| 299 |
# causa troncamento silenzioso β le regole non vengono mai lette.
|
|
|
|
| 1256 |
return None, goal
|
| 1257 |
|
| 1258 |
def _pick_context_rules(self, goal: str) -> str:
|
| 1259 |
+
"""Seleziona regole contestuali basate sul task. Max 3 per non saturare il contesto."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
goal_lower = goal.lower()
|
| 1261 |
matched: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
for patterns, rule in self._CONTEXT_RULES:
|
| 1263 |
+
if any(p in goal_lower for p in patterns):
|
| 1264 |
+
matched.append(rule)
|
| 1265 |
if len(matched) >= 3:
|
| 1266 |
break
|
|
|
|
|
|
|
|
|
|
| 1267 |
if not matched:
|
| 1268 |
return ""
|
| 1269 |
return "\n\nβ‘ REGOLE SPECIFICHE PER QUESTO TASK:\n" + "\n".join(f"β’ {r}" for r in matched)
|
|
|
|
| 1377 |
# Anche: content scoring (keyword nel head del file, +1 per match vs +2 path).
|
| 1378 |
_goal_hint = (getattr(state, 'goal', '') or '')[:300]
|
| 1379 |
_step_hint = (
|
| 1380 |
+
(_goal_hint + ' ' + tool_results[:400]).lower()
|
| 1381 |
if tool_results
|
| 1382 |
else _goal_hint.lower()
|
| 1383 |
)
|
agents/unified_loop_tools.py
CHANGED
|
@@ -95,10 +95,6 @@ class DirectToolsMixin:
|
|
| 95 |
re.IGNORECASE,
|
| 96 |
)
|
| 97 |
|
| 98 |
-
_CURL_FALLBACK_RE = re.compile(
|
| 99 |
-
r"\b(curl|http|request|fetch|api|endpoint|get|post)\b",
|
| 100 |
-
re.IGNORECASE,
|
| 101 |
-
)
|
| 102 |
_IMAGE_GEN_INTENT_RE = re.compile(
|
| 103 |
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
|
| 104 |
# perchΓ© "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
|
|
@@ -181,14 +177,6 @@ class DirectToolsMixin:
|
|
| 181 |
return city
|
| 182 |
return ""
|
| 183 |
|
| 184 |
-
def _extract_curl_command(self, goal: str) -> str:
|
| 185 |
-
# Estrae un comando curl o un URL per il fallback
|
| 186 |
-
m = re.search(r"(curl\s+[^\"\'?]+)", goal, re.IGNORECASE)
|
| 187 |
-
if m: return m.group(1).strip()
|
| 188 |
-
m = re.search(r"(https?://[\w\d\-\./?=&%]+)", goal)
|
| 189 |
-
if m: return f"curl -s {m.group(1)}"
|
| 190 |
-
return ""
|
| 191 |
-
|
| 192 |
def _extract_search_query(self, goal: str) -> str:
|
| 193 |
m = self._SEARCH_QUERY_RE.search(goal)
|
| 194 |
if m:
|
|
@@ -448,25 +436,6 @@ class DirectToolsMixin:
|
|
| 448 |
except Exception as exc:
|
| 449 |
return f"[web_search: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 450 |
|
| 451 |
-
|
| 452 |
-
async def _t_curl_fallback() -> str | None:
|
| 453 |
-
# S-RECOVERY: fallback se curl Γ¨ menzionato o implicitamente utile
|
| 454 |
-
if not self._CURL_FALLBACK_RE.search(goal):
|
| 455 |
-
return None
|
| 456 |
-
cmd = self._extract_curl_command(goal)
|
| 457 |
-
if not cmd or not _gov_check("execute_shell", cmd):
|
| 458 |
-
return None
|
| 459 |
-
try:
|
| 460 |
-
if on_step:
|
| 461 |
-
await _maybe_await(on_step({"action": "tool_start", "status": "running",
|
| 462 |
-
"title": "Fallback: Shell/Curl", "explanation": f"Eseguo fallback: {cmd[:60]}..."}))
|
| 463 |
-
r = await asyncio.wait_for(TOOL_REGISTRY["execute_shell"]["_fn"](command=cmd), timeout=15)
|
| 464 |
-
if r.get("ok"):
|
| 465 |
-
return f"[FALLBACK CURL RIUSCITO]\nOutput:\n{r.get('stdout', '')[:1000]}"
|
| 466 |
-
return f"[fallback_curl: errore β {r.get('stderr', '')[:200]}]"
|
| 467 |
-
except Exception as exc:
|
| 468 |
-
return f"[fallback_curl: eccezione β {str(exc)[:200]}]"
|
| 469 |
-
|
| 470 |
async def _t_generate_image() -> str | None:
|
| 471 |
if not self._IMAGE_GEN_INTENT_RE.search(goal):
|
| 472 |
return None
|
|
@@ -564,14 +533,14 @@ class DirectToolsMixin:
|
|
| 564 |
if r.get("stderr"):
|
| 565 |
# S573: 200β400 β stderr spesso contiene tracebacks multi-riga
|
| 566 |
# S593: 400β600 β tracebacks Python possono superare 400 chars
|
| 567 |
-
return f"[run_python: stderr β {r['stderr'][:600]}]"
|
| 568 |
return None
|
| 569 |
except asyncio.TimeoutError:
|
| 570 |
return "[run_python: timeout 18s]"
|
| 571 |
except Exception as exc:
|
| 572 |
# S593: 200β300 β exception str puΓ² includere path + msg
|
| 573 |
# S600: 300β500 β parity con altri exception handler
|
| 574 |
-
return f"[run_python: errore β {str(exc)[:500]}]"
|
| 575 |
|
| 576 |
|
| 577 |
async def _t_web_research() -> str | None:
|
|
@@ -788,7 +757,7 @@ class DirectToolsMixin:
|
|
| 788 |
except Exception as _exc:
|
| 789 |
return f"[python_analyze: errore β {str(_exc)[:200]}]"
|
| 790 |
|
| 791 |
-
|
| 792 |
_sem_wrap(_t_get_weather()),
|
| 793 |
_sem_wrap(_t_read_page()),
|
| 794 |
_sem_wrap(_t_calculate()),
|
|
@@ -824,7 +793,7 @@ class DirectToolsMixin:
|
|
| 824 |
"[STRUTTURA PROGETTO", # S764: directory_tree
|
| 825 |
"[FILE TROVATI", # S764: file_search
|
| 826 |
"[NOTIZIE",
|
| 827 |
-
|
| 828 |
"[ANALISI PYTHON", # P30-B1: python_analyze
|
| 829 |
)
|
| 830 |
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
|
|
|
|
| 95 |
re.IGNORECASE,
|
| 96 |
)
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
_IMAGE_GEN_INTENT_RE = re.compile(
|
| 99 |
# S390-B-F: rimosso \b prima di (immagine|...) nel primo branch
|
| 100 |
# perchΓ© "unimmagine" (typo mobile italiano per "un'immagine") non ha word boundary
|
|
|
|
| 177 |
return city
|
| 178 |
return ""
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def _extract_search_query(self, goal: str) -> str:
|
| 181 |
m = self._SEARCH_QUERY_RE.search(goal)
|
| 182 |
if m:
|
|
|
|
| 436 |
except Exception as exc:
|
| 437 |
return f"[web_search: errore β {str(exc)[:300]}]" # S605: 200β300
|
| 438 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
async def _t_generate_image() -> str | None:
|
| 440 |
if not self._IMAGE_GEN_INTENT_RE.search(goal):
|
| 441 |
return None
|
|
|
|
| 533 |
if r.get("stderr"):
|
| 534 |
# S573: 200β400 β stderr spesso contiene tracebacks multi-riga
|
| 535 |
# S593: 400β600 β tracebacks Python possono superare 400 chars
|
| 536 |
+
return f"[run_python: stderr β {r['stderr'][:600]}]"
|
| 537 |
return None
|
| 538 |
except asyncio.TimeoutError:
|
| 539 |
return "[run_python: timeout 18s]"
|
| 540 |
except Exception as exc:
|
| 541 |
# S593: 200β300 β exception str puΓ² includere path + msg
|
| 542 |
# S600: 300β500 β parity con altri exception handler
|
| 543 |
+
return f"[run_python: errore β {str(exc)[:500]}]"
|
| 544 |
|
| 545 |
|
| 546 |
async def _t_web_research() -> str | None:
|
|
|
|
| 757 |
except Exception as _exc:
|
| 758 |
return f"[python_analyze: errore β {str(_exc)[:200]}]"
|
| 759 |
|
| 760 |
+
_parallel_results = await asyncio.gather(
|
| 761 |
_sem_wrap(_t_get_weather()),
|
| 762 |
_sem_wrap(_t_read_page()),
|
| 763 |
_sem_wrap(_t_calculate()),
|
|
|
|
| 793 |
"[STRUTTURA PROGETTO", # S764: directory_tree
|
| 794 |
"[FILE TROVATI", # S764: file_search
|
| 795 |
"[NOTIZIE",
|
| 796 |
+
"[STATO GIT", # S764: git_status
|
| 797 |
"[ANALISI PYTHON", # P30-B1: python_analyze
|
| 798 |
)
|
| 799 |
_n_success = sum(1 for r in results if any(r.startswith(p) for p in _REAL_DATA_PREFIXES))
|
agents/unified_loop_types.py
CHANGED
|
@@ -96,14 +96,10 @@ def _is_goal_ambiguous(goal: str) -> bool:
|
|
| 96 |
|
| 97 |
Un goal e ambiguo se ha meno di 5 parole reali E nessun verbo task riconoscibile.
|
| 98 |
Complementare a S-BENCH-REC-AMB: cattura goal brevi come help, aiutami, fix it.
|
| 99 |
-
Fix: domande (?) e goal con numeri (matematica) non sono ambigui.
|
| 100 |
"""
|
| 101 |
words = re.findall(r'\w+', goal)
|
| 102 |
if len(words) >= 5:
|
| 103 |
return False
|
| 104 |
-
g = goal.strip()
|
| 105 |
-
if g.endswith('?') or re.search(r'\d', goal):
|
| 106 |
-
return False
|
| 107 |
return not bool(_TASK_VERBS_RE.search(goal))
|
| 108 |
|
| 109 |
|
|
|
|
| 96 |
|
| 97 |
Un goal e ambiguo se ha meno di 5 parole reali E nessun verbo task riconoscibile.
|
| 98 |
Complementare a S-BENCH-REC-AMB: cattura goal brevi come help, aiutami, fix it.
|
|
|
|
| 99 |
"""
|
| 100 |
words = re.findall(r'\w+', goal)
|
| 101 |
if len(words) >= 5:
|
| 102 |
return False
|
|
|
|
|
|
|
|
|
|
| 103 |
return not bool(_TASK_VERBS_RE.search(goal))
|
| 104 |
|
| 105 |
|
api/agent.py
CHANGED
|
@@ -1,20 +1,1520 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
-
import
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
# Import dei sub-router modularizzati
|
| 8 |
-
from .agent_loop_routes import router as _loop_router
|
| 9 |
-
from .agent_task_routes import router as _task_router
|
| 10 |
-
from .agent_checkpoint_routes import router as _checkpoint_router
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
_logger = logging.getLogger("api.agent")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
-
# Inclusione dei sub-router
|
| 16 |
-
router.include_router(_loop_router)
|
| 17 |
-
router.include_router(_task_router)
|
| 18 |
-
router.include_router(_checkpoint_router)
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
71753
|
| 2 |
+
"""backend/api/agent.py β Agent tasks, SSE streaming, checkpoints, loops, kernel (S359, S369).
|
| 3 |
+
|
| 4 |
+
S358: stream_agent_task() usa _loop_registry per evitare re-run al reconnect SSE.
|
| 5 |
+
S359: persistenza su Supabase di task metadata + event buffer.
|
| 6 |
+
- Writes: fire-and-forget, non bloccano mai l'SSE.
|
| 7 |
+
- Reads: lazy restore SOLO quando la memoria Γ¨ vuota (dopo restart backend).
|
| 8 |
+
- Scenario restart HF Space β client riconnette β replay eventi da Supabase.
|
| 9 |
+
* Task SUCCESS/ERROR β replay completo + chiusura immediata.
|
| 10 |
+
* Task era RUNNING β replay parziale + evento task_interrupted (no token sprecati).
|
| 11 |
"""
|
| 12 |
+
import os, asyncio, json, uuid, time, re
|
| 13 |
+
|
| 14 |
+
# UTF-8 surrogate fix β Groq occasionally returns lone surrogates in emoji/special chars
|
| 15 |
+
# json.dumps raises UnicodeEncodeError for surrogates β SSE stream crashes, loop never completes
|
| 16 |
+
_RE_SURROGATES = re.compile(r"[οΏ½-οΏ½]", re.UNICODE)
|
| 17 |
+
def _ss(s: object) -> str:
|
| 18 |
+
"""GAP-SURR-FIX: strip lone UTF-16 surrogates + round-trip encode/decode.
|
| 19 |
+
I provider (es. Groq) possono spezzare emoji multi-byte su chunk consecutivi.
|
| 20 |
+
La regex rimuove surrogati isolati; il round-trip cattura byte invalidi residui."""
|
| 21 |
+
if not isinstance(s, str):
|
| 22 |
+
return s
|
| 23 |
+
try:
|
| 24 |
+
cleaned = _RE_SURROGATES.sub("", s)
|
| 25 |
+
cleaned = cleaned.encode("utf-8", errors="replace").decode("utf-8", errors="replace")
|
| 26 |
+
except Exception:
|
| 27 |
+
cleaned = s
|
| 28 |
+
return cleaned
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
def _sanitize_for_json(obj: object) -> object:
|
| 32 |
+
"""BUG-SSE-SURR: ricorsivamente applica _ss() su valori stringa prima
|
| 33 |
+
di json.dumps() β previene UnicodeEncodeError da surrogati Groq/LLM."""
|
| 34 |
+
if isinstance(obj, str):
|
| 35 |
+
return _ss(obj)
|
| 36 |
+
if isinstance(obj, dict):
|
| 37 |
+
return {k: _sanitize_for_json(v) for k, v in obj.items()}
|
| 38 |
+
if isinstance(obj, list):
|
| 39 |
+
return [_sanitize_for_json(x) for x in obj]
|
| 40 |
+
return obj
|
| 41 |
+
|
| 42 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Body
|
| 43 |
+
from fastapi.responses import StreamingResponse
|
| 44 |
+
from .auth_guard import require_role, AuthRole
|
| 45 |
+
from pydantic import BaseModel, field_validator
|
| 46 |
+
from typing import Literal
|
| 47 |
+
from .state import (
|
| 48 |
+
_agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks,
|
| 49 |
+
_prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
|
| 50 |
+
_get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client,
|
| 51 |
+
ReasonLoopIn, AgentTaskIn,
|
| 52 |
+
write_ahead_task_created, # WRITE-AHEAD: persist immediato alla creazione task
|
| 53 |
+
)
|
| 54 |
+
from .speculative import fire_speculative_tools
|
| 55 |
+
try:
|
| 56 |
+
from .quality_guardian import run_quality_check as _run_quality_check
|
| 57 |
+
except Exception:
|
| 58 |
+
_run_quality_check = None
|
| 59 |
+
import logging
|
| 60 |
_logger = logging.getLogger("api.agent")
|
| 61 |
+
|
| 62 |
+
from .persistence import (
|
| 63 |
+
sb_upsert_task, sb_update_status, sb_append_event,
|
| 64 |
+
sb_restore_task, sb_get_events, sb_delete_task_events,
|
| 65 |
+
sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint,
|
| 66 |
+
sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff, # BG-4
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def _log_task_exc(task): # GAP-2.6: log silently-dropped exceptions in fire-and-forget tasks
|
| 70 |
+
if not task.cancelled():
|
| 71 |
+
exc = task.exception()
|
| 72 |
+
if exc:
|
| 73 |
+
_logger.warning("[agent] background task raised %s: %s", type(exc).__name__, exc)
|
| 74 |
+
try:
|
| 75 |
+
from .telegram_notify import notify_task_done as _tg_done, notify_task_error as _tg_error, notify_task_start as _tg_start, notify_task_step as _tg_step
|
| 76 |
+
except Exception:
|
| 77 |
+
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
|
| 78 |
+
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 79 |
+
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 80 |
+
async def _tg_step(*_a, **_kw): pass # type: ignore[misc]
|
| 81 |
+
|
| 82 |
router = APIRouter()
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
+
# ββ Deprecated run_loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
@router.post('/run_loop', deprecated=True)
|
| 88 |
+
async def run_loop():
|
| 89 |
+
"""Deprecated β use /agent/task instead."""
|
| 90 |
+
from fastapi import HTTPException
|
| 91 |
+
raise HTTPException(status_code=410, detail="run_loop is deprecated. Use /agent/task.")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# βββ P17-F5: Persona helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
import re as _re_persona
|
| 96 |
+
|
| 97 |
+
_PERSONA_KEYWORD_MAP: dict = {}
|
| 98 |
+
|
| 99 |
+
def _build_persona_kw_map() -> dict:
|
| 100 |
+
import re
|
| 101 |
+
return {
|
| 102 |
+
'researcher': re.compile(
|
| 103 |
+
r'\b(cerca|ricerca|research|trova|notizie|news|url|leggi|articolo|wikipedia|'
|
| 104 |
+
r'google|fonte|source|scrape|fetch|sito|pagina|web|http|verifica|fact.?check)\b',
|
| 105 |
+
re.IGNORECASE
|
| 106 |
+
),
|
| 107 |
+
'coder': re.compile(
|
| 108 |
+
r'\b(codice|code|funzione|function|bug|script|implementa|python|javascript|'
|
| 109 |
+
r'typescript|refactor|debug|test|classe|class|api|endpoint|sql|database|html|'
|
| 110 |
+
r'css|react|app|applicazione|programma|sviluppa)\b',
|
| 111 |
+
re.IGNORECASE
|
| 112 |
+
),
|
| 113 |
+
'reasoner': re.compile(
|
| 114 |
+
r'\b(analizza|pianifica|strategia|decide|ragiona|valuta|confronta|'
|
| 115 |
+
r'piano|roadmap|architettura|valutazione|decisione|ottimale|consiglia)\b',
|
| 116 |
+
re.IGNORECASE
|
| 117 |
+
),
|
| 118 |
+
'analyst': re.compile(
|
| 119 |
+
r'\b(dati|statistiche|grafico|dataset|csv|dataframe|pandas|matplotlib|'
|
| 120 |
+
r'metriche|kpi|trend|visualizza|dashboard|excel|tabella|percentuale|distribuzione)\b',
|
| 121 |
+
re.IGNORECASE
|
| 122 |
+
),
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
def _classify_persona_server(goal: str) -> str:
|
| 126 |
+
"""P17-F5: classifica la persona dal goal via regex scoring. Zero LLM β zero latency."""
|
| 127 |
+
global _PERSONA_KEYWORD_MAP
|
| 128 |
+
if not _PERSONA_KEYWORD_MAP:
|
| 129 |
+
_PERSONA_KEYWORD_MAP = _build_persona_kw_map()
|
| 130 |
+
if not goal or len(goal) < 4:
|
| 131 |
+
return ''
|
| 132 |
+
best, best_score = '', 0
|
| 133 |
+
for persona_id, pattern in _PERSONA_KEYWORD_MAP.items():
|
| 134 |
+
score = len(pattern.findall(goal))
|
| 135 |
+
if score > best_score:
|
| 136 |
+
best_score, best = score, persona_id
|
| 137 |
+
return best if best_score >= 1 else ''
|
| 138 |
+
|
| 139 |
+
_PERSONA_CLIENT_CACHE: dict = {}
|
| 140 |
+
|
| 141 |
+
def _get_persona_llm_client(persona: str, default_client: object) -> object:
|
| 142 |
+
"""P17-F5: ritorna il client LLM persona-appropriate via role_router.
|
| 143 |
+
Fallback silente su default_client se la chiave API manca o role_router fallisce.
|
| 144 |
+
Cache in-process β zero overhead dopo il primo accesso.""";
|
| 145 |
+
if not persona:
|
| 146 |
+
return default_client
|
| 147 |
+
if persona in _PERSONA_CLIENT_CACHE:
|
| 148 |
+
return _PERSONA_CLIENT_CACHE[persona]
|
| 149 |
+
_ROLE_MAP = {'researcher': 'RESEARCHER', 'analyst': 'RESEARCHER',
|
| 150 |
+
'coder': 'CODER', 'reasoner': 'REASONER', 'architect': 'ARCHITECT'}
|
| 151 |
+
role_name = _ROLE_MAP.get(persona.lower())
|
| 152 |
+
if not role_name:
|
| 153 |
+
return default_client
|
| 154 |
+
try:
|
| 155 |
+
from models.role_router import RoleRouter, Role as _Role
|
| 156 |
+
role = getattr(_Role, role_name, None)
|
| 157 |
+
if role is None:
|
| 158 |
+
return default_client
|
| 159 |
+
client = RoleRouter.get_client(role)
|
| 160 |
+
_PERSONA_CLIENT_CACHE[persona] = client
|
| 161 |
+
return client
|
| 162 |
+
except Exception:
|
| 163 |
+
return default_client
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def run_loop_removed():
|
| 167 |
+
"""S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream."""
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=410,
|
| 170 |
+
detail={
|
| 171 |
+
"error": "Gone",
|
| 172 |
+
"message": "Endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream",
|
| 173 |
+
"migration": "/api/agent/tasks",
|
| 174 |
+
},
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ββ SSE run-stream ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 179 |
+
|
| 180 |
+
@router.post('/api/agent/run-stream')
|
| 181 |
+
async def agent_run_stream(
|
| 182 |
+
body: ReasonLoopIn, request: Request,
|
| 183 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
|
| 184 |
+
):
|
| 185 |
+
async def generate():
|
| 186 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 187 |
+
|
| 188 |
+
async def step_cb(step: dict) -> None:
|
| 189 |
+
await queue.put(step)
|
| 190 |
+
|
| 191 |
+
async def run_loop() -> None:
|
| 192 |
+
try:
|
| 193 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 194 |
+
# S388: usa singleton _get_ai_client() β nessuna re-istanziazione OpenAI() per request
|
| 195 |
+
client = _get_ai_client()
|
| 196 |
+
try:
|
| 197 |
+
from agents.critic import Critic
|
| 198 |
+
from agents.response_verifier import ResponseVerifier
|
| 199 |
+
_critic = Critic(llm_client=client)
|
| 200 |
+
_verifier = ResponseVerifier()
|
| 201 |
+
except Exception:
|
| 202 |
+
_critic = None
|
| 203 |
+
_verifier = None
|
| 204 |
+
# Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through)
|
| 205 |
+
_resume_ctx = getattr(body, '_resume_context', None)
|
| 206 |
+
_resume_max = getattr(body, '_resume_max_steps', None) or body.max_steps
|
| 207 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 208 |
+
# Bug-5-FIX: resume context iniettato DOPO che context_str Γ¨ definito (era NameError)
|
| 209 |
+
if _resume_ctx:
|
| 210 |
+
context_str = f"[RIPRESA AUTOMATICA]\n{_resume_ctx}\n\n{context_str}".strip()
|
| 211 |
+
|
| 212 |
+
loop = UnifiedAgentLoop(
|
| 213 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 214 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 215 |
+
)
|
| 216 |
+
# S456-X5: prepend project context (projectMemory.getContext() dal frontend)
|
| 217 |
+
if body.project_context:
|
| 218 |
+
context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip()
|
| 219 |
+
# S456-X4: inject top failure patterns appresi dal selfLearning frontend
|
| 220 |
+
if body.learning_hints:
|
| 221 |
+
# S591: learning_hints[:3]β[:5] β piΓΉ pattern appresi nel context
|
| 222 |
+
hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5])
|
| 223 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 224 |
+
# P35: vincoli negativi dal frontend (agentConstraints.ts β VFS /.agent/constraints.json)
|
| 225 |
+
_neg_c = getattr(body, 'negative_constraints', '') or ''
|
| 226 |
+
if _neg_c:
|
| 227 |
+
context_str = f"[VINCOLI OPERATIVI APPRESI β NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
|
| 228 |
+
result = await loop.run(
|
| 229 |
+
goal=body.goal, context=context_str,
|
| 230 |
+
max_steps=body.max_steps, on_step=step_cb,
|
| 231 |
+
session_id=getattr(body, "session_id", "") or "",
|
| 232 |
+
)
|
| 233 |
+
await queue.put({
|
| 234 |
+
'__done__': True,
|
| 235 |
+
'result': result.get('output', ''),
|
| 236 |
+
'engine': result.get('engine', 'fallback'),
|
| 237 |
+
'success': result.get('success', False),
|
| 238 |
+
})
|
| 239 |
+
except Exception as exc:
|
| 240 |
+
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
| 241 |
+
try:
|
| 242 |
+
from api.incident_registry import log_incident as _log_inc
|
| 243 |
+
asyncio.create_task(_log_inc(
|
| 244 |
+
task_id=body.goal[:32].replace(' ', '_'),
|
| 245 |
+
goal=body.goal, error=str(exc), source="agent",
|
| 246 |
+
)).add_done_callback(_log_task_exc)
|
| 247 |
+
except Exception as _exc:
|
| 248 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 249 |
+
await queue.put({'__error__': str(exc)})
|
| 250 |
+
|
| 251 |
+
task = asyncio.create_task(run_loop())
|
| 252 |
+
task.add_done_callback(_log_task_exc) # BUG-CB-1
|
| 253 |
+
task_id = str(uuid.uuid4())
|
| 254 |
+
# ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
|
| 255 |
+
_run_stream_tasks[task_id] = {"task": task, "queue": queue}
|
| 256 |
+
yield "retry: 3000\n\n"
|
| 257 |
+
yield f"data: {json.dumps({'type': 'task_start', 'taskId': task_id})}\n\n"
|
| 258 |
+
|
| 259 |
+
# S386: fast-fail β se tutti i provider sono down (heartbeat lo sa giΓ ),
|
| 260 |
+
# non aspettare 120s di tentativi: rispondi subito con errore chiaro.
|
| 261 |
+
try:
|
| 262 |
+
from api.state import _heartbeat_state
|
| 263 |
+
_providers = _heartbeat_state.get("providers", [])
|
| 264 |
+
if _providers and not any(p.get("ok") for p in _providers):
|
| 265 |
+
task.cancel()
|
| 266 |
+
_names = ", ".join(p["name"] for p in _providers)
|
| 267 |
+
yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': f'Nessun provider AI disponibile al momento ({_names}). Riprova tra qualche minuto.'})}\n\n"
|
| 268 |
+
yield "data: [DONE]\n\n"
|
| 269 |
+
return
|
| 270 |
+
except Exception:
|
| 271 |
+
pass # se heartbeat non Γ¨ inizializzato, prosegui normalmente
|
| 272 |
+
|
| 273 |
+
# S386: timeout ridotto 120β60s β risposta entro 1 minuto o errore esplicito
|
| 274 |
+
timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60'))
|
| 275 |
+
heartbeat_secs = 15.0
|
| 276 |
+
elapsed = 0.0
|
| 277 |
+
try:
|
| 278 |
+
while True:
|
| 279 |
+
try:
|
| 280 |
+
item = await asyncio.wait_for(queue.get(), timeout=heartbeat_secs)
|
| 281 |
+
elapsed = 0.0
|
| 282 |
+
except asyncio.TimeoutError:
|
| 283 |
+
elapsed += heartbeat_secs
|
| 284 |
+
if elapsed >= timeout_secs:
|
| 285 |
+
yield f"data: {json.dumps({'type': 'task_error', 'error': 'stream timeout'})}\n\n"
|
| 286 |
+
break
|
| 287 |
+
yield 'data: {"type":"ping"}\n\n'
|
| 288 |
+
continue
|
| 289 |
+
# ABORT-2: segnale abort dall'endpoint POST /api/agent/abort
|
| 290 |
+
if "__abort__" in item:
|
| 291 |
+
yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id})}\n\n"
|
| 292 |
+
break
|
| 293 |
+
if '__error__' in item:
|
| 294 |
+
yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': _ss(item['__error__'])})}\n\n"
|
| 295 |
+
break
|
| 296 |
+
# S420: streaming token β emetti subito al frontend senza accumulare
|
| 297 |
+
if item.get('action') == 'text_chunk':
|
| 298 |
+
yield f"data: {json.dumps({'type': 'text_chunk', 'token': _ss(item.get('token', '')), 'taskId': task_id})}\n\n"
|
| 299 |
+
continue
|
| 300 |
+
# S758-P4.1: tool_use β chip pre-esecuzione (agent_run_stream path)
|
| 301 |
+
_rs_act = item.get('action', '')
|
| 302 |
+
_rs_st = item.get('status', '')
|
| 303 |
+
if ((_rs_act == 'tool_start' and _rs_st == 'running') or
|
| 304 |
+
(_rs_act.startswith('executor:') and _rs_st == 'started')):
|
| 305 |
+
_rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act
|
| 306 |
+
yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': _ss(str(item.get('title', _rs_tool.replace('_', ' ').capitalize())))})}\n\n" # BUG-SSE-SURR
|
| 307 |
+
if '__done__' in item:
|
| 308 |
+
yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n"
|
| 309 |
+
break
|
| 310 |
+
# S393 Priority 1: Narrative Streaming β arricchisce step_done con explanation
|
| 311 |
+
_NARR_QUICK = {
|
| 312 |
+
'llm': 'Elaborazione risposta AI',
|
| 313 |
+
'direct_tools': 'Strumenti diretti',
|
| 314 |
+
'web_search': 'Ricerca web', 'get_weather': 'Dati meteo',
|
| 315 |
+
'read_page': 'Lettura pagina', 'calculate': 'Calcolo matematico',
|
| 316 |
+
'generate_image': 'Generazione immagine AI',
|
| 317 |
+
'execution_validator_fix': 'Auto-correzione codice (S393)',
|
| 318 |
+
'tool_governor_skip': 'Tool giΓ eseguito β risultato riutilizzato',
|
| 319 |
+
# S661: label narrative per tool aggiunti in S648-S659 β prima usavano
|
| 320 |
+
# _act_q.replace('_',' ').capitalize() β "Apply patch", "Call api" (generico)
|
| 321 |
+
'apply_patch': 'Applico patch al fileβ¦',
|
| 322 |
+
'call_api': 'Chiamo API RESTβ¦',
|
| 323 |
+
'send_email': 'Invio emailβ¦',
|
| 324 |
+
'create_pdf': 'Genero documento PDFβ¦',
|
| 325 |
+
'web_research': 'Ricerca multi-fonteβ¦',
|
| 326 |
+
'write_file': 'Scrivo fileβ¦',
|
| 327 |
+
'read_file': 'Leggo fileβ¦',
|
| 328 |
+
'execute_shell': 'Eseguo comando shellβ¦',
|
| 329 |
+
'analyze_image': 'Analizzo immagineβ¦',
|
| 330 |
+
'run_python': 'Eseguo Python (Pyodide)β¦',
|
| 331 |
+
# S-GAP1: narrative fasi strategiche
|
| 332 |
+
'plan': 'Analizzo la richiesta e preparo un piano di esecuzioneβ¦',
|
| 333 |
+
'reflective_debug': 'Ho incontrato un ostacolo β ricalcolo una strategia piΓΉ efficienteβ¦',
|
| 334 |
+
'fallback': 'Adotto un approccio alternativo per completare il taskβ¦',
|
| 335 |
+
'smolagents': 'Orchestro gli strumenti necessariβ¦',
|
| 336 |
+
}
|
| 337 |
+
_act_q = item.get('action', '')
|
| 338 |
+
if 'explanation' not in item:
|
| 339 |
+
item['explanation'] = _NARR_QUICK.get(_act_q, _act_q.replace('_', ' ').capitalize())
|
| 340 |
+
if 'title' not in item:
|
| 341 |
+
item['title'] = item['explanation']
|
| 342 |
+
|
| 343 |
+
# S403: SSE Visibility Guard β classifica ogni step event:
|
| 344 |
+
# "internal" β mai visibile (pipeline internals: planner, llm, reflection)
|
| 345 |
+
# "progress" β visibile come progress card (tool reali, auto-fix)
|
| 346 |
+
# "debug" β visibile solo in dev mode (direct_tools, fast_path)
|
| 347 |
+
# Il frontend filtra per visibility β solo "progress" mostrato all'utente.
|
| 348 |
+
_STEP_VISIBILITY: dict[str, str] = {
|
| 349 |
+
# Internal pipeline β never shown to user
|
| 350 |
+
'plan': 'progress', # S-GAP1
|
| 351 |
+
'llm': 'internal',
|
| 352 |
+
'smolagents': 'internal',
|
| 353 |
+
'fallback': 'progress', # S-GAP1
|
| 354 |
+
'reflective_debug': 'progress', # S-GAP1
|
| 355 |
+
'fast_path': 'internal',
|
| 356 |
+
'executor': 'internal',
|
| 357 |
+
# Progress β shown as step cards (user-visible)
|
| 358 |
+
'tool_start': 'progress',
|
| 359 |
+
'execution_validator_fix': 'progress',
|
| 360 |
+
'goal_verifier': 'progress',
|
| 361 |
+
'web_search': 'progress',
|
| 362 |
+
'get_weather': 'progress',
|
| 363 |
+
'read_page': 'progress',
|
| 364 |
+
'calculate': 'progress',
|
| 365 |
+
'generate_image': 'progress',
|
| 366 |
+
'run_python': 'progress',
|
| 367 |
+
'tool_governor_skip': 'progress',
|
| 368 |
+
# S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY β
|
| 369 |
+
# fallback rule: _act_q.startswith('tool_') era False per questi β
|
| 370 |
+
# classificati 'debug' β nascosti all'utente durante esecuzione.
|
| 371 |
+
'apply_patch': 'progress',
|
| 372 |
+
'call_api': 'progress',
|
| 373 |
+
'send_email': 'progress',
|
| 374 |
+
'create_pdf': 'progress',
|
| 375 |
+
'web_research': 'progress',
|
| 376 |
+
'write_file': 'progress',
|
| 377 |
+
'read_file': 'progress',
|
| 378 |
+
'execute_shell': 'progress',
|
| 379 |
+
'analyze_image': 'progress',
|
| 380 |
+
# Debug β shown only when devMode active
|
| 381 |
+
'direct_tools': 'debug',
|
| 382 |
+
# S-LOOP2: fase esecuzione avanzata β visibili come progress card
|
| 383 |
+
'reasoning_core': 'progress', # S-LOOP2: ReasoningCore multi-step
|
| 384 |
+
'browser_verifier': 'progress', # S-LOOP2: Browser Goal Verification live
|
| 385 |
+
}
|
| 386 |
+
# Fallback: azioni sconosciute con "tool_" prefix β progress; resto β debug
|
| 387 |
+
_vis = _STEP_VISIBILITY.get(_act_q)
|
| 388 |
+
if _vis is None:
|
| 389 |
+
_vis = 'progress' if _act_q.startswith('tool_') or _act_q.startswith('executor:') else 'debug'
|
| 390 |
+
item['visibility'] = _vis
|
| 391 |
+
|
| 392 |
+
yield f"data: {json.dumps({'type': 'step_done', 'step': _sanitize_for_json(item), 'taskId': task_id})}\n\n" # BUG-SSE-SURR
|
| 393 |
+
finally:
|
| 394 |
+
task.cancel()
|
| 395 |
+
# ABORT-3: cleanup registro β libera memoria e impedisce abort su task giΓ terminati
|
| 396 |
+
_run_stream_tasks.pop(task_id, None)
|
| 397 |
+
yield "data: [DONE]\n\n"
|
| 398 |
+
|
| 399 |
+
return StreamingResponse(generate(), media_type="text/event-stream",
|
| 400 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"})
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ββ Reason loop / Unified loop βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 404 |
+
|
| 405 |
+
@router.post('/api/reason/loop')
|
| 406 |
+
async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 407 |
+
try:
|
| 408 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 409 |
+
# S388: singleton β riusa il client giΓ inizializzato
|
| 410 |
+
client = _get_ai_client()
|
| 411 |
+
try:
|
| 412 |
+
from agents.critic import Critic
|
| 413 |
+
from agents.response_verifier import ResponseVerifier
|
| 414 |
+
_critic = Critic(llm_client=client)
|
| 415 |
+
_verifier = ResponseVerifier()
|
| 416 |
+
except Exception:
|
| 417 |
+
_critic = None
|
| 418 |
+
_verifier = None
|
| 419 |
+
loop = UnifiedAgentLoop(
|
| 420 |
+
llm_client=client, critic=_critic, verifier=_verifier,
|
| 421 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 422 |
+
)
|
| 423 |
+
context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
|
| 424 |
+
# N-2-FIX: accumula step intermedi tramite on_step β inclusi nel response JSON per debug frontend
|
| 425 |
+
_steps_log: list[dict] = []
|
| 426 |
+
async def _on_step(step_data: dict) -> None:
|
| 427 |
+
_steps_log.append({
|
| 428 |
+
'action': step_data.get('action', ''),
|
| 429 |
+
'output': str(step_data.get('output', ''))[:400], # S577: 200β400
|
| 430 |
+
})
|
| 431 |
+
result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
|
| 432 |
+
if isinstance(result, dict):
|
| 433 |
+
output_text = result.get('output', '') or ''
|
| 434 |
+
engine_used = result.get('engine', 'unknown')
|
| 435 |
+
errors_list = result.get('errors', [])
|
| 436 |
+
else:
|
| 437 |
+
output_text = str(result)
|
| 438 |
+
engine_used = 'unknown'
|
| 439 |
+
errors_list = []
|
| 440 |
+
return {
|
| 441 |
+
'ok': bool(output_text and output_text.strip()),
|
| 442 |
+
'success': bool(output_text and output_text.strip()), # alias compat frontend
|
| 443 |
+
'output': output_text, # alias compat frontend
|
| 444 |
+
'result': output_text,
|
| 445 |
+
'source': 'backend_loop',
|
| 446 |
+
'engine': engine_used,
|
| 447 |
+
'errors': errors_list,
|
| 448 |
+
'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
|
| 449 |
+
}
|
| 450 |
+
except Exception as e:
|
| 451 |
+
_logger.error("[reason/loop] Error: %s", e)
|
| 452 |
+
return {
|
| 453 |
+
'ok': False,
|
| 454 |
+
'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
|
| 455 |
+
'source': 'fallback',
|
| 456 |
+
'steps': [],
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
@router.post('/api/unified/loop')
|
| 461 |
+
async def unified_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: alias che chiama reason_loop direttamente β Depends NON si propagano
|
| 462 |
+
"""Alias di /api/reason/loop β compatibilitΓ con tutte le versioni frontend."""
|
| 463 |
+
return await reason_loop(body)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# ββ Agent kernel ββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 467 |
+
|
| 468 |
+
@router.get('/api/agent-kernel/status')
|
| 469 |
+
async def agent_kernel_status(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 470 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 471 |
+
return {
|
| 472 |
+
'dispatch_available': bool(gh_token),
|
| 473 |
+
'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
|
| 474 |
+
'mobile_url': 'https://github.com/Baida98/AI/actions',
|
| 475 |
+
'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN', 'NVIDIA_API_KEY'],
|
| 476 |
+
'usage': 'Vai su GitHub Actions β Agent Kernel β no PC β Run workflow β inserisci il goal',
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
# S442-FIX3: modello Pydantic per agent_kernel_dispatch.
|
| 481 |
+
# Prima: body: dict grezzo β mode non validato, goal controllato solo dopo estrazione.
|
| 482 |
+
# Ora: validazione in ingresso β 422 chiaro invece di 500 a runtime.
|
| 483 |
+
class AgentKernelDispatchIn(BaseModel):
|
| 484 |
+
goal: str
|
| 485 |
+
mode: Literal["plan", "execute", "analyze"] = "plan"
|
| 486 |
+
|
| 487 |
+
@field_validator('goal', mode='before')
|
| 488 |
+
@classmethod
|
| 489 |
+
def validate_goal(cls, v: object) -> str:
|
| 490 |
+
if not isinstance(v, str) or not str(v).strip():
|
| 491 |
+
raise ValueError('goal must be a non-empty string')
|
| 492 |
+
return str(v).strip()
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@router.post('/api/agent-kernel/dispatch')
|
| 496 |
+
async def agent_kernel_dispatch(body: AgentKernelDispatchIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 497 |
+
gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
|
| 498 |
+
if not gh_token:
|
| 499 |
+
raise HTTPException(503, detail={
|
| 500 |
+
'error': 'no_github_token',
|
| 501 |
+
'message': 'GITHUB_TOKEN non configurato nel backend.',
|
| 502 |
+
})
|
| 503 |
+
goal = body.goal
|
| 504 |
+
mode = body.mode
|
| 505 |
+
import httpx as _httpx
|
| 506 |
+
try:
|
| 507 |
+
async with _httpx.AsyncClient(timeout=15) as _hc:
|
| 508 |
+
_resp = await _hc.post(
|
| 509 |
+
'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
|
| 510 |
+
json={'ref': 'main', 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'}},
|
| 511 |
+
headers={
|
| 512 |
+
'Authorization': f'Bearer {gh_token}',
|
| 513 |
+
'Accept': 'application/vnd.github+json',
|
| 514 |
+
'X-GitHub-Api-Version': '2022-11-28',
|
| 515 |
+
},
|
| 516 |
+
)
|
| 517 |
+
if _resp.status_code >= 400:
|
| 518 |
+
raise HTTPException(_resp.status_code, detail=_resp.text[:500])
|
| 519 |
+
return {'ok': True, 'status': _resp.status_code, 'goal': goal, 'mode': mode}
|
| 520 |
+
except _httpx.HTTPError as e:
|
| 521 |
+
raise HTTPException(502, detail=str(e)[:500])
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
# ββ Agent tasks (FASE 2.1 + S359 persistence) ββββββββββββββββββββββββββββββββββ
|
| 525 |
+
|
| 526 |
+
@router.post('/api/agent/tasks')
|
| 527 |
+
async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 528 |
+
"""
|
| 529 |
+
Crea o recupera un task agent.
|
| 530 |
+
|
| 531 |
+
S359: se task_id non Γ¨ in memoria ma esiste su Supabase (backend ha riavviato),
|
| 532 |
+
il task viene ripristinato dallo store persistente invece di essere riavviato.
|
| 533 |
+
Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
|
| 534 |
+
"""
|
| 535 |
+
_prune_agent_tasks()
|
| 536 |
+
task_id = body.taskId or str(uuid.uuid4())
|
| 537 |
+
|
| 538 |
+
# Already in memory β return immediately (normal path, includes S358 reconnect)
|
| 539 |
+
if task_id in _agent_tasks:
|
| 540 |
+
return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
|
| 541 |
+
|
| 542 |
+
# S359: try Supabase lazy restore (only hit network after backend restart)
|
| 543 |
+
restored = await sb_restore_task(task_id)
|
| 544 |
+
if restored:
|
| 545 |
+
# Put restored metadata back into memory so stream_agent_task can use it.
|
| 546 |
+
# Use context from the incoming request (not persisted to save space).
|
| 547 |
+
restored['context'] = body.context
|
| 548 |
+
_agent_tasks[task_id] = restored
|
| 549 |
+
return {'taskId': task_id, 'status': restored['status'], 'restored': True}
|
| 550 |
+
|
| 551 |
+
# Brand new task
|
| 552 |
+
created_at = int(time.time() * 1000)
|
| 553 |
+
_agent_tasks[task_id] = {
|
| 554 |
+
'id': task_id,
|
| 555 |
+
'status': 'QUEUED',
|
| 556 |
+
'goal': body.goal,
|
| 557 |
+
'context': body.context,
|
| 558 |
+
'max_steps': body.max_steps,
|
| 559 |
+
'created_at': created_at,
|
| 560 |
+
'project_context': body.project_context, # S456-X5
|
| 561 |
+
'learning_hints': body.learning_hints, # S456-X4
|
| 562 |
+
'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
|
| 563 |
+
'persona': body.persona, # P17-F5: expertise persona hint
|
| 564 |
+
'session_id': body.session_id or '', # P17-F2: BB session key (normalize Noneβ'')
|
| 565 |
+
}
|
| 566 |
+
# WRITE-AHEAD: persiste il task su Supabase immediatamente, prima del checkpoint
|
| 567 |
+
# periodico (15-60s). Finestra di perdita per la fase di creazione β zero.
|
| 568 |
+
asyncio.create_task(write_ahead_task_created(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 569 |
+
# BG-4: restore cross-session handoff context (async, non-blocking)
|
| 570 |
+
if body.session_id:
|
| 571 |
+
_hctx = await sb_restore_handoff_context(body.session_id)
|
| 572 |
+
if _hctx:
|
| 573 |
+
_agent_tasks[task_id]['_handoff_context'] = _hctx
|
| 574 |
+
asyncio.create_task(sb_delete_handoff(body.session_id)).add_done_callback(_log_task_exc)
|
| 575 |
+
# Persist asynchronously β never block the response
|
| 576 |
+
asyncio.create_task(
|
| 577 |
+
sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
|
| 578 |
+
).add_done_callback(_log_task_exc)
|
| 579 |
+
# S361: Speculative Tool Firing β pre-fires read-only tools in parallel
|
| 580 |
+
# while the main model processes. Results cached for _run_direct_tools to consume.
|
| 581 |
+
asyncio.create_task(fire_speculative_tools(task_id, body.goal)).add_done_callback(_log_task_exc)
|
| 582 |
+
return {'taskId': task_id, 'status': 'QUEUED'}
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
# ββ S369: List agent tasks (in-memory + Supabase merge) βββββββββββββββββββββ
|
| 586 |
+
|
| 587 |
+
@router.get('/api/agent/tasks')
|
| 588 |
+
async def list_agent_tasks(limit: int = 50, status: str = '', role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 589 |
+
"""
|
| 590 |
+
S369 β Lista tutti i task agent: unione di in-memory (_agent_tasks) e
|
| 591 |
+
Supabase (ultimi N task persistiti). In-memory ha sempre precedenza.
|
| 592 |
+
|
| 593 |
+
Query params:
|
| 594 |
+
limit β max task da Supabase (default 50, max 200)
|
| 595 |
+
status β filtra per status (es. RUNNING, SUCCESS, ERROR); vuoto = tutti
|
| 596 |
+
"""
|
| 597 |
+
_prune_agent_tasks()
|
| 598 |
+
now_ms = int(time.time() * 1000)
|
| 599 |
+
limit = min(max(limit, 1), 200)
|
| 600 |
+
|
| 601 |
+
# 1. Task in-memory (live)
|
| 602 |
+
mem_tasks = []
|
| 603 |
+
for tid, t in _agent_tasks.items():
|
| 604 |
+
reg = _loop_registry.get(tid)
|
| 605 |
+
is_live = reg is not None and not reg.get('done', True)
|
| 606 |
+
mem_tasks.append({
|
| 607 |
+
'taskId': tid,
|
| 608 |
+
'goal': (t.get('goal') or '')[:300], # S606: 200β300
|
| 609 |
+
'status': t.get('status', 'UNKNOWN'),
|
| 610 |
+
'maxSteps': t.get('max_steps', 8),
|
| 611 |
+
'createdAt': t.get('created_at', 0),
|
| 612 |
+
'ageMs': now_ms - t.get('created_at', now_ms),
|
| 613 |
+
'source': 'memory',
|
| 614 |
+
'isLive': is_live,
|
| 615 |
+
})
|
| 616 |
+
|
| 617 |
+
mem_ids = {t['taskId'] for t in mem_tasks}
|
| 618 |
+
|
| 619 |
+
# 2. Supabase recent tasks (only if Supabase available)
|
| 620 |
+
sb_tasks = []
|
| 621 |
+
try:
|
| 622 |
+
sb_rows = await sb_list_tasks(limit=limit, status_filter=status or None)
|
| 623 |
+
for r in sb_rows:
|
| 624 |
+
if r['task_id'] in mem_ids:
|
| 625 |
+
continue # already included from memory
|
| 626 |
+
sb_tasks.append({
|
| 627 |
+
'taskId': r['task_id'],
|
| 628 |
+
'goal': (r.get('goal') or '')[:300], # S606: 200β300
|
| 629 |
+
'status': r.get('status', 'UNKNOWN'),
|
| 630 |
+
'maxSteps': r.get('max_steps', 8),
|
| 631 |
+
'createdAt': r.get('created_at', 0),
|
| 632 |
+
'ageMs': now_ms - r.get('created_at', now_ms),
|
| 633 |
+
'source': 'supabase',
|
| 634 |
+
'isLive': False,
|
| 635 |
+
})
|
| 636 |
+
except Exception as _exc:
|
| 637 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 638 |
+
|
| 639 |
+
all_tasks = mem_tasks + sb_tasks
|
| 640 |
+
# Apply status filter to in-memory tasks too
|
| 641 |
+
if status:
|
| 642 |
+
all_tasks = [t for t in all_tasks if t['status'] == status.upper()]
|
| 643 |
+
|
| 644 |
+
# Sort by createdAt desc (newest first)
|
| 645 |
+
all_tasks.sort(key=lambda t: t['createdAt'], reverse=True)
|
| 646 |
+
|
| 647 |
+
return {
|
| 648 |
+
'count': len(all_tasks),
|
| 649 |
+
'memory': len(mem_tasks),
|
| 650 |
+
'supabase': len(sb_tasks),
|
| 651 |
+
'tasks': all_tasks[:limit],
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
@router.delete('/api/agent/tasks/{task_id}')
|
| 656 |
+
async def cancel_agent_task(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 657 |
+
if task_id in _agent_tasks:
|
| 658 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 659 |
+
reg = _loop_registry.get(task_id)
|
| 660 |
+
if reg and not reg.get('done'):
|
| 661 |
+
at = reg.get('asyncio_task')
|
| 662 |
+
if at and not at.done():
|
| 663 |
+
at.cancel()
|
| 664 |
+
# Persist status + clean up events
|
| 665 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 666 |
+
asyncio.create_task(sb_delete_task_events(task_id)).add_done_callback(_log_task_exc)
|
| 667 |
+
# S361: clean speculative cache for cancelled task
|
| 668 |
+
try:
|
| 669 |
+
goal = _agent_tasks.get(task_id, {}).get('goal', '')
|
| 670 |
+
if goal:
|
| 671 |
+
from .speculative import purge_speculative
|
| 672 |
+
purge_speculative(goal)
|
| 673 |
+
except Exception as _exc:
|
| 674 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 675 |
+
return {'cancelled': task_id}
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
@router.get('/api/agent/tasks/{task_id}/status')
|
| 680 |
+
async def get_agent_task_status(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 681 |
+
"""
|
| 682 |
+
Controlla lo stato di un task agent senza aprire un SSE stream.
|
| 683 |
+
Usato dal frontend per recovery al boot: verifica se un task in sospeso
|
| 684 |
+
e` ancora in esecuzione, completato, o scomparso dopo riavvio HF Space.
|
| 685 |
+
Returns: {taskId, status, goal, source: 'memory'|'supabase'|'not_found'}
|
| 686 |
+
"""
|
| 687 |
+
if task_id in _agent_tasks:
|
| 688 |
+
t = _agent_tasks[task_id]
|
| 689 |
+
return {'taskId': task_id, 'status': t.get('status', 'UNKNOWN'),
|
| 690 |
+
'goal': (t.get('goal') or '')[:300], 'source': 'memory'}
|
| 691 |
+
restored = await sb_restore_task(task_id)
|
| 692 |
+
if restored:
|
| 693 |
+
return {'taskId': task_id, 'status': restored.get('status', 'UNKNOWN'),
|
| 694 |
+
'goal': (restored.get('goal') or '')[:300], 'source': 'supabase'}
|
| 695 |
+
return {'taskId': task_id, 'status': 'NOT_FOUND', 'source': None}
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
@router.get('/api/agent/tasks/{task_id}/stream')
|
| 699 |
+
async def stream_agent_task(task_id: str, request: Request, resume: int = 0, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 700 |
+
"""
|
| 701 |
+
SSE stream per un task agent.
|
| 702 |
+
|
| 703 |
+
S358: reconnect-safe via _loop_registry fanout (no re-run mentre il backend gira).
|
| 704 |
+
S359: lazy restore da Supabase dopo restart HF Space:
|
| 705 |
+
- Task SUCCESS/ERROR β replay event buffer da Supabase β chiusura immediata.
|
| 706 |
+
- Task era RUNNING β replay buffer parziale + evento task_interrupted.
|
| 707 |
+
- Task non trovato β prova sb_restore_task prima di 404.
|
| 708 |
+
"""
|
| 709 |
+
# S359: se task_id non Γ¨ in memoria, prova il restore da Supabase
|
| 710 |
+
if task_id not in _agent_tasks:
|
| 711 |
+
restored = await sb_restore_task(task_id)
|
| 712 |
+
if restored:
|
| 713 |
+
restored['context'] = []
|
| 714 |
+
_agent_tasks[task_id] = restored
|
| 715 |
+
else:
|
| 716 |
+
raise HTTPException(404, detail=f'Task {task_id} non trovato')
|
| 717 |
+
|
| 718 |
+
task = _agent_tasks[task_id]
|
| 719 |
+
_last_event_id = request.headers.get("Last-Event-ID") or request.headers.get("last-event-id")
|
| 720 |
+
_resume_from = int(_last_event_id) if (_last_event_id and _last_event_id.isdigit()) else resume
|
| 721 |
+
|
| 722 |
+
sub_q: asyncio.Queue[str | None] = asyncio.Queue()
|
| 723 |
+
|
| 724 |
+
async def generate():
|
| 725 |
+
yield "retry: 3000\n\n"
|
| 726 |
+
|
| 727 |
+
reg = _loop_registry.get(task_id)
|
| 728 |
+
|
| 729 |
+
is_done_reconnect = reg is not None and reg.get('done', False)
|
| 730 |
+
is_reconnect = reg is not None and not reg.get('done', False)
|
| 731 |
+
|
| 732 |
+
# ββ Case 1: loop giΓ finito in questa sessione β replay buffer in-memory ββ
|
| 733 |
+
if is_done_reconnect:
|
| 734 |
+
for evt_str in reg['event_buffer'][_resume_from:]:
|
| 735 |
+
yield evt_str
|
| 736 |
+
yield "data: [DONE]\n\n"
|
| 737 |
+
return
|
| 738 |
+
|
| 739 |
+
# ββ Case 2: loop attivo in questa sessione β reconnect SSE (S358) βββββββββ
|
| 740 |
+
if is_reconnect:
|
| 741 |
+
join_idx = len(reg['event_buffer'])
|
| 742 |
+
reg['subscriber_queues'].append(sub_q)
|
| 743 |
+
try:
|
| 744 |
+
for evt_str in reg['event_buffer'][_resume_from:join_idx]:
|
| 745 |
+
yield evt_str
|
| 746 |
+
while True:
|
| 747 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 748 |
+
break
|
| 749 |
+
try:
|
| 750 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 751 |
+
if item is None:
|
| 752 |
+
break
|
| 753 |
+
yield item
|
| 754 |
+
except asyncio.TimeoutError:
|
| 755 |
+
yield ': heartbeat\n\n'
|
| 756 |
+
finally:
|
| 757 |
+
try:
|
| 758 |
+
reg['subscriber_queues'].remove(sub_q)
|
| 759 |
+
except ValueError as _exc:
|
| 760 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 761 |
+
yield "data: [DONE]\n\n"
|
| 762 |
+
return
|
| 763 |
+
|
| 764 |
+
# ββ Case 2.5 (S359): backend riavviato β prova Supabase event buffer ββββββ
|
| 765 |
+
sb_events = await sb_get_events(task_id)
|
| 766 |
+
if sb_events:
|
| 767 |
+
task_status = task.get('status', 'UNKNOWN')
|
| 768 |
+
terminal = task_status in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 769 |
+
# Replay buffer from resume point
|
| 770 |
+
for evt_str in sb_events[_resume_from:]:
|
| 771 |
+
yield evt_str
|
| 772 |
+
if terminal:
|
| 773 |
+
# Task giΓ completato β niente da fare, client ha tutto
|
| 774 |
+
yield "data: [DONE]\n\n"
|
| 775 |
+
return
|
| 776 |
+
else:
|
| 777 |
+
# Task era in esecuzione quando il backend Γ¨ crashato β prova resume automatico
|
| 778 |
+
_cp_sb = _task_checkpoints.get(task_id) or await sb_get_checkpoint(task_id)
|
| 779 |
+
_can_resume = (
|
| 780 |
+
_cp_sb is not None and
|
| 781 |
+
len(_cp_sb.get('plan', [])) >= 1 and
|
| 782 |
+
len(_cp_sb.get('logs', [])) >= 2
|
| 783 |
+
)
|
| 784 |
+
if _can_resume:
|
| 785 |
+
# GAP-SYNC-FIX: usa _backend_steps se disponibili (context preciso per resume)
|
| 786 |
+
_bsteps = _cp_sb.get('_backend_steps', [])
|
| 787 |
+
if _bsteps:
|
| 788 |
+
_steps_text = '\n'.join(
|
| 789 |
+
f" Passo {s['step']}: {s['action']} β {s['result'][:80]}"
|
| 790 |
+
for s in _bsteps[-8:]
|
| 791 |
+
)
|
| 792 |
+
_rctx = (
|
| 793 |
+
f"[RESUME AUTOMATICO] Step giΓ completati dal backend:\n{_steps_text}\n"
|
| 794 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)+1} senza ripetere quelli giΓ eseguiti."
|
| 795 |
+
)
|
| 796 |
+
else:
|
| 797 |
+
# Fallback: context semantico (piano + log riassuntivi)
|
| 798 |
+
_rctx = (
|
| 799 |
+
f"Piano giΓ definito: {' | '.join((_cp_sb.get('plan') or [])[:5])}\n"
|
| 800 |
+
f"Log fin qui: {' | '.join((_cp_sb.get('logs') or [])[-5:])}\n"
|
| 801 |
+
f"Riprendi dal passo {_cp_sb.get('step', 0)} senza ripetere gli step giΓ fatti."
|
| 802 |
+
)
|
| 803 |
+
task['_resume_context'] = _rctx
|
| 804 |
+
task['_resume_max_steps'] = max(1, task.get('max_steps', 8) - _cp_sb.get('step', 0))
|
| 805 |
+
# Fall through a Case 3 β NON fare return
|
| 806 |
+
else:
|
| 807 |
+
# Nessun checkpoint utile β fallback onesto (comportamento precedente)
|
| 808 |
+
interrupted_evt = json.dumps({
|
| 809 |
+
'event': 'task_interrupted',
|
| 810 |
+
'taskId': task_id,
|
| 811 |
+
'reason': 'backend_restarted',
|
| 812 |
+
'message': 'Il backend si Γ¨ riavviato durante l\'esecuzione. '
|
| 813 |
+
'Premi "Riprova" per rieseguire il task.',
|
| 814 |
+
})
|
| 815 |
+
yield f"data: {interrupted_evt}\n\n"
|
| 816 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 817 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 818 |
+
yield "data: [DONE]\n\n"
|
| 819 |
+
return
|
| 820 |
+
# ββ Case 3: nuova esecuzione ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 821 |
+
_prune_loop_registry()
|
| 822 |
+
reg_entry: dict = {
|
| 823 |
+
'asyncio_task': None,
|
| 824 |
+
'event_buffer': [],
|
| 825 |
+
'subscriber_queues': [sub_q],
|
| 826 |
+
'done': False,
|
| 827 |
+
'finished_at': 0.0,
|
| 828 |
+
}
|
| 829 |
+
_loop_registry[task_id] = reg_entry
|
| 830 |
+
_ctr = [0]
|
| 831 |
+
|
| 832 |
+
def _sse(event: str, data: dict) -> None:
|
| 833 |
+
"""Emit one SSE frame: buffer it, fanout to all subscribers, persist async."""
|
| 834 |
+
_ctr[0] += 1
|
| 835 |
+
s = f"id: {_ctr[0]}\ndata: {json.dumps({'event': event, **data})}\n\n"
|
| 836 |
+
# GAP-3-FIX: text_chunk bypass buffer β fanout diretto, no persist.
|
| 837 |
+
# 800 token x 1 evento/token saturerebbero il cap da 500 evictando step cruciali.
|
| 838 |
+
# Su reconnect iOS i token non servono replay (streaming completato o ricominciato).
|
| 839 |
+
if event == 'text_chunk':
|
| 840 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 841 |
+
try:
|
| 842 |
+
q.put_nowait(s)
|
| 843 |
+
except Exception as _exc:
|
| 844 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 845 |
+
return
|
| 846 |
+
reg_entry['event_buffer'].append(s)
|
| 847 |
+
# N-5-FIX: cap buffer a 500 eventi β evita crescita illimitata su task lunghi
|
| 848 |
+
if len(reg_entry['event_buffer']) > 500:
|
| 849 |
+
reg_entry['event_buffer'] = reg_entry['event_buffer'][-500:]
|
| 850 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 851 |
+
try:
|
| 852 |
+
q.put_nowait(s)
|
| 853 |
+
except Exception as _exc:
|
| 854 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 855 |
+
# S359: persist event asynchronously (fire-and-forget)
|
| 856 |
+
asyncio.create_task(sb_append_event(task_id, _ctr[0], s)).add_done_callback(_log_task_exc)
|
| 857 |
+
|
| 858 |
+
_agent_tasks[task_id]['status'] = 'RUNNING'
|
| 859 |
+
asyncio.create_task(sb_update_status(task_id, 'RUNNING')).add_done_callback(_log_task_exc)
|
| 860 |
+
_prune_agent_tasks()
|
| 861 |
+
|
| 862 |
+
async def run_loop() -> None:
|
| 863 |
+
try:
|
| 864 |
+
from agents.unified_loop import UnifiedAgentLoop
|
| 865 |
+
# S388: singleton β evita OpenAI() per ogni task
|
| 866 |
+
client = _get_ai_client()
|
| 867 |
+
try:
|
| 868 |
+
from agents.critic import Critic
|
| 869 |
+
from agents.response_verifier import ResponseVerifier
|
| 870 |
+
_critic = Critic(llm_client=client)
|
| 871 |
+
_verifier = ResponseVerifier()
|
| 872 |
+
except Exception:
|
| 873 |
+
_critic = None
|
| 874 |
+
_verifier = None
|
| 875 |
+
|
| 876 |
+
context_str = '\n'.join(m.get('content', '') for m in task['context']) if task['context'] else ''
|
| 877 |
+
# S456-X5/X4: inject project context + learning hints stored at task creation
|
| 878 |
+
_proj_ctx = task.get('project_context', '')
|
| 879 |
+
if _proj_ctx:
|
| 880 |
+
context_str = f"[PROGETTO CORRENTE]\n{_proj_ctx}\n\n{context_str}".strip()
|
| 881 |
+
_hints = task.get('learning_hints', [])
|
| 882 |
+
if _hints:
|
| 883 |
+
# S591: _hints[:3]β[:5] β piΓΉ pattern appresi nel context (task replay)
|
| 884 |
+
hints_str = "\n".join(f"- {h}" for h in _hints[:5])
|
| 885 |
+
context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip()
|
| 886 |
+
# P16-F3: inject resume hint if task was promoted from queue at a specific step
|
| 887 |
+
_resume_step = task.get('resume_from_step')
|
| 888 |
+
if _resume_step:
|
| 889 |
+
context_str = f"[RIPRESA DA PASSO {_resume_step}] Riprendi dall'iterazione {_resume_step} del task.\n\n{context_str}".strip()
|
| 890 |
+
# P39-UX: Tocco Finale Manus β spiega all'agente come segnalare OAuth mancante
|
| 891 |
+
_connector_hint = (
|
| 892 |
+
"[CONNETTORI OAUTH]\n"
|
| 893 |
+
"Se durante il task hai bisogno di un accesso OAuth (GitHub, Google Calendar, Instagram)\n"
|
| 894 |
+
"ma non hai il token disponibile, includi nella tua risposta finale o parziale:\n"
|
| 895 |
+
" [CONNECTOR_NEEDED:github] oppure [CONNECTOR_NEEDED:google] oppure [CONNECTOR_NEEDED:instagram]\n"
|
| 896 |
+
"Il frontend mostrerΓ automaticamente un pulsante 'Connetti' all'utente."
|
| 897 |
+
)
|
| 898 |
+
context_str = f"{context_str}\n\n{_connector_hint}".strip() if context_str else _connector_hint
|
| 899 |
+
# GAP-SYNC-FIX: inject _resume_context (set da stream_agent_task su reconnect con checkpoint)
|
| 900 |
+
# Bug: _resume_context era settato su task{} ma mai letto qui β context perduto su resume.
|
| 901 |
+
_resume_ctx = task.get('_resume_context', '')
|
| 902 |
+
if _resume_ctx:
|
| 903 |
+
context_str = f"{_resume_ctx}\n\n{context_str}".strip()
|
| 904 |
+
# P17-F5: inject Expertise Persona hint se specificato
|
| 905 |
+
_PERSONA_HINTS = {
|
| 906 |
+
"researcher": (
|
| 907 |
+
"[PERSONA: RICERCATORE ESPERTO]\n"
|
| 908 |
+
"- Priorizza sempre la ricerca web aggiornata prima di rispondere\n"
|
| 909 |
+
"- Cita fonti specifiche (URL, titolo, data) per ogni claim importante\n"
|
| 910 |
+
"- Struttura le risposte: Sommario β Dettaglio β Fonti\n"
|
| 911 |
+
"- Verifica incrociando piΓΉ fonti prima di concludere\n"
|
| 912 |
+
"- Strumenti preferiti: web_search, read_page, fetch_url, research"
|
| 913 |
+
),
|
| 914 |
+
"coder": (
|
| 915 |
+
"[PERSONA: SENIOR ENGINEER]\n"
|
| 916 |
+
"- Scrivi codice production-ready: tipizzato, documentato, con error handling\n"
|
| 917 |
+
"- Esegui il codice per verificare il funzionamento prima di rispondere\n"
|
| 918 |
+
"- Preferisci soluzioni robuste e testate su approcci creativi ma fragili\n"
|
| 919 |
+
"- Documenta funzioni e classi con docstring/JSDoc\n"
|
| 920 |
+
"- Strumenti preferiti: run_python, write_file, read_file, pip_install"
|
| 921 |
+
),
|
| 922 |
+
"architect": (
|
| 923 |
+
"[PERSONA: ARCHITECT]\n"
|
| 924 |
+
"- Priorizza analisi, design di sistema e decisioni strategiche\n"
|
| 925 |
+
"- Struttura l'architettura in componenti chiari e mantenibili\n"
|
| 926 |
+
"- Considera scalabilitΓ , manutenibilitΓ e trade-off tecnici\n"
|
| 927 |
+
"- Documenta le decisioni architetturali e il loro razionale"
|
| 928 |
+
),
|
| 929 |
+
"reasoner": (
|
| 930 |
+
"[PERSONA: RAGIONATORE STRATEGICO]\n"
|
| 931 |
+
"- Usa ragionamento step-by-step esplicito: mostra il processo di pensiero\n"
|
| 932 |
+
"- Analizza ogni prospettiva prima di concludere\n"
|
| 933 |
+
"- Struttura la risposta: Analisi β Pro/Contro β Raccomandazione\n"
|
| 934 |
+
"- Considera le implicazioni di lungo termine delle scelte"
|
| 935 |
+
),
|
| 936 |
+
"analyst": (
|
| 937 |
+
"[PERSONA: ANALISTA DATI]\n"
|
| 938 |
+
"- Usa Python per elaborare e analizzare dati quando disponibili\n"
|
| 939 |
+
"- Produci visualizzazioni chiare (grafici, tabelle) ove possibile\n"
|
| 940 |
+
"- Interpreta i risultati con rigore: distingui correlazione da causalitΓ \n"
|
| 941 |
+
"- Struttura i report: Executive Summary β Metodologia β Risultati β Conclusioni\n"
|
| 942 |
+
"- Strumenti preferiti: run_python, web_search, vision"
|
| 943 |
+
),
|
| 944 |
+
}
|
| 945 |
+
_persona = task.get('persona') or ''
|
| 946 |
+
# P17-F5-IMPROVED: server-side classification se persona vuota/auto
|
| 947 |
+
_persona_auto = False
|
| 948 |
+
if not _persona:
|
| 949 |
+
_persona = _classify_persona_server(task.get('goal', ''))
|
| 950 |
+
if _persona:
|
| 951 |
+
_persona_auto = True
|
| 952 |
+
task['persona'] = _persona # persist per history/resume
|
| 953 |
+
_persona_hint = _PERSONA_HINTS.get(_persona.lower().strip(), '')
|
| 954 |
+
if _persona_hint:
|
| 955 |
+
context_str = f"{_persona_hint}\n\n{context_str}".strip()
|
| 956 |
+
# P17-F5: emit persona_classified SSE event β UI badge feedback
|
| 957 |
+
if _persona:
|
| 958 |
+
_persona_conf = 0.85 if not _persona_auto else 0.78
|
| 959 |
+
_sse('persona_classified', {
|
| 960 |
+
'taskId': task_id,
|
| 961 |
+
'persona': _persona,
|
| 962 |
+
'confidence': _persona_conf,
|
| 963 |
+
'auto': _persona_auto,
|
| 964 |
+
})
|
| 965 |
+
# BG-4: inject cross-session handoff context if available
|
| 966 |
+
_hctx = task.get("_handoff_context", "")
|
| 967 |
+
if _hctx:
|
| 968 |
+
context_str = f"{_hctx}\n\n{context_str}".strip()
|
| 969 |
+
# P17-F5: route primary LLM to persona-appropriate client
|
| 970 |
+
_persona_client = _get_persona_llm_client(_persona, client)
|
| 971 |
+
loop = UnifiedAgentLoop(
|
| 972 |
+
llm_client=_persona_client, critic=_critic, verifier=_verifier,
|
| 973 |
+
memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(),
|
| 974 |
+
)
|
| 975 |
+
step_idx = [0]
|
| 976 |
+
_backend_steps: list[dict] = [] # GAP-SYNC-FIX: log step per resume preciso
|
| 977 |
+
|
| 978 |
+
async def step_cb(step_data: dict) -> None:
|
| 979 |
+
step_idx[0] += 1
|
| 980 |
+
_action = step_data.get('action', f'Step {step_idx[0]}')
|
| 981 |
+
# S420: streaming token β emetti direttamente senza passare dal buffer step
|
| 982 |
+
if _action == 'text_chunk':
|
| 983 |
+
_sse('text_chunk', {'taskId': task_id, 'token': _ss(step_data.get('token', ''))})
|
| 984 |
+
return
|
| 985 |
+
|
| 986 |
+
# S363-Blueprint: Narrative Streaming β explanation lookup for ALL step_done events
|
| 987 |
+
# S376: _STEP_NARRATIONS espanso β aggiunge 12 tool mancanti
|
| 988 |
+
# Il fallback `_action.replace('_', ' ').capitalize()` Γ¨ troppo generico
|
| 989 |
+
# per tool composti β narrativa esplicita migliora la UX del LiveStreamBlock
|
| 990 |
+
_STEP_NARRATIONS = {
|
| 991 |
+
'plan': 'Analisi del goal e creazione piano di azione',
|
| 992 |
+
'llm': 'Elaborazione risposta AI',
|
| 993 |
+
'fallback': 'Completamento task',
|
| 994 |
+
'smolagents': 'Esecuzione agente autonomo con strumenti',
|
| 995 |
+
'web_search': 'Cerco informazioni aggiornate sul web',
|
| 996 |
+
'read_page': 'Leggo il contenuto della pagina web',
|
| 997 |
+
'fetch_url': 'Recupero dati dall\'URL richiesto',
|
| 998 |
+
'fetch_url_content': 'Scarico il contenuto dell\'URL',
|
| 999 |
+
'run_code': 'Eseguo il codice nel sandbox',
|
| 1000 |
+
'write_file': 'Scrivo il file nel progetto',
|
| 1001 |
+
'read_file': 'Leggo il file dal VFS',
|
| 1002 |
+
'delete_file': 'Rimuovo il file dal progetto',
|
| 1003 |
+
'create_file': 'Creo il file nel progetto',
|
| 1004 |
+
'list_files': 'Elenco i file del progetto',
|
| 1005 |
+
'search_github': 'Cerco codice e repository su GitHub',
|
| 1006 |
+
'search_github_code': 'Cerco snippet di codice su GitHub',
|
| 1007 |
+
'search_wikipedia': 'Consulto Wikipedia per informazioni',
|
| 1008 |
+
'get_weather': 'Recupero le previsioni meteo',
|
| 1009 |
+
'get_news': 'Carico le ultime notizie',
|
| 1010 |
+
'get_currency': 'Consulto il tasso di cambio',
|
| 1011 |
+
'get_location': 'Rilevo la posizione geografica',
|
| 1012 |
+
'calculate': 'Calcolo l\'espressione matematica',
|
| 1013 |
+
'math_eval': 'Valuto l\'espressione matematica',
|
| 1014 |
+
'generate_image': 'Genero l\'immagine con AI (Pollinations)',
|
| 1015 |
+
'remember': 'Salvo informazioni in memoria',
|
| 1016 |
+
'recall': 'Recupero informazioni dalla memoria',
|
| 1017 |
+
'direct_tools': 'Utilizzo strumenti diretti',
|
| 1018 |
+
'critic_retry': 'Auto-correzione risposta (Quality Gate)',
|
| 1019 |
+
'execution_validator_fix': 'Auto-fix codice rilevato (ExecutionValidator)',
|
| 1020 |
+
'__thinking__': 'Ragionamento interno in corso',
|
| 1021 |
+
'__plan__': 'Pianificazione step successivo',
|
| 1022 |
+
'__verify__': 'Verifica e validazione risposta',
|
| 1023 |
+
'reflective_debug': 'Analisi root cause errore (Chain-of-Verification)',
|
| 1024 |
+
'lint_result': 'Validazione sintattica file',
|
| 1025 |
+
'lint_code': 'Analisi statica del codice',
|
| 1026 |
+
'project_skeleton': 'Mappa aggiornata del progetto',
|
| 1027 |
+
'tool_governor_skip': 'Tool giΓ eseguito β risultato riutilizzato',
|
| 1028 |
+
'severity_retry': 'Retry adattivo per tipologia errore (S376)',
|
| 1029 |
+
# S-LOOP2: narrations per fasi avanzate
|
| 1030 |
+
'reasoning_core': 'Ragionamento multi-step (ReasoningCore attivo)',
|
| 1031 |
+
'browser_verifier': 'Verifica app live in tempo reale (Playwright)',
|
| 1032 |
+
}
|
| 1033 |
+
_tool_key_narr = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 1034 |
+
_narration = _STEP_NARRATIONS.get(_tool_key_narr,
|
| 1035 |
+
_action.replace('executor:', '').replace('_', ' ').capitalize())
|
| 1036 |
+
# P16-B4: propaga 'truncated' dal loop (finish_reason==length) β frontend
|
| 1037 |
+
_step_truncated = bool(step_data.get('truncated', False))
|
| 1038 |
+
_sse('step_done', {
|
| 1039 |
+
'taskId': task_id,
|
| 1040 |
+
'step': {
|
| 1041 |
+
'name': _action,
|
| 1042 |
+
'index': step_idx[0],
|
| 1043 |
+
'status': step_data.get('status', 'done'),
|
| 1044 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:500],
|
| 1045 |
+
'explanation': _narration, # S363-Blueprint: narrative field
|
| 1046 |
+
'truncated': _step_truncated, # P16-B4: segnala max_tokens raggiunto
|
| 1047 |
+
},
|
| 1048 |
+
})
|
| 1049 |
+
# P39-UX: rileva [CONNECTOR_NEEDED:provider] nel result β emetti SSE connector_needed
|
| 1050 |
+
import re as _re_cn
|
| 1051 |
+
_cn_result = str(step_data.get('result', step_data.get('output', '')))
|
| 1052 |
+
_cn_matches = _re_cn.findall(r'\[CONNECTOR_NEEDED:([\w]+)\]', _cn_result)
|
| 1053 |
+
for _cn_prov in _cn_matches:
|
| 1054 |
+
_PROVIDER_LABELS = {'github': 'GitHub', 'google': 'Google Calendar', 'instagram': 'Instagram'}
|
| 1055 |
+
_cn_label = _PROVIDER_LABELS.get(_cn_prov.lower(), _cn_prov.capitalize())
|
| 1056 |
+
_sse('connector_needed', {
|
| 1057 |
+
'taskId': task_id,
|
| 1058 |
+
'provider': _cn_prov.lower(),
|
| 1059 |
+
'label': _cn_label,
|
| 1060 |
+
'message': f"Per completare il task ho bisogno di accedere a {_cn_label}. Connettiti con un tap.",
|
| 1061 |
+
})
|
| 1062 |
+
# GAP-SYNC-FIX: accumula step results per resume preciso (checkpoint backend-side)
|
| 1063 |
+
_backend_steps.append({
|
| 1064 |
+
'step': step_idx[0],
|
| 1065 |
+
'action': _action,
|
| 1066 |
+
'result': str(step_data.get('result', step_data.get('output', '')))[:150],
|
| 1067 |
+
'ok': step_data.get('status', 'done') not in ('error', 'failed'),
|
| 1068 |
+
})
|
| 1069 |
+
# Ogni 2 step: persisti il log su Supabase (non saturare Supabase su loop lunghi)
|
| 1070 |
+
if step_idx[0] % 2 == 0:
|
| 1071 |
+
asyncio.create_task(
|
| 1072 |
+
sb_save_checkpoint(task_id, step_idx[0], {
|
| 1073 |
+
'_backend_steps': _backend_steps[-10:], # ultime 10 step
|
| 1074 |
+
'step': step_idx[0],
|
| 1075 |
+
})
|
| 1076 |
+
).add_done_callback(_log_task_exc)
|
| 1077 |
+
# TG-STEP: notifica step intermedio rilevante (fire-and-forget, rate-limited 30s)
|
| 1078 |
+
asyncio.create_task(_tg_step(task_id, _action, _narration)).add_done_callback(_log_task_exc)
|
| 1079 |
+
# S362: emit vfs_update when a file operation is detected
|
| 1080 |
+
# SYNC-1: file_written (da unified_loop GAP-1) incluso + content forwarding
|
| 1081 |
+
_VFS_ACTIONS = ('write_file', 'file_write', 'create_file', 'delete_file', 'file_delete', 'file_written')
|
| 1082 |
+
if _action in _VFS_ACTIONS or step_data.get('file_path'):
|
| 1083 |
+
# S581: 120β200 β path file spesso 120-200 chars
|
| 1084 |
+
# S596: 200β400 β result/output puΓ² contenere path completo di progetto
|
| 1085 |
+
# S604: 400β500 β parity con altri campi step
|
| 1086 |
+
# SYNC-1: file_written porta path in 'path', non 'file_path'
|
| 1087 |
+
_vfs_file = (step_data.get('path') or
|
| 1088 |
+
step_data.get('file_path') or
|
| 1089 |
+
step_data.get('result', '')[:500] or
|
| 1090 |
+
step_data.get('output', '')[:500])
|
| 1091 |
+
_vfs_op = 'delete' if 'delete' in _action else 'write'
|
| 1092 |
+
_vfs_evt: dict = {'taskId': task_id, 'file': str(_vfs_file)[:500], 'op': _vfs_op}
|
| 1093 |
+
# SYNC-1: includi content nel SSE event per file_written (β€60KB)
|
| 1094 |
+
# Frontend scrive direttamente nel VFS locale senza fetch aggiuntivo
|
| 1095 |
+
if _action == 'file_written' and step_data.get('content'):
|
| 1096 |
+
_vfs_evt['content'] = str(step_data['content'])[:60_000]
|
| 1097 |
+
_sse('vfs_update', _vfs_evt)
|
| 1098 |
+
|
| 1099 |
+
# S363-UI: thought event β emitted when planner completes
|
| 1100 |
+
if _action == 'plan' and step_data.get('status') == 'done':
|
| 1101 |
+
_plan_obj = step_data.get('result', step_data.get('output', ''))
|
| 1102 |
+
_thought = (_plan_obj.get('goal', '') if isinstance(_plan_obj, dict) else str(_plan_obj))[:400] # S604: 280β400
|
| 1103 |
+
if _thought:
|
| 1104 |
+
_sse('thought', {'taskId': task_id, 'text': _thought,
|
| 1105 |
+
'complexity': _plan_obj.get('complexity') if isinstance(_plan_obj, dict) else None})
|
| 1106 |
+
# S367: plan_update β structured subtask list for live plan tracking UI
|
| 1107 |
+
if isinstance(_plan_obj, dict) and _plan_obj.get('subtasks'):
|
| 1108 |
+
_sse('plan_update', {
|
| 1109 |
+
'taskId': task_id,
|
| 1110 |
+
'subtasks': [
|
| 1111 |
+
{
|
| 1112 |
+
'id': s.get('id', _si + 1),
|
| 1113 |
+
'description': s.get('description', '')[:200], # S581: 80β200
|
| 1114 |
+
'tool': s.get('tool', ''),
|
| 1115 |
+
'status': 'pending',
|
| 1116 |
+
}
|
| 1117 |
+
for _si, s in enumerate(_plan_obj['subtasks'])
|
| 1118 |
+
],
|
| 1119 |
+
'goal': _plan_obj.get('goal', ''),
|
| 1120 |
+
})
|
| 1121 |
+
|
| 1122 |
+
# S367: subtask_done β mark individual subtask complete for live checkbox update
|
| 1123 |
+
if step_data.get('subtask_id') and step_data.get('status') == 'done':
|
| 1124 |
+
_sse('plan_update', {
|
| 1125 |
+
'taskId': task_id,
|
| 1126 |
+
'subtask_done': step_data['subtask_id'],
|
| 1127 |
+
})
|
| 1128 |
+
|
| 1129 |
+
# S363-UI: action event β tool execution phase
|
| 1130 |
+
_TOOL_EXPLAINS_S363 = {
|
| 1131 |
+
'web_search': 'Cerco informazioni in rete',
|
| 1132 |
+
'get_weather': 'Recupero dati meteo',
|
| 1133 |
+
'get_news': 'Carico notizie recenti',
|
| 1134 |
+
'search_wikipedia': 'Consulto Wikipedia',
|
| 1135 |
+
'fetch_url': 'Leggo la pagina web',
|
| 1136 |
+
'search_github': 'Cerco su GitHub',
|
| 1137 |
+
'run_code': 'Eseguo il codice',
|
| 1138 |
+
'write_file': 'Scrivo il file',
|
| 1139 |
+
'read_file': 'Leggo il file',
|
| 1140 |
+
'direct_tools': 'Eseguo strumenti diretti',
|
| 1141 |
+
}
|
| 1142 |
+
_tool_key = _action.replace('executor:', '') if _action.startswith('executor:') else _action
|
| 1143 |
+
if _action.startswith('executor:') or _tool_key in _TOOL_EXPLAINS_S363:
|
| 1144 |
+
_sse('action', {
|
| 1145 |
+
'taskId': task_id,
|
| 1146 |
+
'log': _tool_key.upper().replace('_', ' ')[:30],
|
| 1147 |
+
'explain': _TOOL_EXPLAINS_S363.get(_tool_key, f'Esecuzione: {_tool_key}'),
|
| 1148 |
+
})
|
| 1149 |
+
# S758-P4.1: tool_use β chip pre-esecuzione (stream_agent_task path)
|
| 1150 |
+
_is_pre_exec = (
|
| 1151 |
+
(_action == 'tool_start' and step_data.get('status') == 'running') or
|
| 1152 |
+
(_action.startswith('executor:') and step_data.get('status') == 'started')
|
| 1153 |
+
)
|
| 1154 |
+
if _is_pre_exec:
|
| 1155 |
+
_sse('tool_use', {
|
| 1156 |
+
'taskId': task_id,
|
| 1157 |
+
'tool': _tool_key,
|
| 1158 |
+
'name': _tool_key,
|
| 1159 |
+
'label': (step_data.get('title') or
|
| 1160 |
+
_TOOL_EXPLAINS_S363.get(_tool_key,
|
| 1161 |
+
_tool_key.replace('_', ' ').capitalize())),
|
| 1162 |
+
'args': {},
|
| 1163 |
+
})
|
| 1164 |
+
# S758-P4.1: task_thinking β chip ragionamento LLM
|
| 1165 |
+
if (_action in ('__thinking__', 'reflective_debug') and
|
| 1166 |
+
step_data.get('status') in ('started', 'running', 'running_deep')):
|
| 1167 |
+
_sse('task_thinking', {
|
| 1168 |
+
'taskId': task_id,
|
| 1169 |
+
'message': (step_data.get('explanation') or step_data.get('title') or
|
| 1170 |
+
"Lβagente sta elaborandoβ¦"),
|
| 1171 |
+
})
|
| 1172 |
+
|
| 1173 |
+
_sse('task_start', {'taskId': task_id, 'goal': task['goal']})
|
| 1174 |
+
_task_started_ms = int(time.time() * 1000) # NOTIFY-BOT: elapsed tracking
|
| 1175 |
+
asyncio.create_task(_tg_start(task_id, task['goal'])).add_done_callback(_log_task_exc)
|
| 1176 |
+
_sse('step_start', {'taskId': task_id, 'step': {'name': 'Analisi goal', 'index': 0}})
|
| 1177 |
+
|
| 1178 |
+
# S364: inject project skeleton into context from VFS (Gap 4)
|
| 1179 |
+
if task.get('conversation_id'):
|
| 1180 |
+
try:
|
| 1181 |
+
from api.project_manifest import build_manifest_from_vfs, get_skeleton
|
| 1182 |
+
await asyncio.wait_for(
|
| 1183 |
+
build_manifest_from_vfs(task['conversation_id']),
|
| 1184 |
+
timeout=3.0,
|
| 1185 |
+
)
|
| 1186 |
+
_skeleton = await get_skeleton(task['conversation_id'])
|
| 1187 |
+
if _skeleton:
|
| 1188 |
+
context_str = (_skeleton + '\n\n' + context_str).strip()
|
| 1189 |
+
except Exception:
|
| 1190 |
+
pass # S364: skeleton injection is optional
|
| 1191 |
+
|
| 1192 |
+
result = await loop.run(
|
| 1193 |
+
goal=task['goal'],
|
| 1194 |
+
context=context_str,
|
| 1195 |
+
max_steps=task.get('_resume_max_steps', task.get('max_steps', 8)), # AG-BUG-1: _resume_max mai definito in questo scope
|
| 1196 |
+
on_step=step_cb,
|
| 1197 |
+
session_id=task.get('session_id', '') or '',
|
| 1198 |
+
)
|
| 1199 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1200 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1201 |
+
_result_text = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1202 |
+
_sse('task_done', {'taskId': task_id, 'result': _result_text[:8000]})
|
| 1203 |
+
asyncio.create_task(_tg_done(task_id, task.get('goal', ''), _result_text[:500], _task_started_ms)).add_done_callback(_log_task_exc)
|
| 1204 |
+
|
| 1205 |
+
# S363: fire-and-forget quality check when code detected in output
|
| 1206 |
+
if _run_quality_check:
|
| 1207 |
+
_qg_result = str(result.get('output', result) if isinstance(result, dict) else result)
|
| 1208 |
+
if len(_qg_result) > 500 and _qg_result.count('```') >= 2: # S373: threshold raised β evita QG su snippet brevi
|
| 1209 |
+
asyncio.create_task(_run_quality_check(
|
| 1210 |
+
task_id, task['goal'], _qg_result,
|
| 1211 |
+
on_event=lambda ev: _sse(ev.get('type', 'test_result'), ev),
|
| 1212 |
+
)).add_done_callback(_log_task_exc)
|
| 1213 |
+
|
| 1214 |
+
|
| 1215 |
+
except asyncio.CancelledError:
|
| 1216 |
+
_agent_tasks[task_id]['status'] = 'CANCELLED'
|
| 1217 |
+
asyncio.create_task(sb_update_status(task_id, 'CANCELLED')).add_done_callback(_log_task_exc)
|
| 1218 |
+
_sse('task_cancelled', {'taskId': task_id})
|
| 1219 |
+
|
| 1220 |
+
except (ImportError, ModuleNotFoundError):
|
| 1221 |
+
_agent_tasks[task_id]['status'] = 'SUCCESS'
|
| 1222 |
+
asyncio.create_task(sb_update_status(task_id, 'SUCCESS')).add_done_callback(_log_task_exc)
|
| 1223 |
+
_sse('step_done', {'taskId': task_id, 'step': {'name': 'Ragionamento', 'index': 0}})
|
| 1224 |
+
_sse('task_done', {'taskId': task_id, 'result': (
|
| 1225 |
+
f'Goal ricevuto: {task["goal"]}\n\n'
|
| 1226 |
+
'Il backend non ha il modulo agents.unified_loop. '
|
| 1227 |
+
'Configura HuggingFace Spaces con smolagents per l\'esecuzione autonoma.'
|
| 1228 |
+
)})
|
| 1229 |
+
|
| 1230 |
+
except Exception as err:
|
| 1231 |
+
_agent_tasks[task_id]['status'] = 'ERROR'
|
| 1232 |
+
asyncio.create_task(sb_update_status(task_id, 'ERROR')).add_done_callback(_log_task_exc)
|
| 1233 |
+
_logger.error('[agent/stream] %s error: %s', task_id, err, exc_info=True)
|
| 1234 |
+
_sse('task_error', {'taskId': task_id, 'error': str(err)[:1000]})
|
| 1235 |
+
asyncio.create_task(_tg_error(task_id, task.get('goal', ''), str(err))).add_done_callback(_log_task_exc)
|
| 1236 |
+
|
| 1237 |
+
finally:
|
| 1238 |
+
reg_entry['done'] = True
|
| 1239 |
+
reg_entry['finished_at'] = time.time()
|
| 1240 |
+
for q in list(reg_entry['subscriber_queues']):
|
| 1241 |
+
try:
|
| 1242 |
+
q.put_nowait(None)
|
| 1243 |
+
except Exception as _exc:
|
| 1244 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 1245 |
+
|
| 1246 |
+
reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
|
| 1247 |
+
reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
|
| 1248 |
+
|
| 1249 |
+
try:
|
| 1250 |
+
while True:
|
| 1251 |
+
if _agent_tasks.get(task_id, {}).get('status') == 'CANCELLED':
|
| 1252 |
+
at = reg_entry.get('asyncio_task')
|
| 1253 |
+
if at and not at.done():
|
| 1254 |
+
at.cancel()
|
| 1255 |
+
break
|
| 1256 |
+
try:
|
| 1257 |
+
item = await asyncio.wait_for(sub_q.get(), timeout=15.0)
|
| 1258 |
+
if item is None:
|
| 1259 |
+
break
|
| 1260 |
+
yield item
|
| 1261 |
+
except asyncio.TimeoutError:
|
| 1262 |
+
yield ': heartbeat\n\n'
|
| 1263 |
+
finally:
|
| 1264 |
+
try:
|
| 1265 |
+
reg_entry['subscriber_queues'].remove(sub_q)
|
| 1266 |
+
except ValueError as _exc:
|
| 1267 |
+
_logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 1268 |
+
|
| 1269 |
+
yield "data: [DONE]\n\n"
|
| 1270 |
+
|
| 1271 |
+
return StreamingResponse(
|
| 1272 |
+
generate(),
|
| 1273 |
+
media_type='text/event-stream',
|
| 1274 |
+
headers={
|
| 1275 |
+
'Cache-Control': 'no-cache',
|
| 1276 |
+
'X-Accel-Buffering': 'no',
|
| 1277 |
+
'Connection': 'keep-alive',
|
| 1278 |
+
},
|
| 1279 |
+
)
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
# ββ Task checkpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1283 |
+
|
| 1284 |
+
class CheckpointIn(BaseModel):
|
| 1285 |
+
taskId: str
|
| 1286 |
+
step: int
|
| 1287 |
+
goal: str
|
| 1288 |
+
plan: list[str] = []
|
| 1289 |
+
logs: list[str] = []
|
| 1290 |
+
artifacts: list[str] = []
|
| 1291 |
+
retryCount: int = 0
|
| 1292 |
+
extra: dict = {}
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
@router.post('/api/agent/tasks/{task_id}/checkpoint')
|
| 1296 |
+
async def save_checkpoint(task_id: str, body: CheckpointIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1297 |
+
_prune_checkpoints()
|
| 1298 |
+
_task_checkpoints[task_id] = {
|
| 1299 |
+
'taskId': task_id,
|
| 1300 |
+
'step': body.step,
|
| 1301 |
+
'goal': body.goal,
|
| 1302 |
+
'plan': body.plan,
|
| 1303 |
+
'logs': body.logs[-50:],
|
| 1304 |
+
'artifacts': body.artifacts,
|
| 1305 |
+
'retryCount': body.retryCount,
|
| 1306 |
+
'extra': body.extra,
|
| 1307 |
+
'savedAt': int(time.time() * 1000),
|
| 1308 |
+
}
|
| 1309 |
+
asyncio.create_task(sb_save_checkpoint(task_id, _task_checkpoints[task_id])).add_done_callback(_log_task_exc)
|
| 1310 |
+
return {'saved': True, 'taskId': task_id, 'step': body.step}
|
| 1311 |
+
|
| 1312 |
+
|
| 1313 |
+
@router.get('/api/agent/tasks/{task_id}/checkpoint')
|
| 1314 |
+
async def get_checkpoint(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1315 |
+
_prune_checkpoints()
|
| 1316 |
+
cp = _task_checkpoints.get(task_id)
|
| 1317 |
+
if not cp:
|
| 1318 |
+
cp = await sb_get_checkpoint(task_id)
|
| 1319 |
+
if not cp:
|
| 1320 |
+
raise HTTPException(404, detail={'error': 'checkpoint_not_found', 'taskId': task_id})
|
| 1321 |
+
return cp
|
| 1322 |
+
|
| 1323 |
+
|
| 1324 |
+
@router.delete('/api/agent/tasks/{task_id}/checkpoint')
|
| 1325 |
+
async def delete_checkpoint(task_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1326 |
+
_task_checkpoints.pop(task_id, None)
|
| 1327 |
+
return {'deleted': task_id}
|
| 1328 |
+
|
| 1329 |
+
|
| 1330 |
+
@router.get('/api/agent/checkpoints')
|
| 1331 |
+
async def list_checkpoints(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1332 |
+
_prune_checkpoints()
|
| 1333 |
+
now = int(time.time() * 1000)
|
| 1334 |
+
return {
|
| 1335 |
+
'count': len(_task_checkpoints),
|
| 1336 |
+
'checkpoints': [
|
| 1337 |
+
{'taskId': k, 'step': v['step'], 'goal': v['goal'][:300], 'age_ms': now - v['savedAt']} # S606: 200β300
|
| 1338 |
+
for k, v in _task_checkpoints.items()
|
| 1339 |
+
],
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
|
| 1343 |
+
# βββ Sprint 5 ITEM 15: /debug/timing β telemetria timing + qualitΓ agente ββββ
|
| 1344 |
+
# Usato da TelemetryDashboard.tsx (frontend) per la sezione "QualitΓ agente".
|
| 1345 |
+
# Espone: timing_stats (avg/count per fase) + repair_stats (contatori qualitΓ ).
|
| 1346 |
+
# Non richiede auth β dati aggregati, nessun dato sensibile.
|
| 1347 |
+
@router.get('/debug/timing')
|
| 1348 |
+
async def get_debug_timing(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: internal diagnostics
|
| 1349 |
+
"""
|
| 1350 |
+
Espone timing breakdown per fase (classify/plan/coder/verifier/browser)
|
| 1351 |
+
e contatori qualitΓ (goal_success, repair_success, tool_failure, req_engine).
|
| 1352 |
+
Formato: { timing_stats: {label: {avg, count}}, repair_stats: {key: count} }
|
| 1353 |
+
"""
|
| 1354 |
+
try:
|
| 1355 |
+
from api.state import _TIMING_STORE, _REPAIR_STATS
|
| 1356 |
+
timing_stats: dict = {}
|
| 1357 |
+
for label, samples in _TIMING_STORE.items():
|
| 1358 |
+
if samples:
|
| 1359 |
+
avg_val = round(sum(samples) / len(samples), 1)
|
| 1360 |
+
else:
|
| 1361 |
+
avg_val = None
|
| 1362 |
+
timing_stats[label] = {"avg": avg_val, "count": len(samples)}
|
| 1363 |
+
return {
|
| 1364 |
+
"timing_stats": timing_stats,
|
| 1365 |
+
"repair_stats": dict(_REPAIR_STATS),
|
| 1366 |
+
}
|
| 1367 |
+
except Exception as exc:
|
| 1368 |
+
return {"timing_stats": {}, "repair_stats": {}, "error": str(exc)}
|
| 1369 |
+
|
| 1370 |
+
|
| 1371 |
+
# βββ GAP-SKILL-SYNC: /api/agent/skill-stats β statistiche tool adattive ββββββ
|
| 1372 |
+
# Espone i dati del SkillTracker (session-scoped success/fail per tool)
|
| 1373 |
+
# al frontend per merge con skillRegistry Dexie β vista cross-runtime unificata.
|
| 1374 |
+
@router.get('/api/agent/skill-stats/{session_id}')
|
| 1375 |
+
async def get_skill_stats(session_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1376 |
+
"""Success/fail rate + Wilson score per ogni tool nella sessione.
|
| 1377 |
+
|
| 1378 |
+
Il frontend usa questa API per arricchire i dati Dexie di skillRegistry.ts
|
| 1379 |
+
con le stats backend: confidence reale (server-side) vs contatori browser-only.
|
| 1380 |
+
"""
|
| 1381 |
+
try:
|
| 1382 |
+
from agents.skill_tracker import get_skill_tracker
|
| 1383 |
+
return {
|
| 1384 |
+
"session_id": session_id,
|
| 1385 |
+
"stats": get_skill_tracker().get_stats(session_id),
|
| 1386 |
+
}
|
| 1387 |
+
except Exception as exc:
|
| 1388 |
+
return {"session_id": session_id, "stats": {}, "error": str(exc)}
|
| 1389 |
+
|
| 1390 |
+
|
| 1391 |
+
@router.get('/api/agent/skill-stats')
|
| 1392 |
+
async def list_all_skill_sessions(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1393 |
+
"""Debug: panoramica di tutte le sessioni SkillTracker attive (tool count, call count)."""
|
| 1394 |
+
try:
|
| 1395 |
+
from agents.skill_tracker import get_skill_tracker
|
| 1396 |
+
return get_skill_tracker().get_all_sessions()
|
| 1397 |
+
except Exception as exc:
|
| 1398 |
+
return {"error": str(exc)}
|
| 1399 |
+
|
| 1400 |
+
# ββ /api/agent/circuit-status/{session_id} β circuit breaker live status ββββββ
|
| 1401 |
+
# Espone per ogni tool tracciato in sessione: stato circuito, Wilson score,
|
| 1402 |
+
# recovery calls effettuate β utile per debug e monitoring real-time.
|
| 1403 |
+
@router.get('/api/agent/circuit-status/{session_id}')
|
| 1404 |
+
async def get_circuit_status(session_id: str, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 1405 |
+
"""
|
| 1406 |
+
Stato real-time del circuit breaker per ogni tool di una sessione.
|
| 1407 |
+
|
| 1408 |
+
Per ogni tool tracciato, classifica il circuito come:
|
| 1409 |
+
- open β Wilson score < 0.15 AND total_count >= 3 AND tool ha fallback
|
| 1410 |
+
(il tool viene bypassato β routing automatico ai fallback)
|
| 1411 |
+
- closed β performance sufficiente o dati insufficienti per aprire il circuit
|
| 1412 |
+
|
| 1413 |
+
Campi per tool:
|
| 1414 |
+
wilson_score: lower bound dell'intervallo di confidenza al 95% (0β1)
|
| 1415 |
+
success_count: successi registrati nella sessione
|
| 1416 |
+
fail_count: fallimenti registrati nella sessione
|
| 1417 |
+
total_count: chiamate totali
|
| 1418 |
+
success_rate: raw rate (NON usato dal circuit β solo informativo)
|
| 1419 |
+
avg_latency_ms: latenza media (ms)
|
| 1420 |
+
has_fallbacks: True se TOOL_REGISTRY definisce fallback per il tool
|
| 1421 |
+
recovery_calls: quante volte il recovery credit ha concesso un tentativo
|
| 1422 |
+
circuit_state: "open" | "closed" | "no_data" | "insufficient_data"
|
| 1423 |
+
|
| 1424 |
+
Thresholds (from executor.py):
|
| 1425 |
+
circuit_open_threshold: 0.15 (Wilson score sotto cui il circuit si apre)
|
| 1426 |
+
min_calls_for_circuit: 3 (chiamate minime prima che il circuit possa aprirsi)
|
| 1427 |
+
recovery_interval: 5 (ogni N call con circuit open β recovery attempt)
|
| 1428 |
+
"""
|
| 1429 |
+
try:
|
| 1430 |
+
from agents.skill_tracker import get_skill_tracker
|
| 1431 |
+
from tools.registry import TOOL_REGISTRY
|
| 1432 |
+
from api.state import _get_executor
|
| 1433 |
+
from agents.executor import (
|
| 1434 |
+
_CIRCUIT_OPEN_THRESHOLD,
|
| 1435 |
+
_MIN_CALLS_FOR_CIRCUIT,
|
| 1436 |
+
_RECOVERY_INTERVAL,
|
| 1437 |
+
)
|
| 1438 |
+
|
| 1439 |
+
stats = get_skill_tracker().get_stats(session_id)
|
| 1440 |
+
|
| 1441 |
+
# Recovery counts vivono nell'istanza Executor singleton
|
| 1442 |
+
executor = _get_executor()
|
| 1443 |
+
rec_counts: dict = {}
|
| 1444 |
+
if executor is not None:
|
| 1445 |
+
rec_counts = getattr(executor, '_circuit_recovery_counts', {})
|
| 1446 |
+
|
| 1447 |
+
circuits_open: list[dict] = []
|
| 1448 |
+
circuits_closed: list[dict] = []
|
| 1449 |
+
|
| 1450 |
+
for tool_name, s in stats.items():
|
| 1451 |
+
has_fallbacks = bool(TOOL_REGISTRY.get(tool_name, {}).get('fallbacks'))
|
| 1452 |
+
recovery_calls = rec_counts.get(tool_name, 0)
|
| 1453 |
+
|
| 1454 |
+
# Replica logica _is_circuit_open() di executor.py
|
| 1455 |
+
if s['total_count'] == 0:
|
| 1456 |
+
state = 'no_data'
|
| 1457 |
+
elif s['total_count'] < _MIN_CALLS_FOR_CIRCUIT:
|
| 1458 |
+
state = 'insufficient_data'
|
| 1459 |
+
elif s['wilson_score'] < _CIRCUIT_OPEN_THRESHOLD and has_fallbacks:
|
| 1460 |
+
state = 'open'
|
| 1461 |
+
else:
|
| 1462 |
+
state = 'closed'
|
| 1463 |
+
|
| 1464 |
+
entry = {
|
| 1465 |
+
'tool': tool_name,
|
| 1466 |
+
'circuit_state': state,
|
| 1467 |
+
'wilson_score': s['wilson_score'],
|
| 1468 |
+
'success_count': s['success_count'],
|
| 1469 |
+
'fail_count': s['fail_count'],
|
| 1470 |
+
'total_count': s['total_count'],
|
| 1471 |
+
'success_rate': s['success_rate'],
|
| 1472 |
+
'avg_latency_ms': s['avg_latency_ms'],
|
| 1473 |
+
'has_fallbacks': has_fallbacks,
|
| 1474 |
+
'recovery_calls': recovery_calls,
|
| 1475 |
+
}
|
| 1476 |
+
if state == 'open':
|
| 1477 |
+
circuits_open.append(entry)
|
| 1478 |
+
else:
|
| 1479 |
+
circuits_closed.append(entry)
|
| 1480 |
+
|
| 1481 |
+
# Ordina open per Wilson score asc (peggiori prima), closed per desc (migliori prima)
|
| 1482 |
+
circuits_open.sort(key=lambda x: x['wilson_score'])
|
| 1483 |
+
circuits_closed.sort(key=lambda x: x['wilson_score'], reverse=True)
|
| 1484 |
+
|
| 1485 |
+
return {
|
| 1486 |
+
'session_id': session_id,
|
| 1487 |
+
'total_tools_tracked': len(stats),
|
| 1488 |
+
'circuits_open_count': len(circuits_open),
|
| 1489 |
+
'circuits_closed_count': len(circuits_closed),
|
| 1490 |
+
'circuits_open': circuits_open,
|
| 1491 |
+
'circuits_closed': circuits_closed,
|
| 1492 |
+
'thresholds': {
|
| 1493 |
+
'circuit_open_threshold': _CIRCUIT_OPEN_THRESHOLD,
|
| 1494 |
+
'min_calls_for_circuit': _MIN_CALLS_FOR_CIRCUIT,
|
| 1495 |
+
'recovery_interval': _RECOVERY_INTERVAL,
|
| 1496 |
+
},
|
| 1497 |
+
}
|
| 1498 |
+
except Exception as exc:
|
| 1499 |
+
return {
|
| 1500 |
+
'session_id': session_id,
|
| 1501 |
+
'total_tools_tracked': 0,
|
| 1502 |
+
'circuits_open_count': 0,
|
| 1503 |
+
'circuits_open': [],
|
| 1504 |
+
'circuits_closed': [],
|
| 1505 |
+
'error': str(exc),
|
| 1506 |
+
}
|
| 1507 |
+
|
| 1508 |
+
@router.post('/api/agent/abort')
|
| 1509 |
+
async def abort_agent_task(taskId: str = Body(..., embed=True), role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
| 1510 |
+
"""
|
| 1511 |
+
ABORT-ENDPOINT: Permette di cancellare un task in streaming (run-stream).
|
| 1512 |
+
Inserisce un segnale __abort__ nella coda del task, che verrΓ catturato
|
| 1513 |
+
dal loop SSE per interrompere l'esecuzione e pulire le risorse.
|
| 1514 |
+
"""
|
| 1515 |
+
if taskId in _run_stream_tasks:
|
| 1516 |
+
queue = _run_stream_tasks[taskId].get("queue")
|
| 1517 |
+
if queue:
|
| 1518 |
+
await queue.put({"__abort__": True})
|
| 1519 |
+
return {"ok": True, "message": f"Abort signal sent to task {taskId}"}
|
| 1520 |
+
return {"ok": False, "message": "Task not found or already finished"}
|
api/agent_memory.py
CHANGED
|
@@ -8,22 +8,17 @@ Fix: dopo ogni write Supabase riuscita, schedula un tentativo di sync del
|
|
| 8 |
fallback β se ci sono voci orfane le pubblica su Supabase e le rimuove dal
|
| 9 |
fallback locale. Nessun job periodico (troppo pesante su free-tier) β lazy
|
| 10 |
reconciliation al primo write riuscito dopo un periodo di downtime Supabase.
|
| 11 |
-
|
| 12 |
-
GAP-AUTH-MEMORY fix: POST e DELETE protetti con require_role(AuthRole.MACHINE).
|
| 13 |
-
GAP-MEM-RECONCILE-BREAK fix: continue invece di break per errori record-level.
|
| 14 |
-
GAP-AGENT-MEMORY-RECONCILE-TASK fix: create_task wrappato in try/except RuntimeError.
|
| 15 |
"""
|
| 16 |
import time, asyncio
|
| 17 |
from fastapi import APIRouter, Depends
|
|
|
|
| 18 |
from pydantic import BaseModel
|
| 19 |
from .state import _sb, _mem_fallback
|
| 20 |
-
from .auth_guard import require_role, AuthRole
|
| 21 |
-
from .global_state_sync import get_global_state_sync
|
| 22 |
|
| 23 |
import logging
|
| 24 |
_logger = logging.getLogger("api.agent_memory")
|
| 25 |
|
| 26 |
-
router = APIRouter()
|
| 27 |
|
| 28 |
|
| 29 |
class MemoryEntry(BaseModel):
|
|
@@ -41,10 +36,6 @@ async def _reconcile_fallback() -> int:
|
|
| 41 |
solo in fallback (es. dopo un periodo di downtime Supabase), le pubblica.
|
| 42 |
Ritorna il numero di voci sincronizzate.
|
| 43 |
Non solleva mai eccezioni β fire-and-forget.
|
| 44 |
-
|
| 45 |
-
GAP-MEM-RECONCILE-BREAK fix: break solo su errori network/connessione;
|
| 46 |
-
continue per errori specifici al record (tipo sbagliato, valore too large, ecc.)
|
| 47 |
-
per non bloccare la riconciliazione delle voci successive.
|
| 48 |
"""
|
| 49 |
if not _sb or not _mem_fallback:
|
| 50 |
return 0
|
|
@@ -60,14 +51,8 @@ async def _reconcile_fallback() -> int:
|
|
| 60 |
}, on_conflict='key').execute()
|
| 61 |
synced += 1
|
| 62 |
except Exception as _e:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
if _is_network:
|
| 66 |
-
_logger.debug("[memory] reconcile: network error at key=%s β stopping: %s", key, _e)
|
| 67 |
-
break # Supabase non raggiungibile β interrompi, riprova al prossimo write
|
| 68 |
-
# Errore specifico al record (tipo sbagliato, valore corrotto, ecc.) β salta e continua
|
| 69 |
-
_logger.debug("[memory] reconcile: record-level error at key=%s β skipping: %s", key, _e)
|
| 70 |
-
continue
|
| 71 |
if synced:
|
| 72 |
_logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
|
| 73 |
return synced
|
|
@@ -75,21 +60,6 @@ async def _reconcile_fallback() -> int:
|
|
| 75 |
|
| 76 |
@router.get('/api/memory/agent')
|
| 77 |
async def list_agent_memory():
|
| 78 |
-
# S766-GRID: Global State Sync layer (Supabase Federation)
|
| 79 |
-
sync = get_global_state_sync()
|
| 80 |
-
try:
|
| 81 |
-
unified = await sync.get_unified_memory("all_entries")
|
| 82 |
-
if unified and unified.get("data"):
|
| 83 |
-
# Mappa i dati unificati nel formato atteso dal frontend
|
| 84 |
-
entries = [
|
| 85 |
-
{'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
|
| 86 |
-
'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
|
| 87 |
-
for r in unified["data"]
|
| 88 |
-
]
|
| 89 |
-
return {'entries': entries}
|
| 90 |
-
except Exception as _exc:
|
| 91 |
-
_logger.debug("[memory] grid sync list fail: %s", _exc)
|
| 92 |
-
|
| 93 |
if _sb:
|
| 94 |
try:
|
| 95 |
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute()
|
|
@@ -118,13 +88,7 @@ async def get_agent_memory(key: str):
|
|
| 118 |
|
| 119 |
|
| 120 |
@router.post('/api/memory/agent')
|
| 121 |
-
async def set_agent_memory(
|
| 122 |
-
entry: MemoryEntry,
|
| 123 |
-
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 124 |
-
):
|
| 125 |
-
"""GAP-AUTH-MEMORY fix: endpoint protetto con require_role(MACHINE).
|
| 126 |
-
Richiede X-Internal-Token header (aggiunto dal CF Worker su tutte le route non-public).
|
| 127 |
-
"""
|
| 128 |
now = int(time.time() * 1000)
|
| 129 |
record = {
|
| 130 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
|
@@ -140,12 +104,9 @@ async def set_agent_memory(
|
|
| 140 |
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 141 |
}, on_conflict='key').execute()
|
| 142 |
# GAP-MEM-FIX: Supabase disponibile β schedula riconciliazione fallback orfano
|
| 143 |
-
#
|
| 144 |
if len(_mem_fallback) > 1:
|
| 145 |
-
|
| 146 |
-
asyncio.create_task(_reconcile_fallback())
|
| 147 |
-
except RuntimeError:
|
| 148 |
-
pass # Event loop non attivo (test/startup context) β task silently dropped
|
| 149 |
except Exception as _e:
|
| 150 |
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
| 151 |
|
|
@@ -153,13 +114,7 @@ async def set_agent_memory(
|
|
| 153 |
|
| 154 |
|
| 155 |
@router.delete('/api/memory/agent/{key}')
|
| 156 |
-
async def delete_agent_memory(
|
| 157 |
-
key: str,
|
| 158 |
-
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 159 |
-
):
|
| 160 |
-
"""GAP-AUTH-MEMORY fix: endpoint protetto con require_role(MACHINE).
|
| 161 |
-
Richiede X-Internal-Token header (aggiunto dal CF Worker su tutte le route non-public).
|
| 162 |
-
"""
|
| 163 |
if _sb:
|
| 164 |
try:
|
| 165 |
_sb.table('agent_memory').delete().eq('key', key).execute()
|
|
|
|
| 8 |
fallback β se ci sono voci orfane le pubblica su Supabase e le rimuove dal
|
| 9 |
fallback locale. Nessun job periodico (troppo pesante su free-tier) β lazy
|
| 10 |
reconciliation al primo write riuscito dopo un periodo di downtime Supabase.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
import time, asyncio
|
| 13 |
from fastapi import APIRouter, Depends
|
| 14 |
+
from .auth_guard import require_role, AuthRole
|
| 15 |
from pydantic import BaseModel
|
| 16 |
from .state import _sb, _mem_fallback
|
|
|
|
|
|
|
| 17 |
|
| 18 |
import logging
|
| 19 |
_logger = logging.getLogger("api.agent_memory")
|
| 20 |
|
| 21 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 22 |
|
| 23 |
|
| 24 |
class MemoryEntry(BaseModel):
|
|
|
|
| 36 |
solo in fallback (es. dopo un periodo di downtime Supabase), le pubblica.
|
| 37 |
Ritorna il numero di voci sincronizzate.
|
| 38 |
Non solleva mai eccezioni β fire-and-forget.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
"""
|
| 40 |
if not _sb or not _mem_fallback:
|
| 41 |
return 0
|
|
|
|
| 51 |
}, on_conflict='key').execute()
|
| 52 |
synced += 1
|
| 53 |
except Exception as _e:
|
| 54 |
+
_logger.debug("[memory] reconcile stopped at key=%s: %s", key, _e)
|
| 55 |
+
break # Supabase non disponibile β interrompi, riprova al prossimo write
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if synced:
|
| 57 |
_logger.info("[memory] GAP-MEM-FIX: reconciled %d fallback entries to Supabase", synced)
|
| 58 |
return synced
|
|
|
|
| 60 |
|
| 61 |
@router.get('/api/memory/agent')
|
| 62 |
async def list_agent_memory():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if _sb:
|
| 64 |
try:
|
| 65 |
data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute()
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
@router.post('/api/memory/agent')
|
| 91 |
+
async def set_agent_memory(entry: MemoryEntry):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
now = int(time.time() * 1000)
|
| 93 |
record = {
|
| 94 |
'key': entry.key, 'value': entry.value, 'category': entry.category,
|
|
|
|
| 104 |
'created_at': entry.createdAt or now, 'updated_at': entry.updatedAt or now,
|
| 105 |
}, on_conflict='key').execute()
|
| 106 |
# GAP-MEM-FIX: Supabase disponibile β schedula riconciliazione fallback orfano
|
| 107 |
+
# (voci scritte solo in fallback durante downtime precedente)
|
| 108 |
if len(_mem_fallback) > 1:
|
| 109 |
+
asyncio.create_task(_reconcile_fallback())
|
|
|
|
|
|
|
|
|
|
| 110 |
except Exception as _e:
|
| 111 |
_logger.warning('[memory] Supabase write error (fallback attivo): %s', _e)
|
| 112 |
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
@router.delete('/api/memory/agent/{key}')
|
| 117 |
+
async def delete_agent_memory(key: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
if _sb:
|
| 119 |
try:
|
| 120 |
_sb.table('agent_memory').delete().eq('key', key).execute()
|
api/agent_telemetry.py
CHANGED
|
@@ -18,11 +18,12 @@ Merge: additive β per ogni (system, verdict) prende max(count) e max(lastSeenM
|
|
| 18 |
"""
|
| 19 |
import os, json, logging, time, asyncio
|
| 20 |
from pathlib import Path
|
| 21 |
-
from fastapi import APIRouter
|
|
|
|
| 22 |
from fastapi.responses import JSONResponse
|
| 23 |
from pydantic import BaseModel
|
| 24 |
|
| 25 |
-
router = APIRouter()
|
| 26 |
_logger = logging.getLogger("agente_ai")
|
| 27 |
|
| 28 |
# βββ Storage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 18 |
"""
|
| 19 |
import os, json, logging, time, asyncio
|
| 20 |
from pathlib import Path
|
| 21 |
+
from fastapi import APIRouter, Depends
|
| 22 |
+
from .auth_guard import require_role, AuthRole
|
| 23 |
from fastapi.responses import JSONResponse
|
| 24 |
from pydantic import BaseModel
|
| 25 |
|
| 26 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 27 |
_logger = logging.getLogger("agente_ai")
|
| 28 |
|
| 29 |
# βββ Storage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
api/auth_guard.py
CHANGED
|
@@ -59,14 +59,8 @@ _upstash_token = os.getenv('UPSTASH_REDIS_TOKEN', '')
|
|
| 59 |
_use_redis = bool(_upstash_url and _upstash_token)
|
| 60 |
|
| 61 |
def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]:
|
| 62 |
-
"""Rate check via Upstash Redis REST API (zero dipendenze extra).
|
| 63 |
-
|
| 64 |
-
GAP-RATE-LIMIT-REDIS-FAILOPEN fix: distingue errori network/timeout (fail-open
|
| 65 |
-
silenzioso β corretto) da errori di parsing/logica (fail-open MA loggati a WARNING
|
| 66 |
-
per visibilitΓ nel monitoraggio). In entrambi i casi non blocca l'utente, ma gli
|
| 67 |
-
errori non-network diventano visibili nei log invece di essere silenziosi.
|
| 68 |
-
"""
|
| 69 |
-
import urllib.request as _ur, urllib.error as _ue, json as _js, time as _rt
|
| 70 |
now_s = int(_rt.time())
|
| 71 |
window_key = f'{key}:{now_s // window_s}'
|
| 72 |
try:
|
|
@@ -85,18 +79,9 @@ def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]:
|
|
| 85 |
if count > limit:
|
| 86 |
return False, window_s
|
| 87 |
return True, 0
|
| 88 |
-
except
|
| 89 |
-
|
| 90 |
-
return
|
| 91 |
-
except Exception as _e:
|
| 92 |
-
# GAP-RATE-LIMIT-REDIS-FAILOPEN fix: errore di parsing JSON o bug nel codice β
|
| 93 |
-
# fail-open ma loggato a WARNING per visibilitΓ (non silenzioso come prima).
|
| 94 |
-
# Causa tipica: struttura risposta Upstash cambiata, bug nel codice di parsing.
|
| 95 |
-
logger.warning(
|
| 96 |
-
"redis_rate_check: non-network error β rate limiter disabled for this request "
|
| 97 |
-
"(key=%s): %s: %s", key, type(_e).__name__, _e
|
| 98 |
-
)
|
| 99 |
-
return True, 0 # fail-open: mai bloccare per bug infrastrutturale
|
| 100 |
|
| 101 |
|
| 102 |
import time as _rl_time
|
|
@@ -128,6 +113,31 @@ def _rate_key(role: int, token_header: str | None, client_ip: str | None = None)
|
|
| 128 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def _check_rate_limit(
|
| 132 |
role: int,
|
| 133 |
token_header: str | None,
|
|
@@ -146,26 +156,10 @@ def _check_rate_limit(
|
|
| 146 |
|
| 147 |
key = _rate_key(role, token_header, client_ip)
|
| 148 |
# DoS-FIX: usa Redis se disponibile (persiste cross-restart HF Space)
|
|
|
|
| 149 |
if _use_redis:
|
| 150 |
return _redis_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 151 |
-
|
| 152 |
-
window_start = now - _RATE_WINDOW_S
|
| 153 |
-
|
| 154 |
-
if key not in _rate_store:
|
| 155 |
-
_rate_store[key] = _col.deque()
|
| 156 |
-
dq = _rate_store[key]
|
| 157 |
-
|
| 158 |
-
# Rimuovi timestamp fuori dalla finestra
|
| 159 |
-
while dq and dq[0] < window_start:
|
| 160 |
-
dq.popleft()
|
| 161 |
-
|
| 162 |
-
if len(dq) >= limit:
|
| 163 |
-
# Prossima slot disponibile = timestamp piΓΉ vecchio + finestra
|
| 164 |
-
retry_after = int(_RATE_WINDOW_S - (now - dq[0])) + 1
|
| 165 |
-
return False, max(retry_after, 1)
|
| 166 |
-
|
| 167 |
-
dq.append(now)
|
| 168 |
-
return True, 0
|
| 169 |
|
| 170 |
|
| 171 |
class AuthRole(IntEnum):
|
|
@@ -186,21 +180,22 @@ async def _resolve_role(
|
|
| 186 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 187 |
) -> AuthRole:
|
| 188 |
"""Risolve il ruolo del chiamante in base agli header presenti."""
|
|
|
|
| 189 |
# ADMIN (massima prioritΓ )
|
| 190 |
admin_tok = _get_token("ADMIN_TOKEN")
|
| 191 |
-
if admin_tok and x_admin_token
|
| 192 |
logger.debug("auth: ADMIN role granted")
|
| 193 |
return AuthRole.ADMIN
|
| 194 |
|
| 195 |
# OPERATOR
|
| 196 |
op_tok = _get_token("OPERATOR_TOKEN")
|
| 197 |
-
if op_tok and x_operator_token
|
| 198 |
logger.debug("auth: OPERATOR role granted")
|
| 199 |
return AuthRole.OPERATOR
|
| 200 |
|
| 201 |
# MACHINE (INTERNAL_TOKEN, giΓ generato al boot da main.py)
|
| 202 |
int_tok = _get_token("INTERNAL_TOKEN")
|
| 203 |
-
if int_tok and x_internal_token
|
| 204 |
logger.debug("auth: MACHINE role granted")
|
| 205 |
return AuthRole.MACHINE
|
| 206 |
|
|
@@ -277,4 +272,4 @@ def require_role(min_role: AuthRole):
|
|
| 277 |
async def any_role(resolved: AuthRole = Depends(_resolve_role)) -> AuthRole:
|
| 278 |
"""Dependency che accetta qualsiasi ruolo (incluso USER senza token).
|
| 279 |
Usare per endpoint pubblici che vogliono loggare il ruolo del chiamante."""
|
| 280 |
-
return resolved
|
|
|
|
| 59 |
_use_redis = bool(_upstash_url and _upstash_token)
|
| 60 |
|
| 61 |
def _redis_rate_check(key: str, limit: int, window_s: int) -> tuple[bool, int]:
|
| 62 |
+
"""Rate check via Upstash Redis REST API (zero dipendenze extra)."""
|
| 63 |
+
import urllib.request as _ur, json as _js, time as _rt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
now_s = int(_rt.time())
|
| 65 |
window_key = f'{key}:{now_s // window_s}'
|
| 66 |
try:
|
|
|
|
| 79 |
if count > limit:
|
| 80 |
return False, window_s
|
| 81 |
return True, 0
|
| 82 |
+
except Exception as _exc: # SEC2-5: Redis irraggiungibile β fallback in-memory, non fail-open puro
|
| 83 |
+
logger.warning('[auth_guard] Redis rate check fallito (%s), fallback in-memory', _exc)
|
| 84 |
+
return _inmem_rate_check(key, limit, window_s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
import time as _rl_time
|
|
|
|
| 113 |
return _rl_hash.sha256(raw.encode()).hexdigest()[:16]
|
| 114 |
|
| 115 |
|
| 116 |
+
|
| 117 |
+
def _inmem_rate_check(key: str, limit: int, window_s: float) -> tuple[bool, int]:
|
| 118 |
+
"""Rate check in-memory sliding window.
|
| 119 |
+
|
| 120 |
+
SEC2-5: usata sia quando Redis non Γ¨ configurato, sia come fallback quando
|
| 121 |
+
Redis Γ¨ temporaneamente irraggiungibile (prima era fail-open puro).
|
| 122 |
+
Thread-safe in asyncio (GIL, nessun await interno).
|
| 123 |
+
"""
|
| 124 |
+
now = _rl_time.monotonic()
|
| 125 |
+
window_start = now - window_s
|
| 126 |
+
|
| 127 |
+
if key not in _rate_store:
|
| 128 |
+
_rate_store[key] = _col.deque()
|
| 129 |
+
dq = _rate_store[key]
|
| 130 |
+
|
| 131 |
+
while dq and dq[0] < window_start:
|
| 132 |
+
dq.popleft()
|
| 133 |
+
|
| 134 |
+
if len(dq) >= limit:
|
| 135 |
+
retry_after = int(window_s - (now - dq[0])) + 1
|
| 136 |
+
return False, max(retry_after, 1)
|
| 137 |
+
|
| 138 |
+
dq.append(now)
|
| 139 |
+
return True, 0
|
| 140 |
+
|
| 141 |
def _check_rate_limit(
|
| 142 |
role: int,
|
| 143 |
token_header: str | None,
|
|
|
|
| 156 |
|
| 157 |
key = _rate_key(role, token_header, client_ip)
|
| 158 |
# DoS-FIX: usa Redis se disponibile (persiste cross-restart HF Space)
|
| 159 |
+
# SEC2-5: se Redis fallisce, _redis_rate_check fa fallback su _inmem_rate_check
|
| 160 |
if _use_redis:
|
| 161 |
return _redis_rate_check(key, limit, int(_RATE_WINDOW_S))
|
| 162 |
+
return _inmem_rate_check(key, limit, int(_RATE_WINDOW_S))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
class AuthRole(IntEnum):
|
|
|
|
| 180 |
x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token"),
|
| 181 |
) -> AuthRole:
|
| 182 |
"""Risolve il ruolo del chiamante in base agli header presenti."""
|
| 183 |
+
import secrets as _sec_comp
|
| 184 |
# ADMIN (massima prioritΓ )
|
| 185 |
admin_tok = _get_token("ADMIN_TOKEN")
|
| 186 |
+
if admin_tok and x_admin_token and _sec_comp.compare_digest(x_admin_token, admin_tok):
|
| 187 |
logger.debug("auth: ADMIN role granted")
|
| 188 |
return AuthRole.ADMIN
|
| 189 |
|
| 190 |
# OPERATOR
|
| 191 |
op_tok = _get_token("OPERATOR_TOKEN")
|
| 192 |
+
if op_tok and x_operator_token and _sec_comp.compare_digest(x_operator_token, op_tok):
|
| 193 |
logger.debug("auth: OPERATOR role granted")
|
| 194 |
return AuthRole.OPERATOR
|
| 195 |
|
| 196 |
# MACHINE (INTERNAL_TOKEN, giΓ generato al boot da main.py)
|
| 197 |
int_tok = _get_token("INTERNAL_TOKEN")
|
| 198 |
+
if int_tok and x_internal_token and _sec_comp.compare_digest(x_internal_token, int_tok):
|
| 199 |
logger.debug("auth: MACHINE role granted")
|
| 200 |
return AuthRole.MACHINE
|
| 201 |
|
|
|
|
| 272 |
async def any_role(resolved: AuthRole = Depends(_resolve_role)) -> AuthRole:
|
| 273 |
"""Dependency che accetta qualsiasi ruolo (incluso USER senza token).
|
| 274 |
Usare per endpoint pubblici che vogliono loggare il ruolo del chiamante."""
|
| 275 |
+
return resolved
|
api/auth_managed.py
CHANGED
|
@@ -9,11 +9,12 @@ Rotte:
|
|
| 9 |
Provider supportati: github, google, instagram.
|
| 10 |
I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
|
| 11 |
|
| 12 |
-
Cifratura token: Fernet(
|
| 13 |
"""
|
| 14 |
import os, time, secrets, json, logging, asyncio
|
| 15 |
from typing import Optional
|
| 16 |
-
from fastapi import APIRouter, Request, HTTPException
|
|
|
|
| 17 |
from fastapi.responses import RedirectResponse, JSONResponse
|
| 18 |
import httpx
|
| 19 |
|
|
@@ -21,62 +22,134 @@ _logger = logging.getLogger('api.auth_managed')
|
|
| 21 |
router = APIRouter()
|
| 22 |
|
| 23 |
# ββ Fernet encryption setup ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def _get_fernet():
|
| 25 |
-
"""Lazy-init Fernet cipher
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
def _encrypt(text: str) -> str:
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
return f.encrypt(text.encode()).decode()
|
| 45 |
|
| 46 |
def _decrypt(token: str) -> str:
|
| 47 |
-
|
| 48 |
-
if not f or not token:
|
| 49 |
return token
|
|
|
|
| 50 |
try:
|
| 51 |
return f.decrypt(token.encode()).decode()
|
| 52 |
except Exception:
|
|
|
|
| 53 |
return '' # corrotto o key cambiata
|
| 54 |
|
| 55 |
-
# ββ
|
| 56 |
-
#
|
| 57 |
-
|
|
|
|
| 58 |
_STATE_TTL = 600 # 10 minuti
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
state = secrets.token_urlsafe(32)
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return state
|
| 65 |
|
| 66 |
-
def _consume_state(state: str) -> Optional[dict]:
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
if not entry:
|
| 70 |
return None
|
| 71 |
if time.time() - entry['created_at'] > _STATE_TTL:
|
| 72 |
return None
|
| 73 |
return entry
|
| 74 |
|
| 75 |
-
def
|
| 76 |
now = time.time()
|
| 77 |
-
expired = [k for k, v in
|
| 78 |
for k in expired:
|
| 79 |
-
|
| 80 |
|
| 81 |
# ββ Provider configs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 82 |
_BACKEND_URL = os.getenv('BACKEND_URL', '').rstrip('/')
|
|
@@ -101,7 +174,9 @@ _PROVIDER_CONFIGS = {
|
|
| 101 |
'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
| 102 |
'token_url': 'https://oauth2.googleapis.com/token',
|
| 103 |
'userinfo_url': 'https://www.googleapis.com/oauth2/v2/userinfo',
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
'client_id_env': 'GOOGLE_OAUTH_CLIENT_ID',
|
| 106 |
'client_secret_env': 'GOOGLE_OAUTH_CLIENT_SECRET',
|
| 107 |
},
|
|
@@ -151,7 +226,7 @@ async def _sb_get_token(user_id: str, provider: str) -> Optional[dict]:
|
|
| 151 |
try:
|
| 152 |
res = await asyncio.to_thread(
|
| 153 |
lambda: _sb.table('managed_tokens')
|
| 154 |
-
.select('provider,scope,expires_at,updated_at,raw_meta,access_token')
|
| 155 |
.eq('user_id', user_id)
|
| 156 |
.eq('provider', provider)
|
| 157 |
.limit(1)
|
|
@@ -198,17 +273,81 @@ async def _sb_delete_token(user_id: str, provider: str) -> None:
|
|
| 198 |
|
| 199 |
# Public helper: altri moduli chiamano questa per ottenere un token decifrato
|
| 200 |
async def get_managed_token(user_id: str, provider: str) -> Optional[str]:
|
| 201 |
-
"""Restituisce il token d'accesso decifrato per (user_id, provider). None se non connesso.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
row = await _sb_get_token(user_id, provider)
|
| 203 |
if not row:
|
| 204 |
return None
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 209 |
|
| 210 |
def _user_id(request: Request) -> str:
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
@router.get('/api/auth/connect/{provider}')
|
|
@@ -227,7 +366,7 @@ async def connect_provider(provider: str, request: Request):
|
|
| 227 |
})
|
| 228 |
|
| 229 |
user_id = _user_id(request)
|
| 230 |
-
state = _make_state(provider, user_id)
|
| 231 |
callback = _get_callback_url(provider)
|
| 232 |
|
| 233 |
params = {
|
|
@@ -265,7 +404,7 @@ async def oauth_callback(provider: str, request: Request):
|
|
| 265 |
_logger.warning('[auth_managed] callback %s error=%s', provider, error)
|
| 266 |
return RedirectResponse(url=f"{frontend}?oauth_error={error}&provider={provider}")
|
| 267 |
|
| 268 |
-
state_data = _consume_state(state)
|
| 269 |
if not state_data:
|
| 270 |
_logger.warning('[auth_managed] invalid/expired state %s', state[:20])
|
| 271 |
return RedirectResponse(url=f"{frontend}?oauth_error=invalid_state&provider={provider}")
|
|
@@ -331,7 +470,7 @@ async def oauth_callback(provider: str, request: Request):
|
|
| 331 |
|
| 332 |
|
| 333 |
@router.get('/api/auth/providers')
|
| 334 |
-
async def list_providers(request: Request):
|
| 335 |
"""Restituisce stato connessione di tutti i provider per l'utente corrente."""
|
| 336 |
user_id = _user_id(request)
|
| 337 |
rows = await _sb_list_tokens(user_id)
|
|
@@ -373,7 +512,7 @@ async def list_providers(request: Request):
|
|
| 373 |
|
| 374 |
|
| 375 |
@router.delete('/api/auth/disconnect/{provider}')
|
| 376 |
-
async def disconnect_provider(provider: str, request: Request):
|
| 377 |
"""Rimuove il token salvato per il provider specificato."""
|
| 378 |
if provider not in _PROVIDER_CONFIGS:
|
| 379 |
raise HTTPException(400, detail={'error': 'unknown_provider'})
|
|
|
|
| 9 |
Provider supportati: github, google, instagram.
|
| 10 |
I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
|
| 11 |
|
| 12 |
+
Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) β richiede cryptography>=42.
|
| 13 |
"""
|
| 14 |
import os, time, secrets, json, logging, asyncio
|
| 15 |
from typing import Optional
|
| 16 |
+
from fastapi import APIRouter, Depends, Request, HTTPException
|
| 17 |
+
from .auth_guard import require_role, AuthRole
|
| 18 |
from fastapi.responses import RedirectResponse, JSONResponse
|
| 19 |
import httpx
|
| 20 |
|
|
|
|
| 22 |
router = APIRouter()
|
| 23 |
|
| 24 |
# ββ Fernet encryption setup ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
# Salt statico pubblico: accettabile per chiave macchina (non password utente).
|
| 26 |
+
# Il VAULT_KEY Γ¨ il segreto; il salt previene rainbow-table cross-application.
|
| 27 |
+
# β οΈ ATTENZIONE MIGRAZIONE: cambiare questo salt o la derivazione invalida
|
| 28 |
+
# tutti i token OAuth cifrati esistenti β gli utenti dovranno riconnettere
|
| 29 |
+
# i provider. Questo Γ¨ intenzionale quando si corregge una derivazione debole.
|
| 30 |
+
_FERNET_SALT = b'agente-ai-vault-v1-pbkdf2'
|
| 31 |
+
|
| 32 |
def _get_fernet():
|
| 33 |
+
"""Lazy-init Fernet cipher da VAULT_KEY via PBKDF2HMAC-SHA256 (260k iter).
|
| 34 |
+
|
| 35 |
+
Fix P19-SEC2-4: SHA-256 raw (veloce, GPU-bruteforce in ore su chiavi brevi)
|
| 36 |
+
sostituito con PBKDF2HMAC 260_000 iter β conforme NIST SP 800-132 (2023).
|
| 37 |
+
β οΈ Cambio di derivazione: i token cifrati con SHA-256 non sono piΓΉ decifrabili.
|
| 38 |
+
_decrypt() ritorna '' su InvalidToken β gli utenti devono riconnettersi.
|
| 39 |
+
"""
|
| 40 |
+
from cryptography.fernet import Fernet
|
| 41 |
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
| 42 |
+
from cryptography.hazmat.primitives import hashes as _hashes
|
| 43 |
+
import base64
|
| 44 |
+
vault_key = os.getenv('VAULT_KEY', '')
|
| 45 |
+
if not vault_key:
|
| 46 |
+
raise RuntimeError('VAULT_KEY non configurata β impossibile cifrare/decifrare i token OAuth')
|
| 47 |
+
kdf = PBKDF2HMAC(
|
| 48 |
+
algorithm=_hashes.SHA256(),
|
| 49 |
+
length=32,
|
| 50 |
+
salt=_FERNET_SALT,
|
| 51 |
+
iterations=260_000, # NIST SP 800-132 (2023): β₯ 210_000 per PBKDF2-SHA256
|
| 52 |
+
)
|
| 53 |
+
fernet_key = base64.urlsafe_b64encode(kdf.derive(vault_key.encode('utf-8')))
|
| 54 |
+
return Fernet(fernet_key)
|
| 55 |
|
| 56 |
def _encrypt(text: str) -> str:
|
| 57 |
+
if not text:
|
| 58 |
+
return text
|
| 59 |
+
f = _get_fernet() # P19-SEC2-4: propaga eccezione β niente piΓΉ plaintext fallback
|
| 60 |
return f.encrypt(text.encode()).decode()
|
| 61 |
|
| 62 |
def _decrypt(token: str) -> str:
|
| 63 |
+
if not token:
|
|
|
|
| 64 |
return token
|
| 65 |
+
f = _get_fernet()
|
| 66 |
try:
|
| 67 |
return f.decrypt(token.encode()).decode()
|
| 68 |
except Exception:
|
| 69 |
+
_logger.error('[auth_managed] decrypt fallito β token corrotto o VAULT_KEY cambiata')
|
| 70 |
return '' # corrotto o key cambiata
|
| 71 |
|
| 72 |
+
# ββ OAuth state store (CSRF) β P19-SEC2-5 ββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
# Era in-memory dict: rotto con >1 worker/replica (state creato su worker A,
|
| 74 |
+
# consumato su worker B β 404/invalid_state) e perso a ogni restart.
|
| 75 |
+
# Ora persistito su Supabase tabella `oauth_states` (vedi sec01_rls_agent_tasks.sql).
|
| 76 |
_STATE_TTL = 600 # 10 minuti
|
| 77 |
|
| 78 |
+
# Fallback in-memory SOLO per sviluppo locale senza Supabase configurato.
|
| 79 |
+
_oauth_states_fallback: dict[str, dict] = {}
|
| 80 |
+
|
| 81 |
+
async def _make_state(provider: str, user_id: str) -> str:
|
| 82 |
+
from api.state import _sb
|
| 83 |
state = secrets.token_urlsafe(32)
|
| 84 |
+
now = time.time()
|
| 85 |
+
if _sb:
|
| 86 |
+
try:
|
| 87 |
+
await asyncio.to_thread(
|
| 88 |
+
lambda: _sb.table('oauth_states').insert({
|
| 89 |
+
'state': state,
|
| 90 |
+
'provider': provider,
|
| 91 |
+
'user_id': user_id,
|
| 92 |
+
# created_at: lasciato al DEFAULT NOW() della tabella (TIMESTAMPTZ)
|
| 93 |
+
}).execute()
|
| 94 |
+
)
|
| 95 |
+
return state
|
| 96 |
+
except Exception as e:
|
| 97 |
+
_logger.warning('[auth_managed] oauth_states insert fallito, fallback memoria: %s', e)
|
| 98 |
+
# SEC2-7: in produzione (Railway/HF Spaces) il fallback in-memory Γ¨ pericoloso
|
| 99 |
+
# su multi-replica: state creato su replica A, consumato su replica B β CSRF bypass.
|
| 100 |
+
# In dev locale (_sb assente o Supabase non configurato) il fallback rimane attivo.
|
| 101 |
+
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
|
| 102 |
+
raise HTTPException(503, detail={
|
| 103 |
+
'error': 'oauth_state_store_unavailable',
|
| 104 |
+
'detail': 'Il database OAuth (Supabase) non Γ¨ raggiungibile. Riprova tra qualche secondo.',
|
| 105 |
+
})
|
| 106 |
+
_purge_states_fallback()
|
| 107 |
+
_oauth_states_fallback[state] = {'provider': provider, 'user_id': user_id, 'created_at': now}
|
| 108 |
return state
|
| 109 |
|
| 110 |
+
async def _consume_state(state: str) -> Optional[dict]:
|
| 111 |
+
from api.state import _sb
|
| 112 |
+
if _sb:
|
| 113 |
+
try:
|
| 114 |
+
res = await asyncio.to_thread(
|
| 115 |
+
lambda: _sb.table('oauth_states').select('*').eq('state', state).limit(1).execute()
|
| 116 |
+
)
|
| 117 |
+
if res.data:
|
| 118 |
+
row = res.data[0]
|
| 119 |
+
await asyncio.to_thread(
|
| 120 |
+
lambda: _sb.table('oauth_states').delete().eq('state', state).execute()
|
| 121 |
+
)
|
| 122 |
+
# created_at Γ¨ TIMESTAMPTZ (stringa ISO8601) β parse per calcolare l'etΓ
|
| 123 |
+
try:
|
| 124 |
+
from datetime import datetime, timezone
|
| 125 |
+
created_raw = row.get('created_at', '')
|
| 126 |
+
created_dt = datetime.fromisoformat(created_raw.replace('Z', '+00:00'))
|
| 127 |
+
age_s = (datetime.now(timezone.utc) - created_dt).total_seconds()
|
| 128 |
+
except Exception:
|
| 129 |
+
age_s = 0 # se il parsing fallisce, non blocchiamo il flow OAuth per questo
|
| 130 |
+
if age_s > _STATE_TTL:
|
| 131 |
+
return None
|
| 132 |
+
return {'provider': row['provider'], 'user_id': row['user_id']}
|
| 133 |
+
return None
|
| 134 |
+
except Exception as e:
|
| 135 |
+
_logger.warning('[auth_managed] oauth_states select fallito, fallback memoria: %s', e)
|
| 136 |
+
# SEC2-7: in produzione, se Supabase Γ¨ irraggiungibile rifiutiamo il state
|
| 137 |
+
# (sicuro: l'utente deve ripetere il flow OAuth). Meglio un 400 che un bypass CSRF.
|
| 138 |
+
if os.getenv('RAILWAY_ENVIRONMENT') or os.getenv('SPACE_ID'):
|
| 139 |
+
return None
|
| 140 |
+
_purge_states_fallback()
|
| 141 |
+
entry = _oauth_states_fallback.pop(state, None)
|
| 142 |
if not entry:
|
| 143 |
return None
|
| 144 |
if time.time() - entry['created_at'] > _STATE_TTL:
|
| 145 |
return None
|
| 146 |
return entry
|
| 147 |
|
| 148 |
+
def _purge_states_fallback():
|
| 149 |
now = time.time()
|
| 150 |
+
expired = [k for k, v in _oauth_states_fallback.items() if now - v['created_at'] > _STATE_TTL]
|
| 151 |
for k in expired:
|
| 152 |
+
_oauth_states_fallback.pop(k, None)
|
| 153 |
|
| 154 |
# ββ Provider configs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
_BACKEND_URL = os.getenv('BACKEND_URL', '').rstrip('/')
|
|
|
|
| 174 |
'authorize_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
| 175 |
'token_url': 'https://oauth2.googleapis.com/token',
|
| 176 |
'userinfo_url': 'https://www.googleapis.com/oauth2/v2/userinfo',
|
| 177 |
+
# P19-SEC2-6: rimosso scope 'calendar' (accesso full R/W al calendario) β
|
| 178 |
+
# non richiesto da nessuna feature attuale, violava il principio del minimo privilegio.
|
| 179 |
+
'scope': 'openid email profile',
|
| 180 |
'client_id_env': 'GOOGLE_OAUTH_CLIENT_ID',
|
| 181 |
'client_secret_env': 'GOOGLE_OAUTH_CLIENT_SECRET',
|
| 182 |
},
|
|
|
|
| 226 |
try:
|
| 227 |
res = await asyncio.to_thread(
|
| 228 |
lambda: _sb.table('managed_tokens')
|
| 229 |
+
.select('provider,scope,expires_at,updated_at,raw_meta,access_token,refresh_token')
|
| 230 |
.eq('user_id', user_id)
|
| 231 |
.eq('provider', provider)
|
| 232 |
.limit(1)
|
|
|
|
| 273 |
|
| 274 |
# Public helper: altri moduli chiamano questa per ottenere un token decifrato
|
| 275 |
async def get_managed_token(user_id: str, provider: str) -> Optional[str]:
|
| 276 |
+
"""Restituisce il token d'accesso decifrato per (user_id, provider). None se non connesso.
|
| 277 |
+
|
| 278 |
+
P19-SEC2-8: prima ritornava sempre il token salvato, anche se scaduto da
|
| 279 |
+
tempo (provider come Google li invalidano dopo ~1h) β chiamate a valle
|
| 280 |
+
fallivano silenziosamente con 401. Ora, se scaduto e c'Γ¨ un refresh_token,
|
| 281 |
+
tenta il refresh presso il provider prima di restituire.
|
| 282 |
+
"""
|
| 283 |
row = await _sb_get_token(user_id, provider)
|
| 284 |
if not row:
|
| 285 |
return None
|
| 286 |
+
|
| 287 |
+
exp = row.get('expires_at', 0)
|
| 288 |
+
now_ms = int(time.time() * 1000)
|
| 289 |
+
if exp and exp > now_ms + 60_000: # ancora valido per >60s
|
| 290 |
+
return _decrypt(row['access_token'])
|
| 291 |
+
|
| 292 |
+
encrypted_refresh = row.get('refresh_token', '')
|
| 293 |
+
if not encrypted_refresh:
|
| 294 |
+
# Niente refresh_token: ritorna quello che c'Γ¨ (potrebbe essere giΓ scaduto)
|
| 295 |
+
return _decrypt(row['access_token'])
|
| 296 |
+
|
| 297 |
+
cfg = _PROVIDER_CONFIGS.get(provider)
|
| 298 |
+
if not cfg:
|
| 299 |
+
return _decrypt(row['access_token'])
|
| 300 |
+
|
| 301 |
+
refresh_token = _decrypt(encrypted_refresh)
|
| 302 |
+
if not refresh_token:
|
| 303 |
+
return _decrypt(row['access_token'])
|
| 304 |
+
|
| 305 |
+
client_id = os.getenv(cfg['client_id_env'], '')
|
| 306 |
+
client_secret = os.getenv(cfg['client_secret_env'], '')
|
| 307 |
+
try:
|
| 308 |
+
async with httpx.AsyncClient(timeout=15) as http:
|
| 309 |
+
resp = await http.post(
|
| 310 |
+
cfg['token_url'],
|
| 311 |
+
data={
|
| 312 |
+
'grant_type': 'refresh_token',
|
| 313 |
+
'refresh_token': refresh_token,
|
| 314 |
+
'client_id': client_id,
|
| 315 |
+
'client_secret': client_secret,
|
| 316 |
+
},
|
| 317 |
+
headers={'Accept': 'application/json'},
|
| 318 |
+
)
|
| 319 |
+
if resp.status_code != 200:
|
| 320 |
+
_logger.warning('[auth_managed] refresh token fallito %s/%s: %s', user_id, provider, resp.text[:200])
|
| 321 |
+
return _decrypt(row['access_token'])
|
| 322 |
+
|
| 323 |
+
tok_json = resp.json()
|
| 324 |
+
new_access = tok_json.get('access_token', '')
|
| 325 |
+
new_refresh = tok_json.get('refresh_token', refresh_token) # alcuni provider non lo riemettono
|
| 326 |
+
expires_in = tok_json.get('expires_in', 0)
|
| 327 |
+
new_expires_at = int((time.time() + expires_in) * 1000) if expires_in else 0
|
| 328 |
+
if not new_access:
|
| 329 |
+
return _decrypt(row['access_token'])
|
| 330 |
+
|
| 331 |
+
await _sb_upsert_token(user_id, provider, new_access, new_refresh,
|
| 332 |
+
new_expires_at, row.get('scope', cfg['scope']),
|
| 333 |
+
json.loads(row.get('raw_meta') or '{}') if isinstance(row.get('raw_meta'), str) else {})
|
| 334 |
+
_logger.info('[auth_managed] token refreshed %s/%s (expires_at=%d)', user_id, provider, new_expires_at)
|
| 335 |
+
return new_access
|
| 336 |
+
except Exception as e:
|
| 337 |
+
_logger.error('[auth_managed] refresh exception %s/%s: %s', user_id, provider, e)
|
| 338 |
+
return _decrypt(row['access_token'])
|
| 339 |
|
| 340 |
|
| 341 |
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 342 |
|
| 343 |
def _user_id(request: Request) -> str:
|
| 344 |
+
# P19-SEC2-7: NON fidarsi di X-User-ID lato client β permetteva a chiunque di
|
| 345 |
+
# impersonare/leggere i token OAuth di un altro utente semplicemente inviando
|
| 346 |
+
# un header diverso. L'app Γ¨ single-tenant (un solo utente reale), quindi si
|
| 347 |
+
# usa sempre 'default'. Se in futuro serve multi-tenant, l'identitΓ va
|
| 348 |
+
# derivata da una sessione autenticata server-side (JWT/cookie firmato),
|
| 349 |
+
# mai da un header controllato dal client.
|
| 350 |
+
return 'default'
|
| 351 |
|
| 352 |
|
| 353 |
@router.get('/api/auth/connect/{provider}')
|
|
|
|
| 366 |
})
|
| 367 |
|
| 368 |
user_id = _user_id(request)
|
| 369 |
+
state = await _make_state(provider, user_id)
|
| 370 |
callback = _get_callback_url(provider)
|
| 371 |
|
| 372 |
params = {
|
|
|
|
| 404 |
_logger.warning('[auth_managed] callback %s error=%s', provider, error)
|
| 405 |
return RedirectResponse(url=f"{frontend}?oauth_error={error}&provider={provider}")
|
| 406 |
|
| 407 |
+
state_data = await _consume_state(state)
|
| 408 |
if not state_data:
|
| 409 |
_logger.warning('[auth_managed] invalid/expired state %s', state[:20])
|
| 410 |
return RedirectResponse(url=f"{frontend}?oauth_error=invalid_state&provider={provider}")
|
|
|
|
| 470 |
|
| 471 |
|
| 472 |
@router.get('/api/auth/providers')
|
| 473 |
+
async def list_providers(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: info-disclosure
|
| 474 |
"""Restituisce stato connessione di tutti i provider per l'utente corrente."""
|
| 475 |
user_id = _user_id(request)
|
| 476 |
rows = await _sb_list_tokens(user_id)
|
|
|
|
| 512 |
|
| 513 |
|
| 514 |
@router.delete('/api/auth/disconnect/{provider}')
|
| 515 |
+
async def disconnect_provider(provider: str, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 516 |
"""Rimuove il token salvato per il provider specificato."""
|
| 517 |
if provider not in _PROVIDER_CONFIGS:
|
| 518 |
raise HTTPException(400, detail={'error': 'unknown_provider'})
|
api/benchmark.py
CHANGED
|
@@ -17,13 +17,14 @@ Uso da Replit:
|
|
| 17 |
tok = hmac.new(seed.encode(), day.encode(), hashlib.sha256).hexdigest()
|
| 18 |
print(tok)
|
| 19 |
"
|
| 20 |
-
curl '
|
| 21 |
"""
|
| 22 |
import os, sys, asyncio, time, hmac, hashlib, datetime, importlib, tempfile, subprocess, re, uuid
|
| 23 |
-
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Request
|
|
|
|
| 24 |
from fastapi.responses import JSONResponse
|
| 25 |
|
| 26 |
-
router = APIRouter()
|
| 27 |
|
| 28 |
# ββ Seed per HMAC daily token β NON Γ¨ un secret, Γ¨ solo anti-scraping βββββββββ
|
| 29 |
_BENCH_SEED = "agente-ai-bench-2026"
|
|
@@ -700,7 +701,7 @@ async def run_self_benchmark(request: "Request"):
|
|
| 700 |
e ritorna lo scorecard completo.
|
| 701 |
|
| 702 |
Esempio:
|
| 703 |
-
curl -X POST
|
| 704 |
-H "X-Internal-Token: <token>"
|
| 705 |
"""
|
| 706 |
import os as _os
|
|
|
|
| 17 |
tok = hmac.new(seed.encode(), day.encode(), hashlib.sha256).hexdigest()
|
| 18 |
print(tok)
|
| 19 |
"
|
| 20 |
+
curl '<hf-space-a-url>/api/debug/benchmark?token=<tok>'
|
| 21 |
"""
|
| 22 |
import os, sys, asyncio, time, hmac, hashlib, datetime, importlib, tempfile, subprocess, re, uuid
|
| 23 |
+
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException, Query, Request
|
| 24 |
+
from .auth_guard import require_role, AuthRole
|
| 25 |
from fastapi.responses import JSONResponse
|
| 26 |
|
| 27 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 28 |
|
| 29 |
# ββ Seed per HMAC daily token β NON Γ¨ un secret, Γ¨ solo anti-scraping βββββββββ
|
| 30 |
_BENCH_SEED = "agente-ai-bench-2026"
|
|
|
|
| 701 |
e ritorna lo scorecard completo.
|
| 702 |
|
| 703 |
Esempio:
|
| 704 |
+
curl -X POST <hf-space-a-url>/api/benchmark/run-self \
|
| 705 |
-H "X-Internal-Token: <token>"
|
| 706 |
"""
|
| 707 |
import os as _os
|
api/blackboard.py
CHANGED
|
@@ -6,13 +6,9 @@ in backend/api/llm_cache.py β zero setup aggiuntivo.
|
|
| 6 |
|
| 7 |
TTL: 600s (10 minuti) β session-scoped.
|
| 8 |
Endpoint:
|
| 9 |
-
POST /api/blackboard/{session_id}/write β scrive una entry
|
| 10 |
-
GET /api/blackboard/{session_id}/read β legge tutte le entry
|
| 11 |
-
DEL /api/blackboard/{session_id} β pulisce il blackboard
|
| 12 |
-
|
| 13 |
-
GAP-BLACKBOARD-NOAUTH fix: write e delete protetti con require_role(MACHINE).
|
| 14 |
-
La lettura rimane pubblica (solo findings, nessun segreto) ma con struttura
|
| 15 |
-
che non espone dati sensibili β i valori nel blackboard sono findings agente.
|
| 16 |
|
| 17 |
Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
|
| 18 |
il backend persiste su Upstash per cross-tab/cross-reload continuity.
|
|
@@ -22,10 +18,10 @@ import json
|
|
| 22 |
import httpx
|
| 23 |
import asyncio
|
| 24 |
from fastapi import APIRouter, Depends
|
| 25 |
-
from pydantic import BaseModel
|
| 26 |
from .auth_guard import require_role, AuthRole
|
|
|
|
| 27 |
|
| 28 |
-
router = APIRouter(prefix="/api/blackboard", tags=["blackboard"])
|
| 29 |
|
| 30 |
_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 31 |
_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
|
@@ -92,15 +88,8 @@ async def _redis_scan(pattern: str) -> list[str]:
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.post("/{session_id}/write")
|
| 95 |
-
async def bb_write(
|
| 96 |
-
|
| 97 |
-
entry: BBEntry,
|
| 98 |
-
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 99 |
-
):
|
| 100 |
-
"""GAP-BLACKBOARD-NOAUTH fix: scrittura protetta con require_role(MACHINE).
|
| 101 |
-
Richiede X-Internal-Token header (aggiunto dal CF Worker su route non-public).
|
| 102 |
-
Senza auth, un attaccante poteva avvelenare il contesto dei sub-agenti.
|
| 103 |
-
"""
|
| 104 |
rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
|
| 105 |
payload = json.dumps({
|
| 106 |
"agentId": entry.agentId,
|
|
@@ -116,7 +105,7 @@ async def bb_write(
|
|
| 116 |
|
| 117 |
@router.get("/{session_id}/read")
|
| 118 |
async def bb_read(session_id: str):
|
| 119 |
-
"""Legge tutte le entries del blackboard per la sessione.
|
| 120 |
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 121 |
if not keys:
|
| 122 |
return {"entries": [], "session_id": session_id}
|
|
@@ -134,14 +123,8 @@ async def bb_read(session_id: str):
|
|
| 134 |
|
| 135 |
|
| 136 |
@router.delete("/{session_id}")
|
| 137 |
-
async def bb_clear(
|
| 138 |
-
|
| 139 |
-
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 140 |
-
):
|
| 141 |
-
"""GAP-BLACKBOARD-NOAUTH fix: delete protetto con require_role(MACHINE).
|
| 142 |
-
Senza auth, un attaccante poteva cancellare il blackboard di sessioni attive
|
| 143 |
-
β VALIDATOR cieco, self-healing disabilitato silenziosamente.
|
| 144 |
-
"""
|
| 145 |
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 146 |
if keys:
|
| 147 |
await _redis_post(["DEL"] + keys)
|
|
|
|
| 6 |
|
| 7 |
TTL: 600s (10 minuti) β session-scoped.
|
| 8 |
Endpoint:
|
| 9 |
+
POST /api/blackboard/{session_id}/write β scrive una entry
|
| 10 |
+
GET /api/blackboard/{session_id}/read β legge tutte le entry
|
| 11 |
+
DEL /api/blackboard/{session_id} β pulisce il blackboard
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
|
| 14 |
il backend persiste su Upstash per cross-tab/cross-reload continuity.
|
|
|
|
| 18 |
import httpx
|
| 19 |
import asyncio
|
| 20 |
from fastapi import APIRouter, Depends
|
|
|
|
| 21 |
from .auth_guard import require_role, AuthRole
|
| 22 |
+
from pydantic import BaseModel
|
| 23 |
|
| 24 |
+
router = APIRouter(prefix="/api/blackboard", tags=["blackboard"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 25 |
|
| 26 |
_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
|
| 27 |
_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
@router.post("/{session_id}/write")
|
| 91 |
+
async def bb_write(session_id: str, entry: BBEntry):
|
| 92 |
+
"""Scrive una entry nel blackboard Upstash. Fail-safe: ritorna ok=True anche se Upstash non disponibile."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
|
| 94 |
payload = json.dumps({
|
| 95 |
"agentId": entry.agentId,
|
|
|
|
| 105 |
|
| 106 |
@router.get("/{session_id}/read")
|
| 107 |
async def bb_read(session_id: str):
|
| 108 |
+
"""Legge tutte le entries del blackboard per la sessione."""
|
| 109 |
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 110 |
if not keys:
|
| 111 |
return {"entries": [], "session_id": session_id}
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
@router.delete("/{session_id}")
|
| 126 |
+
async def bb_clear(session_id: str):
|
| 127 |
+
"""Pulisce il blackboard al termine della sessione."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
keys = await _redis_scan(f"bb:{session_id}:*")
|
| 129 |
if keys:
|
| 130 |
await _redis_post(["DEL"] + keys)
|
api/browser.py
CHANGED
|
@@ -33,9 +33,9 @@ Problematiche W-NAV anticipate:
|
|
| 33 |
import os
|
| 34 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 35 |
from typing import Optional, Any
|
| 36 |
-
from fastapi import APIRouter,
|
| 37 |
-
from .auth_guard import require_role, AuthRole
|
| 38 |
from pydantic import BaseModel
|
|
|
|
| 39 |
|
| 40 |
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
| 41 |
_logger = logging.getLogger("browser")
|
|
@@ -711,7 +711,7 @@ async def verify_goal_browser(
|
|
| 711 |
# βββ /screenshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 712 |
|
| 713 |
@router.post("/screenshot", response_model=BrowserResult)
|
| 714 |
-
async def browser_screenshot(req: ScreenshotRequest):
|
| 715 |
"""Screenshot headless di una pagina web (stateless)."""
|
| 716 |
if not _safe_url(req.url):
|
| 717 |
raise HTTPException(400, "URL non consentita")
|
|
@@ -745,7 +745,7 @@ async def browser_screenshot(req: ScreenshotRequest):
|
|
| 745 |
# βββ /navigate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 746 |
|
| 747 |
@router.post("/navigate", response_model=BrowserResult)
|
| 748 |
-
async def browser_navigate(req: NavigateRequest):
|
| 749 |
"""
|
| 750 |
Naviga, esegui azioni, restituisce screenshot + testo (stateless).
|
| 751 |
W-NAV: text_content ora estratto via trafilatura (da 2000β5000 chars utili).
|
|
@@ -794,8 +794,8 @@ async def browser_navigate(req: NavigateRequest):
|
|
| 794 |
|
| 795 |
@router.post("/open", response_model=BrowserResult)
|
| 796 |
async def browser_open(
|
| 797 |
-
req: BrowserOpenRequest,
|
| 798 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 799 |
):
|
| 800 |
"""
|
| 801 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
|
@@ -858,8 +858,8 @@ async def browser_open(
|
|
| 858 |
|
| 859 |
@router.post("/act", response_model=BrowserResult)
|
| 860 |
async def browser_act(
|
| 861 |
-
req: BrowserActRequest,
|
| 862 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 863 |
):
|
| 864 |
"""
|
| 865 |
Esegue azioni su una sessione aperta.
|
|
@@ -940,7 +940,7 @@ async def browser_act(
|
|
| 940 |
# βββ /close βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 941 |
|
| 942 |
@router.post("/close", response_model=BrowserResult)
|
| 943 |
-
async def browser_close(req: BrowserCloseRequest):
|
| 944 |
"""Chiude esplicitamente una sessione persistente e libera risorse."""
|
| 945 |
if req.session_id not in _sessions:
|
| 946 |
return BrowserResult(ok=True, session_id=req.session_id)
|
|
@@ -951,7 +951,7 @@ async def browser_close(req: BrowserCloseRequest):
|
|
| 951 |
# βββ GET /screenshot/{session_id} βββββββββββββββββββββββββββββββββββββββββββββ
|
| 952 |
|
| 953 |
@router.get("/screenshot/{session_id}", response_model=BrowserResult)
|
| 954 |
-
async def browser_session_screenshot(session_id: str, full_page: bool = False):
|
| 955 |
"""Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
|
| 956 |
sess = _sessions.get(session_id)
|
| 957 |
if not sess:
|
|
@@ -976,7 +976,7 @@ async def browser_session_screenshot(session_id: str, full_page: bool = False):
|
|
| 976 |
# βββ /sessions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 977 |
|
| 978 |
@router.get("/sessions")
|
| 979 |
-
async def list_sessions():
|
| 980 |
now = time.time()
|
| 981 |
return {
|
| 982 |
sid: {
|
|
|
|
| 33 |
import os
|
| 34 |
import asyncio, base64, hashlib, os, time, uuid, logging
|
| 35 |
from typing import Optional, Any
|
| 36 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
| 37 |
from pydantic import BaseModel
|
| 38 |
+
from .auth_guard import require_role, AuthRole
|
| 39 |
|
| 40 |
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
| 41 |
_logger = logging.getLogger("browser")
|
|
|
|
| 711 |
# βββ /screenshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 712 |
|
| 713 |
@router.post("/screenshot", response_model=BrowserResult)
|
| 714 |
+
async def browser_screenshot(req: ScreenshotRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 715 |
"""Screenshot headless di una pagina web (stateless)."""
|
| 716 |
if not _safe_url(req.url):
|
| 717 |
raise HTTPException(400, "URL non consentita")
|
|
|
|
| 745 |
# βββ /navigate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 746 |
|
| 747 |
@router.post("/navigate", response_model=BrowserResult)
|
| 748 |
+
async def browser_navigate(req: NavigateRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 749 |
"""
|
| 750 |
Naviga, esegui azioni, restituisce screenshot + testo (stateless).
|
| 751 |
W-NAV: text_content ora estratto via trafilatura (da 2000β5000 chars utili).
|
|
|
|
| 794 |
|
| 795 |
@router.post("/open", response_model=BrowserResult)
|
| 796 |
async def browser_open(
|
| 797 |
+
req: BrowserOpenRequest, request: Request,
|
| 798 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 799 |
):
|
| 800 |
"""
|
| 801 |
Apre una sessione Playwright persistente, naviga all'URL, restituisce
|
|
|
|
| 858 |
|
| 859 |
@router.post("/act", response_model=BrowserResult)
|
| 860 |
async def browser_act(
|
| 861 |
+
req: BrowserActRequest, request: Request,
|
| 862 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 863 |
):
|
| 864 |
"""
|
| 865 |
Esegue azioni su una sessione aperta.
|
|
|
|
| 940 |
# βββ /close βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 941 |
|
| 942 |
@router.post("/close", response_model=BrowserResult)
|
| 943 |
+
async def browser_close(req: BrowserCloseRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 944 |
"""Chiude esplicitamente una sessione persistente e libera risorse."""
|
| 945 |
if req.session_id not in _sessions:
|
| 946 |
return BrowserResult(ok=True, session_id=req.session_id)
|
|
|
|
| 951 |
# βββ GET /screenshot/{session_id} βββββββββββββββββββββββββββββββββββββββββββββ
|
| 952 |
|
| 953 |
@router.get("/screenshot/{session_id}", response_model=BrowserResult)
|
| 954 |
+
async def browser_session_screenshot(session_id: str, full_page: bool = False, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 955 |
"""Snapshot della pagina corrente senza azioni. Aggiorna last_used."""
|
| 956 |
sess = _sessions.get(session_id)
|
| 957 |
if not sess:
|
|
|
|
| 976 |
# βββ /sessions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 977 |
|
| 978 |
@router.get("/sessions")
|
| 979 |
+
async def list_sessions(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 980 |
now = time.time()
|
| 981 |
return {
|
| 982 |
sid: {
|
api/coding.py
CHANGED
|
@@ -18,7 +18,8 @@ import textwrap
|
|
| 18 |
import time
|
| 19 |
from typing import Optional, List
|
| 20 |
|
| 21 |
-
from fastapi import APIRouter
|
|
|
|
| 22 |
from fastapi.responses import JSONResponse
|
| 23 |
from pydantic import BaseModel, field_validator
|
| 24 |
|
|
@@ -134,7 +135,7 @@ def _suggestions(lint: dict, cc: dict, code_lines: int) -> list[str]:
|
|
| 134 |
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
|
| 136 |
@router.post("/analyze")
|
| 137 |
-
async def analyze(req: AnalyzeReq) -> JSONResponse:
|
| 138 |
"""
|
| 139 |
Analisi statica Python: sintassi, complessitΓ ciclomatica, imports, suggerimenti.
|
| 140 |
Se run_code=True: esegue in sandbox isolata via exec_sandbox (max _MAX_RUN_LINES righe).
|
|
@@ -215,5 +216,5 @@ class _SR(BaseModel):
|
|
| 215 |
|
| 216 |
|
| 217 |
@router.post("/session")
|
| 218 |
-
async def session(_req: _SR) -> JSONResponse:
|
| 219 |
return JSONResponse(status_code=501, content={"error": _STUB_MSG})
|
|
|
|
| 18 |
import time
|
| 19 |
from typing import Optional, List
|
| 20 |
|
| 21 |
+
from fastapi import APIRouter, Depends
|
| 22 |
+
from .auth_guard import require_role, AuthRole
|
| 23 |
from fastapi.responses import JSONResponse
|
| 24 |
from pydantic import BaseModel, field_validator
|
| 25 |
|
|
|
|
| 135 |
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
|
| 137 |
@router.post("/analyze")
|
| 138 |
+
async def analyze(req: AnalyzeReq, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: run_code=True esegue Python
|
| 139 |
"""
|
| 140 |
Analisi statica Python: sintassi, complessitΓ ciclomatica, imports, suggerimenti.
|
| 141 |
Se run_code=True: esegue in sandbox isolata via exec_sandbox (max _MAX_RUN_LINES righe).
|
|
|
|
| 216 |
|
| 217 |
|
| 218 |
@router.post("/session")
|
| 219 |
+
async def session(_req: _SR, role: AuthRole = Depends(require_role(AuthRole.MACHINE))) -> JSONResponse: # GAP-1-fix: stub 501 ma consistent
|
| 220 |
return JSONResponse(status_code=501, content={"error": _STUB_MSG})
|
api/conversations.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
import json, logging
|
| 3 |
from .state import safe_json_dumps
|
| 4 |
from typing import Optional, Any
|
| 5 |
-
from fastapi import APIRouter, Body, HTTPException
|
|
|
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from .state import sb
|
| 8 |
|
| 9 |
-
router = APIRouter()
|
| 10 |
_logger = logging.getLogger("conversations")
|
| 11 |
|
| 12 |
|
|
|
|
| 2 |
import json, logging
|
| 3 |
from .state import safe_json_dumps
|
| 4 |
from typing import Optional, Any
|
| 5 |
+
from fastapi import APIRouter, Depends, Body, HTTPException
|
| 6 |
+
from .auth_guard import require_role, AuthRole
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from .state import sb
|
| 9 |
|
| 10 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 11 |
_logger = logging.getLogger("conversations")
|
| 12 |
|
| 13 |
|
api/daemon_status.py
CHANGED
|
@@ -14,11 +14,12 @@ import logging
|
|
| 14 |
import time
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
-
from fastapi import APIRouter
|
|
|
|
| 18 |
|
| 19 |
_logger = logging.getLogger("api.daemon_status")
|
| 20 |
|
| 21 |
-
router = APIRouter(prefix="/api/daemon", tags=["daemon-status"])
|
| 22 |
|
| 23 |
# ACTIVE_TTL: allineato a session-daemon.mjs (5 * 60_000 ms)
|
| 24 |
_ACTIVE_TTL_MS = 5 * 60 * 1000 # 5 minuti
|
|
|
|
| 14 |
import time
|
| 15 |
from typing import Any
|
| 16 |
|
| 17 |
+
from fastapi import APIRouter, Depends
|
| 18 |
+
from .auth_guard import require_role, AuthRole
|
| 19 |
|
| 20 |
_logger = logging.getLogger("api.daemon_status")
|
| 21 |
|
| 22 |
+
router = APIRouter(prefix="/api/daemon", tags=["daemon-status"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 23 |
|
| 24 |
# ACTIVE_TTL: allineato a session-daemon.mjs (5 * 60_000 ms)
|
| 25 |
_ACTIVE_TTL_MS = 5 * 60 * 1000 # 5 minuti
|
api/database.py
CHANGED
|
@@ -20,10 +20,20 @@ Sicurezza:
|
|
| 20 |
MAX 500 righe per query per evitare payload giganti.
|
| 21 |
"""
|
| 22 |
import asyncio, os, logging, re as _re
|
| 23 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 24 |
from pydantic import BaseModel
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
_logger = logging.getLogger("database")
|
| 28 |
|
| 29 |
_DANGEROUS = frozenset({"drop","truncate","delete","update","insert","alter","create","grant","revoke"})
|
|
@@ -67,10 +77,8 @@ def _is_dangerous(sql: str) -> str | None:
|
|
| 67 |
|
| 68 |
|
| 69 |
@router.post("/query")
|
| 70 |
-
async def database_query(req: QueryRequest
|
| 71 |
-
|
| 72 |
-
if _internal_token and request.headers.get('X-Internal-Token') != _internal_token:
|
| 73 |
-
raise HTTPException(401, 'Unauthorized')
|
| 74 |
db_url = os.getenv("DATABASE_URL", "").strip()
|
| 75 |
if not db_url:
|
| 76 |
return {
|
|
@@ -150,4 +158,4 @@ async def _sqlite_query(db_url: str, sql: str, params: list):
|
|
| 150 |
return {
|
| 151 |
"ok": True, "rows": rows, "columns": cols, "count": len(rows),
|
| 152 |
"truncated": len(rows) == _MAX_ROWS,
|
| 153 |
-
}
|
|
|
|
| 20 |
MAX 500 righe per query per evitare payload giganti.
|
| 21 |
"""
|
| 22 |
import asyncio, os, logging, re as _re
|
| 23 |
+
from fastapi import APIRouter, HTTPException, Request, Depends
|
| 24 |
from pydantic import BaseModel
|
| 25 |
|
| 26 |
+
from .auth_guard import require_role, AuthRole
|
| 27 |
+
|
| 28 |
+
# P19-SEC2: era fail-open (nessuna auth su /api/database). Il fix concorrente
|
| 29 |
+
# (backend/auth/auth_managed.get_user_session_token) usava un token placeholder
|
| 30 |
+
# hardcoded ("secure-session-token") e non importava Depends: rotto e insicuro.
|
| 31 |
+
# Usiamo lo stesso pattern require_role(AuthRole.MACHINE) giΓ in uso su
|
| 32 |
+
# exec/search/browser/research/email per coerenza con l'architettura esistente.
|
| 33 |
+
router = APIRouter(
|
| 34 |
+
prefix="/api/database", tags=["database"],
|
| 35 |
+
dependencies=[Depends(require_role(AuthRole.MACHINE))],
|
| 36 |
+
)
|
| 37 |
_logger = logging.getLogger("database")
|
| 38 |
|
| 39 |
_DANGEROUS = frozenset({"drop","truncate","delete","update","insert","alter","create","grant","revoke"})
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
@router.post("/query")
|
| 80 |
+
async def database_query(req: QueryRequest):
|
| 81 |
+
# Auth gestita dal router-level Depends(require_role(AuthRole.MACHINE)) β nessun doppio check.
|
|
|
|
|
|
|
| 82 |
db_url = os.getenv("DATABASE_URL", "").strip()
|
| 83 |
if not db_url:
|
| 84 |
return {
|
|
|
|
| 158 |
return {
|
| 159 |
"ok": True, "rows": rows, "columns": cols, "count": len(rows),
|
| 160 |
"truncated": len(rows) == _MAX_ROWS,
|
| 161 |
+
}
|
api/decision_memory.py
CHANGED
|
@@ -22,13 +22,14 @@ import json
|
|
| 22 |
import time
|
| 23 |
from typing import Optional
|
| 24 |
|
| 25 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 26 |
from pydantic import BaseModel
|
| 27 |
import logging
|
| 28 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 29 |
|
| 30 |
logger = logging.getLogger("agente_ai.decision_memory")
|
| 31 |
-
router = APIRouter(prefix="/api/memory/decision", tags=["decision-memory"])
|
| 32 |
|
| 33 |
# βββ In-memory store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
_decisions: dict[str, dict] = {} # signature β decision
|
|
@@ -125,13 +126,6 @@ def get_blacklist() -> list[dict]:
|
|
| 125 |
|
| 126 |
async def _sb_save(decision: dict) -> None:
|
| 127 |
try:
|
| 128 |
-
# βββ Mirroring su Hugging Face Dataset (Zero-Cost Backup) βββββββββββββ
|
| 129 |
-
try:
|
| 130 |
-
from .hf_storage import hf_fire_and_forget
|
| 131 |
-
hf_fire_and_forget("decisions.jsonl", decision)
|
| 132 |
-
except Exception as hf_exc:
|
| 133 |
-
logger.debug("HF Mirroring silenced: %s", hf_exc)
|
| 134 |
-
|
| 135 |
from .state import _sb
|
| 136 |
if not _sb:
|
| 137 |
return
|
|
|
|
| 22 |
import time
|
| 23 |
from typing import Optional
|
| 24 |
|
| 25 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 26 |
+
from .auth_guard import require_role, AuthRole
|
| 27 |
from pydantic import BaseModel
|
| 28 |
import logging
|
| 29 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 30 |
|
| 31 |
logger = logging.getLogger("agente_ai.decision_memory")
|
| 32 |
+
router = APIRouter(prefix="/api/memory/decision", tags=["decision-memory"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 33 |
|
| 34 |
# βββ In-memory store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
_decisions: dict[str, dict] = {} # signature β decision
|
|
|
|
| 126 |
|
| 127 |
async def _sb_save(decision: dict) -> None:
|
| 128 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
from .state import _sb
|
| 130 |
if not _sb:
|
| 131 |
return
|
api/deploy.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""backend/api/deploy.py β S750: CI status + deploy trigger (Railway) + S754-C: Quick Preview."""
|
| 2 |
import os, time, json, pathlib, asyncio, secrets as _prv_secrets, mimetypes as _prv_mimetypes
|
| 3 |
-
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
| 4 |
from fastapi.responses import HTMLResponse as _HtmlResp, Response as _BinResp
|
| 5 |
from pydantic import BaseModel
|
| 6 |
import httpx
|
|
@@ -20,7 +21,7 @@ _INT_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
|
| 20 |
# S-GAP4: parametrizzati da env β portabili su qualsiasi repo
|
| 21 |
GH_OWNER = os.getenv("GH_OWNER", "Baida98")
|
| 22 |
GH_REPO = os.getenv("GH_REPO", "AI")
|
| 23 |
-
HF_SPACE_ID = os.getenv("HF_SPACE_ID", "
|
| 24 |
|
| 25 |
|
| 26 |
def _auth(request: Request) -> None:
|
|
@@ -32,9 +33,9 @@ def _auth(request: Request) -> None:
|
|
| 32 |
|
| 33 |
|
| 34 |
@router.get("/api/ci/status")
|
| 35 |
-
async def ci_status(request: Request):
|
| 36 |
"""Controlla stato GitHub Actions + ultimo deploy CF Pages."""
|
| 37 |
-
|
| 38 |
result: dict = {
|
| 39 |
"ci_ok": False, "billing_ok": True,
|
| 40 |
"last_run_status": "unknown", "last_run_conclusion": "unknown",
|
|
@@ -87,7 +88,7 @@ class DeployRequest(BaseModel):
|
|
| 87 |
|
| 88 |
# ββ Deploy Status multi-target (S-DEPLOY-UNIFY) ββββββββββββββββββββββββββββββ
|
| 89 |
@router.get("/api/deploy/status")
|
| 90 |
-
async def deploy_status_all(request: Request):
|
| 91 |
"""
|
| 92 |
S-DEPLOY-UNIFY: Stato in tempo reale di tutti e 3 i target di deploy.
|
| 93 |
|
|
@@ -98,7 +99,7 @@ async def deploy_status_all(request: Request):
|
|
| 98 |
|
| 99 |
Non richiede token se _CF_TOKEN Γ¨ assente: risponde ugualmente con stato parziale.
|
| 100 |
"""
|
| 101 |
-
|
| 102 |
checked_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
| 103 |
|
| 104 |
async def _check_cloudflare() -> dict:
|
|
@@ -136,7 +137,7 @@ async def deploy_status_all(request: Request):
|
|
| 136 |
return r
|
| 137 |
|
| 138 |
async def _check_railway() -> dict:
|
| 139 |
-
url =
|
| 140 |
r: dict = {"ok": False, "status": "unknown", "url": url, "latency_ms": None, "error": None}
|
| 141 |
t0 = time.monotonic()
|
| 142 |
try:
|
|
@@ -192,7 +193,7 @@ class AutoRepairRequest(BaseModel):
|
|
| 192 |
|
| 193 |
|
| 194 |
@router.post("/api/deploy/auto")
|
| 195 |
-
async def deploy_auto(body: AutoRepairRequest, request: Request):
|
| 196 |
"""
|
| 197 |
S-AUTO: Diagnostica + auto-riparazione di tutti i target in un'unica chiamata.
|
| 198 |
|
|
@@ -203,7 +204,7 @@ async def deploy_auto(body: AutoRepairRequest, request: Request):
|
|
| 203 |
|
| 204 |
dry_run=True restituisce il piano senza eseguire azioni reali.
|
| 205 |
"""
|
| 206 |
-
|
| 207 |
t0 = time.time()
|
| 208 |
actions: list[str] = []
|
| 209 |
fixed: list[str] = []
|
|
@@ -267,7 +268,7 @@ async def deploy_auto(body: AutoRepairRequest, request: Request):
|
|
| 267 |
# ββ Railway βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
if "railway" in body.targets:
|
| 269 |
if not body.dry_run:
|
| 270 |
-
railway_url = "
|
| 271 |
alive = False
|
| 272 |
for attempt in range(1, 6):
|
| 273 |
try:
|
|
@@ -323,9 +324,16 @@ async def deploy_auto(body: AutoRepairRequest, request: Request):
|
|
| 323 |
woke = False
|
| 324 |
try:
|
| 325 |
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
except Exception as e:
|
| 330 |
actions.append(f" GET wake-up: {str(e)[:50]}")
|
| 331 |
|
|
@@ -362,9 +370,9 @@ async def deploy_auto(body: AutoRepairRequest, request: Request):
|
|
| 362 |
|
| 363 |
|
| 364 |
@router.post("/api/deploy-trigger")
|
| 365 |
-
async def deploy_trigger_endpoint(body: DeployRequest, request: Request):
|
| 366 |
"""Triggera deploy: prima Actions dispatch, poi fallback HF + Replit CF."""
|
| 367 |
-
|
| 368 |
t0 = time.time()
|
| 369 |
steps: list[str] = []
|
| 370 |
|
|
@@ -519,16 +527,16 @@ def _get_base_url(request: Request) -> str:
|
|
| 519 |
invece che al backend HF/Railway β 404 garantito in produzione.
|
| 520 |
|
| 521 |
PrioritΓ corretta:
|
| 522 |
-
1. HF_SPACE_URL env var β config esplicita, raccomandata (es. '
|
| 523 |
-
2. SPACE_HOST env var β iniettata da HF Spaces automaticamente (es. '
|
| 524 |
3. X-Forwarded-Host β iniettato da proxy/ingress Railway o Traefik
|
| 525 |
4. '' (stringa vuota) β nessuna fonte disponibile β quick_preview risponde con errore strutturato
|
| 526 |
"""
|
| 527 |
-
# 1. Variabile esplicita: HF_SPACE_URL=
|
| 528 |
explicit = os.getenv('HF_SPACE_URL', '').rstrip('/')
|
| 529 |
if explicit:
|
| 530 |
return explicit
|
| 531 |
-
# 2. SPACE_HOST β iniettata da HF Spaces (es. '
|
| 532 |
space_host = os.getenv('SPACE_HOST', '')
|
| 533 |
if space_host:
|
| 534 |
return f'https://{space_host}'
|
|
@@ -552,7 +560,7 @@ _PREVIEW_HEADERS = {
|
|
| 552 |
|
| 553 |
|
| 554 |
@router.post('/api/preview/quick')
|
| 555 |
-
async def quick_preview(body: QuickPreviewRequest, request: Request):
|
| 556 |
"""
|
| 557 |
S754-C: Deploy rapido su backend HF Space.
|
| 558 |
Salva i file in /data/previews/<id>/ β restituisce URL pubblico < 1s.
|
|
@@ -602,7 +610,7 @@ async def quick_preview(body: QuickPreviewRequest, request: Request):
|
|
| 602 |
'files': saved,
|
| 603 |
'error': 'URL backend non rilevabile: nessun env var HF_SPACE_URL / SPACE_HOST trovato.',
|
| 604 |
'hint': (
|
| 605 |
-
'Imposta la variabile d\'ambiente HF_SPACE_URL=
|
| 606 |
'nelle impostazioni del backend (Railway β Variables o HF Space β Settings β Variables). '
|
| 607 |
'I file della preview sono stati salvati con ID: ' + preview_id
|
| 608 |
),
|
|
|
|
| 1 |
"""backend/api/deploy.py β S750: CI status + deploy trigger (Railway) + S754-C: Quick Preview."""
|
| 2 |
import os, time, json, pathlib, asyncio, secrets as _prv_secrets, mimetypes as _prv_mimetypes
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 4 |
+
from .auth_guard import require_role, AuthRole
|
| 5 |
from fastapi.responses import HTMLResponse as _HtmlResp, Response as _BinResp
|
| 6 |
from pydantic import BaseModel
|
| 7 |
import httpx
|
|
|
|
| 21 |
# S-GAP4: parametrizzati da env β portabili su qualsiasi repo
|
| 22 |
GH_OWNER = os.getenv("GH_OWNER", "Baida98")
|
| 23 |
GH_REPO = os.getenv("GH_REPO", "AI")
|
| 24 |
+
HF_SPACE_ID = os.getenv("HF_SPACE_ID", "") # S-DYN: rimosso default hardcoded # GAP-3: parametrizzato da env
|
| 25 |
|
| 26 |
|
| 27 |
def _auth(request: Request) -> None:
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
@router.get("/api/ci/status")
|
| 36 |
+
async def ci_status(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: era _auth fail-open
|
| 37 |
"""Controlla stato GitHub Actions + ultimo deploy CF Pages."""
|
| 38 |
+
# auth gestita da Depends(require_role(MACHINE))
|
| 39 |
result: dict = {
|
| 40 |
"ci_ok": False, "billing_ok": True,
|
| 41 |
"last_run_status": "unknown", "last_run_conclusion": "unknown",
|
|
|
|
| 88 |
|
| 89 |
# ββ Deploy Status multi-target (S-DEPLOY-UNIFY) ββββββββββββββββββββββββββββββ
|
| 90 |
@router.get("/api/deploy/status")
|
| 91 |
+
async def deploy_status_all(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 92 |
"""
|
| 93 |
S-DEPLOY-UNIFY: Stato in tempo reale di tutti e 3 i target di deploy.
|
| 94 |
|
|
|
|
| 99 |
|
| 100 |
Non richiede token se _CF_TOKEN Γ¨ assente: risponde ugualmente con stato parziale.
|
| 101 |
"""
|
| 102 |
+
# auth gestita da Depends(require_role(MACHINE))
|
| 103 |
checked_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
| 104 |
|
| 105 |
async def _check_cloudflare() -> dict:
|
|
|
|
| 137 |
return r
|
| 138 |
|
| 139 |
async def _check_railway() -> dict:
|
| 140 |
+
url = os.getenv("RAILWAY_PUBLIC_URL", "") # S-DYN: usa env var
|
| 141 |
r: dict = {"ok": False, "status": "unknown", "url": url, "latency_ms": None, "error": None}
|
| 142 |
t0 = time.monotonic()
|
| 143 |
try:
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
@router.post("/api/deploy/auto")
|
| 196 |
+
async def deploy_auto(body: AutoRepairRequest, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 197 |
"""
|
| 198 |
S-AUTO: Diagnostica + auto-riparazione di tutti i target in un'unica chiamata.
|
| 199 |
|
|
|
|
| 204 |
|
| 205 |
dry_run=True restituisce il piano senza eseguire azioni reali.
|
| 206 |
"""
|
| 207 |
+
# auth gestita da Depends(require_role(MACHINE))
|
| 208 |
t0 = time.time()
|
| 209 |
actions: list[str] = []
|
| 210 |
fixed: list[str] = []
|
|
|
|
| 268 |
# ββ Railway βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 269 |
if "railway" in body.targets:
|
| 270 |
if not body.dry_run:
|
| 271 |
+
railway_url = f"{os.getenv('RAILWAY_PUBLIC_URL', '')}/health"
|
| 272 |
alive = False
|
| 273 |
for attempt in range(1, 6):
|
| 274 |
try:
|
|
|
|
| 324 |
woke = False
|
| 325 |
try:
|
| 326 |
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
|
| 327 |
+
# S-DYN: usa HF_SPACE_URL se disponibile, altrimenti prova a costruirlo
|
| 328 |
+
hf_url = os.getenv("HF_SPACE_URL")
|
| 329 |
+
if not hf_url and os.getenv("SPACE_ID"):
|
| 330 |
+
hf_url = f"https://{os.getenv('SPACE_ID').replace('/', '-').lower()}.hf.space"
|
| 331 |
+
if hf_url:
|
| 332 |
+
wr = await c.get(hf_url)
|
| 333 |
+
woke = wr.status_code < 500
|
| 334 |
+
actions.append(f" GET wake-up: HTTP {wr.status_code}")
|
| 335 |
+
else:
|
| 336 |
+
woke = False; actions.append(" β οΈ HF_SPACE_URL non configurato")
|
| 337 |
except Exception as e:
|
| 338 |
actions.append(f" GET wake-up: {str(e)[:50]}")
|
| 339 |
|
|
|
|
| 370 |
|
| 371 |
|
| 372 |
@router.post("/api/deploy-trigger")
|
| 373 |
+
async def deploy_trigger_endpoint(body: DeployRequest, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 374 |
"""Triggera deploy: prima Actions dispatch, poi fallback HF + Replit CF."""
|
| 375 |
+
# auth gestita da Depends(require_role(MACHINE))
|
| 376 |
t0 = time.time()
|
| 377 |
steps: list[str] = []
|
| 378 |
|
|
|
|
| 527 |
invece che al backend HF/Railway β 404 garantito in produzione.
|
| 528 |
|
| 529 |
PrioritΓ corretta:
|
| 530 |
+
1. HF_SPACE_URL env var β config esplicita, raccomandata (es. '<hf-space-a-url>')
|
| 531 |
+
2. SPACE_HOST env var β iniettata da HF Spaces automaticamente (es. '<hf-space-a-url>')
|
| 532 |
3. X-Forwarded-Host β iniettato da proxy/ingress Railway o Traefik
|
| 533 |
4. '' (stringa vuota) β nessuna fonte disponibile β quick_preview risponde con errore strutturato
|
| 534 |
"""
|
| 535 |
+
# 1. Variabile esplicita: HF_SPACE_URL=<hf-space-a-url>
|
| 536 |
explicit = os.getenv('HF_SPACE_URL', '').rstrip('/')
|
| 537 |
if explicit:
|
| 538 |
return explicit
|
| 539 |
+
# 2. SPACE_HOST β iniettata da HF Spaces (es. '<hf-space-a-url>')
|
| 540 |
space_host = os.getenv('SPACE_HOST', '')
|
| 541 |
if space_host:
|
| 542 |
return f'https://{space_host}'
|
|
|
|
| 560 |
|
| 561 |
|
| 562 |
@router.post('/api/preview/quick')
|
| 563 |
+
async def quick_preview(body: QuickPreviewRequest, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: era aperto
|
| 564 |
"""
|
| 565 |
S754-C: Deploy rapido su backend HF Space.
|
| 566 |
Salva i file in /data/previews/<id>/ β restituisce URL pubblico < 1s.
|
|
|
|
| 610 |
'files': saved,
|
| 611 |
'error': 'URL backend non rilevabile: nessun env var HF_SPACE_URL / SPACE_HOST trovato.',
|
| 612 |
'hint': (
|
| 613 |
+
'Imposta la variabile d\'ambiente HF_SPACE_URL=<hf-space-a-url> '
|
| 614 |
'nelle impostazioni del backend (Railway β Variables o HF Space β Settings β Variables). '
|
| 615 |
'I file della preview sono stati salvati con ID: ' + preview_id
|
| 616 |
),
|
api/email.py
CHANGED
|
@@ -6,7 +6,9 @@ Endpoint:
|
|
| 6 |
|
| 7 |
Configurazione:
|
| 8 |
RESEND_API_KEY env var β richiesto (chiave Resend, formato re_...)
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
Comportamento:
|
| 12 |
- body puΓ² essere testo plain o HTML
|
|
@@ -14,6 +16,11 @@ Comportamento:
|
|
| 14 |
- Supporta cc, bcc, reply_to, allegati (future extension)
|
| 15 |
- Rate limit Resend free tier: 100 email/day, 1 email/sec
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
Error handling:
|
| 18 |
- Resend 422: validazione campi (to/from non validi)
|
| 19 |
- Resend 429: rate limit
|
|
@@ -21,15 +28,59 @@ Error handling:
|
|
| 21 |
"""
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
-
import os, logging, httpx
|
| 25 |
-
from fastapi import APIRouter,
|
|
|
|
| 26 |
from .auth_guard import require_role, AuthRole
|
| 27 |
-
from pydantic import BaseModel, EmailStr, field_validator
|
| 28 |
from typing import Optional, List
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/api/email", tags=["email"])
|
| 31 |
_logger = logging.getLogger("email_api")
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
# ββ Modelli βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
|
| 35 |
class SendEmailRequest(BaseModel):
|
|
@@ -50,6 +101,23 @@ class SendEmailRequest(BaseModel):
|
|
| 50 |
raise ValueError("Il campo non puΓ² essere vuoto")
|
| 51 |
return v.strip()
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
class SendEmailResponse(BaseModel):
|
| 55 |
ok: bool
|
|
@@ -62,8 +130,8 @@ class SendEmailResponse(BaseModel):
|
|
| 62 |
|
| 63 |
@router.post("/send", response_model=SendEmailResponse)
|
| 64 |
async def send_email(
|
| 65 |
-
req: SendEmailRequest,
|
| 66 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 67 |
) -> SendEmailResponse:
|
| 68 |
"""
|
| 69 |
Invia email via Resend API.
|
|
@@ -84,20 +152,21 @@ async def send_email(
|
|
| 84 |
),
|
| 85 |
)
|
| 86 |
|
| 87 |
-
#
|
| 88 |
-
import re as _re_email
|
| 89 |
-
_EMAIL_RE = _re_email.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$')
|
| 90 |
-
if not _EMAIL_RE.match(req.to):
|
| 91 |
-
raise HTTPException(400, "Destinatario non valido")
|
| 92 |
-
|
| 93 |
-
_allowed_domains = {os.getenv("RESEND_DOMAIN", "resend.dev"), "agente-ai.pages.dev"}
|
| 94 |
from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
| 95 |
-
_from_domain = from_email.split("@")[-1] if "@" in from_email else ""
|
| 96 |
-
if _from_domain not in _allowed_domains and not from_email.endswith(".resend.dev"):
|
| 97 |
-
# Se il dominio non Γ¨ in allowlist, forza il mittente di sistema
|
| 98 |
-
from_email = os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
| 99 |
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
|
| 102 |
|
| 103 |
# Payload Resend
|
|
|
|
| 6 |
|
| 7 |
Configurazione:
|
| 8 |
RESEND_API_KEY env var β richiesto (chiave Resend, formato re_...)
|
| 9 |
+
RESEND_FROM_EMAIL β indirizzo mittente default (es. noreply@tuodominio.com)
|
| 10 |
+
RESEND_ALLOWED_DOMAINS β domini FROM consentiti, separati da virgola (SEC2-9)
|
| 11 |
+
Default from: noreply@resend.dev (sandbox Resend)
|
| 12 |
|
| 13 |
Comportamento:
|
| 14 |
- body puΓ² essere testo plain o HTML
|
|
|
|
| 16 |
- Supporta cc, bcc, reply_to, allegati (future extension)
|
| 17 |
- Rate limit Resend free tier: 100 email/day, 1 email/sec
|
| 18 |
|
| 19 |
+
Sicurezza (SEC2-9):
|
| 20 |
+
- Dominio mittente (from_email) validato contro allowlist RESEND_ALLOWED_DOMAINS
|
| 21 |
+
o dominio di RESEND_FROM_EMAIL. Impedisce open relay su domini arbitrari.
|
| 22 |
+
- Indirizzi to/cc/bcc validati con regex prima della chiamata Resend.
|
| 23 |
+
|
| 24 |
Error handling:
|
| 25 |
- Resend 422: validazione campi (to/from non validi)
|
| 26 |
- Resend 429: rate limit
|
|
|
|
| 28 |
"""
|
| 29 |
from __future__ import annotations
|
| 30 |
|
| 31 |
+
import os, re, logging, httpx
|
| 32 |
+
from fastapi import APIRouter, Depends, Request
|
| 33 |
+
from pydantic import BaseModel, field_validator
|
| 34 |
from .auth_guard import require_role, AuthRole
|
|
|
|
| 35 |
from typing import Optional, List
|
| 36 |
|
| 37 |
router = APIRouter(prefix="/api/email", tags=["email"])
|
| 38 |
_logger = logging.getLogger("email_api")
|
| 39 |
|
| 40 |
+
# ββ Validazione indirizzi e domini (SEC2-9) βββββββββββββββββββββββββββββββββββ
|
| 41 |
+
|
| 42 |
+
# Regex RFC 5321 semplificata: accetta "nome@dominio.tld"
|
| 43 |
+
_EMAIL_RE = re.compile(r'^[^@\s<>,;]+@([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)$')
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _extract_email(addr: str) -> str:
|
| 47 |
+
"""Estrae l'indirizzo grezzo da 'Nome <email>' o 'email'."""
|
| 48 |
+
addr = addr.strip()
|
| 49 |
+
m = re.search(r'<([^>]+)>', addr)
|
| 50 |
+
return m.group(1).strip() if m else addr
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _valid_email(addr: str) -> bool:
|
| 54 |
+
"""True se addr Γ¨ un indirizzo email valido (ammette anche 'Nome <email>')."""
|
| 55 |
+
return bool(_EMAIL_RE.match(_extract_email(addr)))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _from_domain_allowed(email: str) -> bool:
|
| 59 |
+
"""Controlla che il dominio di email sia nell'allowlist FROM.
|
| 60 |
+
|
| 61 |
+
Allowlist (in ordine di prioritΓ ):
|
| 62 |
+
1. RESEND_ALLOWED_DOMAINS β lista separata da virgola
|
| 63 |
+
2. Dominio estratto da RESEND_FROM_EMAIL
|
| 64 |
+
3. Fallback: resend.dev (sandbox Resend, valido in test)
|
| 65 |
+
|
| 66 |
+
SEC2-9: impedisce di usare /api/email/send come open relay
|
| 67 |
+
su domini arbitrari non verificati in Resend.
|
| 68 |
+
"""
|
| 69 |
+
explicit = os.getenv('RESEND_ALLOWED_DOMAINS', '')
|
| 70 |
+
if explicit:
|
| 71 |
+
allowed = {d.strip().lower() for d in explicit.split(',') if d.strip()}
|
| 72 |
+
else:
|
| 73 |
+
default_from = os.getenv('RESEND_FROM_EMAIL', '')
|
| 74 |
+
m = _EMAIL_RE.match(default_from)
|
| 75 |
+
allowed = {m.group(1).lower()} if m else {'resend.dev'}
|
| 76 |
+
|
| 77 |
+
raw = _extract_email(email)
|
| 78 |
+
m2 = _EMAIL_RE.match(raw)
|
| 79 |
+
if not m2:
|
| 80 |
+
return False
|
| 81 |
+
return m2.group(1).lower() in allowed
|
| 82 |
+
|
| 83 |
+
|
| 84 |
# ββ Modelli βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
|
| 86 |
class SendEmailRequest(BaseModel):
|
|
|
|
| 101 |
raise ValueError("Il campo non puΓ² essere vuoto")
|
| 102 |
return v.strip()
|
| 103 |
|
| 104 |
+
@field_validator("to", "reply_to")
|
| 105 |
+
@classmethod
|
| 106 |
+
def validate_single_addr(cls, v: str) -> str: # noqa: N805
|
| 107 |
+
"""SEC2-9: valida formato indirizzo singolo."""
|
| 108 |
+
if v and not _valid_email(v):
|
| 109 |
+
raise ValueError(f"Indirizzo email non valido: {v!r}")
|
| 110 |
+
return v.strip()
|
| 111 |
+
|
| 112 |
+
@field_validator("cc", "bcc")
|
| 113 |
+
@classmethod
|
| 114 |
+
def validate_list_addrs(cls, v: list) -> list: # noqa: N805
|
| 115 |
+
"""SEC2-9: valida formato di ogni indirizzo in cc/bcc."""
|
| 116 |
+
for addr in v:
|
| 117 |
+
if not _valid_email(addr):
|
| 118 |
+
raise ValueError(f"Indirizzo email non valido: {addr!r}")
|
| 119 |
+
return v
|
| 120 |
+
|
| 121 |
|
| 122 |
class SendEmailResponse(BaseModel):
|
| 123 |
ok: bool
|
|
|
|
| 130 |
|
| 131 |
@router.post("/send", response_model=SendEmailResponse)
|
| 132 |
async def send_email(
|
| 133 |
+
req: SendEmailRequest, request: Request,
|
| 134 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 135 |
) -> SendEmailResponse:
|
| 136 |
"""
|
| 137 |
Invia email via Resend API.
|
|
|
|
| 152 |
),
|
| 153 |
)
|
| 154 |
|
| 155 |
+
# Costruisci e valida dominio mittente (SEC2-9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
from_email = req.from_email or os.getenv("RESEND_FROM_EMAIL", "noreply@resend.dev")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
+
if not _from_domain_allowed(from_email):
|
| 159 |
+
_allowed_hint = os.getenv("RESEND_ALLOWED_DOMAINS") or os.getenv("RESEND_FROM_EMAIL", "resend.dev")
|
| 160 |
+
return SendEmailResponse(
|
| 161 |
+
ok=False,
|
| 162 |
+
message=(
|
| 163 |
+
f"β Dominio mittente non consentito: '{_extract_email(from_email)}'. "
|
| 164 |
+
"Usa un dominio verificato in Resend. "
|
| 165 |
+
"Per aggiungere domini extra: imposta RESEND_ALLOWED_DOMAINS=dominio1.com,dominio2.com "
|
| 166 |
+
f"nei Secrets HF Space. Domini correnti consentiti: {_allowed_hint!r}."
|
| 167 |
+
),
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
from_field = f"{req.from_name} <{from_email}>" if req.from_name else from_email
|
| 171 |
|
| 172 |
# Payload Resend
|
api/exec.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""backend/api/exec.py β Code execution, shell, pip-install, LLM fix (S354)."""
|
| 2 |
import os, asyncio, sys, tempfile, time, resource as _resource, signal as _signal
|
| 3 |
import re as _re_exec
|
|
|
|
| 4 |
import ast as _ast_mod
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 6 |
from pydantic import BaseModel, model_validator
|
|
@@ -51,6 +52,28 @@ def _get_venv_pip_cmd(pkgs: list) -> list:
|
|
| 51 |
|
| 52 |
BLOCKED_CMDS = {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# ββ GAP-EXEC-FIX: resource limits per subprocess child βββββββββββββββββββββββ
|
| 55 |
# Chiamato come preexec_fn DOPO fork/PRIMA exec nel processo figlio.
|
| 56 |
# Ogni setrlimit Γ¨ in try/except isolato β non blocca su piattaforme che non
|
|
@@ -269,29 +292,22 @@ def _ast_sandbox_check(code: str) -> tuple[bool, str]:
|
|
| 269 |
|
| 270 |
@router.post('/api/exec')
|
| 271 |
async def exec_code(
|
| 272 |
-
req: ExecRequest,
|
| 273 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 274 |
):
|
| 275 |
code = req.code.strip()
|
| 276 |
lang = req.lang.lower()
|
| 277 |
if not code:
|
| 278 |
return {'stdout': '', 'stderr': 'No code', 'exit_code': 1, 'durationMs': 0}
|
| 279 |
|
| 280 |
-
# ββ GAP-NODE-NOSANDBOX fix: filtri per JS/TS βββββββββββββββββββββββββββ
|
| 281 |
-
_normalized = _re_exec.sub(r'\s+', ' ', code)
|
| 282 |
if lang == 'python':
|
|
|
|
| 283 |
_blocked_match = _EXEC_BLOCKED_RE.search(_normalized)
|
| 284 |
if _blocked_match:
|
| 285 |
return {'stdout': '', 'stderr': f'Blocked: pattern "{_blocked_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 286 |
_ast_safe, _ast_reason = _ast_sandbox_check(code)
|
| 287 |
if not _ast_safe:
|
| 288 |
return {'stdout': '', 'stderr': f'Blocked (AST): {_ast_reason}', 'exit_code': 1, 'durationMs': 0}
|
| 289 |
-
elif lang in ('javascript', 'js', 'typescript', 'ts'):
|
| 290 |
-
# Blocca accesso a shell, fs, env e rete in Node.js
|
| 291 |
-
_JS_BLOCKED = _re_exec.compile(r'child_process|process\.env|require\s*\(|import\s+.*from|fs\.|net\.|http')
|
| 292 |
-
_js_match = _JS_BLOCKED.search(_normalized)
|
| 293 |
-
if _js_match:
|
| 294 |
-
return {'stdout': '', 'stderr': f'Blocked (JS/TS): pattern "{_js_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 295 |
|
| 296 |
t0 = int(time.time() * 1000)
|
| 297 |
try:
|
|
@@ -300,13 +316,30 @@ async def exec_code(
|
|
| 300 |
try:
|
| 301 |
if lang == 'python':
|
| 302 |
cmd = [_get_venv_python(), '-c', code]
|
| 303 |
-
elif lang in ('javascript', 'js'):
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
f.
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
else:
|
| 311 |
return {'stdout': '', 'stderr': f'Unsupported lang: {lang}', 'exit_code': 1, 'durationMs': 0}
|
| 312 |
|
|
@@ -355,22 +388,16 @@ async def exec_code(
|
|
| 355 |
|
| 356 |
@router.post('/api/execute-shell')
|
| 357 |
async def execute_shell(
|
| 358 |
-
cmd: ShellCmd,
|
| 359 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 360 |
):
|
| 361 |
raw = cmd.command.strip()
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
raise HTTPException(400, 'Command blocked for safety')
|
| 369 |
-
# Se non Γ¨ in allowlist, limitiamo a sola lettura/info
|
| 370 |
-
_DANGEROUS = {'rm ', 'mv ', 'cp ', 'chmod ', 'chown ', 'wget ', 'curl ', '>', '>>', '|'}
|
| 371 |
-
for d in _DANGEROUS:
|
| 372 |
-
if d in raw:
|
| 373 |
-
raise HTTPException(400, f'Command "{d.strip()}" not allowed in this shell')
|
| 374 |
timeout = min(max(cmd.timeout, 1), 60)
|
| 375 |
try:
|
| 376 |
async with _realtime_job(timeout_s=90.0):
|
|
@@ -382,11 +409,7 @@ async def execute_shell(
|
|
| 382 |
stderr=asyncio.subprocess.PIPE,
|
| 383 |
cwd=tmpdir,
|
| 384 |
preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC
|
| 385 |
-
env=
|
| 386 |
-
'HOME': tmpdir, 'TMPDIR': tmpdir,
|
| 387 |
-
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
| 388 |
-
'PYTHONPATH': os.environ.get('PYTHONPATH', ''),
|
| 389 |
-
},
|
| 390 |
)
|
| 391 |
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
| 392 |
return {
|
|
@@ -580,8 +603,8 @@ class ToolDispatchRequest(BaseModel):
|
|
| 580 |
|
| 581 |
@router.post('/api/exec/tool')
|
| 582 |
async def exec_tool_dispatch(
|
| 583 |
-
req: ToolDispatchRequest,
|
| 584 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 585 |
):
|
| 586 |
"""GAP-2: dispatcher generico β risolve tool nel TOOL_REGISTRY e chiama _fn."""
|
| 587 |
try:
|
|
|
|
| 1 |
"""backend/api/exec.py β Code execution, shell, pip-install, LLM fix (S354)."""
|
| 2 |
import os, asyncio, sys, tempfile, time, resource as _resource, signal as _signal
|
| 3 |
import re as _re_exec
|
| 4 |
+
from tools._shell_safety import validate_shell_command as _validate_shell
|
| 5 |
import ast as _ast_mod
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 7 |
from pydantic import BaseModel, model_validator
|
|
|
|
| 52 |
|
| 53 |
BLOCKED_CMDS = {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
|
| 54 |
|
| 55 |
+
# Shell validation -> tools._shell_safety (_validate_shell)
|
| 56 |
+
|
| 57 |
+
# ββ P19-SEC2-3: blocklist minima per execute-code JS (era assente) ββββββββββ
|
| 58 |
+
_JS_BLOCKED_RE = _re_exec.compile(
|
| 59 |
+
r'\brequire\s*\(\s*[\'"]child_process[\'"]\s*\)'
|
| 60 |
+
r'|\bprocess\.env\b'
|
| 61 |
+
r'|\bfs\s*\.\s*(read|write|unlink|rm)'
|
| 62 |
+
r'|\bexecSync\b|\bspawnSync\b|\bexec\s*\('
|
| 63 |
+
r'|\brequire\s*\(\s*[\'"](net|http|https|dgram|cluster|worker_threads)[\'"]\s*\)',
|
| 64 |
+
_re_exec.IGNORECASE,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# ββ P19-SEC2-2: env sanificata per execute-shell β niente secret ereditati βββ
|
| 68 |
+
def _safe_shell_env(tmpdir: str) -> dict:
|
| 69 |
+
return {
|
| 70 |
+
'HOME': tmpdir, 'TMPDIR': tmpdir,
|
| 71 |
+
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
| 72 |
+
'LANG': os.environ.get('LANG', 'en_US.UTF-8'),
|
| 73 |
+
'TERM': 'xterm-256color',
|
| 74 |
+
# Deliberatamente ESCLUSI: HF_TOKEN, INTERNAL_TOKEN, SUPABASE_*, VAULT_KEY, ADMIN_TOKEN, ecc.
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
# ββ GAP-EXEC-FIX: resource limits per subprocess child βββββββββββββββββββββββ
|
| 78 |
# Chiamato come preexec_fn DOPO fork/PRIMA exec nel processo figlio.
|
| 79 |
# Ogni setrlimit Γ¨ in try/except isolato β non blocca su piattaforme che non
|
|
|
|
| 292 |
|
| 293 |
@router.post('/api/exec')
|
| 294 |
async def exec_code(
|
| 295 |
+
req: ExecRequest, request: Request,
|
| 296 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 297 |
):
|
| 298 |
code = req.code.strip()
|
| 299 |
lang = req.lang.lower()
|
| 300 |
if not code:
|
| 301 |
return {'stdout': '', 'stderr': 'No code', 'exit_code': 1, 'durationMs': 0}
|
| 302 |
|
|
|
|
|
|
|
| 303 |
if lang == 'python':
|
| 304 |
+
_normalized = _re_exec.sub(r'\s+', ' ', code)
|
| 305 |
_blocked_match = _EXEC_BLOCKED_RE.search(_normalized)
|
| 306 |
if _blocked_match:
|
| 307 |
return {'stdout': '', 'stderr': f'Blocked: pattern "{_blocked_match.group()}"', 'exit_code': 1, 'durationMs': 0}
|
| 308 |
_ast_safe, _ast_reason = _ast_sandbox_check(code)
|
| 309 |
if not _ast_safe:
|
| 310 |
return {'stdout': '', 'stderr': f'Blocked (AST): {_ast_reason}', 'exit_code': 1, 'durationMs': 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
t0 = int(time.time() * 1000)
|
| 313 |
try:
|
|
|
|
| 316 |
try:
|
| 317 |
if lang == 'python':
|
| 318 |
cmd = [_get_venv_python(), '-c', code]
|
| 319 |
+
elif lang in ('javascript', 'js', 'typescript', 'ts'):
|
| 320 |
+
# P20-JS-SANDBOX: real sandbox β Deno deny-all (primary) or Node vm.createContext (fallback)
|
| 321 |
+
# Belt: module-level _JS_BLOCKED_RE pre-filter (O(1) check before subprocess spawn)
|
| 322 |
+
_js_pre = _JS_BLOCKED_RE.search(code)
|
| 323 |
+
if _js_pre:
|
| 324 |
+
return {'stdout': '', 'stderr': f'js_blocked: pattern "{_js_pre.group()}" non permesso', 'exit_code': 1, 'durationMs': 0}
|
| 325 |
+
from .js_sandbox import run_js_sandbox as _run_js_sandbox
|
| 326 |
+
_sb = await _run_js_sandbox(
|
| 327 |
+
code=code, lang=lang, tmpdir=tmpdir,
|
| 328 |
+
timeout=12.0,
|
| 329 |
+
preexec_fn=_child_resource_limits,
|
| 330 |
+
safe_env={
|
| 331 |
+
'HOME': tmpdir, 'TMPDIR': tmpdir, 'NODE_ENV': 'production',
|
| 332 |
+
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
| 333 |
+
'npm_config_cache': '/data/npm-cache',
|
| 334 |
+
'NODE_OPTIONS': '--max-old-space-size=384',
|
| 335 |
+
},
|
| 336 |
+
)
|
| 337 |
+
return {
|
| 338 |
+
'stdout': _sb['stdout'],
|
| 339 |
+
'stderr': _sb['stderr'],
|
| 340 |
+
'exit_code': _sb['returncode'],
|
| 341 |
+
'durationMs': int(time.time() * 1000) - t0,
|
| 342 |
+
}
|
| 343 |
else:
|
| 344 |
return {'stdout': '', 'stderr': f'Unsupported lang: {lang}', 'exit_code': 1, 'durationMs': 0}
|
| 345 |
|
|
|
|
| 388 |
|
| 389 |
@router.post('/api/execute-shell')
|
| 390 |
async def execute_shell(
|
| 391 |
+
cmd: ShellCmd, request: Request,
|
| 392 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 393 |
):
|
| 394 |
raw = cmd.command.strip()
|
| 395 |
+
_shell_err = _validate_shell(raw)
|
| 396 |
+
if _shell_err:
|
| 397 |
+
raise HTTPException(400, {'error':'shell_command_rejected','hint':_shell_err})
|
| 398 |
+
for bad in BLOCKED_CMDS:
|
| 399 |
+
if bad in raw:
|
| 400 |
+
raise HTTPException(400, 'Command blocked for safety')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
timeout = min(max(cmd.timeout, 1), 60)
|
| 402 |
try:
|
| 403 |
async with _realtime_job(timeout_s=90.0):
|
|
|
|
| 409 |
stderr=asyncio.subprocess.PIPE,
|
| 410 |
cwd=tmpdir,
|
| 411 |
preexec_fn=_child_resource_limits, # GAP-EXEC-FIX: RLIMIT_AS/CPU/NOFILE/NPROC
|
| 412 |
+
env=_safe_shell_env(tmpdir), # P19-SEC2-2: era **os.environ (leak totale secret)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
)
|
| 414 |
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
| 415 |
return {
|
|
|
|
| 603 |
|
| 604 |
@router.post('/api/exec/tool')
|
| 605 |
async def exec_tool_dispatch(
|
| 606 |
+
req: ToolDispatchRequest, request: Request,
|
| 607 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 608 |
):
|
| 609 |
"""GAP-2: dispatcher generico β risolve tool nel TOOL_REGISTRY e chiama _fn."""
|
| 610 |
try:
|
api/files.py
CHANGED
|
@@ -10,10 +10,16 @@ GAP-VFS-FIX (P37):
|
|
| 10 |
"""
|
| 11 |
import uuid, time, asyncio
|
| 12 |
from typing import Optional
|
| 13 |
-
from fastapi import APIRouter, Body, HTTPException
|
| 14 |
from .state import sb
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
# βββ S364: fire-and-forget lint + manifest helper ββββββββββββββββββββββββββββ
|
|
@@ -131,41 +137,8 @@ async def get_file(file_id: str):
|
|
| 131 |
raise HTTPException(status_code=404, detail='File not found')
|
| 132 |
|
| 133 |
|
| 134 |
-
import os
|
| 135 |
-
|
| 136 |
-
# S42: Soglia per offloading su HF Storage (512 KB)
|
| 137 |
-
VFS_OFFLOAD_THRESHOLD_BYTES = 512 * 1024
|
| 138 |
-
|
| 139 |
-
async def _offload_to_hf(content: str, filename: str) -> str:
|
| 140 |
-
"""Carica il contenuto su HF Storage e restituisce l'URL."""
|
| 141 |
-
from .hf_storage import hf_append_record
|
| 142 |
-
record = {
|
| 143 |
-
"filename": filename,
|
| 144 |
-
"content_preview": content[:1000],
|
| 145 |
-
"full_content": content,
|
| 146 |
-
"offloaded_at": int(time.time() * 1000)
|
| 147 |
-
}
|
| 148 |
-
# Usiamo hf_append_record per salvare il file nel dataset
|
| 149 |
-
success = await hf_append_record("offloaded_files.jsonl", record)
|
| 150 |
-
if success:
|
| 151 |
-
# Nota: In produzione qui genereremmo un URL diretto R2 o HF
|
| 152 |
-
return f"https://huggingface.co/datasets/{os.getenv('HF_DATASET_REPO', 'Arjanit98/agent-memory')}/raw/main/offloaded_files.jsonl"
|
| 153 |
-
return ""
|
| 154 |
-
|
| 155 |
-
|
| 156 |
@router.post('/api/files')
|
| 157 |
async def save_file(body: dict = Body(...)):
|
| 158 |
-
_content = body.get('content', '') or ''
|
| 159 |
-
_path = body.get('path', 'unnamed')
|
| 160 |
-
|
| 161 |
-
# S42: Offloading logic
|
| 162 |
-
if len(_content.encode('utf-8')) > VFS_OFFLOAD_THRESHOLD_BYTES:
|
| 163 |
-
body['is_offloaded'] = True
|
| 164 |
-
body['original_size'] = len(_content)
|
| 165 |
-
# Per ora simuliamo l'offload salvando un riferimento
|
| 166 |
-
# In un'implementazione reale, caricheremmo su R2/S3 qui
|
| 167 |
-
body['download_url'] = f'/api/files/raw/{_path}'
|
| 168 |
-
|
| 169 |
if 'id' not in body:
|
| 170 |
body['id'] = str(uuid.uuid4())
|
| 171 |
if 'updated_at' not in body:
|
|
@@ -385,7 +358,7 @@ async def scaffold_project_endpoint(body: dict = Body(...)):
|
|
| 385 |
project_name: slug del progetto (verrΓ sanitizzato)
|
| 386 |
"""
|
| 387 |
import re as _re_ep
|
| 388 |
-
|
| 389 |
|
| 390 |
framework = str(body.get('framework', 'react')).strip().lower()
|
| 391 |
project_name = str(body.get('project_name', 'my-project')).strip()
|
|
@@ -411,4 +384,4 @@ async def scaffold_project_endpoint(body: dict = Body(...)):
|
|
| 411 |
except Exception as e:
|
| 412 |
return {'success': False, 'error': str(e)[:300], 'files': {}}
|
| 413 |
|
| 414 |
-
return result
|
|
|
|
| 10 |
"""
|
| 11 |
import uuid, time, asyncio
|
| 12 |
from typing import Optional
|
| 13 |
+
from fastapi import APIRouter, Body, HTTPException, Depends
|
| 14 |
from .state import sb
|
| 15 |
|
| 16 |
+
from .auth_guard import require_role, AuthRole
|
| 17 |
+
from tools.registry import _scaffold_project
|
| 18 |
+
|
| 19 |
+
# P19-SEC2: era fail-open. Il fix concorrente (backend/auth/auth_managed.get_user_session_token)
|
| 20 |
+
# usava un token placeholder hardcoded e non importava Depends (NameError a runtime).
|
| 21 |
+
# Allineato al pattern require_role(AuthRole.MACHINE) usato su tutti gli altri endpoint interni.
|
| 22 |
+
router = APIRouter(dependencies=[Depends(require_role(AuthRole.MACHINE))])
|
| 23 |
|
| 24 |
|
| 25 |
# βββ S364: fire-and-forget lint + manifest helper ββββββββββββββββββββββββββββ
|
|
|
|
| 137 |
raise HTTPException(status_code=404, detail='File not found')
|
| 138 |
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
@router.post('/api/files')
|
| 141 |
async def save_file(body: dict = Body(...)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
if 'id' not in body:
|
| 143 |
body['id'] = str(uuid.uuid4())
|
| 144 |
if 'updated_at' not in body:
|
|
|
|
| 358 |
project_name: slug del progetto (verrΓ sanitizzato)
|
| 359 |
"""
|
| 360 |
import re as _re_ep
|
| 361 |
+
_do_scaffold = _scaffold_project
|
| 362 |
|
| 363 |
framework = str(body.get('framework', 'react')).strip().lower()
|
| 364 |
project_name = str(body.get('project_name', 'my-project')).strip()
|
|
|
|
| 384 |
except Exception as e:
|
| 385 |
return {'success': False, 'error': str(e)[:300], 'files': {}}
|
| 386 |
|
| 387 |
+
return result
|
api/gemini_vision.py
CHANGED
|
@@ -15,7 +15,8 @@ Gap chiuso (P48):
|
|
| 15 |
@module gemini_vision
|
| 16 |
"""
|
| 17 |
import os, base64, httpx, logging
|
| 18 |
-
from fastapi import APIRouter
|
|
|
|
| 19 |
from pydantic import BaseModel
|
| 20 |
|
| 21 |
router = APIRouter(prefix="/api/vision", tags=["gemini_vision"])
|
|
@@ -100,7 +101,7 @@ async def gemini_analyze(
|
|
| 100 |
# βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
|
| 102 |
@router.post("/gemini")
|
| 103 |
-
async def gemini_vision_direct(req: GeminiAnalyzeRequest):
|
| 104 |
"""
|
| 105 |
POST /api/vision/gemini β Analisi diretta Gemini Vision (bypass fallback chain).
|
| 106 |
Accetta url o base64_image. Provider primario zero-cost per vision tasks.
|
|
@@ -127,7 +128,7 @@ async def gemini_vision_direct(req: GeminiAnalyzeRequest):
|
|
| 127 |
|
| 128 |
|
| 129 |
@router.post("/screenshot_analyze")
|
| 130 |
-
async def screenshot_analyze(req: ScreenshotAnalyzeRequest):
|
| 131 |
"""
|
| 132 |
POST /api/vision/screenshot_analyze β Playwright screenshot + Gemini analyze.
|
| 133 |
|
|
|
|
| 15 |
@module gemini_vision
|
| 16 |
"""
|
| 17 |
import os, base64, httpx, logging
|
| 18 |
+
from fastapi import APIRouter, Depends
|
| 19 |
+
from .auth_guard import require_role, AuthRole
|
| 20 |
from pydantic import BaseModel
|
| 21 |
|
| 22 |
router = APIRouter(prefix="/api/vision", tags=["gemini_vision"])
|
|
|
|
| 101 |
# βββ Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
|
| 103 |
@router.post("/gemini")
|
| 104 |
+
async def gemini_vision_direct(req: GeminiAnalyzeRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 105 |
"""
|
| 106 |
POST /api/vision/gemini β Analisi diretta Gemini Vision (bypass fallback chain).
|
| 107 |
Accetta url o base64_image. Provider primario zero-cost per vision tasks.
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
@router.post("/screenshot_analyze")
|
| 131 |
+
async def screenshot_analyze(req: ScreenshotAnalyzeRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 132 |
"""
|
| 133 |
POST /api/vision/screenshot_analyze β Playwright screenshot + Gemini analyze.
|
| 134 |
|
api/incident_registry.py
CHANGED
|
@@ -24,13 +24,14 @@ import json
|
|
| 24 |
import time
|
| 25 |
from typing import Any, Optional
|
| 26 |
|
| 27 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 28 |
from pydantic import BaseModel
|
| 29 |
import logging
|
| 30 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 31 |
|
| 32 |
logger = logging.getLogger("agente_ai.incident_registry")
|
| 33 |
-
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
| 34 |
|
| 35 |
# βββ In-memory store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
_incidents: dict[str, dict] = {} # id β incident
|
|
@@ -151,13 +152,6 @@ def get_delta(since_ms: int) -> list[dict]:
|
|
| 151 |
|
| 152 |
async def _sb_save(incident: dict) -> None:
|
| 153 |
try:
|
| 154 |
-
# βββ Mirroring su Hugging Face Dataset (Zero-Cost Backup) βββββββββββββ
|
| 155 |
-
try:
|
| 156 |
-
from .hf_storage import hf_fire_and_forget
|
| 157 |
-
hf_fire_and_forget("incidents.jsonl", incident)
|
| 158 |
-
except Exception as hf_exc:
|
| 159 |
-
logger.debug("HF Mirroring (incident) silenced: %s", hf_exc)
|
| 160 |
-
|
| 161 |
from .state import _sb
|
| 162 |
if not _sb:
|
| 163 |
return
|
|
|
|
| 24 |
import time
|
| 25 |
from typing import Any, Optional
|
| 26 |
|
| 27 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 28 |
+
from .auth_guard import require_role, AuthRole
|
| 29 |
from pydantic import BaseModel
|
| 30 |
import logging
|
| 31 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 32 |
|
| 33 |
logger = logging.getLogger("agente_ai.incident_registry")
|
| 34 |
+
router = APIRouter(prefix="/api/incidents", tags=["incidents"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 35 |
|
| 36 |
# βββ In-memory store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
_incidents: dict[str, dict] = {} # id β incident
|
|
|
|
| 152 |
|
| 153 |
async def _sb_save(incident: dict) -> None:
|
| 154 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
from .state import _sb
|
| 156 |
if not _sb:
|
| 157 |
return
|
api/integrity_manager.py
CHANGED
|
@@ -26,11 +26,12 @@ import os
|
|
| 26 |
import time
|
| 27 |
from typing import Any, Callable, Coroutine, Optional
|
| 28 |
|
| 29 |
-
from fastapi import APIRouter, Body
|
|
|
|
| 30 |
from fastapi.responses import JSONResponse
|
| 31 |
|
| 32 |
_logger = logging.getLogger("api.integrity")
|
| 33 |
-
router = APIRouter(prefix="/api/integrity", tags=["integrity"])
|
| 34 |
|
| 35 |
# In-memory fallback (usato quando Supabase non disponibile o per rapidita')
|
| 36 |
_SNAP_CACHE: dict[str, dict] = {} # snap_id -> snapshot dict
|
|
|
|
| 26 |
import time
|
| 27 |
from typing import Any, Callable, Coroutine, Optional
|
| 28 |
|
| 29 |
+
from fastapi import APIRouter, Depends, Body
|
| 30 |
+
from .auth_guard import require_role, AuthRole
|
| 31 |
from fastapi.responses import JSONResponse
|
| 32 |
|
| 33 |
_logger = logging.getLogger("api.integrity")
|
| 34 |
+
router = APIRouter(prefix="/api/integrity", tags=["integrity"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 35 |
|
| 36 |
# In-memory fallback (usato quando Supabase non disponibile o per rapidita')
|
| 37 |
_SNAP_CACHE: dict[str, dict] = {} # snap_id -> snapshot dict
|
api/job_queue.py
CHANGED
|
@@ -1,345 +1,468 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from .auth_guard import require_role, AuthRole
|
| 15 |
from pydantic import BaseModel
|
| 16 |
-
from .load_balancer import balancer # S951
|
| 17 |
-
import httpx
|
| 18 |
|
| 19 |
-
_logger = logging.getLogger("
|
| 20 |
-
router = APIRouter(prefix="/jq", tags=["job_queue"])
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
_SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown")
|
| 24 |
-
_JQ_ENABLED = os.getenv("JQ_ENABLED", "0") == "1"
|
| 25 |
-
_INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
# Redis keys
|
| 31 |
-
|
| 32 |
-
_K_WAKE
|
| 33 |
-
|
| 34 |
-
_K_RESULT
|
| 35 |
-
_K_EVENTS
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
return None
|
| 50 |
try:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
json=cmd,
|
| 56 |
-
timeout=5.0
|
| 57 |
-
)
|
| 58 |
-
return r.json()
|
| 59 |
-
except Exception as e:
|
| 60 |
-
_logger.error("[jq] redis error: %s", e)
|
| 61 |
return None
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
async def _llen(key: str) -> int:
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
async def _lrange(key: str, start: int,
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"""
|
| 79 |
-
if not _redis_ok():
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
|
|
|
| 85 |
except Exception:
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
-
await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
while True:
|
|
|
|
| 99 |
try:
|
| 100 |
await publish_load_metrics()
|
| 101 |
-
except Exception as
|
| 102 |
-
_logger.debug("[jq] load
|
| 103 |
-
await asyncio.sleep(30)
|
| 104 |
|
| 105 |
-
async def _hands_consumer_loop():
|
| 106 |
-
"""Loop consumer reale per nodi distribuiti (S42)."""
|
| 107 |
-
global _active_job_count
|
| 108 |
-
_logger.info("[jq] consumer loop avviato per ruolo: %s", _SPACE_ROLE)
|
| 109 |
-
_consecutive_errors = 0
|
| 110 |
-
_MAX_CONSECUTIVE_ERRORS = 3 # INV-R1: dopo 3 errori infra β pausa 30s
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
while True:
|
|
|
|
| 113 |
try:
|
| 114 |
-
#
|
| 115 |
-
await
|
| 116 |
-
|
| 117 |
-
#
|
| 118 |
-
|
| 119 |
-
if
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
else
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
from .state import (
|
| 147 |
-
_get_ai_client, _get_mem_manager_async,
|
| 148 |
-
_get_executor, _get_planner,
|
| 149 |
-
)
|
| 150 |
-
ai_client = _get_ai_client()
|
| 151 |
-
try:
|
| 152 |
-
from agents.critic import Critic
|
| 153 |
-
from agents.response_verifier import ResponseVerifier
|
| 154 |
-
_critic = Critic(llm_client=ai_client)
|
| 155 |
-
_verifier = ResponseVerifier()
|
| 156 |
-
except Exception:
|
| 157 |
-
_critic = None
|
| 158 |
-
_verifier = None
|
| 159 |
-
|
| 160 |
-
_steps: list = []
|
| 161 |
-
|
| 162 |
-
async def _on_step(step: dict) -> None:
|
| 163 |
-
_steps.append({
|
| 164 |
-
"action": step.get("action", ""),
|
| 165 |
-
"output": str(step.get("output", ""))[:200],
|
| 166 |
-
})
|
| 167 |
-
|
| 168 |
-
loop_inst = UnifiedAgentLoop(
|
| 169 |
-
llm_client=ai_client,
|
| 170 |
-
critic=_critic,
|
| 171 |
-
verifier=_verifier,
|
| 172 |
-
memory=await _get_mem_manager_async(),
|
| 173 |
-
executor=_get_executor(),
|
| 174 |
-
planner=_get_planner(),
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
_priority = int(job_data.get("priority", 1))
|
| 178 |
-
_max_steps = max(5, min(4 + _priority * 2, 16))
|
| 179 |
-
|
| 180 |
-
# C2-FIX: incrementa contatore PRIMA di run(), decrementa in finally
|
| 181 |
-
_active_job_count += 1
|
| 182 |
-
try:
|
| 183 |
-
raw_result = await loop_inst.run(
|
| 184 |
-
goal=goal,
|
| 185 |
-
context=context_str,
|
| 186 |
-
max_steps=_max_steps,
|
| 187 |
-
on_step=_on_step,
|
| 188 |
-
session_id=task_id,
|
| 189 |
-
)
|
| 190 |
-
finally:
|
| 191 |
-
_active_job_count = max(0, _active_job_count - 1)
|
| 192 |
-
|
| 193 |
-
output = raw_result.get("output", "") if isinstance(raw_result, dict) else str(raw_result)
|
| 194 |
-
success = bool(raw_result.get("success", False)) if isinstance(raw_result, dict) else bool(output)
|
| 195 |
-
engine = raw_result.get("engine", "unknown") if isinstance(raw_result, dict) else "unknown"
|
| 196 |
-
|
| 197 |
-
result_payload = {
|
| 198 |
-
"taskId": task_id,
|
| 199 |
-
"status": "completed" if success else "failed",
|
| 200 |
-
"worker": _SPACE_ROLE,
|
| 201 |
-
"output": output[:4000],
|
| 202 |
-
"engine": engine,
|
| 203 |
-
"success": success,
|
| 204 |
-
"steps": len(_steps),
|
| 205 |
-
"ts": int(time.time() * 1000),
|
| 206 |
-
}
|
| 207 |
-
_consecutive_errors = 0
|
| 208 |
-
_logger.info("[jq] job completato: %s | engine: %s | success: %s", task_id, engine, success)
|
| 209 |
-
|
| 210 |
-
except (ImportError, ModuleNotFoundError) as imp_err:
|
| 211 |
-
_logger.warning("[jq] agents.unified_loop non disponibile su %s: %s", _SPACE_ROLE, imp_err)
|
| 212 |
-
result_payload = {
|
| 213 |
-
"taskId": task_id,
|
| 214 |
-
"status": "unavailable",
|
| 215 |
-
"worker": _SPACE_ROLE,
|
| 216 |
-
"error": f"UnifiedAgentLoop non disponibile su nodo {_SPACE_ROLE}: {imp_err}",
|
| 217 |
-
"ts": int(time.time() * 1000),
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
except Exception as exec_err:
|
| 221 |
-
_consecutive_errors += 1
|
| 222 |
-
_logger.error("[jq] job execution error task=%s: %s", task_id, exec_err)
|
| 223 |
-
result_payload = {
|
| 224 |
-
"taskId": task_id,
|
| 225 |
-
"status": "error",
|
| 226 |
-
"worker": _SPACE_ROLE,
|
| 227 |
-
"error": str(exec_err)[:500],
|
| 228 |
-
"ts": int(time.time() * 1000),
|
| 229 |
-
}
|
| 230 |
-
if _consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
|
| 231 |
-
_logger.error(
|
| 232 |
-
"[jq] INV-R1: %d errori consecutivi β pausa 30s (nodo: %s)",
|
| 233 |
-
_consecutive_errors, _SPACE_ROLE,
|
| 234 |
-
)
|
| 235 |
-
await asyncio.sleep(30)
|
| 236 |
-
|
| 237 |
-
# 3. Salva risultato su Redis (TTL 1h) β Tool Success Contract S429
|
| 238 |
-
await _rcmd(["SET", _K_RESULT(task_id), json.dumps(result_payload), "EX", "3600"])
|
| 239 |
-
_logger.info("[jq] risultato Redis: %s β %s", task_id, result_payload.get("status"))
|
| 240 |
-
|
| 241 |
-
else:
|
| 242 |
-
_consecutive_errors = 0
|
| 243 |
-
await asyncio.sleep(2)
|
| 244 |
-
|
| 245 |
-
except Exception as e:
|
| 246 |
-
_logger.error("[jq] consumer loop error: %s", e)
|
| 247 |
-
await asyncio.sleep(5)
|
| 248 |
|
| 249 |
async def start_job_queue_consumer() -> None:
|
| 250 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
if not _redis_ok():
|
| 252 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 253 |
return
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
else:
|
| 258 |
_logger.info("[jq] SPACE_ROLE=%s β consumer non avviato (solo load publisher)", _SPACE_ROLE)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# ββ FastAPI endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 261 |
@router.get("/status")
|
| 262 |
-
async def jq_status(
|
| 263 |
-
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
"space_role": _SPACE_ROLE,
|
| 267 |
"jq_enabled": _JQ_ENABLED,
|
| 268 |
-
"redis_configured":
|
| 269 |
-
"active_tasks": max(0, _active_job_count),
|
| 270 |
"ts": int(time.time() * 1000),
|
| 271 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
@router.get("/load/{role}")
|
| 274 |
-
async def jq_load(
|
| 275 |
-
role:
|
| 276 |
-
|
| 277 |
-
)
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
@router.post("/submit")
|
| 289 |
-
async def jq_submit(
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
assigned_role = await balancer.get_best_node(target_role)
|
| 302 |
-
|
| 303 |
-
job_payload = {
|
| 304 |
-
'taskId': task_id,
|
| 305 |
-
'goal': job.goal,
|
| 306 |
-
'context': job.context or {},
|
| 307 |
-
'priority': job.priority,
|
| 308 |
-
'submittedAt': int(time.time() * 1000),
|
| 309 |
-
'submittedBy': _SPACE_ROLE,
|
| 310 |
-
'assignedTo': assigned_role,
|
| 311 |
-
}
|
| 312 |
-
res = await _rcmd(["LPUSH", _K_PENDING, json.dumps(job_payload)])
|
| 313 |
-
if res is None:
|
| 314 |
-
raise HTTPException(503, "Errore Redis durante la sottomissione del job")
|
| 315 |
|
| 316 |
-
queue_len = int(res.get("result", 0)) if res else 0
|
| 317 |
-
await _rcmd(["SET", _K_WAKE, "1", "EX", "10"])
|
| 318 |
-
_logger.info("[jq] job sottomesso: %s (coda: %d)", task_id, queue_len)
|
| 319 |
-
return {
|
| 320 |
-
"taskId": task_id,
|
| 321 |
-
"status": "queued",
|
| 322 |
-
"queueLength": queue_len,
|
| 323 |
-
"ts": int(time.time() * 1000),
|
| 324 |
-
}
|
| 325 |
|
| 326 |
@router.get("/result/{task_id}")
|
| 327 |
-
async def jq_result(
|
| 328 |
-
|
| 329 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 330 |
-
):
|
| 331 |
-
"""S429 β Tool Success Contract: recupero risultato job per taskId."""
|
| 332 |
if not _redis_ok():
|
| 333 |
-
raise HTTPException(503, "
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
return {"taskId": task_id, "status": "pending", "ts": int(time.time() * 1000)}
|
| 338 |
try:
|
| 339 |
-
return json.loads(
|
| 340 |
-
except
|
| 341 |
-
|
| 342 |
-
|
| 343 |
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/api/job_queue.py β Redis coordination BRAINβHANDS (S-DUAL-2)
|
| 3 |
+
|
| 4 |
+
Questo modulo implementa tre livelli di coordinamento via Upstash Redis:
|
| 5 |
+
|
| 6 |
+
1. LOAD EXCHANGE β entrambi gli Space pubblicano metriche ogni 30s su Redis.
|
| 7 |
+
Il CF router legge via /api/jq/load/{role} senza HTTP probe costosi.
|
| 8 |
+
Chiavi: jq:load:brain jq:load:hands (TTL 90s)
|
| 9 |
+
|
| 10 |
+
2. WAKE-UP β BRAIN segnala HANDS di scaldarsi prima che il circuito si chiuda.
|
| 11 |
+
HANDS consumer legge i segnali e chiama /health su se stesso.
|
| 12 |
+
Chiave: jq:wake (LIST, RPOP, TTL 30s per elemento)
|
| 13 |
+
|
| 14 |
+
3. TASK DELEGATION β BRAIN accoda task, HANDS consuma ed esegue.
|
| 15 |
+
Chiave: jq:tasks:pending (LIST, LPUSH/RPOP)
|
| 16 |
+
Chiave: jq:result:{taskId} (STRING, TTL 300s)
|
| 17 |
+
Chiave: jq:events:{taskId} (LIST, TTL 300s)
|
| 18 |
+
Chiave: jq:consumer:alive (STRING, TTL 30s β heartbeat HANDS consumer)
|
| 19 |
+
|
| 20 |
+
Abilitazione:
|
| 21 |
+
JQ_ENABLED=1 + UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN + SPACE_ROLE
|
| 22 |
+
Se JQ_ENABLED non Γ¨ impostato β modulo in standby (load publish attivo, queue no)
|
| 23 |
+
|
| 24 |
+
Endpoints:
|
| 25 |
+
GET /api/jq/status β diagnostica queue
|
| 26 |
+
GET /api/jq/load/{role} β metriche load da Redis (brain|hands)
|
| 27 |
+
POST /api/jq/wake β segnala wake-up HANDS (solo BRAIN)
|
| 28 |
+
POST /api/jq/submit β sottometti job a HANDS via Redis (solo BRAIN)
|
| 29 |
+
GET /api/jq/result/{taskId} β leggi risultato job da Redis
|
| 30 |
+
GET /api/jq/events/{taskId} β leggi eventi SSE da Redis
|
| 31 |
+
"""
|
| 32 |
+
import os, asyncio, json, time, uuid, logging
|
| 33 |
+
from fastapi import APIRouter, Depends, Request, HTTPException
|
| 34 |
from .auth_guard import require_role, AuthRole
|
| 35 |
from pydantic import BaseModel
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
_logger = logging.getLogger("api.job_queue")
|
|
|
|
| 38 |
|
| 39 |
+
router = APIRouter(prefix="/api/jq", tags=["job-queue"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
# ββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
_SPACE_ROLE = os.getenv("SPACE_ROLE", "unknown") # brain | hands | unknown
|
| 43 |
+
_JQ_ENABLED = os.getenv("JQ_ENABLED", "0").strip() == "1"
|
| 44 |
+
_LOAD_TTL = 90 # s β TTL metriche load su Redis
|
| 45 |
+
_RESULT_TTL = 300 # s β TTL risultato job su Redis
|
| 46 |
+
_EVENTS_TTL = 300 # s β TTL lista eventi SSE su Redis
|
| 47 |
+
_WAKE_TTL = 30 # s β TTL singolo wake signal
|
| 48 |
+
_CONSUMER_HB_TTL = 30 # s β TTL heartbeat consumer HANDS
|
| 49 |
|
| 50 |
+
# ββ Redis keys βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
_K_LOAD = lambda role: f"jq:load:{role}" # STRING β metriche load
|
| 52 |
+
_K_WAKE = "jq:wake" # LIST β wake signals
|
| 53 |
+
_K_PENDING = "jq:tasks:pending" # LIST β job queue
|
| 54 |
+
_K_RESULT = lambda tid: f"jq:result:{tid}" # STRING β risultato job
|
| 55 |
+
_K_EVENTS = lambda tid: f"jq:events:{tid}" # LIST β eventi SSE
|
| 56 |
+
_K_CONSUMER = "jq:consumer:alive" # STRING β HB consumer
|
| 57 |
|
| 58 |
+
# ββ Bootstrap check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
def _redis_ok() -> bool:
|
| 60 |
+
"""Verifica che Redis sia configurato (non controlla la connettivitΓ )."""
|
| 61 |
+
return bool(
|
| 62 |
+
os.getenv("UPSTASH_REDIS_REST_URL") and
|
| 63 |
+
os.getenv("UPSTASH_REDIS_REST_TOKEN")
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def _rcmd(command: list, timeout: float = 3.0) -> dict | None:
|
| 68 |
+
"""Esegue comando Redis via modulo centralizzato backend/redis.py."""
|
|
|
|
| 69 |
try:
|
| 70 |
+
from redis import redis_cmd
|
| 71 |
+
return await redis_cmd(command, timeout=timeout)
|
| 72 |
+
except Exception as exc:
|
| 73 |
+
_logger.debug("[jq] redis_cmd error: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
return None
|
| 75 |
|
| 76 |
+
|
| 77 |
+
async def _rpush(key: str, value: str, ttl: int | None = None) -> bool:
|
| 78 |
+
"""LPUSH key value + EXPIRE key ttl (se ttl != None)."""
|
| 79 |
+
r = await _rcmd(["LPUSH", key, value])
|
| 80 |
+
if r and ttl:
|
| 81 |
+
await _rcmd(["EXPIRE", key, ttl])
|
| 82 |
+
return r is not None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def _rpop(key: str) -> str | None:
|
| 86 |
+
"""RPOP key. Ritorna None se lista vuota o errore."""
|
| 87 |
+
r = await _rcmd(["RPOP", key])
|
| 88 |
+
if r and r.get("result") is not None:
|
| 89 |
+
return r["result"]
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
|
| 93 |
async def _llen(key: str) -> int:
|
| 94 |
+
"""LLEN key."""
|
| 95 |
+
r = await _rcmd(["LLEN", key])
|
| 96 |
+
return int(r.get("result", 0)) if r else 0
|
| 97 |
+
|
| 98 |
|
| 99 |
+
async def _lrange(key: str, start: int = 0, stop: int = -1) -> list[str]:
|
| 100 |
+
"""LRANGE key start stop."""
|
| 101 |
+
r = await _rcmd(["LRANGE", key, start, stop])
|
| 102 |
+
return r.get("result", []) if r else []
|
| 103 |
|
| 104 |
+
|
| 105 |
+
# ββ Load metric exchange βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
async def publish_load_metrics(role: str | None = None) -> bool:
|
| 108 |
+
"""
|
| 109 |
+
Pubblica le metriche di carico di questo Space su Redis.
|
| 110 |
+
Chiamato ogni 30s dal background loop _load_publisher_loop().
|
| 111 |
+
Complementa /api/health/load (HTTP) con uno snapshot Redis consultabile
|
| 112 |
+
senza HTTP call dall'altro Space.
|
| 113 |
"""
|
| 114 |
+
if not _redis_ok():
|
| 115 |
+
return False
|
| 116 |
+
_role = role or _SPACE_ROLE
|
| 117 |
+
try:
|
| 118 |
+
from api.priority import get_load_metrics as _glm
|
| 119 |
+
metrics = _glm()
|
| 120 |
+
except Exception:
|
| 121 |
+
metrics = {}
|
| 122 |
+
try:
|
| 123 |
+
from api.state import _agent_tasks
|
| 124 |
+
active = sum(1 for t in _agent_tasks.values() if t.get("status") in ("RUNNING", "running"))
|
| 125 |
+
except Exception:
|
| 126 |
+
active = 0
|
| 127 |
+
|
| 128 |
+
payload = json.dumps({
|
| 129 |
+
"space_role": _role,
|
| 130 |
+
"active_agent_tasks": active,
|
| 131 |
+
"realtime_active": metrics.get("realtime_active", 0),
|
| 132 |
+
"background_active": metrics.get("background_active", 0),
|
| 133 |
+
"realtime_available": metrics.get("realtime_available", 6),
|
| 134 |
+
"consumer_enabled": _JQ_ENABLED,
|
| 135 |
+
"ts": int(time.time() * 1000),
|
| 136 |
+
})
|
| 137 |
+
r = await _rcmd(["SET", _K_LOAD(_role), payload, "EX", _LOAD_TTL])
|
| 138 |
+
return r is not None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def get_remote_load(role: str) -> dict | None:
|
| 142 |
+
"""Legge le metriche load dell'altro Space da Redis. Ritorna None se stale."""
|
| 143 |
+
if not _redis_ok():
|
| 144 |
+
return None
|
| 145 |
+
r = await _rcmd(["GET", _K_LOAD(role)])
|
| 146 |
+
if not r or r.get("result") is None:
|
| 147 |
+
return None
|
| 148 |
+
try:
|
| 149 |
+
return json.loads(r["result"])
|
| 150 |
+
except Exception:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ββ Wake-up signaling ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
+
|
| 156 |
+
async def publish_wake_signal(reason: str = "circuit_open") -> bool:
|
| 157 |
+
"""
|
| 158 |
+
BRAIN pubblica un segnale di wake-up per HANDS.
|
| 159 |
+
HANDS consumer legge il segnale e chiama /health su se stesso
|
| 160 |
+
per prevenire il cold start di HF Space free tier.
|
| 161 |
+
"""
|
| 162 |
+
if not _redis_ok():
|
| 163 |
+
return False
|
| 164 |
+
payload = json.dumps({"reason": reason, "ts": int(time.time() * 1000), "from": _SPACE_ROLE})
|
| 165 |
+
return await _rpush(_K_WAKE, payload, ttl=_WAKE_TTL)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
async def _consume_wake_signals() -> int:
|
| 169 |
+
"""HANDS: legge e processa tutti i wake signal in coda. Ritorna il count."""
|
| 170 |
+
count = 0
|
| 171 |
+
while True:
|
| 172 |
+
raw = await _rpop(_K_WAKE)
|
| 173 |
+
if raw is None:
|
| 174 |
+
break
|
| 175 |
try:
|
| 176 |
+
sig = json.loads(raw)
|
| 177 |
+
_logger.info("[jq] wake signal from %s β reason: %s", sig.get("from"), sig.get("reason"))
|
| 178 |
except Exception:
|
| 179 |
+
pass
|
| 180 |
+
count += 1
|
| 181 |
+
return count
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ββ Task delegation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
|
| 186 |
+
class JobPayload(BaseModel):
|
| 187 |
+
goal: str
|
| 188 |
+
session_id: str = ""
|
| 189 |
+
context: dict = {}
|
| 190 |
+
priority: str = "realtime" # realtime | background
|
| 191 |
+
max_steps: int = 20
|
| 192 |
+
task_id: str = "" # se vuoto β generato da BRAIN
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
async def submit_job(job: JobPayload) -> dict:
|
| 196 |
+
"""
|
| 197 |
+
BRAIN: accoda un task per HANDS via Redis.
|
| 198 |
+
Ritorna {taskId, status, stream_url, queue_depth}.
|
| 199 |
+
|
| 200 |
+
Il client usa stream_url per connettersi direttamente a HANDS (via CF routing)
|
| 201 |
+
e ricevere gli eventi SSE una volta che HANDS ha consumato il job.
|
| 202 |
+
"""
|
| 203 |
+
if not _redis_ok():
|
| 204 |
+
raise HTTPException(503, "Redis non configurato β job queue non disponibile")
|
| 205 |
+
|
| 206 |
+
task_id = job.task_id or str(uuid.uuid4())
|
| 207 |
+
payload = json.dumps({
|
| 208 |
+
"taskId": task_id,
|
| 209 |
+
"goal": job.goal,
|
| 210 |
+
"session_id": job.session_id,
|
| 211 |
+
"context": job.context,
|
| 212 |
+
"priority": job.priority,
|
| 213 |
+
"max_steps": job.max_steps,
|
| 214 |
+
"submitted_at": time.time(),
|
| 215 |
+
"submitted_by": _SPACE_ROLE,
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
ok = await _rpush(_K_PENDING, payload)
|
| 219 |
+
if not ok:
|
| 220 |
+
raise HTTPException(503, "Impossibile accodare il task su Redis")
|
| 221 |
+
|
| 222 |
+
depth = await _llen(_K_PENDING)
|
| 223 |
+
_logger.info("[jq] job queued taskId=%s depth=%d", task_id, depth)
|
| 224 |
+
|
| 225 |
+
return {
|
| 226 |
+
"taskId": task_id,
|
| 227 |
+
"status": "queued",
|
| 228 |
+
"queue_depth": depth,
|
| 229 |
+
"stream_url": f"/api/agent/tasks/{task_id}/stream",
|
| 230 |
}
|
|
|
|
| 231 |
|
| 232 |
+
|
| 233 |
+
async def _execute_queued_job(job: dict) -> None:
|
| 234 |
+
"""
|
| 235 |
+
HANDS: esegue un job prelevato dalla coda Redis.
|
| 236 |
+
Crea il task nel registro locale e avvia unified_loop.
|
| 237 |
+
Gli eventi SSE vengono scritti sia nel registro locale (per streaming diretto)
|
| 238 |
+
sia su Redis (per relay da BRAIN).
|
| 239 |
+
"""
|
| 240 |
+
task_id = job.get("taskId", str(uuid.uuid4()))
|
| 241 |
+
goal = job.get("goal", "")
|
| 242 |
+
_logger.info("[jq] executing queued job taskId=%s goal=%.60s", task_id, goal)
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
from api.state import _agent_tasks
|
| 246 |
+
_agent_tasks[task_id] = {
|
| 247 |
+
"status": "QUEUED",
|
| 248 |
+
"goal": goal,
|
| 249 |
+
"session_id": job.get("session_id", ""),
|
| 250 |
+
"created_at": time.time() * 1000,
|
| 251 |
+
"source": "jq",
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
# Pubblica evento di start su Redis
|
| 255 |
+
await _rcmd(["LPUSH", _K_EVENTS(task_id), json.dumps({
|
| 256 |
+
"type": "task_queued", "taskId": task_id, "ts": int(time.time() * 1000)
|
| 257 |
+
})])
|
| 258 |
+
await _rcmd(["EXPIRE", _K_EVENTS(task_id), _EVENTS_TTL])
|
| 259 |
+
|
| 260 |
+
# Lancia il loop tramite agent.py create_agent_task
|
| 261 |
+
try:
|
| 262 |
+
from api.agent import _create_task_internal
|
| 263 |
+
await _create_task_internal(task_id=task_id, goal=goal, job=job)
|
| 264 |
+
except (ImportError, AttributeError):
|
| 265 |
+
# Fallback: usa unified_loop direttamente
|
| 266 |
+
from agents.unified_loop import UnifiedLoop
|
| 267 |
+
loop = UnifiedLoop()
|
| 268 |
+
result = await loop.run(
|
| 269 |
+
goal=goal,
|
| 270 |
+
context=json.dumps(job.get("context", {})),
|
| 271 |
+
max_steps=job.get("max_steps", 20),
|
| 272 |
+
session_id=job.get("session_id", ""),
|
| 273 |
+
)
|
| 274 |
+
# Pubblica risultato
|
| 275 |
+
await _rcmd(["SET", _K_RESULT(task_id), json.dumps({
|
| 276 |
+
"taskId": task_id,
|
| 277 |
+
"status": "success" if result.get("success") else "error",
|
| 278 |
+
"output": result.get("output", ""),
|
| 279 |
+
"error": result.get("error"),
|
| 280 |
+
"completed_at": time.time(),
|
| 281 |
+
}), "EX", _RESULT_TTL])
|
| 282 |
+
_agent_tasks[task_id]["status"] = "SUCCESS" if result.get("success") else "ERROR"
|
| 283 |
+
|
| 284 |
+
except Exception as exc:
|
| 285 |
+
_logger.error("[jq] job execution failed taskId=%s: %s", task_id, exc, exc_info=True)
|
| 286 |
+
await _rcmd(["SET", _K_RESULT(task_id), json.dumps({
|
| 287 |
+
"taskId": task_id,
|
| 288 |
+
"status": "error",
|
| 289 |
+
"error": str(exc),
|
| 290 |
+
"completed_at": time.time(),
|
| 291 |
+
}), "EX", _RESULT_TTL])
|
| 292 |
+
try:
|
| 293 |
+
from api.state import _agent_tasks
|
| 294 |
+
_agent_tasks[task_id]["status"] = "ERROR"
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ββ Background loops βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 300 |
+
|
| 301 |
+
async def _load_publisher_loop() -> None:
|
| 302 |
+
"""Background loop: pubblica metriche load ogni 30s su Redis."""
|
| 303 |
+
_logger.info("[jq] load publisher avviato (role=%s)", _SPACE_ROLE)
|
| 304 |
while True:
|
| 305 |
+
await asyncio.sleep(30)
|
| 306 |
try:
|
| 307 |
await publish_load_metrics()
|
| 308 |
+
except Exception as exc:
|
| 309 |
+
_logger.debug("[jq] load publish error: %s", exc)
|
|
|
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
+
async def _hands_consumer_loop() -> None:
|
| 313 |
+
"""
|
| 314 |
+
Background loop HANDS: ogni 1s legge dalla queue Redis.
|
| 315 |
+
- Consuma wake signals (log + no-op β siamo giΓ svegli)
|
| 316 |
+
- Consuma job dalla coda e li esegue in background
|
| 317 |
+
- Pubblica heartbeat consumer ogni 10s
|
| 318 |
+
"""
|
| 319 |
+
_logger.info("[jq] HANDS job consumer avviato")
|
| 320 |
+
_hb_tick = 0
|
| 321 |
while True:
|
| 322 |
+
await asyncio.sleep(1)
|
| 323 |
try:
|
| 324 |
+
# Wake signals β siamo giΓ svegli ma logghiamo
|
| 325 |
+
await _consume_wake_signals()
|
| 326 |
+
|
| 327 |
+
# Heartbeat consumer ogni ~10s
|
| 328 |
+
_hb_tick += 1
|
| 329 |
+
if _hb_tick % 10 == 0:
|
| 330 |
+
await _rcmd(["SET", _K_CONSUMER, str(int(time.time())), "EX", _CONSUMER_HB_TTL])
|
| 331 |
+
|
| 332 |
+
if not _JQ_ENABLED:
|
| 333 |
+
continue # load publisher attivo, job consumer no
|
| 334 |
+
|
| 335 |
+
# Preleva job dalla coda
|
| 336 |
+
raw = await _rpop(_K_PENDING)
|
| 337 |
+
if raw is None:
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
try:
|
| 341 |
+
job = json.loads(raw)
|
| 342 |
+
except Exception:
|
| 343 |
+
_logger.warning("[jq] invalid job payload β skipping")
|
| 344 |
+
continue
|
| 345 |
+
|
| 346 |
+
# Esegui in background β non blocca il loop consumer
|
| 347 |
+
t = asyncio.create_task(_execute_queued_job(job))
|
| 348 |
+
t.add_done_callback(lambda task: (
|
| 349 |
+
_logger.error("[jq] job task crashed: %s", task.exception(), exc_info=task.exception())
|
| 350 |
+
if not task.cancelled() and task.exception() else None
|
| 351 |
+
))
|
| 352 |
+
|
| 353 |
+
except Exception as exc:
|
| 354 |
+
_logger.debug("[jq] consumer tick error: %s", exc)
|
| 355 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
async def start_job_queue_consumer() -> None:
|
| 358 |
+
"""
|
| 359 |
+
Punto di ingresso per main.py _on_startup().
|
| 360 |
+
Avvia:
|
| 361 |
+
- _load_publisher_loop() (sempre, su tutti gli Space)
|
| 362 |
+
- _hands_consumer_loop() (solo se SPACE_ROLE=hands o unknown)
|
| 363 |
+
"""
|
| 364 |
if not _redis_ok():
|
| 365 |
_logger.warning("[jq] Redis non configurato β job queue disabilitato")
|
| 366 |
return
|
| 367 |
+
|
| 368 |
+
# Load publisher su tutti gli Space
|
| 369 |
+
asyncio.create_task(_load_publisher_loop())
|
| 370 |
+
|
| 371 |
+
# Consumer solo su HANDS (o se role non impostato per compatibilitΓ )
|
| 372 |
+
if _SPACE_ROLE in ("hands", "unknown"):
|
| 373 |
+
asyncio.create_task(_hands_consumer_loop())
|
| 374 |
else:
|
| 375 |
_logger.info("[jq] SPACE_ROLE=%s β consumer non avviato (solo load publisher)", _SPACE_ROLE)
|
| 376 |
|
| 377 |
+
# Pubblica subito le metriche al boot
|
| 378 |
+
await publish_load_metrics()
|
| 379 |
+
|
| 380 |
+
|
| 381 |
# ββ FastAPI endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 382 |
+
|
| 383 |
@router.get("/status")
|
| 384 |
+
async def jq_status():
|
| 385 |
+
"""Diagnostica completa della job queue."""
|
| 386 |
+
_redis_configured = _redis_ok()
|
| 387 |
+
result = {
|
| 388 |
"space_role": _SPACE_ROLE,
|
| 389 |
"jq_enabled": _JQ_ENABLED,
|
| 390 |
+
"redis_configured": _redis_configured,
|
|
|
|
| 391 |
"ts": int(time.time() * 1000),
|
| 392 |
}
|
| 393 |
+
if _redis_configured:
|
| 394 |
+
result["queue_depth"] = await _llen(_K_PENDING)
|
| 395 |
+
result["wake_pending"] = await _llen(_K_WAKE)
|
| 396 |
+
_hb = await _rcmd(["GET", _K_CONSUMER])
|
| 397 |
+
result["consumer_alive"] = bool(_hb and _hb.get("result"))
|
| 398 |
+
result["brain_load"] = await get_remote_load("brain")
|
| 399 |
+
result["hands_load"] = await get_remote_load("hands")
|
| 400 |
+
return result
|
| 401 |
+
|
| 402 |
|
| 403 |
@router.get("/load/{role}")
|
| 404 |
+
async def jq_load(role: str):
|
| 405 |
+
"""Legge le metriche load di uno Space da Redis. role: brain | hands"""
|
| 406 |
+
if role not in ("brain", "hands"):
|
| 407 |
+
raise HTTPException(400, "role deve essere 'brain' o 'hands'")
|
| 408 |
+
data = await get_remote_load(role)
|
| 409 |
+
if data is None:
|
| 410 |
+
raise HTTPException(404, f"Metriche {role} non disponibili (stale o Redis non configurato)")
|
| 411 |
+
return data
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
@router.post("/wake")
|
| 415 |
+
async def jq_wake(request: Request):
|
| 416 |
+
"""BRAIN: invia segnale wake-up a HANDS. Solo da BRAIN o con X-Internal-Token."""
|
| 417 |
+
_tok = os.getenv("INTERNAL_TOKEN", "")
|
| 418 |
+
if _tok and request.headers.get("X-Internal-Token", "") != _tok:
|
| 419 |
+
raise HTTPException(401, "Unauthorized")
|
| 420 |
+
ok = await publish_wake_signal(reason="manual_wake")
|
| 421 |
+
return {"sent": ok, "ts": int(time.time() * 1000)}
|
| 422 |
+
|
| 423 |
|
| 424 |
@router.post("/submit")
|
| 425 |
+
async def jq_submit(job: JobPayload, request: Request):
|
| 426 |
+
"""
|
| 427 |
+
BRAIN: sottomette un job a HANDS via Redis.
|
| 428 |
+
Richiede X-Internal-Token.
|
| 429 |
+
Ritorna {taskId, status, stream_url} β il client usa stream_url per SSE.
|
| 430 |
+
"""
|
| 431 |
+
_tok = os.getenv("INTERNAL_TOKEN", "")
|
| 432 |
+
if _tok and request.headers.get("X-Internal-Token", "") != _tok:
|
| 433 |
+
raise HTTPException(401, "Unauthorized")
|
| 434 |
+
if not _JQ_ENABLED:
|
| 435 |
+
raise HTTPException(503, "JQ_ENABLED non impostato β job queue disabilitato")
|
| 436 |
+
return await submit_job(job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
@router.get("/result/{task_id}")
|
| 440 |
+
async def jq_result(task_id: str):
|
| 441 |
+
"""Legge il risultato di un job delegato da Redis. Disponibile per ~5 min post-completamento."""
|
|
|
|
|
|
|
|
|
|
| 442 |
if not _redis_ok():
|
| 443 |
+
raise HTTPException(503, "Redis non configurato")
|
| 444 |
+
r = await _rcmd(["GET", _K_RESULT(task_id)])
|
| 445 |
+
if not r or r.get("result") is None:
|
| 446 |
+
raise HTTPException(404, f"Risultato per {task_id} non trovato (non ancora pronto o scaduto)")
|
|
|
|
| 447 |
try:
|
| 448 |
+
return json.loads(r["result"])
|
| 449 |
+
except Exception:
|
| 450 |
+
raise HTTPException(500, "Risultato malformato in Redis")
|
| 451 |
+
|
| 452 |
|
| 453 |
+
@router.get("/events/{task_id}")
|
| 454 |
+
async def jq_events(task_id: str, from_idx: int = 0):
|
| 455 |
+
"""
|
| 456 |
+
Legge gli eventi SSE di un task da Redis (per relay da BRAIN).
|
| 457 |
+
from_idx: indice da cui iniziare (0 = tutti).
|
| 458 |
+
"""
|
| 459 |
+
if not _redis_ok():
|
| 460 |
+
raise HTTPException(503, "Redis non configurato")
|
| 461 |
+
events_raw = await _lrange(_K_EVENTS(task_id), from_idx)
|
| 462 |
+
events = []
|
| 463 |
+
for e in events_raw:
|
| 464 |
+
try:
|
| 465 |
+
events.append(json.loads(e))
|
| 466 |
+
except Exception:
|
| 467 |
+
events.append({"raw": e})
|
| 468 |
+
return {"taskId": task_id, "events": events, "count": len(events), "from_idx": from_idx}
|
api/llm_cache.py
CHANGED
|
@@ -24,9 +24,10 @@ import logging
|
|
| 24 |
import os
|
| 25 |
|
| 26 |
import httpx
|
| 27 |
-
from fastapi import APIRouter
|
|
|
|
| 28 |
|
| 29 |
-
router = APIRouter(prefix="/api/cache", tags=["cache"])
|
| 30 |
_logger = logging.getLogger("llm_cache")
|
| 31 |
|
| 32 |
_CACHE_TTL_S = int(os.getenv("LLM_CACHE_TTL", "3600"))
|
|
|
|
| 24 |
import os
|
| 25 |
|
| 26 |
import httpx
|
| 27 |
+
from fastapi import APIRouter, Depends
|
| 28 |
+
from .auth_guard import require_role, AuthRole
|
| 29 |
|
| 30 |
+
router = APIRouter(prefix="/api/cache", tags=["cache"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 31 |
_logger = logging.getLogger("llm_cache")
|
| 32 |
|
| 33 |
_CACHE_TTL_S = int(os.getenv("LLM_CACHE_TTL", "3600"))
|
api/mcp.py
CHANGED
|
@@ -28,10 +28,11 @@ import time
|
|
| 28 |
from typing import Any
|
| 29 |
import uuid
|
| 30 |
|
| 31 |
-
from fastapi import APIRouter, Request
|
|
|
|
| 32 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 33 |
|
| 34 |
-
router = APIRouter(tags=["mcp"])
|
| 35 |
_logger = logging.getLogger("agente_ai.mcp")
|
| 36 |
|
| 37 |
# P20-Q1: Auth β se MCP_API_KEY Γ¨ impostato nell'env, richiede Authorization: Bearer <key>.
|
|
|
|
| 28 |
from typing import Any
|
| 29 |
import uuid
|
| 30 |
|
| 31 |
+
from fastapi import APIRouter, Depends, Request
|
| 32 |
+
from .auth_guard import require_role, AuthRole
|
| 33 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 34 |
|
| 35 |
+
router = APIRouter(tags=["mcp"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: INTERNAL_TOKEN sempre richiesto + MCP_API_KEY opzionale come secondo layer
|
| 36 |
_logger = logging.getLogger("agente_ai.mcp")
|
| 37 |
|
| 38 |
# P20-Q1: Auth β se MCP_API_KEY Γ¨ impostato nell'env, richiede Authorization: Bearer <key>.
|
api/persistence.py
CHANGED
|
@@ -49,13 +49,6 @@ async def sb_upsert_task(
|
|
| 49 |
'created_at': created_at,
|
| 50 |
'updated_at': now,
|
| 51 |
}
|
| 52 |
-
# βββ Mirroring su Hugging Face Dataset (Zero-Cost Backup) βββββββββββββ
|
| 53 |
-
try:
|
| 54 |
-
from .hf_storage import hf_fire_and_forget
|
| 55 |
-
hf_fire_and_forget("tasks.jsonl", payload)
|
| 56 |
-
except Exception as hf_exc:
|
| 57 |
-
_logger.debug("HF Mirroring (task) silenced: %s", hf_exc)
|
| 58 |
-
|
| 59 |
for _attempt in range(_MAX_RETRY):
|
| 60 |
try:
|
| 61 |
await asyncio.to_thread(
|
|
@@ -100,14 +93,6 @@ async def sb_append_event(task_id: str, event_index: int, event_data: str) -> No
|
|
| 100 |
'event_data': event_data,
|
| 101 |
'created_at': now,
|
| 102 |
}
|
| 103 |
-
# βββ Mirroring su Hugging Face Dataset (Zero-Cost Backup) βββββββββββββ
|
| 104 |
-
try:
|
| 105 |
-
from .hf_storage import hf_fire_and_forget
|
| 106 |
-
# Sharding: i log degli eventi vanno su un dataset potenzialmente diverso (Account B)
|
| 107 |
-
hf_fire_and_forget("task_events.jsonl", payload, repo_id=os.getenv("HF_DATASET_REPO_LOGS"))
|
| 108 |
-
except Exception as hf_exc:
|
| 109 |
-
_logger.debug("HF Mirroring (event) silenced: %s", hf_exc)
|
| 110 |
-
|
| 111 |
for _attempt in range(_MAX_RETRY):
|
| 112 |
try:
|
| 113 |
await asyncio.to_thread(
|
|
|
|
| 49 |
'created_at': created_at,
|
| 50 |
'updated_at': now,
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
for _attempt in range(_MAX_RETRY):
|
| 53 |
try:
|
| 54 |
await asyncio.to_thread(
|
|
|
|
| 93 |
'event_data': event_data,
|
| 94 |
'created_at': now,
|
| 95 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
for _attempt in range(_MAX_RETRY):
|
| 97 |
try:
|
| 98 |
await asyncio.to_thread(
|
api/providers.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 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
|
|
|
|
|
|
|
| 5 |
|
| 6 |
router = APIRouter()
|
| 7 |
_logger = logging.getLogger('agente_ai')
|
|
@@ -22,7 +24,6 @@ _heartbeat_task: asyncio.Task | None = None
|
|
| 22 |
# ββ Health / Status ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
|
| 24 |
@router.get('/health')
|
| 25 |
-
@router.get('/api/health') # alias β Railway health checks usano /api/health
|
| 26 |
async def health():
|
| 27 |
return {
|
| 28 |
'status': 'ok',
|
|
@@ -84,15 +85,15 @@ async def health_load():
|
|
| 84 |
}
|
| 85 |
|
| 86 |
@router.get('/api/version')
|
| 87 |
-
async def api_version():
|
| 88 |
"""S456-X3: versione dettagliata con sprint, capabilities e soglie refusal.
|
| 89 |
Il frontend legge questo endpoint all'avvio per verificare l'allineamento
|
| 90 |
tra la versione del loop browser (agentLoop.ts) e il loop backend (unified_loop.py).
|
| 91 |
"""
|
| 92 |
return {
|
| 93 |
-
'sprint': '
|
| 94 |
-
'version': '3.
|
| 95 |
-
'build_date': '2026-
|
| 96 |
'capabilities': [
|
| 97 |
'never_give_up', # S197: retry forzato su rifiuto LLM
|
| 98 |
'reflective_debug', # S455-P14: fallback chain _reflective_debug
|
|
@@ -130,7 +131,7 @@ async def api_version():
|
|
| 130 |
|
| 131 |
|
| 132 |
@router.get('/api/tools')
|
| 133 |
-
async def list_tools():
|
| 134 |
try:
|
| 135 |
from tools.registry import TOOL_REGISTRY
|
| 136 |
tools_list = [
|
|
@@ -148,10 +149,54 @@ async def list_tools():
|
|
| 148 |
return {"tools": [], "count": 0, "error": str(exc)}
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')
|
|
@@ -160,7 +205,7 @@ async def status(request: Request):
|
|
| 160 |
|
| 161 |
|
| 162 |
@router.get('/api/ai/health')
|
| 163 |
-
async def ai_provider_health():
|
| 164 |
"""Testa tutti i provider AI in parallelo β risultati cachati 60s."""
|
| 165 |
now = time.monotonic()
|
| 166 |
if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
|
|
@@ -201,7 +246,7 @@ async def ai_provider_health():
|
|
| 201 |
# ββ GAP-PROVIDER-FIX: canonical provider order ββββββββββββββββββββββββββββββββββ
|
| 202 |
|
| 203 |
@router.get("/api/providers/canonical")
|
| 204 |
-
async def providers_canonical():
|
| 205 |
"""
|
| 206 |
GAP-PROVIDER-FIX: espone l'ordine di prioritΓ backend dei provider in modo
|
| 207 |
leggibile dal frontend (providerBridge) β elimina la divergenza silenziosa
|
|
@@ -296,13 +341,7 @@ async def _heartbeat_loop() -> None:
|
|
| 296 |
_heartbeat_state["status"] = "error"
|
| 297 |
_heartbeat_state["error"] = str(exc)[:300] # S588: 200β300
|
| 298 |
_logger.error("heartbeat error: %s", exc)
|
| 299 |
-
|
| 300 |
-
# per rilevare il recovery piΓΉ velocemente senza aumentare il carico base.
|
| 301 |
-
_available_count = len([r for r in _heartbeat_state.get("providers", []) if r.get("ok")])
|
| 302 |
-
_adaptive_s = max(30, _HEARTBEAT_INTERVAL_S // 2) if _available_count < 2 else _HEARTBEAT_INTERVAL_S
|
| 303 |
-
if _adaptive_s != _HEARTBEAT_INTERVAL_S:
|
| 304 |
-
_logger.info("heartbeat adaptive: %ds (providers ok=%d)", _adaptive_s, _available_count)
|
| 305 |
-
await asyncio.sleep(_adaptive_s)
|
| 306 |
|
| 307 |
|
| 308 |
def start_heartbeat() -> None:
|
|
@@ -324,35 +363,8 @@ def start_heartbeat() -> None:
|
|
| 324 |
_logger.warning("start_heartbeat failed: %s", exc)
|
| 325 |
|
| 326 |
|
| 327 |
-
|
| 328 |
-
# ββ MX12-P1: get_best_provider_fast() β TTL-guarded in-memory read ββββββββββββ
|
| 329 |
-
# Usato da unified_loop e qualsiasi client interno che vuole il provider migliore
|
| 330 |
-
# senza trigger network. Se heartbeat Γ¨ stale (>_PROVIDER_CACHE_TTL_S) restituisce
|
| 331 |
-
# il fallback configurabile via env DEFAULT_PROVIDER (default: "groq").
|
| 332 |
-
# Thread-safe: legge solo dict primitivi Python (GIL garantisce letture atomiche).
|
| 333 |
-
_PROVIDER_CACHE_TTL_S = int(os.getenv("PROVIDER_CACHE_TTL", "60"))
|
| 334 |
-
|
| 335 |
-
def get_best_provider_fast() -> str:
|
| 336 |
-
"""Provider migliore dalla cache in-memory senza network calls.
|
| 337 |
-
|
| 338 |
-
Regola TTL:
|
| 339 |
-
- Se heartbeat ha girato entro _PROVIDER_CACHE_TTL_S β usa best_provider
|
| 340 |
-
- Se stale o heartbeat non ancora partito β fallback DEFAULT_PROVIDER
|
| 341 |
-
Fallback: 'groq' (tier free 900k tok/giorno, latenza <300ms tipica)
|
| 342 |
-
"""
|
| 343 |
-
last = _heartbeat_state.get("last_run_at") or 0
|
| 344 |
-
age = int(time.time()) - last
|
| 345 |
-
best = _heartbeat_state.get("best_provider")
|
| 346 |
-
if best and age <= _PROVIDER_CACHE_TTL_S:
|
| 347 |
-
return best
|
| 348 |
-
fallback = os.getenv("DEFAULT_PROVIDER", "groq")
|
| 349 |
-
if age > _PROVIDER_CACHE_TTL_S and last > 0:
|
| 350 |
-
_logger.debug("get_best_provider_fast: stale (%ds) β fallback %s", age, fallback)
|
| 351 |
-
return fallback
|
| 352 |
-
|
| 353 |
-
|
| 354 |
@router.get("/debug/timing")
|
| 355 |
-
async def debug_timing():
|
| 356 |
"""S385: Latency telemetry β p50/p95/min/max per metrica LLM e tool call."""
|
| 357 |
def _pct(values: list[float], p: float) -> float | None:
|
| 358 |
if not values:
|
|
@@ -396,7 +408,7 @@ async def debug_timing():
|
|
| 396 |
|
| 397 |
|
| 398 |
@router.get("/api/providers/heartbeat")
|
| 399 |
-
async def providers_heartbeat():
|
| 400 |
now = int(time.time())
|
| 401 |
return {
|
| 402 |
"status": _heartbeat_state["status"],
|
|
@@ -414,138 +426,6 @@ async def providers_heartbeat():
|
|
| 414 |
|
| 415 |
# ββ /api/health/full β aggregated health (AI + Supabase + Telegram + backend) ββ
|
| 416 |
|
| 417 |
-
@router.get("/api/health/full")
|
| 418 |
-
async def health_full():
|
| 419 |
-
"""
|
| 420 |
-
Aggregated health check in un'unica chiamata.
|
| 421 |
-
|
| 422 |
-
Risponde in <200ms:
|
| 423 |
-
- ai: da _heartbeat_state (cache, no LLM probe live)
|
| 424 |
-
- supabase: probe live SELECT limit 1 (timeout 3s)
|
| 425 |
-
- telegram: /getMe live (timeout 3s)
|
| 426 |
-
- backend: task attivi, heartbeat runs, timing snapshot
|
| 427 |
-
|
| 428 |
-
Usato da monitoring, dashboard prod e debug.
|
| 429 |
-
"""
|
| 430 |
-
import time as _t
|
| 431 |
-
|
| 432 |
-
t0 = _t.monotonic()
|
| 433 |
-
now = int(_t.time())
|
| 434 |
-
|
| 435 |
-
# ββ 1. AI providers β heartbeat cache, istantaneo βββββββββββββββββββββββββ
|
| 436 |
-
hb = _heartbeat_state
|
| 437 |
-
_hb_providers = hb.get("providers", [])
|
| 438 |
-
_hb_ok = [p for p in _hb_providers if p.get("ok")]
|
| 439 |
-
_last_run = hb.get("last_run_at") or 0
|
| 440 |
-
ai_section = {
|
| 441 |
-
"ok": len(_hb_ok) > 0,
|
| 442 |
-
"available": len(_hb_ok),
|
| 443 |
-
"total": len(_hb_providers),
|
| 444 |
-
"best": hb.get("best_provider"),
|
| 445 |
-
"best_latency_ms": hb.get("best_latency_ms"),
|
| 446 |
-
"providers": _hb_providers,
|
| 447 |
-
"last_probe_at": _last_run,
|
| 448 |
-
"next_probe_at": hb.get("next_run_at"),
|
| 449 |
-
"stale": (now - _last_run) > (_HEARTBEAT_INTERVAL_S * 2) if _last_run else True,
|
| 450 |
-
"heartbeat_runs": hb.get("runs", 0),
|
| 451 |
-
}
|
| 452 |
-
|
| 453 |
-
# ββ 2. Supabase β probe live (SELECT 1 via table scan, timeout 3s) ββββββββ
|
| 454 |
-
async def _probe_supabase() -> dict:
|
| 455 |
-
if _sb is None:
|
| 456 |
-
return {"ok": False, "configured": False, "error": "not_configured",
|
| 457 |
-
"detail": "Imposta SUPABASE_URL + SUPABASE_KEY in Railway/HF Spaces"}
|
| 458 |
-
_pt = _t.monotonic()
|
| 459 |
-
try:
|
| 460 |
-
await asyncio.wait_for(
|
| 461 |
-
asyncio.to_thread(
|
| 462 |
-
lambda: _sb.table("agent_tasks").select("task_id").limit(1).execute()
|
| 463 |
-
),
|
| 464 |
-
timeout=3.0,
|
| 465 |
-
)
|
| 466 |
-
return {"ok": True, "configured": True,
|
| 467 |
-
"latency_ms": round((_t.monotonic() - _pt) * 1000)}
|
| 468 |
-
except asyncio.TimeoutError:
|
| 469 |
-
return {"ok": False, "configured": True, "error": "timeout",
|
| 470 |
-
"latency_ms": round((_t.monotonic() - _pt) * 1000)}
|
| 471 |
-
except Exception as exc:
|
| 472 |
-
return {"ok": False, "configured": True,
|
| 473 |
-
"latency_ms": round((_t.monotonic() - _pt) * 1000),
|
| 474 |
-
"error": str(exc)[:150]}
|
| 475 |
-
|
| 476 |
-
# ββ 3. Telegram β /getMe live (timeout 3s) ββββββββββββββββββββββββββββββββ
|
| 477 |
-
async def _probe_telegram() -> dict:
|
| 478 |
-
_pt = _t.monotonic()
|
| 479 |
-
try:
|
| 480 |
-
_tg_token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip().replace("\n", "").replace("\r", "")
|
| 481 |
-
_tg_chat = os.getenv("TELEGRAM_CHAT_ID", "").strip().replace("\n", "").replace("\r", "")
|
| 482 |
-
if not _tg_token:
|
| 483 |
-
return {"ok": False, "configured": False,
|
| 484 |
-
"detail": "Imposta TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID in Railway"}
|
| 485 |
-
import httpx
|
| 486 |
-
async with httpx.AsyncClient(timeout=3.0) as hc:
|
| 487 |
-
r = await hc.get(f"https://api.telegram.org/bot{_tg_token}/getMe")
|
| 488 |
-
ms = round((_t.monotonic() - _pt) * 1000)
|
| 489 |
-
if r.status_code == 200 and r.json().get("ok"):
|
| 490 |
-
bot = r.json()["result"]
|
| 491 |
-
return {
|
| 492 |
-
"ok": True,
|
| 493 |
-
"configured": True,
|
| 494 |
-
"latency_ms": ms,
|
| 495 |
-
"username": bot.get("username"),
|
| 496 |
-
"bot_id": bot.get("id"),
|
| 497 |
-
"chat_id_set": bool(_tg_chat),
|
| 498 |
-
}
|
| 499 |
-
return {"ok": False, "configured": True, "latency_ms": ms,
|
| 500 |
-
"error": f"HTTP {r.status_code}: {r.text[:120]}"}
|
| 501 |
-
except asyncio.TimeoutError:
|
| 502 |
-
return {"ok": False, "configured": None, "error": "timeout",
|
| 503 |
-
"latency_ms": round((_t.monotonic() - _pt) * 1000)}
|
| 504 |
-
except Exception as exc:
|
| 505 |
-
return {"ok": False, "configured": None,
|
| 506 |
-
"error": str(exc)[:150],
|
| 507 |
-
"latency_ms": round((_t.monotonic() - _pt) * 1000)}
|
| 508 |
-
|
| 509 |
-
# ββ run supabase + telegram in parallelo ββββββββββββββββββββββββββββββββββ
|
| 510 |
-
sb_res, tg_res = await asyncio.gather(
|
| 511 |
-
_probe_supabase(), _probe_telegram(), return_exceptions=True
|
| 512 |
-
)
|
| 513 |
-
if isinstance(sb_res, Exception):
|
| 514 |
-
sb_res = {"ok": False, "error": str(sb_res)[:150]}
|
| 515 |
-
if isinstance(tg_res, Exception):
|
| 516 |
-
tg_res = {"ok": False, "error": str(tg_res)[:150]}
|
| 517 |
-
|
| 518 |
-
# ββ 4. Backend meta βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 519 |
-
from .state import _agent_tasks as _tasks
|
| 520 |
-
_last_timing = {
|
| 521 |
-
k: round(_TIMING_STORE[k][-1], 1) if _TIMING_STORE.get(k) else None
|
| 522 |
-
for k in ("llm_first_token", "llm_total", "tool_call")
|
| 523 |
-
}
|
| 524 |
-
|
| 525 |
-
# ββ overall status ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 526 |
-
_all_ok = ai_section["ok"] and bool(sb_res.get("ok"))
|
| 527 |
-
return {
|
| 528 |
-
"ok": _all_ok,
|
| 529 |
-
"status": "ok" if _all_ok else ("degraded" if ai_section["ok"] else "down"),
|
| 530 |
-
"elapsed_ms": round((_t.monotonic() - t0) * 1000),
|
| 531 |
-
"server_time": now,
|
| 532 |
-
"version": "3.4.2",
|
| 533 |
-
"ai": ai_section,
|
| 534 |
-
"supabase": sb_res,
|
| 535 |
-
"telegram": tg_res,
|
| 536 |
-
"backend": {
|
| 537 |
-
"active_tasks": len(_tasks),
|
| 538 |
-
"heartbeat_status": hb.get("status"),
|
| 539 |
-
"timing_last": _last_timing,
|
| 540 |
-
"repair_stats": dict(_REPAIR_STATS),
|
| 541 |
-
},
|
| 542 |
-
}
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
# ββ /api/auth/ping β verifica sync INTERNAL_TOKEN (pubblica, no auth richiesta) ββ
|
| 546 |
-
# Risponde immediatamente senza chiamate esterne.
|
| 547 |
-
# Utile per verificare dall'iPhone se CF Worker e HF Space usano lo stesso token.
|
| 548 |
-
@router.get('/api/auth/ping')
|
| 549 |
async def auth_ping(
|
| 550 |
x_internal_token: str | None = None,
|
| 551 |
request: 'Request' = None,
|
|
@@ -561,7 +441,7 @@ async def auth_ping(
|
|
| 561 |
- header_present: True se il chiamante ha inviato X-Internal-Token.
|
| 562 |
|
| 563 |
Uso tipico da iPhone:
|
| 564 |
-
curl
|
| 565 |
β { "internal_token_configured": true, "role_resolved": "USER", ... }
|
| 566 |
|
| 567 |
curl https://agente-ai.pages.dev/api/auth/ping
|
|
@@ -569,7 +449,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
|
|
@@ -594,21 +474,373 @@ async def auth_ping(
|
|
| 594 |
),
|
| 595 |
}
|
| 596 |
|
| 597 |
-
|
| 598 |
-
#
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
async def status_ping():
|
| 603 |
"""
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
"""
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
}
|
| 614 |
|
|
|
|
|
|
|
|
|
|
|
|
| 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 fastapi import Depends
|
| 5 |
+
from .auth_guard import require_role, AuthRole
|
| 6 |
+
from .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
_logger = logging.getLogger('agente_ai')
|
|
|
|
| 24 |
# ββ Health / Status ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
|
| 26 |
@router.get('/health')
|
|
|
|
| 27 |
async def health():
|
| 28 |
return {
|
| 29 |
'status': 'ok',
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
@router.get('/api/version')
|
| 88 |
+
async def api_version(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 89 |
"""S456-X3: versione dettagliata con sprint, capabilities e soglie refusal.
|
| 90 |
Il frontend legge questo endpoint all'avvio per verificare l'allineamento
|
| 91 |
tra la versione del loop browser (agentLoop.ts) e il loop backend (unified_loop.py).
|
| 92 |
"""
|
| 93 |
return {
|
| 94 |
+
'sprint': 'S766-RC1',
|
| 95 |
+
'version': '3.5.0',
|
| 96 |
+
'build_date': '2026-06-19',
|
| 97 |
'capabilities': [
|
| 98 |
'never_give_up', # S197: retry forzato su rifiuto LLM
|
| 99 |
'reflective_debug', # S455-P14: fallback chain _reflective_debug
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
@router.get('/api/tools')
|
| 134 |
+
async def list_tools(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 135 |
try:
|
| 136 |
from tools.registry import TOOL_REGISTRY
|
| 137 |
tools_list = [
|
|
|
|
| 149 |
return {"tools": [], "count": 0, "error": str(exc)}
|
| 150 |
|
| 151 |
|
| 152 |
+
# ββ P17-B2: Skill patterns β sync capabilities backend β frontend βββββββββββββ
|
| 153 |
+
@router.get('/api/skills/patterns')
|
| 154 |
+
async def skills_patterns(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 155 |
+
"""
|
| 156 |
+
P17-B2: Capabilities del backend per sincronizzazione con subAgentRegistry.ts.
|
| 157 |
+
Il frontend usa questo endpoint per sapere quali tool sono disponibili prima
|
| 158 |
+
di assegnare subtask agli agenti, evitando routing verso tool non esistenti.
|
| 159 |
+
|
| 160 |
+
Risposta:
|
| 161 |
+
tools: lista tool con name, goal, description, required, risk, fallbacks
|
| 162 |
+
tool_count: totale tool registrati
|
| 163 |
+
version: versione backend
|
| 164 |
+
ts: timestamp ms (cache busting)
|
| 165 |
+
|
| 166 |
+
Sicurezza: richiede X-Internal-Token (coerente con /api/status e /api/version).
|
| 167 |
+
"""
|
| 168 |
+
_tok = os.getenv('INTERNAL_TOKEN', '')
|
| 169 |
+
if _tok and request.headers.get('X-Internal-Token', '') != _tok:
|
| 170 |
+
from fastapi.responses import JSONResponse as _JSONResp
|
| 171 |
+
return _JSONResp({'error': 'Unauthorized β X-Internal-Token required'}, status_code=401)
|
| 172 |
+
from tools.registry import TOOL_REGISTRY
|
| 173 |
+
_tools = []
|
| 174 |
+
for _name, _info in TOOL_REGISTRY.items():
|
| 175 |
+
if _name.startswith("_"):
|
| 176 |
+
continue
|
| 177 |
+
_tools.append({
|
| 178 |
+
"name": _name,
|
| 179 |
+
"goal": _info.get("goal", ""),
|
| 180 |
+
"description": (_info.get("description") or "")[:300],
|
| 181 |
+
"required": _info.get("required_inputs", []),
|
| 182 |
+
"risk": _info.get("risk_level", "low"),
|
| 183 |
+
"fallbacks": [
|
| 184 |
+
(f.get("name", "") if isinstance(f, dict) else str(f))
|
| 185 |
+
for f in _info.get("fallbacks", [])
|
| 186 |
+
],
|
| 187 |
+
})
|
| 188 |
+
return {
|
| 189 |
+
"tools": _tools,
|
| 190 |
+
"tool_count": len(_tools),
|
| 191 |
+
"version": "3.4.2",
|
| 192 |
+
"ts": int(time.time() * 1000),
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
@router.get('/api/status')
|
| 197 |
+
async def status(request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 198 |
# security-fix: richiede X-Internal-Token β endpoint espone env vars
|
| 199 |
+
_tok = os.getenv('INTERNAL_TOKEN', '')
|
| 200 |
if _tok and request.headers.get('X-Internal-Token', '') != _tok:
|
| 201 |
from fastapi import HTTPException as _HTTPEx
|
| 202 |
raise _HTTPEx(401, 'Unauthorized')
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
@router.get('/api/ai/health')
|
| 208 |
+
async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 209 |
"""Testa tutti i provider AI in parallelo β risultati cachati 60s."""
|
| 210 |
now = time.monotonic()
|
| 211 |
if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
|
|
|
|
| 246 |
# ββ GAP-PROVIDER-FIX: canonical provider order ββββββββββββββββββββββββββββββββββ
|
| 247 |
|
| 248 |
@router.get("/api/providers/canonical")
|
| 249 |
+
async def providers_canonical(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 250 |
"""
|
| 251 |
GAP-PROVIDER-FIX: espone l'ordine di prioritΓ backend dei provider in modo
|
| 252 |
leggibile dal frontend (providerBridge) β elimina la divergenza silenziosa
|
|
|
|
| 341 |
_heartbeat_state["status"] = "error"
|
| 342 |
_heartbeat_state["error"] = str(exc)[:300] # S588: 200β300
|
| 343 |
_logger.error("heartbeat error: %s", exc)
|
| 344 |
+
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
|
| 347 |
def start_heartbeat() -> None:
|
|
|
|
| 363 |
_logger.warning("start_heartbeat failed: %s", exc)
|
| 364 |
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
@router.get("/debug/timing")
|
| 367 |
+
async def debug_timing(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 368 |
"""S385: Latency telemetry β p50/p95/min/max per metrica LLM e tool call."""
|
| 369 |
def _pct(values: list[float], p: float) -> float | None:
|
| 370 |
if not values:
|
|
|
|
| 408 |
|
| 409 |
|
| 410 |
@router.get("/api/providers/heartbeat")
|
| 411 |
+
async def providers_heartbeat(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 412 |
now = int(time.time())
|
| 413 |
return {
|
| 414 |
"status": _heartbeat_state["status"],
|
|
|
|
| 426 |
|
| 427 |
# ββ /api/health/full β aggregated health (AI + Supabase + Telegram + backend) ββ
|
| 428 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
async def auth_ping(
|
| 430 |
x_internal_token: str | None = None,
|
| 431 |
request: 'Request' = None,
|
|
|
|
| 441 |
- header_present: True se il chiamante ha inviato X-Internal-Token.
|
| 442 |
|
| 443 |
Uso tipico da iPhone:
|
| 444 |
+
curl <hf-space-a-url>/api/auth/ping
|
| 445 |
β { "internal_token_configured": true, "role_resolved": "USER", ... }
|
| 446 |
|
| 447 |
curl https://agente-ai.pages.dev/api/auth/ping
|
|
|
|
| 449 |
(CF Worker aggiunge il token β role MACHINE se i due token coincidono)
|
| 450 |
"""
|
| 451 |
import os as _os
|
| 452 |
+
server_token = _os.getenv('INTERNAL_TOKEN', '').strip()
|
| 453 |
|
| 454 |
# Leggi header sia dal parametro sia dall'oggetto request (FastAPI puΓ² passare entrambi)
|
| 455 |
hdr_token = x_internal_token
|
|
|
|
| 474 |
),
|
| 475 |
}
|
| 476 |
|
| 477 |
+
|
| 478 |
+
# ββ P18-B2: /api/health/full β Health check dettagliato ββββββββββββββββββββββ
|
| 479 |
+
|
| 480 |
+
@router.get('/api/health/full')
|
| 481 |
+
async def health_full(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
|
|
|
| 482 |
"""
|
| 483 |
+
P18-B2: Health check dettagliato di tutti i sottosistemi.
|
| 484 |
+
|
| 485 |
+
Auth: MACHINE (X-Internal-Token header obbligatorio).
|
| 486 |
+
|
| 487 |
+
Esegue tutti i check in parallelo con timeout individuali (5s ciascuno).
|
| 488 |
+
|
| 489 |
+
Risposta:
|
| 490 |
+
status "ok" | "degraded" | "critical"
|
| 491 |
+
checks { <nome>: { ok, latency_ms?, error?, ... } }
|
| 492 |
+
|
| 493 |
+
HTTP 503 se almeno un check CRITICAL fallisce
|
| 494 |
+
(CRITICAL = almeno un Supabase raggiungibile + env_config ok).
|
| 495 |
+
|
| 496 |
+
Non-critical failures β HTTP 200 con status "degraded".
|
| 497 |
"""
|
| 498 |
+
from fastapi.responses import JSONResponse # P18-B2: local import β not in module scope
|
| 499 |
+
|
| 500 |
+
import shutil as _shu
|
| 501 |
+
|
| 502 |
+
_CHECK_TIMEOUT = 5.0 # secondi β timeout per ogni singolo check
|
| 503 |
+
|
| 504 |
+
# ββ check: Supabase βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 505 |
+
async def _ck_supabase(client: object, label: str) -> dict:
|
| 506 |
+
if client is None:
|
| 507 |
+
return {"ok": False, "error": "not configured"}
|
| 508 |
+
t0 = time.monotonic()
|
| 509 |
+
try:
|
| 510 |
+
await asyncio.wait_for(
|
| 511 |
+
asyncio.to_thread(
|
| 512 |
+
lambda: client.table("agent_tasks").select("task_id").limit(1).execute()
|
| 513 |
+
),
|
| 514 |
+
timeout=_CHECK_TIMEOUT,
|
| 515 |
+
)
|
| 516 |
+
return {"ok": True, "latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 517 |
+
except asyncio.TimeoutError:
|
| 518 |
+
return {
|
| 519 |
+
"ok": False, "error": "timeout",
|
| 520 |
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
| 521 |
+
}
|
| 522 |
+
except Exception as exc:
|
| 523 |
+
return {
|
| 524 |
+
"ok": False, "error": str(exc)[:200],
|
| 525 |
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
# ββ check: Redis / Upstash ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 529 |
+
async def _ck_redis() -> dict:
|
| 530 |
+
import urllib.request as _ur, json as _js
|
| 531 |
+
url = os.getenv("UPSTASH_REDIS_URL", "").strip()
|
| 532 |
+
token = os.getenv("UPSTASH_REDIS_TOKEN", "").strip()
|
| 533 |
+
if not url or not token:
|
| 534 |
+
return {"ok": False, "error": "not configured"}
|
| 535 |
+
t0 = time.monotonic()
|
| 536 |
+
try:
|
| 537 |
+
def _ping():
|
| 538 |
+
req = _ur.Request(
|
| 539 |
+
f"{url}/ping",
|
| 540 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 541 |
+
method="GET",
|
| 542 |
+
)
|
| 543 |
+
with _ur.urlopen(req, timeout=4) as r:
|
| 544 |
+
return _js.loads(r.read())
|
| 545 |
+
resp = await asyncio.wait_for(asyncio.to_thread(_ping), timeout=_CHECK_TIMEOUT)
|
| 546 |
+
ok = resp.get("result") == "PONG"
|
| 547 |
+
return {
|
| 548 |
+
"ok": ok,
|
| 549 |
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
| 550 |
+
**({} if ok else {"error": f"unexpected response: {resp}"}),
|
| 551 |
+
}
|
| 552 |
+
except asyncio.TimeoutError:
|
| 553 |
+
return {"ok": False, "error": "timeout",
|
| 554 |
+
"latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 555 |
+
except Exception as exc:
|
| 556 |
+
return {"ok": False, "error": str(exc)[:200],
|
| 557 |
+
"latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 558 |
+
|
| 559 |
+
# ββ check: LLM providers (da cache heartbeat β zero latenza) βββββββββββββ
|
| 560 |
+
async def _ck_llm_providers() -> dict:
|
| 561 |
+
cache = _ai_health_cache.get("data")
|
| 562 |
+
if cache and cache.get("providers"):
|
| 563 |
+
age_s = round(time.monotonic() - _ai_health_cache.get("at", 0))
|
| 564 |
+
available = [p for p in cache["providers"] if p.get("ok")]
|
| 565 |
+
best = (
|
| 566 |
+
min(available, key=lambda p: p.get("latency_ms", 99_999))
|
| 567 |
+
if available else None
|
| 568 |
+
)
|
| 569 |
+
return {
|
| 570 |
+
"ok": len(available) > 0,
|
| 571 |
+
"from_cache": True,
|
| 572 |
+
"cache_age_s": age_s,
|
| 573 |
+
"total": len(cache["providers"]),
|
| 574 |
+
"available": len(available),
|
| 575 |
+
"best_provider": best["name"] if best else None,
|
| 576 |
+
"best_latency_ms": best["latency_ms"] if best else None,
|
| 577 |
+
"providers": cache["providers"],
|
| 578 |
+
}
|
| 579 |
+
# Heartbeat non ancora eseguito (boot molto recente)
|
| 580 |
+
return {
|
| 581 |
+
"ok": True,
|
| 582 |
+
"from_cache": False,
|
| 583 |
+
"note": "heartbeat non ancora eseguito β dati disponibili dopo il primo ciclo (90s)",
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
# ββ check: Python exec ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 587 |
+
async def _ck_exec_python() -> dict:
|
| 588 |
+
import sys
|
| 589 |
+
t0 = time.monotonic()
|
| 590 |
+
try:
|
| 591 |
+
proc = await asyncio.wait_for(
|
| 592 |
+
asyncio.create_subprocess_exec(
|
| 593 |
+
sys.executable, "-c", "print('ok')",
|
| 594 |
+
stdout=asyncio.subprocess.PIPE,
|
| 595 |
+
stderr=asyncio.subprocess.PIPE,
|
| 596 |
+
),
|
| 597 |
+
timeout=_CHECK_TIMEOUT,
|
| 598 |
+
)
|
| 599 |
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_CHECK_TIMEOUT)
|
| 600 |
+
ok = proc.returncode == 0 and stdout.strip() == b"ok"
|
| 601 |
+
return {
|
| 602 |
+
"ok": ok,
|
| 603 |
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
| 604 |
+
"python": sys.version.split()[0],
|
| 605 |
+
**({} if ok else {"error": f"returncode={proc.returncode}"}),
|
| 606 |
+
}
|
| 607 |
+
except Exception as exc:
|
| 608 |
+
return {"ok": False, "error": str(exc)[:200],
|
| 609 |
+
"latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 610 |
+
|
| 611 |
+
# ββ check: JS sandbox (Deno β Node vm fallback) βββββββββββββββββββββββββββ
|
| 612 |
+
async def _ck_js_sandbox() -> dict:
|
| 613 |
+
t0 = time.monotonic()
|
| 614 |
+
# Deno
|
| 615 |
+
deno_candidates = [os.getenv("DENO_PATH", ""), "/root/.deno/bin/deno", "deno"]
|
| 616 |
+
for c in deno_candidates:
|
| 617 |
+
if not c:
|
| 618 |
+
continue
|
| 619 |
+
bin_path = c if (os.path.isabs(c) and os.path.isfile(c)) else _shu.which(c)
|
| 620 |
+
if not bin_path:
|
| 621 |
+
continue
|
| 622 |
+
try:
|
| 623 |
+
proc = await asyncio.wait_for(
|
| 624 |
+
asyncio.create_subprocess_exec(
|
| 625 |
+
bin_path, "eval", "console.log('ok')",
|
| 626 |
+
stdout=asyncio.subprocess.PIPE,
|
| 627 |
+
stderr=asyncio.subprocess.PIPE,
|
| 628 |
+
),
|
| 629 |
+
timeout=_CHECK_TIMEOUT,
|
| 630 |
+
)
|
| 631 |
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_CHECK_TIMEOUT)
|
| 632 |
+
if proc.returncode == 0 and b"ok" in stdout:
|
| 633 |
+
return {"ok": True, "sandbox": "deno", "bin": bin_path,
|
| 634 |
+
"latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 635 |
+
except Exception:
|
| 636 |
+
pass
|
| 637 |
+
# Node vm
|
| 638 |
+
node_bin = _shu.which("node")
|
| 639 |
+
if node_bin:
|
| 640 |
+
try:
|
| 641 |
+
proc = await asyncio.wait_for(
|
| 642 |
+
asyncio.create_subprocess_exec(
|
| 643 |
+
node_bin, "-e",
|
| 644 |
+
"const vm=require('vm');vm.runInNewContext('1+1');console.log('ok')",
|
| 645 |
+
stdout=asyncio.subprocess.PIPE,
|
| 646 |
+
stderr=asyncio.subprocess.PIPE,
|
| 647 |
+
),
|
| 648 |
+
timeout=_CHECK_TIMEOUT,
|
| 649 |
+
)
|
| 650 |
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_CHECK_TIMEOUT)
|
| 651 |
+
if proc.returncode == 0 and b"ok" in stdout:
|
| 652 |
+
return {"ok": True, "sandbox": "node_vm", "bin": node_bin,
|
| 653 |
+
"latency_ms": round((time.monotonic() - t0) * 1000)}
|
| 654 |
+
except Exception:
|
| 655 |
+
pass
|
| 656 |
+
return {
|
| 657 |
+
"ok": False,
|
| 658 |
+
"error": "nessun JS sandbox disponibile (Deno e Node non trovati)",
|
| 659 |
+
"latency_ms": round((time.monotonic() - t0) * 1000),
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
# ββ check: Env config βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 663 |
+
async def _ck_env_config() -> dict:
|
| 664 |
+
critical = ["INTERNAL_TOKEN", "SUPABASE_URL", "SUPABASE_KEY"]
|
| 665 |
+
important = ["UPSTASH_REDIS_URL", "ALLOWED_ORIGINS", "RESEND_API_KEY"]
|
| 666 |
+
optional = ["OPERATOR_TOKEN", "ADMIN_TOKEN", "OPENAI_API_KEY",
|
| 667 |
+
"GROQ_API_KEY", "HF_TOKEN_A", "HF_TOKEN_B", "HF_TOKEN_C"]
|
| 668 |
+
miss_crit = [v for v in critical if not os.getenv(v, "").strip()]
|
| 669 |
+
miss_imp = [v for v in important if not os.getenv(v, "").strip()]
|
| 670 |
+
miss_opt = [v for v in optional if not os.getenv(v, "").strip()]
|
| 671 |
+
return {
|
| 672 |
+
"ok": len(miss_crit) == 0,
|
| 673 |
+
"missing_critical": miss_crit,
|
| 674 |
+
"missing_important": miss_imp,
|
| 675 |
+
"missing_optional": miss_opt,
|
| 676 |
+
"railway_env": os.getenv("RAILWAY_ENVIRONMENT", ""),
|
| 677 |
+
"space_role": os.getenv("SPACE_ROLE", ""),
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
# ββ check: Internal token sync ββββββββββββββββββββββββββββββββββββββββββββ
|
| 681 |
+
async def _ck_internal_token() -> dict:
|
| 682 |
+
tok = os.getenv("INTERNAL_TOKEN", "").strip()
|
| 683 |
+
on_railway = bool(
|
| 684 |
+
os.getenv("RAILWAY_ENVIRONMENT") or os.getenv("RAILWAY_PROJECT_ID")
|
| 685 |
+
)
|
| 686 |
+
on_hf = bool(os.getenv("SPACE_ID") or os.getenv("HF_SPACE_ID"))
|
| 687 |
+
configured = bool(tok)
|
| 688 |
+
# Ephemeral = configurato solo in locale senza Railway/HF (generato al boot)
|
| 689 |
+
ephemeral = configured and not (on_railway or on_hf)
|
| 690 |
+
return {
|
| 691 |
+
"ok": configured,
|
| 692 |
+
"configured": configured,
|
| 693 |
+
"ephemeral": ephemeral,
|
| 694 |
+
"env": ("railway" if on_railway else ("hf_space" if on_hf else "local")),
|
| 695 |
+
**({
|
| 696 |
+
"warning": "token generato al boot β CF Worker andrΓ aggiornato ad ogni riavvio"
|
| 697 |
+
} if ephemeral else {}),
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
# ββ 3. Telegram β /getMe live (timeout 3s) ββββββββββββββββββββββββββββββββ
|
| 702 |
+
async def _probe_telegram() -> dict:
|
| 703 |
+
_pt = time.monotonic()
|
| 704 |
+
try:
|
| 705 |
+
from .telegram_notify import _load_config as _tg_cfg
|
| 706 |
+
cfg = await asyncio.wait_for(_tg_cfg(), timeout=2.0)
|
| 707 |
+
if not cfg or not cfg.get("token"):
|
| 708 |
+
return {"ok": False, "configured": False,
|
| 709 |
+
"detail": "Imposta TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID in Railway"}
|
| 710 |
+
import httpx
|
| 711 |
+
async with httpx.AsyncClient(timeout=3.0) as hc:
|
| 712 |
+
r = await hc.get(f"https://api.telegram.org/bot{cfg['token']}/getMe")
|
| 713 |
+
ms = round((time.monotonic() - _pt) * 1000)
|
| 714 |
+
if r.status_code == 200 and r.json().get("ok"):
|
| 715 |
+
bot = r.json()["result"]
|
| 716 |
+
return {
|
| 717 |
+
"ok": True,
|
| 718 |
+
"configured": True,
|
| 719 |
+
"latency_ms": ms,
|
| 720 |
+
"username": bot.get("username"),
|
| 721 |
+
"bot_id": bot.get("id"),
|
| 722 |
+
"chat_id_set": bool(cfg.get("chat_id")),
|
| 723 |
+
}
|
| 724 |
+
return {"ok": False, "configured": True, "latency_ms": ms,
|
| 725 |
+
"error": f"HTTP {r.status_code}: {r.text[:120]}"}
|
| 726 |
+
except asyncio.TimeoutError:
|
| 727 |
+
return {"ok": False, "configured": None, "error": "timeout",
|
| 728 |
+
"latency_ms": round((time.monotonic() - _pt) * 1000)}
|
| 729 |
+
except Exception as exc:
|
| 730 |
+
return {"ok": False, "configured": None,
|
| 731 |
+
"error": str(exc)[:150],
|
| 732 |
+
"latency_ms": round((time.monotonic() - _pt) * 1000)}
|
| 733 |
+
# ββ check: Processo / Memoria βββββββββββββββββββββββββββββββββββββββββββββ
|
| 734 |
+
async def _ck_process() -> dict:
|
| 735 |
+
result: dict = {"ok": True, "uptime_s": round(time.monotonic() - _BOOT_TIME)}
|
| 736 |
+
try:
|
| 737 |
+
import psutil as _ps
|
| 738 |
+
p = _ps.Process(os.getpid())
|
| 739 |
+
mem = p.memory_info()
|
| 740 |
+
result.update({
|
| 741 |
+
"mem_rss_mb": round(mem.rss / 1024 / 1024, 1),
|
| 742 |
+
"mem_vms_mb": round(mem.vms / 1024 / 1024, 1),
|
| 743 |
+
"cpu_percent": p.cpu_percent(interval=0.05),
|
| 744 |
+
"threads": p.num_threads(),
|
| 745 |
+
})
|
| 746 |
+
except ImportError:
|
| 747 |
+
result["mem_note"] = "psutil non disponibile"
|
| 748 |
+
except Exception as exc:
|
| 749 |
+
result["mem_error"] = str(exc)[:100]
|
| 750 |
+
return result
|
| 751 |
+
|
| 752 |
+
# ββ check: Disco ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 753 |
+
async def _ck_disk() -> dict:
|
| 754 |
+
try:
|
| 755 |
+
u = _shu.disk_usage("/")
|
| 756 |
+
free_gb = round(u.free / 1024 ** 3, 2)
|
| 757 |
+
total_gb = round(u.total / 1024 ** 3, 2)
|
| 758 |
+
used_pct = round(u.used / u.total * 100, 1)
|
| 759 |
+
ok = free_gb > 0.5
|
| 760 |
+
return {
|
| 761 |
+
"ok": ok,
|
| 762 |
+
"free_gb": free_gb,
|
| 763 |
+
"total_gb": total_gb,
|
| 764 |
+
"used_pct": used_pct,
|
| 765 |
+
**({
|
| 766 |
+
"warning": f"spazio libero basso: {free_gb:.2f} GB"
|
| 767 |
+
} if not ok else {}),
|
| 768 |
+
}
|
| 769 |
+
except Exception as exc:
|
| 770 |
+
return {"ok": False, "error": str(exc)[:100]}
|
| 771 |
+
|
| 772 |
+
# ββ Esegui tutti i check in parallelo βββββββββββββββββββββββββββββββββββββ
|
| 773 |
+
from .state import _sb as _sb_h, _sb2 as _sb2_h, _sb_fallback as _sbf_h
|
| 774 |
+
|
| 775 |
+
(
|
| 776 |
+
c_sb1, c_sb2, c_sbf,
|
| 777 |
+
c_redis,
|
| 778 |
+
c_llm,
|
| 779 |
+
c_py,
|
| 780 |
+
c_js,
|
| 781 |
+
c_env,
|
| 782 |
+
c_tok,
|
| 783 |
+
c_proc,
|
| 784 |
+
c_disk,
|
| 785 |
+
) = await asyncio.gather(
|
| 786 |
+
_ck_supabase(_sb_h, "primary"),
|
| 787 |
+
_probe_telegram(),
|
| 788 |
+
_ck_supabase(_sb2_h, "secondary"),
|
| 789 |
+
_ck_supabase(_sbf_h, "fallback"),
|
| 790 |
+
_ck_redis(),
|
| 791 |
+
_ck_llm_providers(),
|
| 792 |
+
_ck_exec_python(),
|
| 793 |
+
_ck_js_sandbox(),
|
| 794 |
+
_ck_env_config(),
|
| 795 |
+
_ck_internal_token(),
|
| 796 |
+
_ck_process(),
|
| 797 |
+
_ck_disk(),
|
| 798 |
+
)
|
| 799 |
+
|
| 800 |
+
checks = {
|
| 801 |
+
"supabase_primary": c_sb1,
|
| 802 |
+
"supabase_secondary": c_sb2,
|
| 803 |
+
"supabase_fallback": c_sbf,
|
| 804 |
+
"telegram": c_tg,
|
| 805 |
+
"redis": c_redis,
|
| 806 |
+
"llm_providers": c_llm,
|
| 807 |
+
"exec_python": c_py,
|
| 808 |
+
"exec_js_sandbox": c_js,
|
| 809 |
+
"env_config": c_env,
|
| 810 |
+
"internal_token": c_tok,
|
| 811 |
+
"process": c_proc,
|
| 812 |
+
"disk": c_disk,
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
# ββ Determina stato complessivo βββββββββββββββββββββββββββββββββββββββββββ
|
| 816 |
+
# CRITICAL: almeno un Supabase deve rispondere + env_config deve essere ok
|
| 817 |
+
supabase_any_ok = c_sb1["ok"] or c_sb2["ok"] or c_sbf["ok"]
|
| 818 |
+
critical_ok = supabase_any_ok and c_env["ok"]
|
| 819 |
+
|
| 820 |
+
# Non-critical: tutto il resto
|
| 821 |
+
non_critical_failed = [
|
| 822 |
+
name for name, c in checks.items()
|
| 823 |
+
if name != "env_config" and not c.get("ok")
|
| 824 |
+
]
|
| 825 |
+
|
| 826 |
+
if not critical_ok: overall = "critical"
|
| 827 |
+
elif non_critical_failed: overall = "degraded"
|
| 828 |
+
else: overall = "ok"
|
| 829 |
+
|
| 830 |
+
body = {
|
| 831 |
+
"status": overall,
|
| 832 |
+
"version": "3.4.2",
|
| 833 |
+
"ts": int(time.time() * 1000),
|
| 834 |
+
"checks": checks,
|
| 835 |
+
"summary": {
|
| 836 |
+
"supabase_any_ok": supabase_any_ok,
|
| 837 |
+
"critical_ok": critical_ok,
|
| 838 |
+
"degraded_checks": non_critical_failed,
|
| 839 |
+
"total_checks": len(checks),
|
| 840 |
+
"checks_ok": sum(1 for c in checks.values() if c.get("ok")),
|
| 841 |
+
},
|
| 842 |
}
|
| 843 |
|
| 844 |
+
if overall == "critical":
|
| 845 |
+
return JSONResponse(status_code=503, content=body)
|
| 846 |
+
return body
|
api/quality_guardian.py
CHANGED
|
@@ -392,7 +392,7 @@ async def _check(task_id, goal, llm_output, on_event, session_files: dict | None
|
|
| 392 |
[
|
| 393 |
{"role": "system", "content": _TESTER_SYS},
|
| 394 |
# S589/S597: goal 500 chars
|
| 395 |
-
{"role": "user", "content": f"Goal: {goal[:500]}\n\n```python\n{code[:2000]}\n```"},
|
| 396 |
],
|
| 397 |
temperature=0,
|
| 398 |
max_tokens=400,
|
|
@@ -454,7 +454,7 @@ async def _check(task_id, goal, llm_output, on_event, session_files: dict | None
|
|
| 454 |
"taskId": task_id,
|
| 455 |
"passed": passed,
|
| 456 |
"stdout": exec_result["stdout"][:500], # S604
|
| 457 |
-
"stderr": exec_result["stderr"][:500]
|
| 458 |
})
|
| 459 |
if asyncio.iscoroutine(val):
|
| 460 |
await val
|
|
|
|
| 392 |
[
|
| 393 |
{"role": "system", "content": _TESTER_SYS},
|
| 394 |
# S589/S597: goal 500 chars
|
| 395 |
+
{"role": "user", "content": f"Goal: {goal[:500]}\n\n```python\n{code[:2000]}\n```"},
|
| 396 |
],
|
| 397 |
temperature=0,
|
| 398 |
max_tokens=400,
|
|
|
|
| 454 |
"taskId": task_id,
|
| 455 |
"passed": passed,
|
| 456 |
"stdout": exec_result["stdout"][:500], # S604
|
| 457 |
+
"stderr": exec_result["stderr"][:500], # S597
|
| 458 |
})
|
| 459 |
if asyncio.iscoroutine(val):
|
| 460 |
await val
|
api/research.py
CHANGED
|
@@ -20,9 +20,9 @@ Budget (iPhone free tier):
|
|
| 20 |
"""
|
| 21 |
import asyncio, os, re, logging, time
|
| 22 |
import httpx
|
| 23 |
-
from fastapi import APIRouter,
|
| 24 |
-
from .auth_guard import require_role, AuthRole
|
| 25 |
from pydantic import BaseModel
|
|
|
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/api/web", tags=["web-research"])
|
| 28 |
_logger = logging.getLogger("research")
|
|
@@ -279,11 +279,9 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
|
|
| 279 |
|
| 280 |
@router.post("/research")
|
| 281 |
async def web_research(
|
| 282 |
-
req: ResearchRequest,
|
| 283 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 284 |
):
|
| 285 |
-
# S-GAP23: X-Internal-Token guard β protegge consumi Groq API da abusi esterni.
|
| 286 |
-
|
| 287 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 288 |
|
| 289 |
# ββ ARL loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -607,11 +605,9 @@ async def _synthesize(topic: str, sources: list[dict]) -> str:
|
|
| 607 |
|
| 608 |
@router.post("/research")
|
| 609 |
async def web_research(
|
| 610 |
-
req: ResearchRequest,
|
| 611 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 612 |
):
|
| 613 |
-
# S-GAP23: X-Internal-Token guard β protegge consumi Groq API da abusi esterni.
|
| 614 |
-
|
| 615 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 616 |
|
| 617 |
# ββ ARL loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 20 |
"""
|
| 21 |
import asyncio, os, re, logging, time
|
| 22 |
import httpx
|
| 23 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
| 24 |
from pydantic import BaseModel
|
| 25 |
+
from .auth_guard import require_role, AuthRole
|
| 26 |
|
| 27 |
router = APIRouter(prefix="/api/web", tags=["web-research"])
|
| 28 |
_logger = logging.getLogger("research")
|
|
|
|
| 279 |
|
| 280 |
@router.post("/research")
|
| 281 |
async def web_research(
|
| 282 |
+
req: ResearchRequest, request: Request,
|
| 283 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 284 |
):
|
|
|
|
|
|
|
| 285 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 286 |
|
| 287 |
# ββ ARL loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 605 |
|
| 606 |
@router.post("/research")
|
| 607 |
async def web_research(
|
| 608 |
+
req: ResearchRequest, request: Request,
|
| 609 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 610 |
):
|
|
|
|
|
|
|
| 611 |
n = min(max(int(req.depth), 1), _MAX_URLS_PER_ROUND)
|
| 612 |
|
| 613 |
# ββ ARL loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
api/scheduler.py
CHANGED
|
@@ -19,7 +19,6 @@ Route:
|
|
| 19 |
DELETE /api/scheduler/tasks/{id} cancella task
|
| 20 |
POST /api/scheduler/sync bulk upsert da Dexie (idempotente)
|
| 21 |
POST /api/scheduler/trigger/{id} esecuzione immediata (debug/manuale)
|
| 22 |
-
POST /api/scheduler/tick pacemaker esterno (CF Worker, GHA)
|
| 23 |
GET /api/scheduler/status stato del loop asyncio
|
| 24 |
GET /api/scheduler/webhook/sse SSE stream real-time per frontend
|
| 25 |
"""
|
|
@@ -34,10 +33,10 @@ import uuid
|
|
| 34 |
from pathlib import Path
|
| 35 |
from typing import Any, AsyncGenerator, Optional
|
| 36 |
|
| 37 |
-
from fastapi import APIRouter,
|
|
|
|
| 38 |
from fastapi.responses import StreamingResponse
|
| 39 |
from pydantic import BaseModel
|
| 40 |
-
from .auth_guard import require_role, AuthRole
|
| 41 |
import logging
|
| 42 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 43 |
|
|
@@ -48,40 +47,31 @@ def _log_task_exc(task): # GAP-2.6: log silently-dropped exceptions in fire-and
|
|
| 48 |
exc = task.exception()
|
| 49 |
if exc:
|
| 50 |
logger.warning("[scheduler] background task raised %s: %s", type(exc).__name__, exc)
|
| 51 |
-
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
| 52 |
|
| 53 |
# βββ Telegram notifications (fire-and-forget) βββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
try:
|
| 55 |
from .telegram_notify import (
|
| 56 |
-
notify_task_done as _tg_done,
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
)
|
| 59 |
-
except Exception:
|
| 60 |
-
|
| 61 |
-
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 62 |
-
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 63 |
-
async def _tg_heartbeat(*_a, **_kw): pass # type: ignore[misc]
|
| 64 |
|
| 65 |
# βββ Persistenza JSON βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
# Usa /tmp su HF Space (ephemeral ma dura ore).
|
| 67 |
# Il frontend re-sincronizza Dexie β backend al mount: zero task persi.
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
custom = os.getenv("SCHEDULER_TASKS_FILE", "")
|
| 72 |
-
if custom:
|
| 73 |
-
return Path(custom)
|
| 74 |
-
data_path = Path("/data/agente_scheduler.json")
|
| 75 |
-
try:
|
| 76 |
-
data_path.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
-
if os.access(str(data_path.parent), os.W_OK):
|
| 78 |
-
return data_path
|
| 79 |
-
except (PermissionError, OSError):
|
| 80 |
-
pass
|
| 81 |
-
return Path("/tmp/agente_scheduler.json")
|
| 82 |
-
|
| 83 |
-
_TASKS_FILE = _resolve_tasks_path()
|
| 84 |
-
_TASKS_BAK = Path(str(_TASKS_FILE) + ".bak")
|
| 85 |
_tasks: dict[str, dict] = {} # id β task (in-memory, fonte di veritΓ )
|
| 86 |
_lock = asyncio.Lock() # serializza tutti i write (no race conditions)
|
| 87 |
|
|
@@ -89,104 +79,6 @@ _lock = asyncio.Lock() # serializza tutti i write (no race conditio
|
|
| 89 |
# resettati a "pending" dal tick β previene blocco permanente del loop.
|
| 90 |
_STUCK_TIMEOUT_S = 300 # 5 min β > 120s timeout _run_goal + margine
|
| 91 |
|
| 92 |
-
# βββ Supabase persistence (MX11-SCHED) ββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
-
# Backup permanente dei task scheduler: ad ogni mutazione salva su Supabase
|
| 94 |
-
# in modo fire-and-forget. Al boot, se /tmp Γ¨ vuoto, carica da Supabase.
|
| 95 |
-
# Rende i task immortali: sopravvivono a qualsiasi Railway restart.
|
| 96 |
-
|
| 97 |
-
_SB_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 98 |
-
_SB_KEY = os.getenv("SUPABASE_KEY", "") or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") # GAP1-FIX
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
async def _sb_upsert_tasks(tasks_snapshot: list) -> None:
|
| 102 |
-
"""Upsert su Supabase scheduler_tasks (fire-and-forget, non bloccante)."""
|
| 103 |
-
if not _SB_URL or not _SB_KEY or not tasks_snapshot:
|
| 104 |
-
return
|
| 105 |
-
try:
|
| 106 |
-
import httpx
|
| 107 |
-
rows = [
|
| 108 |
-
{
|
| 109 |
-
"id": t["id"],
|
| 110 |
-
"data": t,
|
| 111 |
-
"updated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 112 |
-
}
|
| 113 |
-
for t in tasks_snapshot
|
| 114 |
-
if isinstance(t, dict) and "id" in t
|
| 115 |
-
]
|
| 116 |
-
async with httpx.AsyncClient(timeout=8.0) as client:
|
| 117 |
-
await client.post(
|
| 118 |
-
f"{_SB_URL}/rest/v1/scheduler_tasks",
|
| 119 |
-
headers={
|
| 120 |
-
"apikey": _SB_KEY,
|
| 121 |
-
"Authorization": f"Bearer {_SB_KEY}",
|
| 122 |
-
"Content-Type": "application/json",
|
| 123 |
-
"Prefer": "resolution=merge-duplicates",
|
| 124 |
-
},
|
| 125 |
-
json=rows,
|
| 126 |
-
)
|
| 127 |
-
except Exception as _exc:
|
| 128 |
-
logger.debug("Scheduler: sb_upsert silenced: %s", _exc)
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
async def _sb_delete_task_row(task_id: str) -> None:
|
| 132 |
-
"""Rimuove un task da Supabase (fire-and-forget)."""
|
| 133 |
-
if not _SB_URL or not _SB_KEY:
|
| 134 |
-
return
|
| 135 |
-
try:
|
| 136 |
-
import httpx
|
| 137 |
-
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 138 |
-
await client.delete(
|
| 139 |
-
f"{_SB_URL}/rest/v1/scheduler_tasks?id=eq.{task_id}",
|
| 140 |
-
headers={
|
| 141 |
-
"apikey": _SB_KEY,
|
| 142 |
-
"Authorization": f"Bearer {_SB_KEY}",
|
| 143 |
-
},
|
| 144 |
-
)
|
| 145 |
-
except Exception as _exc:
|
| 146 |
-
logger.debug("Scheduler: sb_delete silenced: %s", _exc)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
async def _sb_load_tasks_async() -> dict:
|
| 150 |
-
"""Carica task da Supabase β fallback al boot se /tmp Γ¨ vuoto."""
|
| 151 |
-
if not _SB_URL or not _SB_KEY:
|
| 152 |
-
return {}
|
| 153 |
-
try:
|
| 154 |
-
import httpx
|
| 155 |
-
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 156 |
-
r = await client.get(
|
| 157 |
-
f"{_SB_URL}/rest/v1/scheduler_tasks?select=id,data&order=updated_at.asc",
|
| 158 |
-
headers={
|
| 159 |
-
"apikey": _SB_KEY,
|
| 160 |
-
"Authorization": f"Bearer {_SB_KEY}",
|
| 161 |
-
},
|
| 162 |
-
)
|
| 163 |
-
rows = r.json()
|
| 164 |
-
if not isinstance(rows, list):
|
| 165 |
-
return {}
|
| 166 |
-
result = {}
|
| 167 |
-
for row in rows:
|
| 168 |
-
data = row.get("data")
|
| 169 |
-
if isinstance(data, dict) and "id" in data:
|
| 170 |
-
result[data["id"]] = data
|
| 171 |
-
logger.info("Scheduler: caricati %d task da Supabase (fallback boot)", len(result))
|
| 172 |
-
return result
|
| 173 |
-
except Exception as _exc:
|
| 174 |
-
logger.warning("Scheduler: sb_load_tasks failed: %s", _exc)
|
| 175 |
-
return {}
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def _trigger_sb_sync() -> None:
|
| 179 |
-
"""
|
| 180 |
-
Schedula Supabase upsert in background dopo ogni mutazione.
|
| 181 |
-
Chiamato da _broadcast_sse() (giΓ eseguita con _lock acquisito).
|
| 182 |
-
Fire-and-forget: un fallback non blocca l'operazione principale.
|
| 183 |
-
"""
|
| 184 |
-
try:
|
| 185 |
-
snapshot = list(_tasks.values())
|
| 186 |
-
asyncio.create_task(_sb_upsert_tasks(snapshot)).add_done_callback(_log_task_exc)
|
| 187 |
-
except RuntimeError:
|
| 188 |
-
pass # no event loop attivo (chiamata da contesto sync pre-startup)
|
| 189 |
-
|
| 190 |
|
| 191 |
def _load_tasks() -> None:
|
| 192 |
"""Gap-7-FIX: carica da file principale, fallback a backup se corrotto."""
|
|
@@ -201,7 +93,7 @@ def _load_tasks() -> None:
|
|
| 201 |
except Exception as exc:
|
| 202 |
logger.warning("Scheduler: load da %s fallito (%s) β provo backup", _path, exc)
|
| 203 |
_tasks = {}
|
| 204 |
-
logger.warning("Scheduler: nessun task salvato trovato β partenza vuota
|
| 205 |
|
| 206 |
|
| 207 |
def _save_tasks_sync() -> None:
|
|
@@ -234,10 +126,8 @@ def _broadcast_sse() -> None:
|
|
| 234 |
Invia la lista task aggiornata a tutti i client SSE connessi.
|
| 235 |
Fire-and-forget: chiamato dopo ogni mutazione (create/patch/delete/execute).
|
| 236 |
Deve essere chiamato con _lock giΓ acquisito (legge _tasks direttamente).
|
| 237 |
-
MX11-SCHED: schedula anche Supabase sync (backup permanente).
|
| 238 |
"""
|
| 239 |
if not _sse_clients:
|
| 240 |
-
_trigger_sb_sync() # sync Supabase anche senza client SSE
|
| 241 |
return
|
| 242 |
payload = safe_json_dumps(list(_tasks.values()))
|
| 243 |
event = f"event: tasks_updated\ndata: {payload}\n\n"
|
|
@@ -246,7 +136,6 @@ def _broadcast_sse() -> None:
|
|
| 246 |
q.put_nowait(event)
|
| 247 |
except asyncio.QueueFull:
|
| 248 |
pass # client lento β skip questo evento, riceverΓ il prossimo
|
| 249 |
-
_trigger_sb_sync() # backup Supabase ad ogni mutazione
|
| 250 |
|
| 251 |
|
| 252 |
async def _sse_generator(queue: asyncio.Queue, request: Request) -> AsyncGenerator[str, None]:
|
|
@@ -282,14 +171,13 @@ def _is_due(task: dict, now_ms: int) -> bool:
|
|
| 282 |
if tt == "daily": return now_ms >= t.get("nextRun", 0)
|
| 283 |
if tt == "on_open": return True # boot-time task
|
| 284 |
if tt == "issue_poll": return now_ms >= t.get("nextRun", 0)
|
| 285 |
-
if tt == "tiered_scan": return now_ms >= t.get("nextRun", 0) # MX-SCHED-TIERED
|
| 286 |
return False
|
| 287 |
|
| 288 |
|
| 289 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 290 |
t = dict(trigger)
|
| 291 |
tt = t.get("type")
|
| 292 |
-
if tt in ("interval", "issue_poll"
|
| 293 |
t["nextRun"] = now_ms + t.get("intervalMs", 3_600_000)
|
| 294 |
elif tt == "daily":
|
| 295 |
hour = t.get("hour", 9)
|
|
@@ -367,95 +255,85 @@ async def _sb_write_scheduler_result(task_id: str, goal: str, status: str, resul
|
|
| 367 |
|
| 368 |
|
| 369 |
async def _execute_task(task_id: str) -> None:
|
| 370 |
-
"""Esegue un task, aggiorna status e salva.
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
-
# Marca running + broadcast SSE
|
| 375 |
async with _lock:
|
| 376 |
task = _tasks.get(task_id)
|
| 377 |
if not task:
|
| 378 |
return
|
| 379 |
-
task["
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
| 384 |
_save_tasks_sync()
|
| 385 |
_broadcast_sse()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
if _task_notify:
|
| 387 |
-
asyncio.create_task(
|
| 388 |
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
task["
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
_sb_goal_ok = task.get("goal", task.get("label", ""))[:500]
|
| 406 |
-
_sb_stat_ok = "done" if one_shot else "pending"
|
| 407 |
-
|
| 408 |
-
logger.info("Scheduler: β task '%s' (%s)", task.get("label"), task_id)
|
| 409 |
-
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_ok, _sb_stat_ok, result, now_ms)).add_done_callback(_log_task_exc)
|
| 410 |
-
if _task_notify:
|
| 411 |
-
asyncio.create_task(_tg_done(task_id, _task_goal, result[:500])).add_done_callback(_log_task_exc)
|
| 412 |
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
_save_tasks_sync()
|
| 428 |
-
_broadcast_sse()
|
| 429 |
-
|
| 430 |
-
logger.error("Scheduler: β task %s: %s", task_id, exc)
|
| 431 |
-
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
| 432 |
-
try:
|
| 433 |
-
from .incident_registry import log_incident as _log_inc
|
| 434 |
-
asyncio.create_task(_log_inc(
|
| 435 |
-
task_id=task_id, goal=_task_goal, error=str(exc), source="scheduler"
|
| 436 |
-
)).add_done_callback(_log_task_exc)
|
| 437 |
-
except Exception as _exc:
|
| 438 |
-
_logger.debug("[scheduler] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 439 |
-
_sb_goal_err = task.get("goal", task.get("label", ""))[:500] if task else ""
|
| 440 |
-
_sb_stat_err = "failed" if failed else "pending"
|
| 441 |
-
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_err, _sb_stat_err, f"β {str(exc)[:300]}", now_ms)).add_done_callback(_log_task_exc)
|
| 442 |
-
if _task_notify:
|
| 443 |
-
asyncio.create_task(_tg_error(task_id, _task_goal, str(exc)[:300])).add_done_callback(_log_task_exc)
|
| 444 |
|
| 445 |
|
| 446 |
# βββ Background loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 447 |
|
| 448 |
_loop_task: Optional[asyncio.Task] = None
|
| 449 |
_current_running: Optional[str] = None # task_id in esecuzione
|
| 450 |
-
_execute_sem: Optional[asyncio.Semaphore] = None # GAP-SCHEDULER-CONCURRENT
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
def _get_execute_sem() -> asyncio.Semaphore:
|
| 454 |
-
"""GAP-SCHEDULER-CONCURRENT fix: Semaphore(1) lazy β safe senza event loop al module level."""
|
| 455 |
-
global _execute_sem
|
| 456 |
-
if _execute_sem is None:
|
| 457 |
-
_execute_sem = asyncio.Semaphore(1)
|
| 458 |
-
return _execute_sem
|
| 459 |
|
| 460 |
|
| 461 |
async def _tick() -> None:
|
|
@@ -526,10 +404,6 @@ def start_scheduler() -> None:
|
|
| 526 |
"""
|
| 527 |
Avvia il loop scheduler. Chiamato in _on_startup() di main.py.
|
| 528 |
Idempotente β sicuro su multipli import.
|
| 529 |
-
|
| 530 |
-
MX11-SCHED: usa _boot() coroutine che:
|
| 531 |
-
1. Carica task da Supabase se /tmp era vuoto (cross-restart recovery)
|
| 532 |
-
2. Avvia il normal _scheduler_loop()
|
| 533 |
"""
|
| 534 |
global _loop_task
|
| 535 |
_load_tasks()
|
|
@@ -542,23 +416,7 @@ def start_scheduler() -> None:
|
|
| 542 |
_save_tasks_sync()
|
| 543 |
|
| 544 |
if _loop_task is None or _loop_task.done():
|
| 545 |
-
|
| 546 |
-
_was_empty = len(_tasks) == 0
|
| 547 |
-
|
| 548 |
-
async def _boot() -> None:
|
| 549 |
-
if _was_empty:
|
| 550 |
-
sb_tasks = await _sb_load_tasks_async()
|
| 551 |
-
if sb_tasks:
|
| 552 |
-
async with _lock:
|
| 553 |
-
_tasks.update(sb_tasks)
|
| 554 |
-
_save_tasks_sync()
|
| 555 |
-
logger.info(
|
| 556 |
-
"Scheduler: ripristinati %d task da Supabase (cross-restart)",
|
| 557 |
-
len(sb_tasks),
|
| 558 |
-
)
|
| 559 |
-
await _scheduler_loop()
|
| 560 |
-
|
| 561 |
-
_loop_task = asyncio.create_task(_boot())
|
| 562 |
_loop_task.add_done_callback(_log_task_exc) # GAP-2.6: log silently-dropped exceptions
|
| 563 |
logger.info("Scheduler: asyncio task creato β")
|
| 564 |
|
|
@@ -584,19 +442,14 @@ class TaskPatch(BaseModel):
|
|
| 584 |
# βββ REST Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 585 |
|
| 586 |
@router.get("/tasks")
|
| 587 |
-
async def list_tasks(
|
| 588 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 589 |
-
) -> list[dict]:
|
| 590 |
"""Polling dal frontend (fallback se SSE non disponibile) β fonte di veritΓ server-side."""
|
| 591 |
async with _lock:
|
| 592 |
return list(_tasks.values())
|
| 593 |
|
| 594 |
|
| 595 |
@router.post("/tasks", status_code=201)
|
| 596 |
-
async def create_task(
|
| 597 |
-
body: TaskCreate,
|
| 598 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 599 |
-
) -> dict:
|
| 600 |
"""Crea task sul backend. Il frontend chiama questo DOPO il salvataggio Dexie."""
|
| 601 |
tid = body.id or f"sched_{int(time.time()*1000):x}_{uuid.uuid4().hex[:4]}"
|
| 602 |
task: dict[str, Any] = {
|
|
@@ -623,11 +476,7 @@ async def create_task(
|
|
| 623 |
|
| 624 |
|
| 625 |
@router.patch("/tasks/{task_id}")
|
| 626 |
-
async def patch_task(
|
| 627 |
-
task_id: str,
|
| 628 |
-
body: TaskPatch,
|
| 629 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 630 |
-
) -> dict:
|
| 631 |
"""Pausa, riprendi, o aggiorna label/trigger di un task."""
|
| 632 |
async with _lock:
|
| 633 |
task = _tasks.get(task_id)
|
|
@@ -642,26 +491,18 @@ async def patch_task(
|
|
| 642 |
|
| 643 |
|
| 644 |
@router.delete("/tasks/{task_id}", status_code=204)
|
| 645 |
-
async def delete_task(
|
| 646 |
-
|
| 647 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 648 |
-
) -> None:
|
| 649 |
-
"""Cancella task dal backend + Supabase (MX11-SCHED)."""
|
| 650 |
async with _lock:
|
| 651 |
if task_id not in _tasks:
|
| 652 |
raise HTTPException(404, "Task non trovato")
|
| 653 |
del _tasks[task_id]
|
| 654 |
_save_tasks_sync()
|
| 655 |
_broadcast_sse()
|
| 656 |
-
# Rimuovi anche da Supabase (fire-and-forget)
|
| 657 |
-
asyncio.create_task(_sb_delete_task_row(task_id)).add_done_callback(_log_task_exc)
|
| 658 |
|
| 659 |
|
| 660 |
@router.post("/sync")
|
| 661 |
-
async def sync_tasks(
|
| 662 |
-
body: list[dict],
|
| 663 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 664 |
-
) -> dict:
|
| 665 |
"""
|
| 666 |
Bulk upsert da Dexie β backend.
|
| 667 |
Idempotente: inserisce solo i task assenti. Non sovrascrive quelli esistenti.
|
|
@@ -692,10 +533,7 @@ async def sync_tasks(
|
|
| 692 |
|
| 693 |
|
| 694 |
@router.post("/trigger/{task_id}")
|
| 695 |
-
async def trigger_task_now(
|
| 696 |
-
task_id: str,
|
| 697 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 698 |
-
) -> dict:
|
| 699 |
"""Esecuzione immediata ignorando il trigger temporale (debug / run manuale)."""
|
| 700 |
async with _lock:
|
| 701 |
task = _tasks.get(task_id)
|
|
@@ -705,71 +543,8 @@ async def trigger_task_now(
|
|
| 705 |
return {"triggered": task_id, "label": task.get("label")}
|
| 706 |
|
| 707 |
|
| 708 |
-
@router.post("/tick")
|
| 709 |
-
async def external_tick(
|
| 710 |
-
request: Request,
|
| 711 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 712 |
-
) -> dict:
|
| 713 |
-
"""
|
| 714 |
-
Pacemaker esterno β MX11-SCHED.
|
| 715 |
-
|
| 716 |
-
Chiamato dal CF Worker ogni 2min e da GHA daemon-cron come backup.
|
| 717 |
-
Garantisce che lo scheduler giri anche senza browser aperto e
|
| 718 |
-
sopravviva ai crash del loop asyncio Railway.
|
| 719 |
-
|
| 720 |
-
Comportamento:
|
| 721 |
-
- Esegue _tick() immediatamente (no attesa del ciclo 60s)
|
| 722 |
-
- Auto-riavvia il loop asyncio se Γ¨ morto (self-healing)
|
| 723 |
-
- Idempotente: sicuro da piΓΉ sorgenti concorrenti
|
| 724 |
-
- Richiede AuthRole.MACHINE (X-Internal-Token)
|
| 725 |
-
|
| 726 |
-
Response: { ok, source, loopRevived, loopRunning, tasks, pending, running, ts }
|
| 727 |
-
"""
|
| 728 |
-
global _loop_task
|
| 729 |
-
source = request.query_params.get("source", "external")
|
| 730 |
-
|
| 731 |
-
# Self-heal: riavvia il loop se morto
|
| 732 |
-
loop_was_dead = _loop_task is None or _loop_task.done()
|
| 733 |
-
if loop_was_dead:
|
| 734 |
-
logger.warning(
|
| 735 |
-
"Scheduler: loop morto rilevato da external tick (source=%s) β riavvio",
|
| 736 |
-
source,
|
| 737 |
-
)
|
| 738 |
-
_loop_task = asyncio.create_task(_scheduler_loop())
|
| 739 |
-
_loop_task.add_done_callback(_log_task_exc)
|
| 740 |
-
|
| 741 |
-
# Esegui _tick() immediatamente (no attesa 60s)
|
| 742 |
-
try:
|
| 743 |
-
await _tick()
|
| 744 |
-
except Exception as _exc:
|
| 745 |
-
logger.error("Scheduler: external tick error: %s", _exc)
|
| 746 |
-
|
| 747 |
-
async with _lock:
|
| 748 |
-
total = len(_tasks)
|
| 749 |
-
pending = sum(1 for t in _tasks.values() if t.get("status") == "pending")
|
| 750 |
-
running = sum(1 for t in _tasks.values() if t.get("status") == "running")
|
| 751 |
-
|
| 752 |
-
loop_running = _loop_task is not None and not _loop_task.done()
|
| 753 |
-
logger.info(
|
| 754 |
-
"Scheduler tick (source=%s): loop=%s revived=%s tasks=%d pending=%d",
|
| 755 |
-
source, loop_running, loop_was_dead, total, pending,
|
| 756 |
-
)
|
| 757 |
-
return {
|
| 758 |
-
"ok": True,
|
| 759 |
-
"source": source,
|
| 760 |
-
"loopRevived": loop_was_dead,
|
| 761 |
-
"loopRunning": loop_running,
|
| 762 |
-
"tasks": total,
|
| 763 |
-
"pending": pending,
|
| 764 |
-
"running": running,
|
| 765 |
-
"ts": int(time.time() * 1000),
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
|
| 769 |
@router.get("/status")
|
| 770 |
-
async def scheduler_status(
|
| 771 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 772 |
-
) -> dict:
|
| 773 |
"""Stato del loop asyncio β usato dal frontend per il badge βοΈ/π±."""
|
| 774 |
loop_ok = _loop_task is not None and not _loop_task.done()
|
| 775 |
async with _lock:
|
|
@@ -787,10 +562,7 @@ async def scheduler_status(
|
|
| 787 |
|
| 788 |
|
| 789 |
@router.get("/delta")
|
| 790 |
-
async def scheduler_delta(
|
| 791 |
-
since_ms: int = 0,
|
| 792 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 793 |
-
) -> dict:
|
| 794 |
"""GAP-A7: Delta-only view β solo task aggiornati dopo since_ms (epoch ms).
|
| 795 |
|
| 796 |
Permette polling incrementale efficiente dal frontend:
|
|
@@ -843,13 +615,6 @@ async def sse_stream(request: Request) -> StreamingResponse:
|
|
| 843 |
Il client riceve uno snapshot immediato alla connessione, poi push ad ogni mutazione.
|
| 844 |
Riconnessione automatica gestita dal browser (EventSource ha retry built-in).
|
| 845 |
"""
|
| 846 |
-
# GAP-SCHED-SSE-NOAUTH fix: verifica token via query param o header.
|
| 847 |
-
# EventSource non supporta header custom β frontend passa ?token=; CF Worker aggiunge X-Internal-Token.
|
| 848 |
-
_req_token = request.query_params.get("token", "") or request.headers.get("X-Internal-Token", "")
|
| 849 |
-
_svc_token = os.getenv("INTERNAL_TOKEN", "")
|
| 850 |
-
if _svc_token and _req_token != _svc_token:
|
| 851 |
-
from fastapi.responses import JSONResponse as _jr
|
| 852 |
-
return _jr({"error": "non autorizzato β fornire ?token=INTERNAL_TOKEN"}, status_code=401)
|
| 853 |
queue: asyncio.Queue = asyncio.Queue(maxsize=16)
|
| 854 |
_sse_clients.append(queue)
|
| 855 |
logger.info("Scheduler SSE: client connesso (totale: %d)", len(_sse_clients))
|
|
@@ -859,8 +624,10 @@ async def sse_stream(request: Request) -> StreamingResponse:
|
|
| 859 |
async for event in _sse_generator(queue, request):
|
| 860 |
yield event
|
| 861 |
finally:
|
| 862 |
-
|
| 863 |
-
|
|
|
|
|
|
|
| 864 |
logger.info("Scheduler SSE: client disconnesso (totale: %d)", len(_sse_clients))
|
| 865 |
|
| 866 |
return StreamingResponse(
|
|
@@ -872,3 +639,4 @@ async def sse_stream(request: Request) -> StreamingResponse:
|
|
| 872 |
"Connection": "keep-alive",
|
| 873 |
},
|
| 874 |
)
|
|
|
|
|
|
| 19 |
DELETE /api/scheduler/tasks/{id} cancella task
|
| 20 |
POST /api/scheduler/sync bulk upsert da Dexie (idempotente)
|
| 21 |
POST /api/scheduler/trigger/{id} esecuzione immediata (debug/manuale)
|
|
|
|
| 22 |
GET /api/scheduler/status stato del loop asyncio
|
| 23 |
GET /api/scheduler/webhook/sse SSE stream real-time per frontend
|
| 24 |
"""
|
|
|
|
| 33 |
from pathlib import Path
|
| 34 |
from typing import Any, AsyncGenerator, Optional
|
| 35 |
|
| 36 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 37 |
+
from .auth_guard import require_role, AuthRole
|
| 38 |
from fastapi.responses import StreamingResponse
|
| 39 |
from pydantic import BaseModel
|
|
|
|
| 40 |
import logging
|
| 41 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 42 |
|
|
|
|
| 47 |
exc = task.exception()
|
| 48 |
if exc:
|
| 49 |
logger.warning("[scheduler] background task raised %s: %s", type(exc).__name__, exc)
|
| 50 |
+
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: tutti endpoint scheduler ora fail-closed
|
| 51 |
|
| 52 |
# βββ Telegram notifications (fire-and-forget) βββββββββββββββββββββββββββββββββ
|
| 53 |
+
# Definisci sempre le funzioni dummy PRIMA di qualsiasi import tentativo.
|
| 54 |
+
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
|
| 55 |
+
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
|
| 56 |
+
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
|
| 57 |
+
async def _tg_heartbeat(*_a, **_kw): pass # type: ignore[misc]
|
| 58 |
+
|
| 59 |
try:
|
| 60 |
from .telegram_notify import (
|
| 61 |
+
notify_task_done as _tg_done,
|
| 62 |
+
notify_task_error as _tg_error,
|
| 63 |
+
notify_task_start as _tg_start,
|
| 64 |
+
notify_task_heartbeat as _tg_heartbeat,
|
| 65 |
)
|
| 66 |
+
except Exception as _tg_import_err:
|
| 67 |
+
logger.debug("[scheduler] Telegram notifications unavailable: %s", _tg_import_err)
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# βββ Persistenza JSON βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
# Usa /tmp su HF Space (ephemeral ma dura ore).
|
| 71 |
# Il frontend re-sincronizza Dexie β backend al mount: zero task persi.
|
| 72 |
|
| 73 |
+
_TASKS_FILE = Path(os.getenv("SCHEDULER_TASKS_FILE", "/tmp/agente_scheduler.json"))
|
| 74 |
+
_TASKS_BAK = Path(str(os.getenv("SCHEDULER_TASKS_FILE", "/tmp/agente_scheduler.json")) + ".bak")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
_tasks: dict[str, dict] = {} # id β task (in-memory, fonte di veritΓ )
|
| 76 |
_lock = asyncio.Lock() # serializza tutti i write (no race conditions)
|
| 77 |
|
|
|
|
| 79 |
# resettati a "pending" dal tick β previene blocco permanente del loop.
|
| 80 |
_STUCK_TIMEOUT_S = 300 # 5 min β > 120s timeout _run_goal + margine
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
def _load_tasks() -> None:
|
| 84 |
"""Gap-7-FIX: carica da file principale, fallback a backup se corrotto."""
|
|
|
|
| 93 |
except Exception as exc:
|
| 94 |
logger.warning("Scheduler: load da %s fallito (%s) β provo backup", _path, exc)
|
| 95 |
_tasks = {}
|
| 96 |
+
logger.warning("Scheduler: nessun task salvato trovato β partenza vuota")
|
| 97 |
|
| 98 |
|
| 99 |
def _save_tasks_sync() -> None:
|
|
|
|
| 126 |
Invia la lista task aggiornata a tutti i client SSE connessi.
|
| 127 |
Fire-and-forget: chiamato dopo ogni mutazione (create/patch/delete/execute).
|
| 128 |
Deve essere chiamato con _lock giΓ acquisito (legge _tasks direttamente).
|
|
|
|
| 129 |
"""
|
| 130 |
if not _sse_clients:
|
|
|
|
| 131 |
return
|
| 132 |
payload = safe_json_dumps(list(_tasks.values()))
|
| 133 |
event = f"event: tasks_updated\ndata: {payload}\n\n"
|
|
|
|
| 136 |
q.put_nowait(event)
|
| 137 |
except asyncio.QueueFull:
|
| 138 |
pass # client lento β skip questo evento, riceverΓ il prossimo
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
async def _sse_generator(queue: asyncio.Queue, request: Request) -> AsyncGenerator[str, None]:
|
|
|
|
| 171 |
if tt == "daily": return now_ms >= t.get("nextRun", 0)
|
| 172 |
if tt == "on_open": return True # boot-time task
|
| 173 |
if tt == "issue_poll": return now_ms >= t.get("nextRun", 0)
|
|
|
|
| 174 |
return False
|
| 175 |
|
| 176 |
|
| 177 |
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
|
| 178 |
t = dict(trigger)
|
| 179 |
tt = t.get("type")
|
| 180 |
+
if tt in ("interval", "issue_poll"):
|
| 181 |
t["nextRun"] = now_ms + t.get("intervalMs", 3_600_000)
|
| 182 |
elif tt == "daily":
|
| 183 |
hour = t.get("hour", 9)
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
async def _execute_task(task_id: str) -> None:
|
| 258 |
+
"""Esegue un task, aggiorna status e salva."""
|
| 259 |
+
now_ms = int(time.time() * 1000)
|
| 260 |
+
|
| 261 |
+
# Marca running + broadcast SSE
|
| 262 |
+
async with _lock:
|
| 263 |
+
task = _tasks.get(task_id)
|
| 264 |
+
if not task:
|
| 265 |
+
return
|
| 266 |
+
task["status"] = "running"
|
| 267 |
+
task["lastRunAt"] = now_ms
|
| 268 |
+
_task_notify = task.get("notify", True)
|
| 269 |
+
_task_label = task.get("label", task.get("goal", ""))[:200]
|
| 270 |
+
_task_goal = task.get("goal", _task_label)[:200]
|
| 271 |
+
_save_tasks_sync()
|
| 272 |
+
_broadcast_sse()
|
| 273 |
+
if _task_notify:
|
| 274 |
+
asyncio.create_task(_tg_start(task_id, _task_goal)).add_done_callback(_log_task_exc)
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
result = await _run_goal(task["goal"], task.get("conversationId"))
|
| 278 |
|
|
|
|
| 279 |
async with _lock:
|
| 280 |
task = _tasks.get(task_id)
|
| 281 |
if not task:
|
| 282 |
return
|
| 283 |
+
ttype = task["trigger"].get("type")
|
| 284 |
+
one_shot = ttype in ("once", "on_open")
|
| 285 |
+
task["status"] = "done" if one_shot else "pending"
|
| 286 |
+
task["trigger"] = _advance_trigger(task["trigger"], now_ms)
|
| 287 |
+
task["lastRunAt"] = now_ms
|
| 288 |
+
task["lastResult"] = result
|
| 289 |
+
task["errorCount"] = 0
|
| 290 |
_save_tasks_sync()
|
| 291 |
_broadcast_sse()
|
| 292 |
+
_sb_goal_ok = task.get("goal", task.get("label", ""))[:500]
|
| 293 |
+
_sb_stat_ok = "done" if one_shot else "pending"
|
| 294 |
+
|
| 295 |
+
logger.info("Scheduler: β task '%s' (%s)", task.get("label"), task_id)
|
| 296 |
+
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_ok, _sb_stat_ok, result, now_ms)).add_done_callback(_log_task_exc)
|
| 297 |
if _task_notify:
|
| 298 |
+
asyncio.create_task(_tg_done(task_id, _task_goal, result[:500])).add_done_callback(_log_task_exc)
|
| 299 |
|
| 300 |
+
except Exception as exc:
|
| 301 |
+
async with _lock:
|
| 302 |
+
task = _tasks.get(task_id)
|
| 303 |
+
if not task:
|
| 304 |
+
return
|
| 305 |
+
task["errorCount"] = task.get("errorCount", 0) + 1
|
| 306 |
+
failed = task["errorCount"] >= task.get("maxErrors", 3)
|
| 307 |
+
task["status"] = "failed" if failed else "pending"
|
| 308 |
+
if not failed:
|
| 309 |
+
task["trigger"] = _advance_trigger(
|
| 310 |
+
task["trigger"], now_ms + 5 * 60_000
|
| 311 |
+
)
|
| 312 |
+
task["lastRunAt"] = now_ms
|
| 313 |
+
task["lastResult"] = f"β {str(exc)[:300]}"
|
| 314 |
+
_save_tasks_sync()
|
| 315 |
+
_broadcast_sse()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
+
logger.error("Scheduler: β task %s: %s", task_id, exc)
|
| 318 |
+
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
|
| 319 |
+
try:
|
| 320 |
+
from .incident_registry import log_incident as _log_inc
|
| 321 |
+
asyncio.create_task(_log_inc(
|
| 322 |
+
task_id=task_id, goal=_task_goal, error=str(exc), source="scheduler"
|
| 323 |
+
)).add_done_callback(_log_task_exc)
|
| 324 |
+
except Exception as _exc:
|
| 325 |
+
_logger.debug("[scheduler] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 326 |
+
_sb_goal_err = task.get("goal", task.get("label", ""))[:500] if task else ""
|
| 327 |
+
_sb_stat_err = "failed" if failed else "pending"
|
| 328 |
+
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_err, _sb_stat_err, f"β {str(exc)[:300]}", now_ms)).add_done_callback(_log_task_exc)
|
| 329 |
+
if _task_notify:
|
| 330 |
+
asyncio.create_task(_tg_error(task_id, _task_goal, str(exc)[:300])).add_done_callback(_log_task_exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
|
| 333 |
# βββ Background loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 334 |
|
| 335 |
_loop_task: Optional[asyncio.Task] = None
|
| 336 |
_current_running: Optional[str] = None # task_id in esecuzione
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
|
| 339 |
async def _tick() -> None:
|
|
|
|
| 404 |
"""
|
| 405 |
Avvia il loop scheduler. Chiamato in _on_startup() di main.py.
|
| 406 |
Idempotente β sicuro su multipli import.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
"""
|
| 408 |
global _loop_task
|
| 409 |
_load_tasks()
|
|
|
|
| 416 |
_save_tasks_sync()
|
| 417 |
|
| 418 |
if _loop_task is None or _loop_task.done():
|
| 419 |
+
_loop_task = asyncio.create_task(_scheduler_loop())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
_loop_task.add_done_callback(_log_task_exc) # GAP-2.6: log silently-dropped exceptions
|
| 421 |
logger.info("Scheduler: asyncio task creato β")
|
| 422 |
|
|
|
|
| 442 |
# βββ REST Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
|
| 444 |
@router.get("/tasks")
|
| 445 |
+
async def list_tasks() -> list[dict]:
|
|
|
|
|
|
|
| 446 |
"""Polling dal frontend (fallback se SSE non disponibile) β fonte di veritΓ server-side."""
|
| 447 |
async with _lock:
|
| 448 |
return list(_tasks.values())
|
| 449 |
|
| 450 |
|
| 451 |
@router.post("/tasks", status_code=201)
|
| 452 |
+
async def create_task(body: TaskCreate) -> dict:
|
|
|
|
|
|
|
|
|
|
| 453 |
"""Crea task sul backend. Il frontend chiama questo DOPO il salvataggio Dexie."""
|
| 454 |
tid = body.id or f"sched_{int(time.time()*1000):x}_{uuid.uuid4().hex[:4]}"
|
| 455 |
task: dict[str, Any] = {
|
|
|
|
| 476 |
|
| 477 |
|
| 478 |
@router.patch("/tasks/{task_id}")
|
| 479 |
+
async def patch_task(task_id: str, body: TaskPatch) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
"""Pausa, riprendi, o aggiorna label/trigger di un task."""
|
| 481 |
async with _lock:
|
| 482 |
task = _tasks.get(task_id)
|
|
|
|
| 491 |
|
| 492 |
|
| 493 |
@router.delete("/tasks/{task_id}", status_code=204)
|
| 494 |
+
async def delete_task(task_id: str) -> None:
|
| 495 |
+
"""Cancella task dal backend."""
|
|
|
|
|
|
|
|
|
|
| 496 |
async with _lock:
|
| 497 |
if task_id not in _tasks:
|
| 498 |
raise HTTPException(404, "Task non trovato")
|
| 499 |
del _tasks[task_id]
|
| 500 |
_save_tasks_sync()
|
| 501 |
_broadcast_sse()
|
|
|
|
|
|
|
| 502 |
|
| 503 |
|
| 504 |
@router.post("/sync")
|
| 505 |
+
async def sync_tasks(body: list[dict]) -> dict:
|
|
|
|
|
|
|
|
|
|
| 506 |
"""
|
| 507 |
Bulk upsert da Dexie β backend.
|
| 508 |
Idempotente: inserisce solo i task assenti. Non sovrascrive quelli esistenti.
|
|
|
|
| 533 |
|
| 534 |
|
| 535 |
@router.post("/trigger/{task_id}")
|
| 536 |
+
async def trigger_task_now(task_id: str) -> dict:
|
|
|
|
|
|
|
|
|
|
| 537 |
"""Esecuzione immediata ignorando il trigger temporale (debug / run manuale)."""
|
| 538 |
async with _lock:
|
| 539 |
task = _tasks.get(task_id)
|
|
|
|
| 543 |
return {"triggered": task_id, "label": task.get("label")}
|
| 544 |
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
@router.get("/status")
|
| 547 |
+
async def scheduler_status() -> dict:
|
|
|
|
|
|
|
| 548 |
"""Stato del loop asyncio β usato dal frontend per il badge βοΈ/π±."""
|
| 549 |
loop_ok = _loop_task is not None and not _loop_task.done()
|
| 550 |
async with _lock:
|
|
|
|
| 562 |
|
| 563 |
|
| 564 |
@router.get("/delta")
|
| 565 |
+
async def scheduler_delta(since_ms: int = 0) -> dict:
|
|
|
|
|
|
|
|
|
|
| 566 |
"""GAP-A7: Delta-only view β solo task aggiornati dopo since_ms (epoch ms).
|
| 567 |
|
| 568 |
Permette polling incrementale efficiente dal frontend:
|
|
|
|
| 615 |
Il client riceve uno snapshot immediato alla connessione, poi push ad ogni mutazione.
|
| 616 |
Riconnessione automatica gestita dal browser (EventSource ha retry built-in).
|
| 617 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
queue: asyncio.Queue = asyncio.Queue(maxsize=16)
|
| 619 |
_sse_clients.append(queue)
|
| 620 |
logger.info("Scheduler SSE: client connesso (totale: %d)", len(_sse_clients))
|
|
|
|
| 624 |
async for event in _sse_generator(queue, request):
|
| 625 |
yield event
|
| 626 |
finally:
|
| 627 |
+
try:
|
| 628 |
+
_sse_clients.remove(queue)
|
| 629 |
+
except ValueError as _exc:
|
| 630 |
+
_logger.debug("[scheduler] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 631 |
logger.info("Scheduler SSE: client disconnesso (totale: %d)", len(_sse_clients))
|
| 632 |
|
| 633 |
return StreamingResponse(
|
|
|
|
| 639 |
"Connection": "keep-alive",
|
| 640 |
},
|
| 641 |
)
|
| 642 |
+
|
api/search.py
CHANGED
|
@@ -8,7 +8,7 @@ import os, asyncio, html, json
|
|
| 8 |
import re as _re
|
| 9 |
import urllib.request, urllib.parse
|
| 10 |
from typing import Optional
|
| 11 |
-
from fastapi import APIRouter,
|
| 12 |
from pydantic import BaseModel
|
| 13 |
from .auth_guard import require_role, AuthRole
|
| 14 |
|
|
@@ -238,12 +238,8 @@ async def _ddg_instant_search(q: str, limit: int) -> list[dict]:
|
|
| 238 |
|
| 239 |
# ββ Search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
|
| 241 |
-
@router.post('/search')
|
| 242 |
-
async def proxy_search(
|
| 243 |
-
req: SearchRequest,
|
| 244 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 245 |
-
):
|
| 246 |
-
# ββ GAP-FAILOPEN fix: l'endpoint ora richiede AuthRole.MACHINE βββββββββββ
|
| 247 |
q = req.query.strip()[:200]
|
| 248 |
if not q:
|
| 249 |
return {'results': []}
|
|
@@ -342,12 +338,8 @@ async def proxy_search(
|
|
| 342 |
|
| 343 |
# ββ Fetch page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 344 |
|
| 345 |
-
@router.post('/fetch-page')
|
| 346 |
-
async def proxy_fetch_page(
|
| 347 |
-
req: FetchPageRequest,
|
| 348 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 349 |
-
):
|
| 350 |
-
# ββ GAP-FAILOPEN fix: protezione endpoint fetch-page βββββββββββββββββββββ
|
| 351 |
url = req.url.strip()
|
| 352 |
if not url.startswith(('http://', 'https://')):
|
| 353 |
raise HTTPException(400, detail={'error': 'url_invalido'})
|
|
@@ -373,10 +365,10 @@ async def proxy_fetch_page(
|
|
| 373 |
|
| 374 |
# ββ Analyze image ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 375 |
|
| 376 |
-
@router.post('/analyze-image')
|
| 377 |
async def analyze_image(
|
| 378 |
-
body: AnalyzeImageRequest,
|
| 379 |
-
role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
|
| 380 |
):
|
| 381 |
"""
|
| 382 |
Vision AI β OpenRouter free VL models β Gemini fallback.
|
|
|
|
| 8 |
import re as _re
|
| 9 |
import urllib.request, urllib.parse
|
| 10 |
from typing import Optional
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 12 |
from pydantic import BaseModel
|
| 13 |
from .auth_guard import require_role, AuthRole
|
| 14 |
|
|
|
|
| 238 |
|
| 239 |
# ββ Search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
|
| 241 |
+
@router.post('/api/search')
|
| 242 |
+
async def proxy_search(req: SearchRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
q = req.query.strip()[:200]
|
| 244 |
if not q:
|
| 245 |
return {'results': []}
|
|
|
|
| 338 |
|
| 339 |
# ββ Fetch page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 340 |
|
| 341 |
+
@router.post('/api/fetch-page')
|
| 342 |
+
async def proxy_fetch_page(req: FetchPageRequest, role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
url = req.url.strip()
|
| 344 |
if not url.startswith(('http://', 'https://')):
|
| 345 |
raise HTTPException(400, detail={'error': 'url_invalido'})
|
|
|
|
| 365 |
|
| 366 |
# ββ Analyze image ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 367 |
|
| 368 |
+
@router.post('/api/analyze-image')
|
| 369 |
async def analyze_image(
|
| 370 |
+
body: AnalyzeImageRequest, request: Request,
|
| 371 |
+
role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # P19-SEC2-1: era fail-open
|
| 372 |
):
|
| 373 |
"""
|
| 374 |
Vision AI β OpenRouter free VL models β Gemini fallback.
|
api/skills.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""backend/api/skills.py β P17-B2: Endpoint /api/skills/patterns.
|
| 2 |
+
|
| 3 |
+
Espone i pattern di tool-usage aggregati dal SkillTracker per sync frontend.
|
| 4 |
+
GET /api/skills/patterns β aggregato globale (tutte le sessioni)
|
| 5 |
+
GET /api/skills/patterns/{session_id} β singola sessione
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import logging
|
| 9 |
+
import math
|
| 10 |
+
from fastapi import APIRouter, Depends, Query
|
| 11 |
+
from .auth_guard import require_role, AuthRole
|
| 12 |
+
|
| 13 |
+
_logger = logging.getLogger("agente_ai.api.skills")
|
| 14 |
+
router = APIRouter(prefix="/api/skills", tags=["skills"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _wilson(success: int, total: int) -> float:
|
| 18 |
+
if total == 0:
|
| 19 |
+
return 0.5
|
| 20 |
+
p, z = success / total, 1.96
|
| 21 |
+
num = p + z*z/(2*total) - z*math.sqrt((p*(1-p) + z*z/(4*total))/total)
|
| 22 |
+
return max(0.0, min(1.0, num / (1 + z*z/total)))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/patterns")
|
| 26 |
+
async def get_skill_patterns(
|
| 27 |
+
limit: int = Query(default=50, ge=1, le=200),
|
| 28 |
+
min_uses: int = Query(default=1, ge=1, description="Minimo utilizzi totali"),
|
| 29 |
+
):
|
| 30 |
+
"""Pattern aggregati da tutte le sessioni in memoria, ordinati per Wilson score."""
|
| 31 |
+
try:
|
| 32 |
+
from agents.skill_tracker import get_skill_tracker
|
| 33 |
+
tracker = get_skill_tracker()
|
| 34 |
+
agg: dict[str, dict] = {}
|
| 35 |
+
for _sid, sess in tracker._stats.items():
|
| 36 |
+
for tool, stats in sess.items():
|
| 37 |
+
if tool not in agg:
|
| 38 |
+
agg[tool] = {"s": 0, "f": 0, "lat": 0.0, "sessions": 0, "last": 0.0}
|
| 39 |
+
agg[tool]["s"] += stats.success_count
|
| 40 |
+
agg[tool]["f"] += stats.fail_count
|
| 41 |
+
agg[tool]["lat"] += stats.total_latency_ms
|
| 42 |
+
agg[tool]["sessions"] += 1
|
| 43 |
+
agg[tool]["last"] = max(agg[tool]["last"], stats.last_used)
|
| 44 |
+
|
| 45 |
+
out = []
|
| 46 |
+
for tool, a in agg.items():
|
| 47 |
+
total = a["s"] + a["f"]
|
| 48 |
+
if total < min_uses:
|
| 49 |
+
continue
|
| 50 |
+
out.append({
|
| 51 |
+
"tool": tool,
|
| 52 |
+
"total": total,
|
| 53 |
+
"success": a["s"],
|
| 54 |
+
"fail": a["f"],
|
| 55 |
+
"success_rate": round(a["s"] / total if total else 1.0, 4),
|
| 56 |
+
"wilson_score": round(_wilson(a["s"], total), 4),
|
| 57 |
+
"avg_latency_ms": round(a["lat"] / total if total else 0.0, 1),
|
| 58 |
+
"sessions": a["sessions"],
|
| 59 |
+
"last_used": a["last"],
|
| 60 |
+
})
|
| 61 |
+
out.sort(key=lambda x: x["wilson_score"], reverse=True)
|
| 62 |
+
return {"patterns": out[:limit], "total": len(out)}
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
_logger.error("[skills/patterns] %s", exc)
|
| 65 |
+
return {"patterns": [], "total": 0, "error": str(exc)[:200]}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.get("/patterns/{session_id}")
|
| 69 |
+
async def get_session_patterns(session_id: str):
|
| 70 |
+
"""Pattern per una singola sessione."""
|
| 71 |
+
try:
|
| 72 |
+
from agents.skill_tracker import get_skill_tracker
|
| 73 |
+
tracker = get_skill_tracker()
|
| 74 |
+
sess = tracker._stats.get(session_id, {})
|
| 75 |
+
out = []
|
| 76 |
+
for tool, stats in sess.items():
|
| 77 |
+
total = stats.total_count
|
| 78 |
+
out.append({
|
| 79 |
+
"tool": tool,
|
| 80 |
+
"total": total,
|
| 81 |
+
"success": stats.success_count,
|
| 82 |
+
"fail": stats.fail_count,
|
| 83 |
+
"success_rate": round(stats.success_rate, 4),
|
| 84 |
+
"wilson_score": round(stats.wilson_score(), 4),
|
| 85 |
+
"avg_latency_ms": round(stats.avg_latency_ms, 1),
|
| 86 |
+
"last_used": stats.last_used,
|
| 87 |
+
})
|
| 88 |
+
out.sort(key=lambda x: x["wilson_score"], reverse=True)
|
| 89 |
+
return {"session_id": session_id, "patterns": out, "total": len(out)}
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
_logger.error("[skills/patterns/%s] %s", session_id, exc)
|
| 92 |
+
return {"session_id": session_id, "patterns": [], "total": 0, "error": str(exc)[:200]}
|
api/state.py
CHANGED
|
@@ -5,10 +5,6 @@ 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 |
-
|
| 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
|
|
@@ -34,33 +30,52 @@ def safe_json_dumps(obj: object, *, ensure_ascii: bool = False, **kw) -> str:
|
|
| 34 |
# ββ Supabase client βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
_sb: Any = None
|
| 36 |
_sb2: Any = None
|
|
|
|
|
|
|
| 37 |
try:
|
| 38 |
-
_SUPA_URL =
|
| 39 |
-
_SUPA_KEY =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if _SUPA_URL and _SUPA_KEY:
|
| 41 |
-
from supabase import create_client
|
| 42 |
_sb = create_client(_SUPA_URL, _SUPA_KEY)
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
_logger.
|
|
|
|
|
|
|
|
|
|
| 52 |
except Exception as e:
|
| 53 |
_logger.error('BOOT: Supabase init failed: %s', e)
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
def sb() -> Any:
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
raise HTTPException(503, detail={
|
| 60 |
'error': 'supabase_not_configured',
|
| 61 |
'message': 'Imposta SUPABASE_URL e SUPABASE_KEY nelle variabili Railway/HuggingFace per abilitare la persistenza.',
|
| 62 |
})
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def sb_dual() -> list:
|
| 66 |
"""Ritorna entrambi i client se disponibili, per sharding o ridondanza."""
|
|
@@ -74,6 +89,7 @@ SENSITIVE = {
|
|
| 74 |
# security-fix: tutti i segreti esposti da /api/status
|
| 75 |
'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID',
|
| 76 |
'CF_API_TOKEN', 'CLOUDFLARE_API_TOKEN', 'CF_ACCOUNT_ID',
|
|
|
|
| 77 |
'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
|
| 78 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 79 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
|
@@ -154,11 +170,7 @@ async def restore_agent_tasks_from_snap() -> int:
|
|
| 154 |
)
|
| 155 |
if not res or not res.data:
|
| 156 |
return 0
|
| 157 |
-
|
| 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,15 +363,13 @@ async def _get_mem_manager_async() -> Any:
|
|
| 351 |
_mem_manager = MemoryManager()
|
| 352 |
await _mem_manager.init()
|
| 353 |
_mem_manager_inited = True
|
| 354 |
-
except Exception
|
| 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
|
| 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,8 +407,7 @@ def _get_mem_manager() -> Any:
|
|
| 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
|
| 401 |
-
_logger.warning("[state] MemoryManager get failed: %s", _mm3_err)
|
| 402 |
_mem_manager = None
|
| 403 |
return _mem_manager
|
| 404 |
|
|
@@ -414,8 +423,7 @@ def _get_executor() -> Any:
|
|
| 414 |
try:
|
| 415 |
from agents.executor import Executor
|
| 416 |
_executor = Executor(memory=_get_mem_manager())
|
| 417 |
-
except Exception
|
| 418 |
-
_logger.warning("[state] Executor init failed: %s", _ex_err)
|
| 419 |
_executor = None
|
| 420 |
return _executor
|
| 421 |
|
|
@@ -588,4 +596,3 @@ class AgentTaskIn(BaseModel):
|
|
| 588 |
if not isinstance(v, list):
|
| 589 |
return []
|
| 590 |
return [str(h)[:300] for h in v[:5]] # S606: 200β300
|
| 591 |
-
|
|
|
|
| 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
|
|
|
|
| 30 |
# ββ Supabase client βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
_sb: Any = None
|
| 32 |
_sb2: Any = None
|
| 33 |
+
_sb_fallback: Any = None # Collaboratore D
|
| 34 |
+
|
| 35 |
try:
|
| 36 |
+
_SUPA_URL = os.getenv('SUPABASE_URL', '')
|
| 37 |
+
_SUPA_KEY = os.getenv('SUPABASE_KEY') or os.getenv('SUPABASE_ANON_KEY', '')
|
| 38 |
+
|
| 39 |
+
_SUPA_URL2 = os.getenv("SUPABASE_URL_2", "")
|
| 40 |
+
_SUPA_KEY2 = os.getenv("SUPABASE_KEY_2", "")
|
| 41 |
+
|
| 42 |
+
_SUPA_URL_D = os.getenv("SUPABASE_URL_D", "")
|
| 43 |
+
_SUPA_KEY_D = os.getenv("SUPABASE_SERVICE_ROLE_KEY_D", "") or os.getenv("SUPABASE_KEY_D", "")
|
| 44 |
+
|
| 45 |
+
from supabase import create_client
|
| 46 |
+
|
| 47 |
if _SUPA_URL and _SUPA_KEY:
|
|
|
|
| 48 |
_sb = create_client(_SUPA_URL, _SUPA_KEY)
|
| 49 |
+
_logger.info('BOOT: Supabase #1 connected OK')
|
| 50 |
+
|
| 51 |
+
if _SUPA_URL2 and _SUPA_KEY2:
|
| 52 |
+
_sb2 = create_client(_SUPA_URL2, _SUPA_KEY2)
|
| 53 |
+
_logger.info("BOOT: Supabase #2 connected OK")
|
| 54 |
+
|
| 55 |
+
if _SUPA_URL_D and _SUPA_KEY_D:
|
| 56 |
+
_sb_fallback = create_client(_SUPA_URL_D, _SUPA_KEY_D)
|
| 57 |
+
_logger.info("BOOT: Supabase #D (Resilience) connected OK")
|
| 58 |
+
|
| 59 |
+
if not (_sb or _sb2 or _sb_fallback):
|
| 60 |
+
_logger.warning('BOOT: Supabase not configured (Missing all keys)')
|
| 61 |
except Exception as e:
|
| 62 |
_logger.error('BOOT: Supabase init failed: %s', e)
|
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
def sb() -> Any:
|
| 67 |
+
"""Ritorna il miglior client Supabase disponibile, gestendo fallback su quota exceeded (402)."""
|
| 68 |
+
clients = [s for s in [_sb, _sb2, _sb_fallback] if s]
|
| 69 |
+
if not clients:
|
| 70 |
raise HTTPException(503, detail={
|
| 71 |
'error': 'supabase_not_configured',
|
| 72 |
'message': 'Imposta SUPABASE_URL e SUPABASE_KEY nelle variabili Railway/HuggingFace per abilitare la persistenza.',
|
| 73 |
})
|
| 74 |
+
|
| 75 |
+
# Se abbiamo piΓΉ client, proviamo il primo. Se fallisce con 402, passiamo al fallback (D).
|
| 76 |
+
# Nota: In un ambiente asincrono, questa Γ¨ una semplificazione.
|
| 77 |
+
# La logica reale di switch avviene nei chiamanti o tramite un wrapper.
|
| 78 |
+
return _sb or _sb2 or _sb_fallback
|
| 79 |
|
| 80 |
def sb_dual() -> list:
|
| 81 |
"""Ritorna entrambi i client se disponibili, per sharding o ridondanza."""
|
|
|
|
| 89 |
# security-fix: tutti i segreti esposti da /api/status
|
| 90 |
'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID',
|
| 91 |
'CF_API_TOKEN', 'CLOUDFLARE_API_TOKEN', 'CF_ACCOUNT_ID',
|
| 92 |
+
'CF_API_TOKEN_B', 'CF_ACCOUNT_ID_B',
|
| 93 |
'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
|
| 94 |
'VAULT_KEY', 'INTERNAL_TOKEN', 'DEPLOY_SECRET', 'WEBHOOK_TOKEN',
|
| 95 |
'TERMINAL_SECRET', 'EXEC_TOKEN', 'VITE_INTERNAL_TOKEN', 'VITE_TERMINAL_SECRET',
|
|
|
|
| 170 |
)
|
| 171 |
if not res or not res.data:
|
| 172 |
return 0
|
| 173 |
+
snap: dict = _json.loads(res.data["value"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
n = 0
|
| 175 |
for task_id, data in snap.items():
|
| 176 |
if task_id not in _agent_tasks and isinstance(data, dict):
|
|
|
|
| 363 |
_mem_manager = MemoryManager()
|
| 364 |
await _mem_manager.init()
|
| 365 |
_mem_manager_inited = True
|
| 366 |
+
except Exception:
|
|
|
|
| 367 |
_mem_manager = None
|
| 368 |
elif not _mem_manager_inited:
|
| 369 |
try:
|
| 370 |
await _mem_manager.init()
|
| 371 |
_mem_manager_inited = True
|
| 372 |
+
except Exception:
|
|
|
|
| 373 |
_mem_manager_inited = True # evita retry infiniti
|
| 374 |
return _mem_manager
|
| 375 |
|
|
|
|
| 407 |
# Chiamata in contesto sync (es. import-time) β init rimandato al primo call async.
|
| 408 |
# _mem_manager_inited resta False β verrΓ ritentato al prossimo call in loop.
|
| 409 |
_logger.debug("[state] silenced %s", type(_exc).__name__) # noqa: BLE001
|
| 410 |
+
except Exception:
|
|
|
|
| 411 |
_mem_manager = None
|
| 412 |
return _mem_manager
|
| 413 |
|
|
|
|
| 423 |
try:
|
| 424 |
from agents.executor import Executor
|
| 425 |
_executor = Executor(memory=_get_mem_manager())
|
| 426 |
+
except Exception:
|
|
|
|
| 427 |
_executor = None
|
| 428 |
return _executor
|
| 429 |
|
|
|
|
| 596 |
if not isinstance(v, list):
|
| 597 |
return []
|
| 598 |
return [str(h)[:300] for h in v[:5]] # S606: 200β300
|
|
|
api/structured_log.py
CHANGED
|
@@ -19,13 +19,14 @@ import time
|
|
| 19 |
from collections import deque
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
-
from fastapi import APIRouter, Query
|
|
|
|
| 23 |
from fastapi.responses import JSONResponse
|
| 24 |
from pydantic import BaseModel
|
| 25 |
import logging
|
| 26 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 27 |
|
| 28 |
-
router = APIRouter()
|
| 29 |
|
| 30 |
# ββ Ring buffer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
_LOG_RING: deque[dict] = deque(maxlen=500)
|
|
|
|
| 19 |
from collections import deque
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
+
from fastapi import APIRouter, Depends, Query
|
| 23 |
+
from .auth_guard import require_role, AuthRole
|
| 24 |
from fastapi.responses import JSONResponse
|
| 25 |
from pydantic import BaseModel
|
| 26 |
import logging
|
| 27 |
_logger = logging.getLogger("agente_ai") # S-BUGFIX
|
| 28 |
|
| 29 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 30 |
|
| 31 |
# ββ Ring buffer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
_LOG_RING: deque[dict] = deque(maxlen=500)
|
api/telegram_webhook.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/telemetry.py
CHANGED
|
@@ -8,10 +8,11 @@ Response: { ok, timing: { <key>: { avg, p50, p90, n } }, repair: { <key>: int }
|
|
| 8 |
Nessuna autenticazione richiesta (dati aggregati, zero PII).
|
| 9 |
"""
|
| 10 |
import statistics
|
| 11 |
-
from fastapi import APIRouter
|
|
|
|
| 12 |
from fastapi.responses import JSONResponse
|
| 13 |
|
| 14 |
-
router = APIRouter()
|
| 15 |
|
| 16 |
|
| 17 |
def _percentile(data: list[float], p: int) -> float:
|
|
|
|
| 8 |
Nessuna autenticazione richiesta (dati aggregati, zero PII).
|
| 9 |
"""
|
| 10 |
import statistics
|
| 11 |
+
from fastapi import APIRouter, Depends
|
| 12 |
+
from .auth_guard import require_role, AuthRole
|
| 13 |
from fastapi.responses import JSONResponse
|
| 14 |
|
| 15 |
+
router = APIRouter( dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 16 |
|
| 17 |
|
| 18 |
def _percentile(data: list[float], p: int) -> float:
|
api/terminal.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
import os, asyncio, pty, fcntl, struct, termios, json, shlex, time, logging
|
| 3 |
from pathlib import Path
|
| 4 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
|
|
|
| 5 |
|
| 6 |
router = APIRouter()
|
| 7 |
_logger = logging.getLogger("terminal")
|
|
@@ -165,7 +167,7 @@ def _schedule_save(loop: asyncio.AbstractEventLoop) -> None:
|
|
| 165 |
# ββ /api/terminal/packages ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
|
| 167 |
@router.get('/api/terminal/packages')
|
| 168 |
-
async def terminal_packages():
|
| 169 |
"""
|
| 170 |
Restituisce i pacchetti installati nel venv Python (/data/venv) e i pacchetti
|
| 171 |
npm globali (/data/npm-global).
|
|
|
|
| 2 |
import os, asyncio, pty, fcntl, struct, termios, json, shlex, time, logging
|
| 3 |
from pathlib import Path
|
| 4 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 5 |
+
from fastapi import Depends
|
| 6 |
+
from .auth_guard import require_role, AuthRole
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
_logger = logging.getLogger("terminal")
|
|
|
|
| 167 |
# ββ /api/terminal/packages ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
|
| 169 |
@router.get('/api/terminal/packages')
|
| 170 |
+
async def terminal_packages(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix: info-disclosure
|
| 171 |
"""
|
| 172 |
Restituisce i pacchetti installati nel venv Python (/data/venv) e i pacchetti
|
| 173 |
npm globali (/data/npm-global).
|
api/vault.py
CHANGED
|
@@ -2,8 +2,6 @@
|
|
| 2 |
|
| 3 |
GAP-VAULT-CRYPTO (fix): sostituisce XOR-stream (two-time-pad) con Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco).
|
| 4 |
GAP-VAULT-AUTH (fix): aggiunge _require_vault_auth (Bearer VAULT_ADMIN_TOKEN) su endpoint con accesso ai valori.
|
| 5 |
-
GAP-VAULT-AUTH-DEFAULT (fix): fallback su X-Internal-Token quando VAULT_ADMIN_TOKEN assente β non piΓΉ fail-open.
|
| 6 |
-
GAP-VAULT-ENC-FILE (fix): vault_get_token migra automaticamente XORβFernet al primo decrypt riuscito.
|
| 7 |
Backward-compat: _vault_decrypt tenta Fernet, poi fallback XOR per segreti giΓ salvati (migrazione trasparente).
|
| 8 |
"""
|
| 9 |
import os, base64 as _b64, hashlib as _hashlib, hmac as _hmac_mod, json as _json_v, secrets as _secrets_mod, logging, time
|
|
@@ -64,47 +62,21 @@ if not _HAS_FERNET:
|
|
| 64 |
_vault_logger.error('BOOT: cryptography non installata β Fernet non disponibile, uso XOR legacy (meno sicuro). Aggiungi cryptography>=42 a requirements.txt')
|
| 65 |
|
| 66 |
|
| 67 |
-
# ββ GAP-VAULT-AUTH
|
| 68 |
-
_VAULT_ADMIN_TOKEN
|
| 69 |
-
_INTERNAL_TOKEN_VAULT = os.getenv('INTERNAL_TOKEN', '') # aggiunto da main.py al boot su Railway
|
| 70 |
|
| 71 |
-
async def _require_vault_auth(
|
| 72 |
-
|
| 73 |
-
x_internal_token: Optional[str] = Header(None, alias="X-Internal-Token"),
|
| 74 |
-
) -> None:
|
| 75 |
-
"""Richiede autenticazione per endpoint vault sensibili (accesso ai valori).
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
su tutte le route non-public β copre il path production CFβRailway)
|
| 81 |
-
3. Nessun token configurato: fail-secure 503 (GAP-VAULT-AUTH-DEFAULT fix)
|
| 82 |
-
|
| 83 |
-
PRIMA (bug): se VAULT_ADMIN_TOKEN assente β return senza check β fail-open silenzioso.
|
| 84 |
-
DOPO (fix): se VAULT_ADMIN_TOKEN assente β verifica X-Internal-Token β fail-secure se assente.
|
| 85 |
"""
|
| 86 |
-
if _VAULT_ADMIN_TOKEN:
|
| 87 |
-
#
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
return
|
| 92 |
-
# VAULT_ADMIN_TOKEN non configurato: fallback su X-Internal-Token
|
| 93 |
-
if _INTERNAL_TOKEN_VAULT:
|
| 94 |
-
if x_internal_token == _INTERNAL_TOKEN_VAULT:
|
| 95 |
-
return # OK β chiamata interna autenticata via CF Worker
|
| 96 |
-
_vault_logger.warning('vault: unauthorized access attempt (X-Internal-Token mancante o errato)')
|
| 97 |
-
raise HTTPException(status_code=401, detail='Vault: non autorizzato β X-Internal-Token mancante o errato. Configurare VAULT_ADMIN_TOKEN per autenticazione dedicata.')
|
| 98 |
-
# Nessun token configurato: fail-secure (non piΓΉ fail-open)
|
| 99 |
-
_vault_logger.critical(
|
| 100 |
-
'vault: GAP-VAULT-AUTH-DEFAULT β VAULT_ADMIN_TOKEN e INTERNAL_TOKEN assenti. '
|
| 101 |
-
'Accesso al vault bloccato per sicurezza. '
|
| 102 |
-
'Impostare VAULT_ADMIN_TOKEN su HF Spaces secrets.'
|
| 103 |
-
)
|
| 104 |
-
raise HTTPException(
|
| 105 |
-
status_code=503,
|
| 106 |
-
detail='Vault non protetto: VAULT_ADMIN_TOKEN non configurato. Impostare VAULT_ADMIN_TOKEN su HF Spaces secrets per abilitare il vault.',
|
| 107 |
-
)
|
| 108 |
|
| 109 |
|
| 110 |
# ββ Crittografia: Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco) βββββββββββ
|
|
@@ -208,8 +180,8 @@ async def vault_health():
|
|
| 208 |
'Imposta VAULT_KEY nei secrets HF Spaces (Settings > Repository secrets).'
|
| 209 |
)
|
| 210 |
auth_warning = None if _VAULT_ADMIN_TOKEN else (
|
| 211 |
-
'VAULT_ADMIN_TOKEN non configurato: endpoint vault
|
| 212 |
-
'Imposta VAULT_ADMIN_TOKEN nei secrets HF Spaces per
|
| 213 |
)
|
| 214 |
return {
|
| 215 |
'persistent': _VAULT_KEY_IS_PERSISTENT,
|
|
@@ -234,7 +206,7 @@ async def vault_save(
|
|
| 234 |
request: Request,
|
| 235 |
_auth: None = Depends(_require_vault_auth),
|
| 236 |
):
|
| 237 |
-
"""Salva un segreto cifrato con Fernet. Richiede
|
| 238 |
_vault_rate_check(request)
|
| 239 |
key = req.key.lower().strip().replace(' ', '_')
|
| 240 |
if not key or not req.value.strip():
|
|
@@ -252,31 +224,13 @@ async def vault_get_token(
|
|
| 252 |
request: Request,
|
| 253 |
_auth: None = Depends(_require_vault_auth),
|
| 254 |
):
|
| 255 |
-
"""Restituisce il valore decifrato.
|
| 256 |
-
GAP-VAULT-ENC-FILE fix: se il ciphertext Γ¨ in formato XOR legacy, dopo il decrypt
|
| 257 |
-
riscrive la chiave in formato Fernet (migrazione automatica one-shot).
|
| 258 |
-
"""
|
| 259 |
_vault_rate_check(request)
|
| 260 |
data = _vault_load()
|
| 261 |
if key not in data:
|
| 262 |
raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
|
| 263 |
-
ciphertext = data[key]
|
| 264 |
-
# Tenta Fernet prima (formato attuale)
|
| 265 |
-
if _fernet_instance:
|
| 266 |
-
try:
|
| 267 |
-
value = _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8')
|
| 268 |
-
return {'key': key, 'value': value}
|
| 269 |
-
except Exception:
|
| 270 |
-
pass # Potrebbe essere XOR legacy β tenta fallback con migrazione
|
| 271 |
-
# Fallback XOR legacy: decrypt + migrazione automatica a Fernet (GAP-VAULT-ENC-FILE)
|
| 272 |
try:
|
| 273 |
-
|
| 274 |
-
if _fernet_instance:
|
| 275 |
-
# Riscrivi in formato Fernet β migrazione one-shot trasparente
|
| 276 |
-
data[key] = _vault_encrypt(value)
|
| 277 |
-
_vault_save_data(data)
|
| 278 |
-
_vault_logger.info('vault: GAP-VAULT-ENC-FILE β migrated key=%s from XOR to Fernet', key)
|
| 279 |
-
return {'key': key, 'value': value}
|
| 280 |
except Exception as e:
|
| 281 |
raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
|
| 282 |
|
|
@@ -287,7 +241,7 @@ async def vault_delete(
|
|
| 287 |
request: Request,
|
| 288 |
_auth: None = Depends(_require_vault_auth),
|
| 289 |
):
|
| 290 |
-
"""Elimina un segreto. Richiede
|
| 291 |
_vault_rate_check(request)
|
| 292 |
data = _vault_load()
|
| 293 |
if key not in data:
|
|
|
|
| 2 |
|
| 3 |
GAP-VAULT-CRYPTO (fix): sostituisce XOR-stream (two-time-pad) con Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco).
|
| 4 |
GAP-VAULT-AUTH (fix): aggiunge _require_vault_auth (Bearer VAULT_ADMIN_TOKEN) su endpoint con accesso ai valori.
|
|
|
|
|
|
|
| 5 |
Backward-compat: _vault_decrypt tenta Fernet, poi fallback XOR per segreti giΓ salvati (migrazione trasparente).
|
| 6 |
"""
|
| 7 |
import os, base64 as _b64, hashlib as _hashlib, hmac as _hmac_mod, json as _json_v, secrets as _secrets_mod, logging, time
|
|
|
|
| 62 |
_vault_logger.error('BOOT: cryptography non installata β Fernet non disponibile, uso XOR legacy (meno sicuro). Aggiungi cryptography>=42 a requirements.txt')
|
| 63 |
|
| 64 |
|
| 65 |
+
# ββ GAP-VAULT-AUTH: autenticazione Bearer βββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
_VAULT_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '')
|
|
|
|
| 67 |
|
| 68 |
+
async def _require_vault_auth(authorization: Optional[str] = Header(None)) -> None:
|
| 69 |
+
"""Richiede Authorization: Bearer VAULT_ADMIN_TOKEN se configurato.
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
Se VAULT_ADMIN_TOKEN non Γ¨ impostato: nessuna protezione aggiuntiva (backward compat).
|
| 72 |
+
Configura VAULT_ADMIN_TOKEN in HF Spaces secrets per abilitare l'autenticazione.
|
| 73 |
+
Genera con: python3 -c "import secrets; print(secrets.token_hex(32))"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
"""
|
| 75 |
+
if not _VAULT_ADMIN_TOKEN:
|
| 76 |
+
return # Auth disabilitata β imposta VAULT_ADMIN_TOKEN per proteggere il vault
|
| 77 |
+
if authorization != f'Bearer {_VAULT_ADMIN_TOKEN}':
|
| 78 |
+
_vault_logger.warning('vault: unauthorized access attempt')
|
| 79 |
+
raise HTTPException(status_code=401, detail='Vault: non autorizzato β Bearer token non valido o mancante')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
# ββ Crittografia: Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco) βββββββββββ
|
|
|
|
| 180 |
'Imposta VAULT_KEY nei secrets HF Spaces (Settings > Repository secrets).'
|
| 181 |
)
|
| 182 |
auth_warning = None if _VAULT_ADMIN_TOKEN else (
|
| 183 |
+
'VAULT_ADMIN_TOKEN non configurato: endpoint vault accessibili senza autenticazione. '
|
| 184 |
+
'Imposta VAULT_ADMIN_TOKEN nei secrets HF Spaces per proteggere i token.'
|
| 185 |
)
|
| 186 |
return {
|
| 187 |
'persistent': _VAULT_KEY_IS_PERSISTENT,
|
|
|
|
| 206 |
request: Request,
|
| 207 |
_auth: None = Depends(_require_vault_auth),
|
| 208 |
):
|
| 209 |
+
"""Salva un segreto cifrato con Fernet. Richiede Bearer VAULT_ADMIN_TOKEN se configurato."""
|
| 210 |
_vault_rate_check(request)
|
| 211 |
key = req.key.lower().strip().replace(' ', '_')
|
| 212 |
if not key or not req.value.strip():
|
|
|
|
| 224 |
request: Request,
|
| 225 |
_auth: None = Depends(_require_vault_auth),
|
| 226 |
):
|
| 227 |
+
"""Restituisce il valore decifrato. Richiede Bearer VAULT_ADMIN_TOKEN se configurato."""
|
|
|
|
|
|
|
|
|
|
| 228 |
_vault_rate_check(request)
|
| 229 |
data = _vault_load()
|
| 230 |
if key not in data:
|
| 231 |
raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
try:
|
| 233 |
+
return {'key': key, 'value': _vault_decrypt(data[key])}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
except Exception as e:
|
| 235 |
raise HTTPException(status_code=500, detail=f'Decryption error: {e}')
|
| 236 |
|
|
|
|
| 241 |
request: Request,
|
| 242 |
_auth: None = Depends(_require_vault_auth),
|
| 243 |
):
|
| 244 |
+
"""Elimina un segreto. Richiede Bearer VAULT_ADMIN_TOKEN se configurato."""
|
| 245 |
_vault_rate_check(request)
|
| 246 |
data = _vault_load()
|
| 247 |
if key not in data:
|
api/vision.py
CHANGED
|
@@ -18,10 +18,11 @@ Fallback chain analyze_image:
|
|
| 18 |
3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
|
| 19 |
"""
|
| 20 |
import asyncio, base64, os, httpx, logging
|
| 21 |
-
from fastapi import APIRouter
|
|
|
|
| 22 |
from pydantic import BaseModel
|
| 23 |
|
| 24 |
-
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
| 25 |
_logger = logging.getLogger("vision")
|
| 26 |
|
| 27 |
_HF_API = "https://api-inference.huggingface.co"
|
|
|
|
| 18 |
3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
|
| 19 |
"""
|
| 20 |
import asyncio, base64, os, httpx, logging
|
| 21 |
+
from fastapi import APIRouter, Depends
|
| 22 |
+
from .auth_guard import require_role, AuthRole
|
| 23 |
from pydantic import BaseModel
|
| 24 |
|
| 25 |
+
router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
|
| 26 |
_logger = logging.getLogger("vision")
|
| 27 |
|
| 28 |
_HF_API = "https://api-inference.huggingface.co"
|