sync: 191 file da Baida98/AI@5943984b (2026-08-29 20:27 UTC)

#146
by Baida07 - opened
Dockerfile CHANGED
@@ -38,4 +38,4 @@ COPY --chown=user . /home/user/app/
38
 
39
  EXPOSE 7860
40
 
41
- CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1"]
 
38
 
39
  EXPOSE 7860
40
 
41
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers ${WEB_CONCURRENCY:-2}"]
api/agent.py CHANGED
@@ -42,8 +42,21 @@ def _sanitize_for_json(obj: object) -> object:
42
  from fastapi import APIRouter, Depends, HTTPException, Request, Body
43
  from fastapi.responses import StreamingResponse
44
  from .auth_guard import require_role, AuthRole
45
- from pydantic import BaseModel, field_validator
46
  from typing import Literal
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  from .state import (
48
  _agent_tasks, _task_ai_clients, _task_checkpoints, _loop_registry, _run_stream_tasks,
49
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
@@ -89,6 +102,83 @@ except Exception:
89
  router = APIRouter()
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def _attach_byok_client(task_id: str, credentials: object) -> None:
93
  """Create a task-scoped LLM client without persisting or logging credentials."""
94
  if credentials is None:
@@ -204,10 +294,17 @@ async def agent_run_stream(
204
  body: ReasonLoopIn, request: Request,
205
  role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
206
  ):
 
 
 
 
 
207
  async def generate():
208
  queue: asyncio.Queue = asyncio.Queue()
209
 
210
  async def step_cb(step: dict) -> None:
 
 
211
  await queue.put(step)
212
 
213
  async def run_loop() -> None:
@@ -247,16 +344,19 @@ async def agent_run_stream(
247
  _neg_c = getattr(body, 'negative_constraints', '') or ''
248
  if _neg_c:
249
  context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
 
250
  result = await loop.run(
251
  goal=body.goal, context=context_str,
252
  max_steps=body.max_steps, on_step=step_cb,
253
  session_id=getattr(body, "session_id", "") or "",
254
  )
 
255
  await queue.put({
256
  '__done__': True,
257
  'result': result.get('output', ''),
258
  'engine': result.get('engine', 'fallback'),
259
  'success': result.get('success', False),
 
260
  })
261
  except Exception as exc:
262
  # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
@@ -270,7 +370,13 @@ async def agent_run_stream(
270
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
271
  await queue.put({'__error__': str(exc)})
272
 
273
- task = asyncio.create_task(run_loop())
 
 
 
 
 
 
274
  task.add_done_callback(_log_task_exc) # BUG-CB-1
275
  task_id = str(uuid.uuid4())
276
  # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
@@ -333,7 +439,13 @@ async def agent_run_stream(
333
  _err_detail = _ss(item.get('error', ''))
334
  _final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
335
  else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
336
- yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _final_res, 'engine': item.get('engine', 'fallback'), 'success': item.get('success', False)})}\n\n"
 
 
 
 
 
 
337
  break
338
  # S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
339
  _NARR_QUICK = {
@@ -456,7 +568,19 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
456
  'action': step_data.get('action', ''),
457
  'output': str(step_data.get('output', ''))[:400], # S577: 200→400
458
  })
459
- 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 "")
 
 
 
 
 
 
 
 
 
 
 
 
460
  if isinstance(result, dict):
461
  output_text = result.get('output', '') or ''
462
  engine_used = result.get('engine', 'unknown')
@@ -465,6 +589,7 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
465
  output_text = str(result)
466
  engine_used = 'unknown'
467
  errors_list = []
 
