Spaces:
Configuration error
Configuration error
| """agent_loop_routes.py — Route SSE legacy, persona helpers, reason/unified/loop, agent-kernel. | |
| Estratto da agent.py (split 2026-06-30). | |
| Route coperte: | |
| POST /run_loop (deprecated 410) | |
| POST /api/agent/run-stream (SSE legacy loop) | |
| POST /api/reason/loop | |
| POST /api/unified/loop | |
| GET /api/agent-kernel/status | |
| POST /api/agent-kernel/dispatch | |
| """ | |
| from __future__ import annotations | |
| import os, asyncio, json, uuid, time, re | |
| import re as _re_persona | |
| from fastapi import APIRouter, HTTPException, Request, Body | |
| router = APIRouter() | |
| from fastapi.responses import StreamingResponse | |
| from pydantic import BaseModel, field_validator | |
| from typing import Literal | |
| from .state import ( | |
| _agent_tasks, _task_checkpoints, _loop_registry, _run_stream_tasks, | |
| _prune_agent_tasks, _prune_checkpoints, _prune_loop_registry, | |
| _get_mem_manager, _get_mem_manager_async, _get_executor, _get_planner, _get_ai_client, | |
| ReasonLoopIn, AgentTaskIn, | |
| write_ahead_task_created, | |
| ) | |
| from .speculative import fire_speculative_tools | |
| try: | |
| from .quality_guardian import run_quality_check as _run_quality_check | |
| except Exception as _qg_err: | |
| import logging as _qg_log; _qg_log.getLogger(__name__).warning("[routes] quality_guardian import failed: %s", _qg_err) | |
| _run_quality_check = None | |
| import logging | |
| _logger = logging.getLogger("api.agent") | |
| from .persistence import ( | |
| sb_upsert_task, sb_update_status, sb_append_event, | |
| sb_restore_task, sb_get_events, sb_delete_task_events, | |
| sb_list_tasks, sb_save_checkpoint, sb_get_checkpoint, | |
| sb_restore_handoff_context, sb_upsert_handoff, sb_delete_handoff, | |
| ) | |
| from ._agent_helpers import ( | |
| _RE_SURROGATES, _ss, _log_task_exc, | |
| _PERSONA_KEYWORD_MAP, _PERSONA_CLIENT_CACHE, | |
| _build_persona_kw_map, _classify_persona_server, _get_persona_llm_client, | |
| ) | |
| async def run_loop_removed(): | |
| """S352: endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream.""" | |
| raise HTTPException( | |
| status_code=410, | |
| detail={ | |
| "error": "Gone", | |
| "message": "Endpoint rimosso. Usare POST /api/agent/tasks + GET /api/agent/tasks/{id}/stream", | |
| "migration": "/api/agent/tasks", | |
| }, | |
| ) | |
| # ── SSE run-stream ──────────────────────────────────────────────────────────── | |
| async def agent_run_stream(body: ReasonLoopIn, request: Request): | |
| # S-BENCH: auth guard — consistente con /api/exec e /api/execute-shell | |
| _itok = os.getenv('INTERNAL_TOKEN', '') | |
| if _itok and request.headers.get('X-Internal-Token') != _itok: | |
| raise HTTPException(401, 'Unauthorized') | |
| async def generate(): | |
| queue: asyncio.Queue = asyncio.Queue() | |
| async def step_cb(step: dict) -> None: | |
| await queue.put(step) | |
| async def run_loop() -> None: | |
| try: | |
| from agents.unified_loop import UnifiedAgentLoop | |
| # S388: usa singleton _get_ai_client() — nessuna re-istanziazione OpenAI() per request | |
| client = _get_ai_client() | |
| try: | |
| from agents.critic import Critic | |
| from agents.response_verifier import ResponseVerifier | |
| _critic = Critic(llm_client=client) | |
| _verifier = ResponseVerifier() | |
| except Exception as _cv_err: | |
| _logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err) | |
| _critic = None | |
| _verifier = None | |
| # Resume automatico: inietta contesto checkpoint se disponibile (Case 2.5 fall-through) | |
| _resume_ctx = getattr(body, '_resume_context', None) | |
| _resume_max = getattr(body, '_resume_max_steps', None) or body.max_steps | |
| context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else '' | |
| # Bug-5-FIX: resume context iniettato DOPO che context_str è definito (era NameError) | |
| if _resume_ctx: | |
| context_str = f"[RIPRESA AUTOMATICA]\n{_resume_ctx}\n\n{context_str}".strip() | |
| loop = UnifiedAgentLoop( | |
| llm_client=client, critic=_critic, verifier=_verifier, | |
| memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(), | |
| ) | |
| # S456-X5: prepend project context (projectMemory.getContext() dal frontend) | |
| if body.project_context: | |
| context_str = f"[PROGETTO CORRENTE]\n{body.project_context}\n\n{context_str}".strip() | |
| # S456-X4: inject top failure patterns appresi dal selfLearning frontend | |
| if body.learning_hints: | |
| # S591: learning_hints[:3]→[:5] — più pattern appresi nel context | |
| hints_str = "\n".join(f"- {h}" for h in body.learning_hints[:5]) | |
| context_str = f"{context_str}\n\n[PATTERN DI ERRORE APPRESI]\n{hints_str}".strip() | |
| # P35: vincoli negativi dal frontend (agentConstraints.ts → VFS /.agent/constraints.json) | |
| _neg_c = getattr(body, 'negative_constraints', '') or '' | |
| if _neg_c: | |
| context_str = f"[VINCOLI OPERATIVI APPRESI — NON VIOLARE]\n{_neg_c}\n\n{context_str}".strip() | |
| result = await loop.run( | |
| goal=body.goal, context=context_str, | |
| max_steps=body.max_steps, on_step=step_cb, | |
| session_id=getattr(body, "session_id", "") or "", | |
| ) | |
| await queue.put({ | |
| '__done__': True, | |
| 'result': result.get('output', ''), | |
| 'engine': result.get('engine', 'fallback'), | |
| 'success': result.get('success', False), | |
| }) | |
| except Exception as exc: | |
| # GAP-A1: log incident in registry (fire-and-forget, non-blocking) | |
| try: | |
| from api.incident_registry import log_incident as _log_inc | |
| asyncio.create_task(_log_inc( | |
| task_id=body.goal[:32].replace(' ', '_'), | |
| goal=body.goal, error=str(exc), source="agent", | |
| )).add_done_callback(_log_task_exc) | |
| except Exception as _exc: | |
| _logger.debug("[agent] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| await queue.put({'__error__': str(exc)}) | |
| task = asyncio.create_task(run_loop()) | |
| task_id = body.goal[:32].replace(' ', '_') | |
| # ABORT-1: registra task + queue per permettere cancellazione via POST /api/agent/abort | |
| _run_stream_tasks[task_id] = {"task": task, "queue": queue} | |
| yield "retry: 3000\n\n" | |
| yield f"data: {json.dumps({'type': 'task_start', 'taskId': task_id})}\n\n" | |
| # S386: fast-fail — se tutti i provider sono down (heartbeat lo sa già), | |
| # non aspettare 120s di tentativi: rispondi subito con errore chiaro. | |
| try: | |
| from api.state import _heartbeat_state | |
| _providers = _heartbeat_state.get("providers", []) | |
| if _providers and not any(p.get("ok") for p in _providers): | |
| task.cancel() | |
| _names = ", ".join(p["name"] for p in _providers) | |
| 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 | |
| yield "data: [DONE]\n\n" | |
| return | |
| except Exception as _hb_err: | |
| _logger.debug("[routes] heartbeat skip silenced: %s", type(_hb_err).__name__) # non inizializzato, prosegui normalmente | |
| # S386: timeout ridotto 120→60s — risposta entro 1 minuto o errore esplicito | |
| timeout_secs = float(os.getenv('AGENT_STREAM_TIMEOUT', '60')) | |
| heartbeat_secs = 15.0 | |
| elapsed = 0.0 | |
| try: | |
| while True: | |
| try: | |
| item = await asyncio.wait_for(queue.get(), timeout=heartbeat_secs) | |
| elapsed = 0.0 | |
| except asyncio.TimeoutError: | |
| elapsed += heartbeat_secs | |
| if elapsed >= timeout_secs: | |
| yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': 'timeout', 'abort_source': 'stream_timeout'})}\n\n" # MX18-ABORT: timeout → task_aborted | |
| break | |
| yield 'data: {"type":"ping"}\n\n' | |
| continue | |
| # ABORT-2: segnale abort dall'endpoint POST /api/agent/abort | |
| if "__abort__" in item: | |
| _ar = item.get('abort_reason', 'user_stop') # MX18-ABORT: dynamic reason | |
| _src = item.get('abort_source', 'backend_abort_queue') | |
| yield f"data: {json.dumps({'type': 'task_aborted', 'taskId': task_id, 'abort_reason': _ar, 'abort_source': _src})}\n\n" # MX16+MX18-ABORT | |
| break | |
| if '__error__' in item: | |
| yield f"data: {json.dumps({'type': 'task_error', 'taskId': task_id, 'error': _ss(item['__error__'])})}\n\n" | |
| break | |
| # S420: streaming token — emetti subito al frontend senza accumulare | |
| if item.get('action') == 'text_chunk': | |
| yield f"data: {json.dumps({'type': 'text_chunk', 'token': _ss(item.get('token', '')), 'taskId': task_id})}\n\n" | |
| continue | |
| # S758-P4.1: tool_use — chip pre-esecuzione (agent_run_stream path) | |
| _rs_act = item.get('action', '') | |
| _rs_st = item.get('status', '') | |
| if ((_rs_act == 'tool_start' and _rs_st == 'running') or | |
| (_rs_act.startswith('executor:') and _rs_st == 'started')): | |
| _rs_tool = _rs_act.replace('executor:', '') if _rs_act.startswith('executor:') else _rs_act | |
| yield f"data: {json.dumps({'type': 'tool_use', 'taskId': task_id, 'tool': _rs_tool, 'name': _rs_tool, 'label': item.get('title', _rs_tool.replace('_', ' ').capitalize())})}\n\n" | |
| if '__done__' in item: | |
| yield f"data: {json.dumps({'type': 'task_done', 'taskId': task_id, 'result': _ss(item['result']), 'engine': item['engine'], 'success': item['success']})}\n\n" | |
| break | |
| # S393 Priority 1: Narrative Streaming — arricchisce step_done con explanation | |
| _NARR_QUICK = { | |
| 'llm': 'Elaborazione risposta AI', | |
| 'direct_tools': 'Strumenti diretti', | |
| 'web_search': 'Ricerca web', 'get_weather': 'Dati meteo', | |
| 'read_page': 'Lettura pagina', 'calculate': 'Calcolo matematico', | |
| 'generate_image': 'Generazione immagine AI', | |
| 'execution_validator_fix': 'Auto-correzione codice (S393)', | |
| 'tool_governor_skip': 'Tool già eseguito — risultato riutilizzato', | |
| # S661: label narrative per tool aggiunti in S648-S659 — prima usavano | |
| # _act_q.replace('_',' ').capitalize() → "Apply patch", "Call api" (generico) | |
| 'apply_patch': 'Applico patch al file…', | |
| 'call_api': 'Chiamo API REST…', | |
| 'send_email': 'Invio email…', | |
| 'create_pdf': 'Genero documento PDF…', | |
| 'web_research': 'Ricerca multi-fonte…', | |
| 'write_file': 'Scrivo file…', | |
| 'read_file': 'Leggo file…', | |
| 'execute_shell': 'Eseguo comando shell…', | |
| 'analyze_image': 'Analizzo immagine…', | |
| 'run_python': 'Eseguo Python (Pyodide)…', | |
| # S-GAP1: narrative fasi strategiche | |
| 'plan': 'Analizzo la richiesta e preparo un piano di esecuzione…', | |
| 'reflective_debug': 'Ho incontrato un ostacolo — ricalcolo una strategia più efficiente…', | |
| 'fallback': 'Adotto un approccio alternativo per completare il task…', | |
| 'smolagents': 'Orchestro gli strumenti necessari…', | |
| } | |
| _act_q = item.get('action', '') | |
| if 'explanation' not in item: | |
| item['explanation'] = _NARR_QUICK.get(_act_q, _act_q.replace('_', ' ').capitalize()) | |
| if 'title' not in item: | |
| item['title'] = item['explanation'] | |
| # S403: SSE Visibility Guard — classifica ogni step event: | |
| # "internal" → mai visibile (pipeline internals: planner, llm, reflection) | |
| # "progress" → visibile come progress card (tool reali, auto-fix) | |
| # "debug" → visibile solo in dev mode (direct_tools, fast_path) | |
| # Il frontend filtra per visibility — solo "progress" mostrato all'utente. | |
| _STEP_VISIBILITY: dict[str, str] = { | |
| # Internal pipeline — never shown to user | |
| 'plan': 'progress', # S-GAP1 | |
| 'llm': 'internal', | |
| 'smolagents': 'internal', | |
| 'fallback': 'progress', # S-GAP1 | |
| 'reflective_debug': 'progress', # S-GAP1 | |
| 'fast_path': 'internal', | |
| 'executor': 'internal', | |
| # Progress — shown as step cards (user-visible) | |
| 'tool_start': 'progress', | |
| 'execution_validator_fix': 'progress', | |
| 'goal_verifier': 'progress', | |
| 'web_search': 'progress', | |
| 'get_weather': 'progress', | |
| 'read_page': 'progress', | |
| 'calculate': 'progress', | |
| 'generate_image': 'progress', | |
| 'run_python': 'progress', | |
| 'tool_governor_skip': 'progress', | |
| # S660: tool aggiunti in S648-S659 mancanti da _STEP_VISIBILITY → | |
| # fallback rule: _act_q.startswith('tool_') era False per questi → | |
| # classificati 'debug' → nascosti all'utente durante esecuzione. | |
| 'apply_patch': 'progress', | |
| 'call_api': 'progress', | |
| 'send_email': 'progress', | |
| 'create_pdf': 'progress', | |
| 'web_research': 'progress', | |
| 'write_file': 'progress', | |
| 'read_file': 'progress', | |
| 'execute_shell': 'progress', | |
| 'analyze_image': 'progress', | |
| # Debug — shown only when devMode active | |
| 'direct_tools': 'debug', | |
| # S-LOOP2: fase esecuzione avanzata — visibili come progress card | |
| 'reasoning_core': 'progress', # S-LOOP2: ReasoningCore multi-step | |
| 'browser_verifier': 'progress', # S-LOOP2: Browser Goal Verification live | |
| } | |
| # Fallback: azioni sconosciute con "tool_" prefix → progress; resto → debug | |
| _vis = _STEP_VISIBILITY.get(_act_q) | |
| if _vis is None: | |
| _vis = 'progress' if _act_q.startswith('tool_') or _act_q.startswith('executor:') else 'debug' | |
| item['visibility'] = _vis | |
| yield f"data: {json.dumps({'type': 'step_done', 'step': item, 'taskId': task_id})}\n\n" | |
| finally: | |
| task.cancel() | |
| # ABORT-3: cleanup registro — libera memoria e impedisce abort su task già terminati | |
| _run_stream_tasks.pop(task_id, None) | |
| yield "data: [DONE]\n\n" | |
| return StreamingResponse(generate(), media_type="text/event-stream", | |
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) | |
| # ── Reason loop / Unified loop ───────────────────────────────────────────────── | |
| async def reason_loop(body: ReasonLoopIn): | |
| try: | |
| from agents.unified_loop import UnifiedAgentLoop | |
| # S388: singleton — riusa il client già inizializzato | |
| client = _get_ai_client() | |
| try: | |
| from agents.critic import Critic | |
| from agents.response_verifier import ResponseVerifier | |
| _critic = Critic(llm_client=client) | |
| _verifier = ResponseVerifier() | |
| except Exception as _cv_err: | |
| _logger.warning("[routes] Critic/Verifier init failed: %s", _cv_err) | |
| _critic = None | |
| _verifier = None | |
| loop = UnifiedAgentLoop( | |
| llm_client=client, critic=_critic, verifier=_verifier, | |
| memory=await _get_mem_manager_async(), executor=_get_executor(), planner=_get_planner(), | |
| ) | |
| context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else '' | |
| # N-2-FIX: accumula step intermedi tramite on_step — inclusi nel response JSON per debug frontend | |
| _steps_log: list[dict] = [] | |
| async def _on_step(step_data: dict) -> None: | |
| _steps_log.append({ | |
| 'action': step_data.get('action', ''), | |
| 'output': str(step_data.get('output', ''))[:400], # S577: 200→400 | |
| }) | |
| 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 "") | |
| if isinstance(result, dict): | |
| output_text = result.get('output', '') or result.get('answer', '') or '' | |
| engine_used = result.get('engine', 'ambiguity-gate' if result.get('answer') else 'unknown') | |
| errors_list = result.get('errors', []) | |
| else: | |
| output_text = str(result) | |
| engine_used = 'unknown' | |
| errors_list = [] | |
| return { | |
| 'ok': bool(output_text and output_text.strip()), | |
| 'success': bool(output_text and output_text.strip()), # alias compat frontend | |
| 'output': output_text, # alias compat frontend | |
| 'result': output_text, | |
| 'source': 'backend_loop', | |
| 'engine': engine_used, | |
| 'errors': errors_list, | |
| 'steps': _steps_log, # N-2-FIX: step intermedi per debug/telemetria frontend | |
| } | |
| except Exception as e: | |
| _logger.error("[reason/loop] Error: %s", e) | |
| return { | |
| 'ok': False, | |
| 'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.', | |
| 'source': 'fallback', | |
| 'steps': [], | |
| } | |
| async def unified_loop(body: ReasonLoopIn): | |
| """Alias di /api/reason/loop — compatibilità con tutte le versioni frontend.""" | |
| return await reason_loop(body) | |
| # ── Agent kernel ─────────────────────────────────────────────────────────────── | |
| async def agent_kernel_status(): | |
| gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '') | |
| return { | |
| 'dispatch_available': bool(gh_token), | |
| 'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml', | |
| 'mobile_url': 'https://github.com/Baida98/AI/actions', | |
| 'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN', 'NVIDIA_API_KEY'], | |
| 'usage': 'Vai su GitHub Actions → Agent Kernel — no PC → Run workflow → inserisci il goal', | |
| } | |
| # S442-FIX3: modello Pydantic per agent_kernel_dispatch. | |
| # Prima: body: dict grezzo → mode non validato, goal controllato solo dopo estrazione. | |
| # Ora: validazione in ingresso → 422 chiaro invece di 500 a runtime. | |
| class AgentKernelDispatchIn(BaseModel): | |
| goal: str | |
| mode: Literal["plan", "execute", "analyze"] = "plan" | |
| def validate_goal(cls, v: object) -> str: | |
| if not isinstance(v, str) or not str(v).strip(): | |
| raise ValueError('goal must be a non-empty string') | |
| return str(v).strip() | |
| async def agent_kernel_dispatch(body: AgentKernelDispatchIn): | |
| gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '') | |
| if not gh_token: | |
| raise HTTPException(503, detail={ | |
| 'error': 'no_github_token', | |
| 'message': 'GITHUB_TOKEN non configurato nel backend.', | |
| }) | |
| goal = body.goal | |
| mode = body.mode | |
| import httpx as _httpx | |
| try: | |
| async with _httpx.AsyncClient(timeout=15) as _hc: | |
| _resp = await _hc.post( | |
| 'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches', | |
| json={'ref': 'main', 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'}}, | |
| headers={ | |
| 'Authorization': f'Bearer {gh_token}', | |
| 'Accept': 'application/vnd.github+json', | |
| 'X-GitHub-Api-Version': '2022-11-28', | |
| }, | |
| ) | |
| if _resp.status_code >= 400: | |
| raise HTTPException(_resp.status_code, detail=_resp.text[:500]) | |
| return {'ok': True, 'status': _resp.status_code, 'goal': goal, 'mode': mode} | |
| except _httpx.HTTPError as e: | |
| raise HTTPException(502, detail=str(e)[:500]) | |
| # ── Agent tasks (FASE 2.1 + S359 persistence) ────────────────────────────────── | |