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) ──────────────────────────────────