468
  return {
469
  'ok': bool(output_text and output_text.strip()),
470
  'success': bool(output_text and output_text.strip()), # alias compat frontend
@@ -474,6 +599,7 @@ async def reason_loop(body: ReasonLoopIn, role: AuthRole = Depends(require_role(
474
  'engine': engine_used,
475
  'errors': errors_list,
476
  'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
 
477
  }
478
  except Exception as e:
479
  _logger.error("[reason/loop] Error: %s", e)
@@ -605,7 +731,7 @@ async def _create_task_internal(task_id: str, goal: str, job: dict) -> dict:
605
 
606
 
607
  @router.post('/api/agent/tasks')
608
- async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
609
  """
610
  Crea o recupera un task agent.
611
 
@@ -613,11 +739,27 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
613
  il task viene ripristinato dallo store persistente invece di essere riavviato.
614
  Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
615
  """
 
616
  _prune_agent_tasks()
617
- task_id = body.taskId or str(uuid.uuid4())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
  # Already in memory → return immediately (normal path, includes S358 reconnect)
620
- if task_id in _agent_tasks:
621
  if task_id not in _task_ai_clients:
622
  _attach_byok_client(task_id, body.provider_credentials)
623
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
@@ -648,6 +790,7 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
648
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
649
  'persona': body.persona, # P17-F5: expertise persona hint
650
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
 
651
  'forbid_tools': _tool_policy.forbid_tools,
652
  'literal_response': _tool_policy.literal_response,
653
  'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
@@ -655,7 +798,6 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
655
  # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
656
  # e non raggiungono Supabase, checkpoint o buffer SSE.
657
  _attach_byok_client(task_id, body.provider_credentials)
658
-
659
  # BG-4: restore cross-session handoff context (async, non-blocking)
660
  if body.session_id:
661
  _hctx = await sb_restore_handoff_context(body.session_id)
@@ -666,6 +808,7 @@ async def create_agent_task(body: AgentTaskIn, role: AuthRole = Depends(require_
666
  asyncio.create_task(
667
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
668
  ).add_done_callback(_log_task_exc)
 
669
  # S361: gli strumenti speculativi sono consentiti solo quando il messaggio
670
  # utente non li vieta esplicitamente. La policy è fail-closed per questo task.
671
  if not _tool_policy.forbid_tools:
@@ -816,6 +959,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
816
  - Task era RUNNING → replay buffer parziale + evento task_interrupted.
817
  - Task non trovato → prova sb_restore_task prima di 404.
818
  """
 
819
  # S359: se task_id non è in memoria, prova il restore da Supabase
820
  if task_id not in _agent_tasks:
821
  restored = await sb_restore_task(task_id)
@@ -952,6 +1096,18 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
952
  yield "data: [DONE]\n\n"
953
  return
954
  # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
955
  _prune_loop_registry()
956
  reg_entry: dict = {
957
  'asyncio_task': None,
@@ -1401,6 +1557,7 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1401
  except Exception:
1402
  pass # S364: skeleton injection is optional
1403
 
 
1404
  result = await loop.run(
1405
  goal=task['goal'],
1406
  context=context_str,
@@ -1408,9 +1565,9 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1408
  on_step=step_cb,
1409
  session_id=task.get('session_id', '') or '',
1410
  allow_tools=not bool(task.get('forbid_tools', False)),
1411
- allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
1412
  )
1413
-
1414
  # ARTIFACT-CONTRACT-FALLBACK: alcuni provider restituiscono codice HTML
1415
  # nella risposta finale dopo aver narrato una scrittura, senza produrre
1416
  # il tool event `file_written`. Non committiamo mai una falsa positività:
@@ -1579,7 +1736,13 @@ async def stream_agent_task(task_id: str, request: Request, resume: int = 0, rol
1579
  except Exception as _exc:
1580
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
1581
 
1582
- reg_entry['asyncio_task'] = asyncio.create_task(run_loop())
 
 
 
 
 
 
1583
  reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
1584
 
1585
  try:
 
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, TypeAdapter, ValidationError, field_validator
46
  from typing import Literal
47
+
48
+ # P0 JSON contract: expose structured JSON only after schema validation.
49
+ _STRUCTURED_JSON_ADAPTER = TypeAdapter(dict[str, object] | list[object])
50
+
51
+ def _validated_response_json(output: object) -> dict[str, object] | list[object] | None:
52
+ if not isinstance(output, str) or not output.strip():
53
+ return None
54
+ try:
55
+ parsed = json.loads(output)
56
+ return _STRUCTURED_JSON_ADAPTER.validate_python(parsed)
57
+ except (json.JSONDecodeError, ValidationError, TypeError, ValueError):
58
+ return None
59
+
60
  from .state import (
61
  _agent_tasks, _task_ai_clients, _task_checkpoints, _loop_registry, _run_stream_tasks,
62
  _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry,
 
102
  router = APIRouter()
103
 
104
 
105
+ class _LLMAdmission:
106
+ """Bounded in-process admission control for LLM loops."""
107
+ def __init__(self) -> None:
108
+ self.max_concurrency = max(1, int(os.getenv('LLM_MAX_CONCURRENCY', '2')))
109
+ self.max_queue = max(0, int(os.getenv('LLM_MAX_QUEUE', '8')))
110
+ self._condition = asyncio.Condition()
111
+ self._active = 0
112
+ self._queued = 0
113
+
114
+ async def reserve(self) -> bool:
115
+ async with self._condition:
116
+ if self._active + self._queued >= self.max_concurrency + self.max_queue:
117
+ return False
118
+ self._queued += 1
119
+ return True
120
+
121
+ async def acquire(self) -> None:
122
+ async with self._condition:
123
+ try:
124
+ while self._active >= self.max_concurrency:
125
+ await self._condition.wait()
126
+ self._queued = max(0, self._queued - 1)
127
+ self._active += 1
128
+ except BaseException:
129
+ self._queued = max(0, self._queued - 1)
130
+ self._condition.notify(1)
131
+ raise
132
+
133
+ async def cancel(self) -> None:
134
+ async with self._condition:
135
+ self._queued = max(0, self._queued - 1)
136
+ self._condition.notify(1)
137
+
138
+ async def release(self) -> None:
139
+ async with self._condition:
140
+ self._active = max(0, self._active - 1)
141
+ self._condition.notify(1)
142
+
143
+ async def snapshot(self) -> dict[str, int]:
144
+ async with self._condition:
145
+ return {
146
+ 'active': self._active,
147
+ 'queued': self._queued,
148
+ 'max_concurrency': self.max_concurrency,
149
+ 'max_queue': self.max_queue,
150
+ }
151
+
152
+
153
+ _LLM_ADMISSION = _LLMAdmission()
154
+
155
+
156
+ async def _record_phase(phase: str, duration_ms: float = 0.0,
157
+ outcome: str = 'ok', error_class: str | None = None) -> None:
158
+ try:
159
+ from .agent_telemetry import record_runtime_phase
160
+ await record_runtime_phase(phase, duration_ms, outcome, error_class)
161
+ except Exception:
162
+ pass
163
+
164
+
165
+ def _queue_full_error() -> HTTPException:
166
+ return HTTPException(
167
+ status_code=429,
168
+ detail={
169
+ 'error': 'llm_queue_full',
170
+ 'message': 'Coda LLM satura. Riprova tra pochi secondi.',
171
+ 'retry_after_seconds': 5,
172
+ },
173
+ headers={'Retry-After': '5'},
174
+ )
175
+
176
+
177
+ @router.get('/api/agent/queue-status')
178
+ async def agent_queue_status(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
179
+ return await _LLM_ADMISSION.snapshot()
180
+
181
+
182
  def _attach_byok_client(task_id: str, credentials: object) -> None:
183
  """Create a task-scoped LLM client without persisting or logging credentials."""
184
  if credentials is None:
 
294
  body: ReasonLoopIn, request: Request,
295
  role: AuthRole = Depends(require_role(AuthRole.MACHINE)), # GAP-1-fix: era fail-open
296
  ):
297
+ await _record_phase('auth')
298
+ if not await _LLM_ADMISSION.reserve():
299
+ await _record_phase('queue', outcome='rejected', error_class='queue_full')
300
+ raise _queue_full_error()
301
+ await _record_phase('queue')
302
  async def generate():
303
  queue: asyncio.Queue = asyncio.Queue()
304
 
305
  async def step_cb(step: dict) -> None:
306
+ if str(step.get('action', '')).startswith(('tool', 'executor:')):
307
+ await _record_phase('tool', outcome='ok')
308
  await queue.put(step)
309
 
310
  async def run_loop() -> None:
 
344
  _neg_c = getattr(body, 'negative_constraints', '') or ''
345
  if _neg_c:
346
  context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip()
347
+ _provider_started = time.perf_counter()
348
  result = await loop.run(
349
  goal=body.goal, context=context_str,
350
  max_steps=body.max_steps, on_step=step_cb,
351
  session_id=getattr(body, "session_id", "") or "",
352
  )
353
+ await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
354
  await queue.put({
355
  '__done__': True,
356
  'result': result.get('output', ''),
357
  'engine': result.get('engine', 'fallback'),
358
  'success': result.get('success', False),
359
+ 'response_json': _validated_response_json(result.get('output', '')),
360
  })
361
  except Exception as exc:
362
  # GAP-A1: log incident in registry (fire-and-forget, non-blocking)
 
370
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
371
  await queue.put({'__error__': str(exc)})
372
 
373
+ async def _admitted_run_loop():
374
+ await _LLM_ADMISSION.acquire()
375
+ try:
376
+ await run_loop()
377
+ finally:
378
+ await _LLM_ADMISSION.release()
379
+ task = asyncio.create_task(_admitted_run_loop())
380
  task.add_done_callback(_log_task_exc) # BUG-CB-1
381
  task_id = str(uuid.uuid4())
382
  # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort
 
439
  _err_detail = _ss(item.get('error', ''))
440
  _final_res = (f"\u26a0\ufe0f {_err_detail}" if _err_detail
441
  else "\u26a0\ufe0f Tutti i provider AI sono temporaneamente indisponibili (rate limit). Riprova tra qualche minuto.")
442
+ _done_payload = {
443
+ 'type': 'task_done', 'taskId': task_id, 'result': _final_res,
444
+ 'engine': item.get('engine', 'fallback'), 'success': item.get('success', False),
445
+ }
446
+ if item.get('response_json') is not None:
447
+ _done_payload['response_json'] = _sanitize_for_json(item['response_json'])
448
+ yield f"data: {json.dumps(_done_payload)}\n\n"
449
  break
450
  # S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation
451
  _NARR_QUICK = {
 
568
  'action': step_data.get('action', ''),
569
  'output': str(step_data.get('output', ''))[:400], # S577: 200→400
570
  })
571
+ if not await _LLM_ADMISSION.reserve():
572
+ raise _queue_full_error()
573
+ await _LLM_ADMISSION.acquire()
574
+ _provider_started = time.perf_counter()
575
+ try:
576
+ result = await loop.run(goal=body.goal, context=context_str, max_steps=body.max_steps, on_step=_on_step, session_id=getattr(body, "session_id", "") or "")
577
+ except Exception as exc:
578
+ await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000, 'error', type(exc).__name__)
579
+ raise
580
+ else:
581
+ await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
582
+ finally:
583
+ await _LLM_ADMISSION.release()
584
  if isinstance(result, dict):
585
  output_text = result.get('output', '') or ''
586
  engine_used = result.get('engine', 'unknown')
 
589
  output_text = str(result)
590
  engine_used = 'unknown'
591
  errors_list = []
592
+ response_json = _validated_response_json(output_text)
593
  return {
594
  'ok': bool(output_text and output_text.strip()),
595
  'success': bool(output_text and output_text.strip()), # alias compat frontend
 
599
  'engine': engine_used,
600
  'errors': errors_list,
601
  'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend
602
+ **({'response_json': response_json} if response_json is not None else {}),
603
  }
604
  except Exception as e:
605
  _logger.error("[reason/loop] Error: %s", e)
 
731
 
732
 
733
  @router.post('/api/agent/tasks')
734
+ async def create_agent_task(body: AgentTaskIn, request: Request, role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
735
  """
736
  Crea o recupera un task agent.
737
 
 
739
  il task viene ripristinato dallo store persistente invece di essere riavviato.
740
  Questo preserva lo stato SUCCESS/ERROR precedente senza sprecare token.
741
  """
742
+ await _record_phase('auth')
743
  _prune_agent_tasks()
744
+ raw_idempotency_key = (body.idempotency_key or request.headers.get('Idempotency-Key') or '').strip()
745
+ if len(raw_idempotency_key) > 200:
746
+ raise HTTPException(400, detail={'error': 'invalid_idempotency_key'})
747
+ task_id = body.taskId or (
748
+ str(uuid.uuid5(uuid.NAMESPACE_URL, f'baida98-ai:{body.session_id or ""}:{raw_idempotency_key}'))
749
+ if raw_idempotency_key else str(uuid.uuid4())
750
+ )
751
+ _idempotency_claimed = False
752
+ if raw_idempotency_key and task_id not in _agent_tasks:
753
+ # Claim before the first await: concurrent retries in this worker see the
754
+ # same task immediately and cannot create a second provider loop/write.
755
+ _agent_tasks[task_id] = {
756
+ 'id': task_id, 'status': 'CREATING',
757
+ 'idempotency_key': raw_idempotency_key,
758
+ }
759
+ _idempotency_claimed = True
760
 
761
  # Already in memory → return immediately (normal path, includes S358 reconnect)
762
+ if task_id in _agent_tasks and not _idempotency_claimed:
763
  if task_id not in _task_ai_clients:
764
  _attach_byok_client(task_id, body.provider_credentials)
765
  return {'taskId': task_id, 'status': _agent_tasks[task_id]['status']}
 
790
  'resume_from_step': body.resume_from_step, # P16-F3: passo resume dalla coda
791
  'persona': body.persona, # P17-F5: expertise persona hint
792
  'session_id': body.session_id or '', # P17-F2: BB session key (normalize None→'')
793
+ 'idempotency_key': raw_idempotency_key,
794
  'forbid_tools': _tool_policy.forbid_tools,
795
  'literal_response': _tool_policy.literal_response,
796
  'allow_local_csv_conversion': _tool_policy.allow_local_csv_conversion,
 
798
  # Le credenziali BYOK restano in una mappa runtime separata dai metadata task
799
  # e non raggiungono Supabase, checkpoint o buffer SSE.
800
  _attach_byok_client(task_id, body.provider_credentials)
 
801
  # BG-4: restore cross-session handoff context (async, non-blocking)
802
  if body.session_id:
803
  _hctx = await sb_restore_handoff_context(body.session_id)
 
808
  asyncio.create_task(
809
  sb_upsert_task(task_id, body.goal, 'QUEUED', body.max_steps, body.context, created_at)
810
  ).add_done_callback(_log_task_exc)
811
+ await _record_phase('persistence')
812
  # S361: gli strumenti speculativi sono consentiti solo quando il messaggio
813
  # utente non li vieta esplicitamente. La policy è fail-closed per questo task.
814
  if not _tool_policy.forbid_tools:
 
959
  - Task era RUNNING → replay buffer parziale + evento task_interrupted.
960
  - Task non trovato → prova sb_restore_task prima di 404.
961
  """
962
+ await _record_phase('auth')
963
  # S359: se task_id non è in memoria, prova il restore da Supabase
964
  if task_id not in _agent_tasks:
965
  restored = await sb_restore_task(task_id)
 
1096
  yield "data: [DONE]\n\n"
1097
  return
1098
  # ── Case 3: nuova esecuzione ──────────────────────────────────────────────
1099
+ if not await _LLM_ADMISSION.reserve():
1100
+ await _record_phase('queue', outcome='rejected', error_class='queue_full')
1101
+ _agent_tasks[task_id]['status'] = 'RATE_LIMITED'
1102
+ _rate_limited = json.dumps(_sanitize_for_json({
1103
+ 'event': 'task_error', 'taskId': task_id,
1104
+ 'statusCode': 429, 'retryAfter': 5,
1105
+ 'error': 'llm_queue_full',
1106
+ }))
1107
+ yield f'data: {_rate_limited}\n\n'
1108
+ yield 'data: [DONE]\n\n'
1109
+ return
1110
+ await _record_phase('queue')
1111
  _prune_loop_registry()
1112
  reg_entry: dict = {
1113
  'asyncio_task': None,
 
1557
  except Exception:
1558
  pass # S364: skeleton injection is optional
1559
 
1560
+ _provider_started = time.perf_counter()
1561
  result = await loop.run(
1562
  goal=task['goal'],
1563
  context=context_str,
 
1565
  on_step=step_cb,
1566
  session_id=task.get('session_id', '') or '',
1567
  allow_tools=not bool(task.get('forbid_tools', False)),
1568
+ allow_local_csv_conversion=bool(task.get('allow_local_csv_conversion', False)),
1569
  )
1570
+ await _record_phase('provider', (time.perf_counter() - _provider_started) * 1000)
1571
  # ARTIFACT-CONTRACT-FALLBACK: alcuni provider restituiscono codice HTML
1572
  # nella risposta finale dopo aver narrato una scrittura, senza produrre
1573
  # il tool event `file_written`. Non committiamo mai una falsa positività:
 
1736
  except Exception as _exc:
1737
  _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001
1738
 
1739
+ async def _admitted_run_loop():
1740
+ await _LLM_ADMISSION.acquire()
1741
+ try:
1742
+ await run_loop()
1743
+ finally:
1744
+ await _LLM_ADMISSION.release()
1745
+ reg_entry['asyncio_task'] = asyncio.create_task(_admitted_run_loop())
1746
  reg_entry['asyncio_task'].add_done_callback(_log_task_exc) # BUG-CB-2
1747
 
1748
  try:
api/agent_telemetry.py CHANGED
@@ -34,6 +34,52 @@ _MAX_STORE = 500 # max entry totali prima del pruning
34
  # lo stesso store e si sovrascriverebbero. asyncio.Lock() è safe a livello di modulo
35
  # in Python 3.10+ (non richiede event loop attivo all'init del modulo).
36
  _store_lock = asyncio.Lock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  # ─── Store helpers ────────────────────────────────────────────────────────────
39
 
@@ -115,6 +161,11 @@ class TelemetrySyncBody(BaseModel):
115
 
116
  # ─── Endpoints ────────────────────────────────────────────────────────────────
117
 
 
 
 
 
 
118
  @router.post("/api/agent-telemetry/sync")
119
  async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
120
  """
 
34
  # lo stesso store e si sovrascriverebbero. asyncio.Lock() è safe a livello di modulo
35
  # in Python 3.10+ (non richiede event loop attivo all'init del modulo).
36
  _store_lock = asyncio.Lock()
37
+ _RUNTIME_PHASES = ('auth', 'queue', 'provider', 'tool', 'persistence')
38
+ _RUNTIME_MAX_SAMPLES = 200
39
+ _runtime_lock = asyncio.Lock()
40
+ _runtime_store: dict[str, dict] = {
41
+ phase: {'samples_ms': [], 'ok': 0, 'errors': {}}
42
+ for phase in _RUNTIME_PHASES
43
+ }
44
+
45
+
46
+ async def record_runtime_phase(phase: str, duration_ms: float = 0.0,
47
+ outcome: str = 'ok', error_class: str | None = None) -> None:
48
+ if phase not in _runtime_store:
49
+ return
50
+ async with _runtime_lock:
51
+ bucket = _runtime_store[phase]
52
+ samples = bucket['samples_ms']
53
+ samples.append(max(0.0, round(float(duration_ms), 2)))
54
+ if len(samples) > _RUNTIME_MAX_SAMPLES:
55
+ del samples[:-_RUNTIME_MAX_SAMPLES]
56
+ if outcome == 'ok':
57
+ bucket['ok'] += 1
58
+ else:
59
+ key = error_class or outcome or 'unknown'
60
+ bucket['errors'][key] = bucket['errors'].get(key, 0) + 1
61
+
62
+
63
+ def _percentile(samples: list[float], percentile: float) -> float:
64
+ if not samples:
65
+ return 0.0
66
+ ordered = sorted(samples)
67
+ index = min(len(ordered) - 1, int(round((percentile / 100) * (len(ordered) - 1))))
68
+ return ordered[index]
69
+
70
+
71
+ async def runtime_snapshot() -> dict[str, dict]:
72
+ async with _runtime_lock:
73
+ return {
74
+ phase: {
75
+ 'count': len(bucket['samples_ms']),
76
+ 'ok': bucket['ok'],
77
+ 'errors': dict(bucket['errors']),
78
+ 'p50_ms': _percentile(bucket['samples_ms'], 50),
79
+ 'p95_ms': _percentile(bucket['samples_ms'], 95),
80
+ }
81
+ for phase, bucket in _runtime_store.items()
82
+ }
83
 
84
  # ─── Store helpers ────────────────────────────────────────────────────────────
85
 
 
161
 
162
  # ─── Endpoints ────────────────────────────────────────────────────────────────
163
 
164
+ @router.get("/api/agent-telemetry/runtime")
165
+ async def get_runtime_telemetry() -> JSONResponse:
166
+ return JSONResponse({'ok': True, 'phases': await runtime_snapshot(), 'server_ts': int(time.time() * 1000)})
167
+
168
+
169
  @router.post("/api/agent-telemetry/sync")
170
  async def post_agent_telemetry(body: TelemetrySyncBody) -> JSONResponse:
171
  """
api/auth_managed.py CHANGED
@@ -11,7 +11,7 @@ I CLIENT_ID/SECRET vanno nei secret HF Spaces (mai nel codice).
11
 
12
  Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) — richiede cryptography>=42.
13
  """
14
- import os, time, secrets, json, logging, asyncio
15
  from typing import Optional
16
  from fastapi import APIRouter, Depends, Request, HTTPException
17
  from .auth_guard import require_role, AuthRole
@@ -21,6 +21,29 @@ import httpx
21
  _logger = logging.getLogger('api.auth_managed')
22
  router = APIRouter()
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # ── Fernet encryption setup ──────────────────────────────────────────────────
25
  # Salt statico pubblico: accettabile per chiave macchina (non password utente).
26
  # Il VAULT_KEY è il segreto; il salt previene rainbow-table cross-application.
 
11
 
12
  Cifratura token: Fernet(PBKDF2HMAC-SHA256, 260k iter, salt fisso) — richiede cryptography>=42.
13
  """
14
+ import os, time, secrets, json, logging, asyncio, hashlib
15
  from typing import Optional
16
  from fastapi import APIRouter, Depends, Request, HTTPException
17
  from .auth_guard import require_role, AuthRole
 
21
  _logger = logging.getLogger('api.auth_managed')
22
  router = APIRouter()
23
 
24
+ _DIAGNOSTIC_SECRET_NAMES = (
25
+ 'ADMIN_DIAGNOSTICS_TOKEN', 'INTERNAL_TOKEN', 'PUBLIC_API_TOKEN',
26
+ 'PRIVATE_STATE_INTERNAL_TOKEN', 'SUPABASE_SERVICE_ROLE_KEY',
27
+ 'SUPABASE_SERVICE_ROLE_KEY_B', 'GROQ_API_KEY', 'HF_TOKEN',
28
+ 'GEMINI_API_KEY', 'CEREBRAS_API_KEY', 'SAMBANOVA_API_KEY',
29
+ 'OPENROUTER_API_KEY', 'OPENROUTER_API_KEY_B', 'OPENROUTER_API_KEY_C',
30
+ 'OPENROUTER_PROFILES_JSON', 'NVIDIA_API_KEY', 'NVIDIA_API_KEY_B',
31
+ )
32
+
33
+
34
+ def _secret_fingerprint(name: str) -> dict[str, object]:
35
+ value = os.getenv(name, '').strip()
36
+ return {
37
+ 'configured': bool(value),
38
+ 'length': len(value),
39
+ 'sha256': hashlib.sha256(value.encode('utf-8')).hexdigest()[:16] if value else None,
40
+ }
41
+
42
+
43
+ @router.get('/api/admin/secret-fingerprint')
44
+ async def secret_fingerprint(role: AuthRole = Depends(require_role(AuthRole.MACHINE))):
45
+ return {'secrets': {name: _secret_fingerprint(name) for name in _DIAGNOSTIC_SECRET_NAMES}}
46
+
47
  # ── Fernet encryption setup ──────────────────────────────────────────────────
48
  # Salt statico pubblico: accettabile per chiave macchina (non password utente).
49
  # Il VAULT_KEY è il segreto; il salt previene rainbow-table cross-application.
api/state.py CHANGED
@@ -319,6 +319,7 @@ class AgentTaskIn(BaseModel):
319
  context: list[dict] = []
320
  max_steps: int = 8
321
  taskId: Optional[str] = None
 
322
  project_context: str = ""
323
  learning_hints: list[str] = []
324
  session_id: Optional[str] = None
 
319
  context: list[dict] = []
320
  max_steps: int = 8
321
  taskId: Optional[str] = None
322
+ idempotency_key: Optional[str] = None
323
  project_context: str = ""
324
  learning_hints: list[str] = []
325
  session_id: Optional[str] = None
api/webhook.py CHANGED
@@ -1,6 +1,27 @@
1
  """backend/api/webhook.py — Webhook inbound + Public REST API (S354)."""
2
  import os, asyncio
3
  from typing import Optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from fastapi import APIRouter, Depends, HTTPException, Request
5
  from pydantic import BaseModel, ValidationError, field_validator
6
  from .state import _get_mem_manager, _get_executor, _get_planner
@@ -271,7 +292,7 @@ async def inbound_webhook(webhook_token: str, request: Request):
271
  loop.run(goal=body.goal, context=context_str,
272
  max_steps=body.max_steps, on_step=lambda _s: None,
273
  allow_tools=not _task_policy.forbid_tools),
274
- timeout=float(os.getenv('AGENT_STREAM_TIMEOUT', '120')),
275
  )
276
  except asyncio.TimeoutError:
277
  asyncio.ensure_future(_tg_error(_wh_task_id, body.goal, "Timeout: task terminato dopo 120s"))
@@ -306,7 +327,7 @@ async def public_chat(payload: PublicChatPayload, request: Request):
306
  S292 — API REST pubblica autenticata per integrazioni esterne.
307
  Auth: Authorization: Bearer <PUBLIC_API_TOKEN>
308
  """
309
- _expected = (os.getenv('PUBLIC_API_TOKEN') or os.getenv('INTERNAL_TOKEN', '')).strip()
310
  if not _expected:
311
  raise HTTPException(
312
  status_code=503,
@@ -324,10 +345,16 @@ async def public_chat(payload: PublicChatPayload, request: Request):
324
  'conversation_id': payload.conversation_id, 'steps': 0,
325
  }
326
 
 
327
  try:
 
 
 
 
 
 
328
  from agents.unified_loop import UnifiedAgentLoop
329
- from models.ai_client import AIClient
330
- client = AIClient()
331
  try:
332
  from agents.critic import Critic
333
  from agents.response_verifier import ResponseVerifier
@@ -347,7 +374,7 @@ async def public_chat(payload: PublicChatPayload, request: Request):
347
  loop.run(goal=payload.message, context='',
348
  max_steps=payload.max_steps, on_step=lambda _s: None,
349
  allow_tools=not _task_policy.forbid_tools),
350
- timeout=float(os.getenv('AGENT_STREAM_TIMEOUT', '120')),
351
  )
352
  except asyncio.TimeoutError:
353
  asyncio.ensure_future(_tg_error(_pc_task_id, payload.message, "Timeout dopo 120s"))
@@ -368,3 +395,6 @@ async def public_chat(payload: PublicChatPayload, request: Request):
368
  raise
369
  except Exception as exc:
370
  raise HTTPException(status_code=500, detail=f'Errore agente: {exc}')
 
 
 
 
1
  """backend/api/webhook.py — Webhook inbound + Public REST API (S354)."""
2
  import os, asyncio
3
  from typing import Optional
4
+
5
+ # Public agent execution is deliberately bounded: each agent run fans out to
6
+ # several provider calls, so unbounded HTTP concurrency can exhaust a 1-worker
7
+ # Space and cause the peer to reset connections. The limit is configurable but
8
+ # capped to keep deployments safe.
9
+ _PUBLIC_AGENT_CONCURRENCY = max(1, min(int(os.getenv("PUBLIC_AGENT_CONCURRENCY", "3")), 8))
10
+ _PUBLIC_AGENT_QUEUE_TIMEOUT = max(5.0, float(os.getenv("PUBLIC_AGENT_QUEUE_TIMEOUT", "90")))
11
+ _PUBLIC_AGENT_TIMEOUT = max(15.0, float(os.getenv("PUBLIC_AGENT_TIMEOUT", "90")))
12
+ _public_agent_slots = asyncio.Semaphore(_PUBLIC_AGENT_CONCURRENCY)
13
+ _public_agent_client = None
14
+ _public_agent_client_lock = asyncio.Lock()
15
+
16
+ async def _get_public_agent_client():
17
+ """Create AIClient once per worker, off the event loop, and reuse it."""
18
+ global _public_agent_client
19
+ if _public_agent_client is None:
20
+ async with _public_agent_client_lock:
21
+ if _public_agent_client is None:
22
+ from models.ai_client import AIClient
23
+ _public_agent_client = await asyncio.to_thread(AIClient, None, True)
24
+ return _public_agent_client
25
  from fastapi import APIRouter, Depends, HTTPException, Request
26
  from pydantic import BaseModel, ValidationError, field_validator
27
  from .state import _get_mem_manager, _get_executor, _get_planner
 
292
  loop.run(goal=body.goal, context=context_str,
293
  max_steps=body.max_steps, on_step=lambda _s: None,
294
  allow_tools=not _task_policy.forbid_tools),
295
+ timeout=min(float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), _PUBLIC_AGENT_TIMEOUT),
296
  )
297
  except asyncio.TimeoutError:
298
  asyncio.ensure_future(_tg_error(_wh_task_id, body.goal, "Timeout: task terminato dopo 120s"))
 
327
  S292 — API REST pubblica autenticata per integrazioni esterne.
328
  Auth: Authorization: Bearer <PUBLIC_API_TOKEN>
329
  """
330
+ _expected = (os.getenv('PUBLIC_API_TOKEN') or os.getenv('INTERNAL_TOKEN')).strip()
331
  if not _expected:
332
  raise HTTPException(
333
  status_code=503,
 
345
  'conversation_id': payload.conversation_id, 'steps': 0,
346
  }
347
 
348
+ acquired = False
349
  try:
350
+ try:
351
+ await asyncio.wait_for(_public_agent_slots.acquire(), timeout=_PUBLIC_AGENT_QUEUE_TIMEOUT)
352
+ acquired = True
353
+ except asyncio.TimeoutError as exc:
354
+ raise HTTPException(429, detail="Server occupato: riprova tra poco.", headers={"Retry-After": "5"}) from exc
355
+
356
  from agents.unified_loop import UnifiedAgentLoop
357
+ client = await _get_public_agent_client()
 
358
  try:
359
  from agents.critic import Critic
360
  from agents.response_verifier import ResponseVerifier
 
374
  loop.run(goal=payload.message, context='',
375
  max_steps=payload.max_steps, on_step=lambda _s: None,
376
  allow_tools=not _task_policy.forbid_tools),
377
+ timeout=min(float(os.getenv('AGENT_STREAM_TIMEOUT', '120')), _PUBLIC_AGENT_TIMEOUT),
378
  )
379
  except asyncio.TimeoutError:
380
  asyncio.ensure_future(_tg_error(_pc_task_id, payload.message, "Timeout dopo 120s"))
 
395
  raise
396
  except Exception as exc:
397
  raise HTTPException(status_code=500, detail=f'Errore agente: {exc}')
398
+ finally:
399
+ if acquired:
400
+ _public_agent_slots.release()
models/ai_client.py CHANGED
@@ -74,9 +74,10 @@ _PROVIDER_DEFS = [
74
 
75
 
76
  class AIClient:
77
- def __init__(self, byok_credentials: dict[str, list[str]] | None = None) -> None:
78
  # Le chiavi BYOK appartengono a un singolo task e vivono solo in questa
79
  # istanza: non vengono scritte in env, Supabase, cache semantica o log.
 
80
  self._byok_providers = self._providers_from_byok(byok_credentials or {})
81
  # I profili BYOK precedono i provider runtime: la stessa API conserva
82
  # comunque il fallback server-side in caso di quota o errore upstream.
@@ -447,6 +448,11 @@ class AIClient:
447
  # profili condividano quota e client, mentre provider diversi restano
448
  # disponibili come ensemble/fallback.
449
  pool = self._execution_pool(pool, primary_purpose)
 
 
 
 
 
450
  results = []
451
  if pool:
452
  tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]
 
74
 
75
 
76
  class AIClient:
77
+ def __init__(self, byok_credentials: dict[str, list[str]] | None = None, single_provider: bool = False) -> None:
78
  # Le chiavi BYOK appartengono a un singolo task e vivono solo in questa
79
  # istanza: non vengono scritte in env, Supabase, cache semantica o log.
80
+ self._single_provider = single_provider
81
  self._byok_providers = self._providers_from_byok(byok_credentials or {})
82
  # I profili BYOK precedono i provider runtime: la stessa API conserva
83
  # comunque il fallback server-side in caso di quota o errore upstream.
 
448
  # profili condividano quota e client, mentre provider diversi restano
449
  # disponibili come ensemble/fallback.
450
  pool = self._execution_pool(pool, primary_purpose)
451
+ # Public API requests use one provider at a time. The previous ensemble
452
+ # fan-out multiplied outbound connections per request and exhausted the
453
+ # small Space under concurrent load. Fallback remains available below.
454
+ if self._single_provider:
455
+ pool = pool[:1]
456
  results = []
457
  if pool:
458
  tasks = [self._fetch_one(p, messages, temperature, max_tokens) for p in pool]