Baida07 commited on
Commit
87e9c33
Β·
verified Β·
1 Parent(s): fbc619f

sync: 149 file da Baida98/AI@37832425 (2026-07-01 10:26 UTC)

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