Baida-A commited on
Commit
e5220dc
Β·
verified Β·
1 Parent(s): c66a1ba

sync: 125 file da Baida98/AI@8374b07e (2026-07-10 21:36 UTC)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
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 unified_loop_fallback.py (FallbackMixin._run_fallback) prima del `break`.
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 as e:
135
- _logger.debug("[context_manager] _expand_tokens failed: %s", e)
136
  return tokens
137
 
138
 
139
  def _split_camel_snake(text: str) -> list[str]:
 
 
 
 
 
 
 
 
 
140
  try:
141
  snake = _CAMEL_SPLIT_RE.sub(r'\1_\2', text)
142
  parts = re.split(r'[_\-./]', snake)
143
  return [p.lower() for p in parts if len(p) >= 3]
144
- except Exception as e:
145
- _logger.debug("[context_manager] _split_camel_snake failed: %s", e)
146
  return []
147
 
148
 
@@ -152,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 as e:
213
- _logger.error("[context_manager] rank_files_by_relevance critical failure: %s", e)
214
  return [f.get('path', '') for f in all_files[:k] if f.get('path')]
215
 
216
 
217
  def _extract_signatures(content: str, language: str) -> list[str]:
 
218
  try:
219
  lang = (language or '').lower()
220
  sigs: list[str] = []
@@ -231,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 as e:
235
- _logger.debug("[context_manager] _extract_signatures failed: %s", e)
236
  return []
237
 
238
 
239
  def build_file_skeleton(path: str, content: str, language: str) -> str:
 
240
  sigs = _extract_signatures(content, language)
241
  line_count = content.count('\n') + 1
242
  sigs_str = ', '.join(sigs[:8]) if sigs else '(no symbols)'
@@ -244,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 as e:
258
- _logger.error("[context_manager] build_project_skeleton failed: %s", e)
259
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 as e:
60
- _logger.debug("[executor] _get_session_id failed: %s", e)
61
  return "default"
62
 
63
 
 
 
64
  class Executor:
65
  def __init__(
66
  self,
@@ -71,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 as e:
88
- _logger.debug("[executor] _is_circuit_open failed to get skill tracker: %s", e)
89
  return False
90
  if not stats:
91
  return False
@@ -93,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("[executor] recovery credit: riprovo %s (circuit call #%d)", tool_name, count)
100
- return False
 
 
 
101
  return True
102
 
 
 
103
  async def _try_fallbacks(
104
  self,
105
  primary_name: str,
@@ -107,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 as e:
119
- _logger.debug("[executor] _try_fallbacks failed to sort: %s", e)
120
- sorted_fbs = fallbacks
121
 
122
  for fb_name in sorted_fbs:
123
  fb_tool = TOOL_REGISTRY.get(fb_name)
124
  if not fb_tool or not fb_tool.get("_fn"):
125
  continue
126
- _logger.info("[executor] %s fallita β€” provo fallback %s", primary_name, fb_name)
 
 
 
127
  try:
128
  _t0 = _time_mod.monotonic()
129
  _fb_to = _timeout_tracker.adaptive_timeout(fb_name, timeout)
130
  result = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
131
  _timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
 
132
  try:
133
  from agents.skill_tracker import get_skill_tracker
134
  get_skill_tracker().record(session_id, fb_name, True)
135
- except Exception as e:
136
- _logger.debug("[executor] record success failed: %s", e)
137
  return {
138
  "success": True,
139
  "tool": fb_name,
@@ -147,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 e:
151
- _logger.debug("[executor] record timeout failed: %s", e)
152
  except Exception as fb_exc:
153
  _logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
154
  try:
155
  from agents.skill_tracker import get_skill_tracker
156
  get_skill_tracker().record(session_id, fb_name, False)
157
- except Exception as e:
158
- _logger.debug("[executor] record error failed: %s", e)
159
 
160
- return None
 
 
161
 
162
  async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
163
  tool = TOOL_REGISTRY.get(tool_name)
@@ -170,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("[executor] circuit OPEN per %s β€” routing a fallback", tool_name)
 
 
 
175
  fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
176
  if fb_result:
177
  return fb_result
178
- _logger.warning("[executor] tutti i fallback di %s falliti β€” provo primario", tool_name)
 
 
 
 
179
 
 
180
  fn = tool.get("_fn")
181
  if fn is None:
182
  return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
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
- try:
193
- await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:500]}", str(result)[:500], True)
194
- except Exception as e:
195
- _logger.debug("[executor] memory save failed: %s", e)
 
 
 
196
  return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
197
 
198
  except asyncio.TimeoutError:
 
199
  _timeout_tracker.record(tool_name, timeout * 1.2)
200
  last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
201
  if attempt == self.max_retries:
 
 
 
 
 
202
  fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
203
  if fb_result:
204
  return fb_result
@@ -208,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 (per task maturi)
26
- _MIN_EXEC_DONE: int = 4 # GAP-DRIFT-THRESHOLD-FIXED: era 2, ora 4
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 4 subtask completati)
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
- # GAP-DRIFT-THRESHOLD-FIXED: usa threshold dinamica invece di fissa 25%
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} (threshold={effective_thr:.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])) # S595: 300->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]): # S595
204
  return 0.25
