Spaces:
Running
Running
File size: 22,408 Bytes
03e5649 b5a81e7 03e5649 87e9c33 03e5649 c66a1ba 03e5649 87e9c33 03e5649 87e9c33 03e5649 87e9c33 03e5649 b5a81e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | """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,
)
@router.post('/run_loop')
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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post('/api/agent/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 βββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post('/api/reason/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': [],
}
@router.post('/api/unified/loop')
async def unified_loop(body: ReasonLoopIn):
"""Alias di /api/reason/loop β compatibilitΓ con tutte le versioni frontend."""
return await reason_loop(body)
# ββ Agent kernel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get('/api/agent-kernel/status')
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"
@field_validator('goal', mode='before')
@classmethod
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()
@router.post('/api/agent-kernel/dispatch')
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) ββββββββββββββββββββββββββββββββββ
|