Spaces:
Running
Running
sync: 191 file da Baida98/AI@5943984b (2026-08-29 20:27 UTC)
#146
by Baida07 - opened
- Dockerfile +1 -1
- api/agent.py +174 -11
- api/agent_telemetry.py +51 -0
- api/auth_managed.py +24 -1
- api/state.py +1 -0
- api/webhook.py +35 -5
- models/ai_client.py +7 -1
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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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 |
-
|
| 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]
|