205
- if _COMPLEX_CODE_RE.search(g[:500]): # S595
206
  return 0.55
207
- if cls._CODE_RE.search(g[:500]): # S595
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) # S586: 120β†’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]) # S591: 3->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 "" # S595: 2->4
291
  if failed_reqs:
292
- hint = f"Requisiti FAIL: {', '.join(failed_reqs[:5])}. {hint}" # S595: 3->5
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], # S595
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:]) # S594: content[:500] per msg # S572: 100β†’300β†’500 / S590: -3β†’-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] # S577: 200β†’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->500
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 = "\n".join(_sig_lines)
184
  if len(_ctx_smart) >= 500:
185
  _ctx_raw = (
186
- f"[SMART CHUNK β€” {len(_skels)} file β€” solo firme estratte]\n"
187
  + _ctx_smart[:10000]
188
  )
189
  else:
190
- _ctx_raw = _ctx_raw[:6000] + "\n... [troncato β€” usa file_search per dettagli]"
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
- tasks = [
236
- self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1), # BRAIN: Conservativo
237
- self.llm.chat([{"role": "user", "content": prompt + "\nSii creativo e pensa fuori dagli schemi."}], temperature=0.7), # HANDS: Creativo
238
- self.llm.chat([{"role": "user", "content": prompt + "\nFocalizzati sulla massima efficienza e sicurezza."}], temperature=0.0) # MEMORY: Deterministico
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[:200], # S578: 120β†’200
273
  "confidence": decision.confidence
274
  })
275
 
@@ -277,13 +284,11 @@ Rispondi SOLO con JSON valido:
277
  break
278
 
279
  elif decision.action == "analyze":
280
- _wm_raw = await self.analyze_project(context or goal)
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
- _strat_raw = await self.develop_strategy(state)
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", [])) # S590: using errors[-5:] window
328
 
329
  state.loop_count += 1
330
 
331
  return {
332
  "goal": goal,
333
  "loops": state.loop_count,
 
334
  "results": results,
335
- "final_state": state
 
 
 
336
  }
337
 
338
- async def run_loop_to_answer(self, goal: str, max_loops: int = 5) -> str:
339
- """S575: convenience wrapper β€” never raises, returns '' on failure.
 
 
340
 
341
- Nota: il path 'continue' usa LLM diretta; direct_response non esiste
342
- come tool registrato (S575 β€” fix: rimosso run_tool('direct_response')).
 
 
343
  """
344
  try:
345
- result = await self.run(goal)
346
- fs = result.get("final_state")
347
- if fs:
348
- return fs.last_result or ""
349
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, _detect_user_lang, _LANG_INSTRUCTIONS, _maybe_await
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. GENERAZIONE IMMAGINI (S276): Se ti viene chiesto di generare un'immagine AI, "
 
 
 
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
- "15. SCAFFOLD PROGETTO: Quando i tool_results contengono output scaffold_project "
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
- # ── S200: Context-aware rule injection ──────────────────────────────────────
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[:600]).lower() # S576: 400β†’600
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]}]" # S593: 400->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]}]" # S593: 200->300->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
- _parallel_results = await asyncio.gather(
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
- "[STATO GIT", # S764: git_status
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
- """backend/api/agent.py β€” Thin Router orchestrator (S359).
2
- Svolge solo il ruolo di punto di ingresso per i sub-router modularizzati.
 
 
 
 
 
 
 
 
3
  """
4
- import logging
5
- from fastapi import APIRouter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _logger.info("[agent] Thin Router initialized with Loop, Task and Checkpoint modules.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # GAP-MEM-RECONCILE-BREAK: distingui errore network (stop tutto) da errore record
64
- _is_network = isinstance(_e, (ConnectionError, TimeoutError, OSError))
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
- # GAP-AGENT-MEMORY-RECONCILE-TASK fix: wrappa in try/except per contesti senza event loop
144
  if len(_mem_fallback) > 1:
145
- try:
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 (_ue.URLError, _ue.HTTPError, TimeoutError, OSError):
89
- # Rete/Redis non raggiungibile β€” fail-open silenzioso (comportamento atteso)
90
- return True, 0
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
- now = _rl_time.monotonic()
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 == admin_tok:
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 == op_tok:
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 == int_tok:
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(VAULT_KEY[:32] base64url-padded) β€” richiede cryptography>=42.
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 from VAULT_KEY. Returns None if not configured."""
26
- try:
27
- from cryptography.fernet import Fernet
28
- import base64
29
- vault_key = os.getenv('VAULT_KEY', '')
30
- if not vault_key:
31
- return None
32
- # Derive 32-byte key from VAULT_KEY (pad/truncate) β†’ Fernet requires 32-byte urlsafe-b64
33
- raw = (vault_key[:32] + '0' * 32)[:32].encode('utf-8')
34
- fernet_key = base64.urlsafe_b64encode(raw)
35
- return Fernet(fernet_key)
36
- except Exception as e:
37
- _logger.warning('[auth_managed] Fernet init failed: %s', e)
38
- return None
 
 
 
 
 
 
 
 
39
 
40
  def _encrypt(text: str) -> str:
41
- f = _get_fernet()
42
- if not f or not text:
43
- return text # fallback: plain (discouraged in prod)
44
  return f.encrypt(text.encode()).decode()
45
 
46
  def _decrypt(token: str) -> str:
47
- f = _get_fernet()
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
- # ── In-memory OAuth state store (CSRF) ──────────────────────────────────────
56
- # {state_token: {'provider': str, 'user_id': str, 'created_at': float}}
57
- _oauth_states: dict[str, dict] = {}
 
58
  _STATE_TTL = 600 # 10 minuti
59
 
60
- def _make_state(provider: str, user_id: str) -> str:
61
- _purge_states()
 
 
 
62
  state = secrets.token_urlsafe(32)
63
- _oauth_states[state] = {'provider': provider, 'user_id': user_id, 'created_at': time.time()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return state
65
 
66
- def _consume_state(state: str) -> Optional[dict]:
67
- _purge_states()
68
- entry = _oauth_states.pop(state, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _purge_states():
76
  now = time.time()
77
- expired = [k for k, v in _oauth_states.items() if now - v['created_at'] > _STATE_TTL]
78
  for k in expired:
79
- _oauth_states.pop(k, None)
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
- 'scope': 'openid email profile https://www.googleapis.com/auth/calendar',
 
 
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
- return _decrypt(row['access_token'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
 
208
  # ── Routes ────────────────────────────────────────────────────────────────────
209
 
210
  def _user_id(request: Request) -> str:
211
- return request.headers.get('X-User-ID', 'default')
 
 
 
 
 
 
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 'https://arjanit98-terminal.hf.space/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, 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 https://arjanit98-terminal.hf.space/api/benchmark/run-self \
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 (MACHINE auth)
10
- GET /api/blackboard/{session_id}/read β€” legge tutte le entry (pubblico)
11
- DEL /api/blackboard/{session_id} β€” pulisce il blackboard (MACHINE auth)
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
- session_id: str,
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. Pubblico (solo findings)."""
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
- session_id: str,
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, HTTPException, Request, Depends
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
- router = APIRouter(prefix="/api/database", tags=["database"])
 
 
 
 
 
 
 
 
 
 
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, request: Request):
71
- _internal_token = os.getenv('INTERNAL_TOKEN', '')
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", "Arjanit98/Terminal") # GAP-3: parametrizzato da env
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
- _auth(request)
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
- _auth(request)
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 = "https://ai-production-4c06.up.railway.app"
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
- _auth(request)
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 = "https://ai-production-4c06.up.railway.app/health"
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
- wr = await c.get("https://arjanit98-terminal.hf.space")
327
- woke = wr.status_code < 500
328
- actions.append(f" GET wake-up: HTTP {wr.status_code}")
 
 
 
 
 
 
 
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
- _auth(request)
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. 'https://arjanit98-terminal.hf.space')
523
- 2. SPACE_HOST env var β€” iniettata da HF Spaces automaticamente (es. 'arjanit98-terminal.hf.space')
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=https://arjanit98-terminal.hf.space
528
  explicit = os.getenv('HF_SPACE_URL', '').rstrip('/')
529
  if explicit:
530
  return explicit
531
- # 2. SPACE_HOST β€” iniettata da HF Spaces (es. 'arjanit98-terminal.hf.space')
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=https://arjanit98-terminal.hf.space '
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
- Default from: noreply@<dominio configurato in Resend>
 
 
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, Request, Depends, HTTPException
 
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
- # ── GAP-EMAIL-OPENRELAY fix: allowlist domini mittente e validazione ──
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
- # Costruisci mittente
 
 
 
 
 
 
 
 
 
 
 
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
- cmd = ['node', '-e', code]
305
- elif lang in ('typescript', 'ts'):
306
- fname = os.path.join(tmpdir, 'snippet.ts')
307
- with open(fname, 'w') as f:
308
- f.write(code)
309
- cmd = ['npx', '--yes', 'ts-node', '--transpile-only', fname]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # ── GAP-SHELL-NOFILTER fix: Allowlist invece di blocklist minima ─────────
363
- _SAFE_SHELL = _re_exec.compile(r'^(ls|pwd|date|echo|cat|grep|find|du|df|uptime|ps|top|free|git status|git log|pnpm|npm|pip|python3|node|ts-node)( .*)?$')
364
- if not _SAFE_SHELL.match(raw):
365
- # Fallback blocklist per comandi composti o non in lista
366
- for bad in BLOCKED_CMDS:
367
- if bad in raw:
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
- router = APIRouter()
 
 
 
 
 
 
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
- from tools.registry import _scaffold_project as _do_scaffold
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
- import os
2
- import json
3
- import time
4
- import asyncio
5
- import logging
6
- import uuid
7
- try:
8
- import resource as _resource
9
- _HAS_RESOURCE = True
10
- except ImportError:
11
- _HAS_RESOURCE = False
12
- from typing import Optional, List, Dict, Any
13
- from fastapi import APIRouter, HTTPException, Request, Depends
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("agente_ai.jq")
20
- router = APIRouter(prefix="/jq", tags=["job_queue"])
21
 
22
- # ── Configurazione ────────────────────────────────────────────────────────────
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
- # C2-FIX: contatore reale di job attivi β€” incrementato/decrementato intorno a loop_inst.run()
28
- _active_job_count: int = 0
 
 
 
 
 
 
29
 
30
- # Redis keys (Upstash)
31
- _K_PENDING = "jq:pending"
32
- _K_WAKE = "jq:wake"
33
- _K_CONSUMER = f"jq:consumer:{_SPACE_ROLE}"
34
- _K_RESULT = lambda tid: f"jq:result:{tid}"
35
- _K_EVENTS = lambda tid: f"jq:events:{tid}"
36
- _K_LOAD = lambda role: f"jq:load:{role}"
37
 
38
- class JobPayload(BaseModel):
39
- taskId: str
40
- goal: str
41
- context: Optional[Dict[str, Any]] = None
42
- priority: int = 1
43
-
44
- # ── Helper Redis (via HTTP REST per stabilitΓ  mobile/serverless) ──────────────
45
- async def _rcmd(cmd: List[Any]) -> Optional[Dict[str, Any]]:
46
- url = os.getenv("UPSTASH_REDIS_REST_URL")
47
- tok = os.getenv("UPSTASH_REDIS_REST_TOKEN")
48
- if not url or not tok:
49
- return None
50
  try:
51
- async with httpx.AsyncClient() as client:
52
- r = await client.post(
53
- url,
54
- headers={"Authorization": f"Bearer {tok}"},
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
- def _redis_ok() -> bool:
64
- return bool(os.getenv("UPSTASH_REDIS_REST_URL") and os.getenv("UPSTASH_REDIS_REST_TOKEN"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  async def _llen(key: str) -> int:
67
- res = await _rcmd(["LLEN", key])
68
- return int(res.get("result", 0)) if res else 0
 
 
69
 
70
- async def _lrange(key: str, start: int, end: int = -1) -> List[str]:
71
- res = await _rcmd(["LRANGE", key, start, end])
72
- return res.get("result", []) if res else []
 
73
 
74
- # ── Core Logic ────────────────────────────────────────────────────────────────
75
- async def publish_load_metrics():
76
- """Pubblica il carico corrente su Redis per il bilanciamento (S951).
77
- C2-FIX: usa _active_job_count (contatore reale) invece di asyncio.all_tasks()-5.
 
 
 
 
 
78
  """
79
- if not _redis_ok(): return
80
- # Memoria processo in MB (Linux: ru_maxrss Γ¨ in kB)
81
- mem_mb = 0
82
- if _HAS_RESOURCE:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  try:
84
- mem_mb = _resource.getrusage(_resource.RUSAGE_SELF).ru_maxrss // 1024
 
85
  except Exception:
86
- mem_mb = 0
87
- data = {
88
- "role": _SPACE_ROLE,
89
- "ts": int(time.time() * 1000),
90
- "active_tasks": max(0, _active_job_count), # mai negativo
91
- "cpu": 0, # placeholder β€” psutil non installato
92
- "mem": mem_mb,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
94
- await _rcmd(["SET", _K_LOAD(_SPACE_ROLE), json.dumps(data), "EX", "60"])
95
 
96
- async def _load_publisher_loop():
97
- """Loop periodico per aggiornare lo stato del nodo."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  while True:
 
99
  try:
100
  await publish_load_metrics()
101
- except Exception as e:
102
- _logger.debug("[jq] load publisher error: %s", e)
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
- # 1. Heartbeat consumer
115
- await _rcmd(["SET", _K_CONSUMER, "1", "EX", "15"])
116
-
117
- # 2. Prelievo job
118
- res = await _rcmd(["RPOP", _K_PENDING])
119
- if res and res.get("result"):
120
- job_raw = res["result"]
121
- try:
122
- job_data = json.loads(job_raw)
123
- # S951: Check assignment
124
- assigned_to = job_data.get("assignedTo", "hands")
125
- if assigned_to != _SPACE_ROLE and _SPACE_ROLE != "brain":
126
- await _rcmd(["LPUSH", _K_PENDING, job_raw])
127
- await asyncio.sleep(1)
128
- continue
129
- except Exception:
130
- pass
131
- task_id = job_data.get("taskId", str(uuid.uuid4()))
132
- goal = job_data.get("goal", "")
133
- context_raw = job_data.get("context", {})
134
- _logger.info("[jq] job ricevuto: %s | goal: %.80s", task_id, goal)
135
-
136
- if isinstance(context_raw, dict):
137
- context_str = "\n".join(f"{k}: {v}" for k, v in context_raw.items() if v)
138
- elif isinstance(context_raw, str):
139
- context_str = context_raw
140
- else:
141
- context_str = ""
142
-
143
- result_payload: Dict[str, Any] = {}
144
- try:
145
- from agents.unified_loop import UnifiedAgentLoop
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
- """Punto di ingresso per main.py _on_startup()."""
 
 
 
 
 
251
  if not _redis_ok():
252
  _logger.warning("[jq] Redis non configurato β€” job queue disabilitato")
253
  return
254
- _bg_tasks.append(asyncio.create_task(_load_publisher_loop()))
255
- if _SPACE_ROLE in ("hands", "memory", "audit", "unknown"):
256
- _bg_tasks.append(asyncio.create_task(_hands_consumer_loop()))
 
 
 
 
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
- role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
264
- ):
265
- return {
266
  "space_role": _SPACE_ROLE,
267
  "jq_enabled": _JQ_ENABLED,
268
- "redis_configured": _redis_ok(),
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: str,
276
- auth_role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
277
- ):
278
- if role not in ("brain", "hands", "memory", "audit"):
279
- raise HTTPException(400, "role non valido")
280
- res = await _rcmd(["GET", _K_LOAD(role)])
281
- if not res or not res.get("result"):
282
- raise HTTPException(404, f"Metriche {role} non disponibili")
283
- try:
284
- return json.loads(res["result"])
285
- except json.JSONDecodeError as _je:
286
- raise HTTPException(500, f"Metriche Redis corrotte per {role}: {_je}")
 
 
 
 
 
 
287
 
288
  @router.post("/submit")
289
- async def jq_submit(
290
- job: JobPayload,
291
- role: AuthRole = Depends(require_role(AuthRole.MACHINE)),
292
- ):
293
- """S42 β€” Grid Orchestrator: sottomissione job reale su Redis."""
294
- if not _redis_ok():
295
- raise HTTPException(503, "Job queue non disponibile: Redis non configurato")
296
-
297
- task_id = job.taskId if job.taskId else str(uuid.uuid4())
298
-
299
- # S951: Dynamic Load Balancing
300
- target_role = job.context.get('preferred_role', 'hands') if job.context else 'hands'
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
- task_id: str,
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, "Job queue non disponibile: Redis non configurato")
334
-
335
- res = await _rcmd(["GET", _K_RESULT(task_id)])
336
- if not res or not res.get("result"):
337
- return {"taskId": task_id, "status": "pending", "ts": int(time.time() * 1000)}
338
  try:
339
- return json.loads(res["result"])
340
- except json.JSONDecodeError as _je:
341
- _logger.error("[jq] risultato Redis corrotto task=%s: %s", task_id, _je)
342
- raise HTTPException(500, f"Risultato corrotto in Redis per {task_id}: {_je}")
343
 
344
- # Lista dei background tasks (popolata da start_job_queue_consumer)
345
- _bg_tasks: List[asyncio.Task] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 .state import _sb, SENSITIVE, _ai_health_cache, _AI_HEALTH_TTL, _heartbeat_state, _TIMING_STORE, _REPAIR_STATS, get_env_secret
 
 
5
 
6
  router = APIRouter()
7
  _logger = logging.getLogger('agente_ai')
@@ -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': 'S800-RC1',
94
- 'version': '3.6.0',
95
- 'build_date': '2026-07-02',
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 = get_env_secret('INTERNAL_TOKEN')
155
  if _tok and request.headers.get('X-Internal-Token', '') != _tok:
156
  from fastapi import HTTPException as _HTTPEx
157
  raise _HTTPEx(401, 'Unauthorized')
@@ -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
- # MX12-P2: adaptive interval β€” dimezza quando <2 provider disponibili
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 https://arjanit98-terminal.hf.space/api/auth/ping
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 = _get_env_secret('INTERNAL_TOKEN').strip()
573
 
574
  # Leggi header sia dal parametro sia dall'oggetto request (FastAPI puΓ² passare entrambi)
575
  hdr_token = x_internal_token
@@ -594,21 +474,373 @@ async def auth_ping(
594
  ),
595
  }
596
 
597
- # ── /api/status/ping β€” alias /health non bloccato da Railway Hikari ──────────
598
- # Railway Hikari intercetta /api/health e /api/healthz come path riservati (405).
599
- # Questo alias usa /api/status/ping che passa attraverso Hikari normalmente.
600
- # Usato dal frontend come fallback quando /health non Γ¨ raggiungibile via CF proxy.
601
- @router.get('/api/status/ping')
602
- async def status_ping():
603
  """
604
- Alias leggero di /health β€” non bloccato da Railway Hikari.
605
- Railway riserva /api/health e /api/healthz come path di sistema (risponde 405).
606
- Questo endpoint Γ¨ identico a /health ma usa un path non riservato.
 
 
 
 
 
 
 
 
 
 
 
607
  """
608
- return {
609
- 'status': 'ok',
610
- 'version': '3.4.2',
611
- 'supabase': _sb is not None,
612
- 'backend': 'HuggingFace Spaces / Railway',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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```"}, # S597: 300->500
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] # S597+S598: 300->500, # S597
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, HTTPException, Request, Depends
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, HTTPException, Request, Depends
 
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, notify_task_error as _tg_error,
57
- notify_task_start as _tg_start, notify_task_heartbeat as _tg_heartbeat,
 
 
58
  )
59
- except Exception:
60
- async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
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
- def _resolve_tasks_path() -> Path:
70
- """GAP-SCHEDULERFILE-TMP fix: usa /data (persistente HF Spaces) con fallback a /tmp."""
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 (proverΓ² Supabase)")
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", "tiered_scan"): # MX-SCHED-TIERED
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. GAP-SCHEDULER-CONCURRENT: Semaphore(1)."""
371
- async with _get_execute_sem(): # GAP-SCHEDULER-CONCURRENT: un solo task per volta
372
- now_ms = int(time.time() * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
- # Marca running + broadcast SSE
375
  async with _lock:
376
  task = _tasks.get(task_id)
377
  if not task:
378
  return
379
- task["status"] = "running"
380
- task["lastRunAt"] = now_ms
381
- _task_notify = task.get("notify", True)
382
- _task_label = task.get("label", task.get("goal", ""))[:200]
383
- _task_goal = task.get("goal", _task_label)[:200]
 
 
384
  _save_tasks_sync()
385
  _broadcast_sse()
 
 
 
 
 
386
  if _task_notify:
387
- asyncio.create_task(_tg_start(task_id, _task_goal)).add_done_callback(_log_task_exc)
388
 
389
- try:
390
- result = await _run_goal(task["goal"], task.get("conversationId"))
391
-
392
- async with _lock:
393
- task = _tasks.get(task_id)
394
- if not task:
395
- return
396
- ttype = task["trigger"].get("type")
397
- one_shot = ttype in ("once", "on_open")
398
- task["status"] = "done" if one_shot else "pending"
399
- task["trigger"] = _advance_trigger(task["trigger"], now_ms)
400
- task["lastRunAt"] = now_ms
401
- task["lastResult"] = result
402
- task["errorCount"] = 0
403
- _save_tasks_sync()
404
- _broadcast_sse()
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
- except Exception as exc:
414
- async with _lock:
415
- task = _tasks.get(task_id)
416
- if not task:
417
- return
418
- task["errorCount"] = task.get("errorCount", 0) + 1
419
- failed = task["errorCount"] >= task.get("maxErrors", 3)
420
- task["status"] = "failed" if failed else "pending"
421
- if not failed:
422
- task["trigger"] = _advance_trigger(
423
- task["trigger"], now_ms + 5 * 60_000
424
- )
425
- task["lastRunAt"] = now_ms
426
- task["lastResult"] = f"❌ {str(exc)[:300]}"
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
- # MX11-SCHED: boot coroutine β€” carica Supabase se /tmp vuoto, poi avvia loop
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
- task_id: str,
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
- # GAP-SSE-CLIENT-GROW fix: list comprehension β€” evita ValueError su race cleanup
863
- _sse_clients[:] = [q for q in _sse_clients if q is not queue]
 
 
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, HTTPException, Request, Depends
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 = get_env_secret('SUPABASE_URL')
39
- _SUPA_KEY = get_env_secret('SUPABASE_KEY') or get_env_secret('SUPABASE_ANON_KEY')
 
 
 
 
 
 
 
 
 
40
  if _SUPA_URL and _SUPA_KEY:
41
- from supabase import create_client
42
  _sb = create_client(_SUPA_URL, _SUPA_KEY)
43
- # Leggi SUPABASE_URL_B (canonico multi-instance) con fallback legacy SUPABASE_URL_2
44
- _SUPA_URL2 = os.getenv("SUPABASE_URL_B") or os.getenv("SUPABASE_URL_2", "")
45
- _SUPA_KEY2 = os.getenv("SUPABASE_KEY_B") or os.getenv("SUPABASE_KEY_2", "")
46
- if _SUPA_URL2 and _SUPA_KEY2:
47
- _sb2 = create_client(_SUPA_URL2, _SUPA_KEY2)
48
- _logger.info("BOOT: Supabase #2 connected OK")
49
- _logger.info('BOOT: Supabase connected OK')
50
- else:
51
- _logger.warning('BOOT: Supabase not configured (SUPABASE_URL / SUPABASE_KEY missing)')
 
 
 
52
  except Exception as e:
53
  _logger.error('BOOT: Supabase init failed: %s', e)
54
 
55
 
56
 
57
  def sb() -> Any:
58
- if not _sb and not _sb2:
 
 
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
- return _sb or _sb2
 
 
 
 
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
- try:
158
- snap: dict = _json.loads(res.data["value"])
159
- except _json.JSONDecodeError as _snap_jd:
160
- _logger.warning("BOOT: GAP-STATE snapshot JSON corrotto: %s", _snap_jd)
161
- return 0
162
  n = 0
163
  for task_id, data in snap.items():
164
  if task_id not in _agent_tasks and isinstance(data, dict):
@@ -351,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 as _mm_err:
355
- _logger.warning("[state] MemoryManager.init failed (1st): %s", _mm_err)
356
  _mem_manager = None
357
  elif not _mem_manager_inited:
358
  try:
359
  await _mem_manager.init()
360
  _mem_manager_inited = True
361
- except Exception as _mm2_err:
362
- _logger.warning("[state] MemoryManager.init failed (2nd, no retry): %s", _mm2_err)
363
  _mem_manager_inited = True # evita retry infiniti
364
  return _mem_manager
365
 
@@ -397,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 as _mm3_err:
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 as _ex_err:
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 + GAP-VAULT-AUTH-DEFAULT: autenticazione Bearer con fallback X-Internal-Token ──
68
- _VAULT_ADMIN_TOKEN = os.getenv('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
- authorization: Optional[str] = Header(None),
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
- PrioritΓ :
78
- 1. VAULT_ADMIN_TOKEN configurato: richiede Authorization: Bearer VAULT_ADMIN_TOKEN
79
- 2. VAULT_ADMIN_TOKEN assente: fallback su X-Internal-Token (aggiunto dal CF Worker
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
- # Token admin dedicato configurato: richiede Bearer
88
- if authorization != f'Bearer {_VAULT_ADMIN_TOKEN}':
89
- _vault_logger.warning('vault: unauthorized access attempt (wrong/missing Bearer)')
90
- raise HTTPException(status_code=401, detail='Vault: non autorizzato β€” Bearer token non valido o mancante')
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 protetti da X-Internal-Token (fallback). '
212
- 'Imposta VAULT_ADMIN_TOKEN nei secrets HF Spaces per autenticazione dedicata al vault.'
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 autenticazione."""
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
- value = _vault_decrypt_xor(ciphertext)
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 autenticazione."""
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"