/** * backendDelegationBlock.ts — S496: extracted from agentLoop.ts * * Tenta di delegare il task al backend smolagents (HuggingFace Spaces) tramite * SSE streaming. Viene attivato solo per task ad alta complessità quando il * backend è sano e disponibile. * * Comportamento: * - Se il backend risponde con successo → streama chunks e restituisce true * (il chiamante deve fare `return` dal loop agente). * - Se il backend non è eleggibile (non healthy / task non adatto) → { handled: false, failed: false } * - Se il backend risponde con successo → { handled: true, failed: false } * - Se il backend viene tentato ma fallisce (SSE error / risposta vuota) → { handled: false, failed: true } * (S638: il chiamante può usare failed:true per attivare graph orchestration come fallback) * * @module backendDelegationBlock */ import type { PlanLike } from "../agent/GraphOrchestrator"; import { vfsAsync } from "@/lib/vfsDb"; // GAP-2: vfsAsync per notifica Safari export interface BackendDelegationCtx { BACKEND_BASE: string | undefined; BACKEND_TOKEN: string | undefined; isBackendHealthy: () => boolean; taskPlan: PlanLike | null; signal?: AbortSignal; lastUserMsg: string; initialMessages: Array<{ role: string; content: string }>; onStatus: (msg: string) => void; onChunk: (c: string) => void; getProjectContext: () => string; getLearnHints: () => string[]; recordBackendResult: (success: boolean) => void; } export interface BackendDelegationResult { handled: boolean; // true → backend ha gestito il task, caller deve fare return failed: boolean; // true → backend tentato ma fallito (S638: attiva graph fallback) updatedFiles: Array<{ path: string; content: string }>; // GAP-1: file scritti dal backend per VFS sync // GAP-4: stato parziale catturato prima del timeout — seed GraphOrchestrator con tool gia' completati partialToolOutputs: Array<{ tool: string; result: string; path?: string }>; } // S638: return type cambiato da boolean a BackendDelegationResult per distinguere // "non eleggibile" (failed:false) da "tentato e fallito" (failed:true). export async function runBackendDelegation(ctx: BackendDelegationCtx): Promise { const { BACKEND_BASE, BACKEND_TOKEN, isBackendHealthy, taskPlan, signal, lastUserMsg, initialMessages, onStatus, onChunk, getProjectContext, getLearnHints, recordBackendResult, } = ctx; // Gate: backend presente, sano, task complesso, non abortito if ( !BACKEND_BASE || !isBackendHealthy() || !taskPlan || !( taskPlan.complexity === "high" || (taskPlan.complexity === "medium" && (taskPlan.subtasks?.length ?? 0) >= 3) ) || (taskPlan.subtasks?.length ?? 0) < 2 || signal?.aborted ) { return { handled: false, failed: false, updatedFiles: [], partialToolOutputs: [] }; // S638: non eleggibile, non tentato } // T009: SSE consumer — mostra step progress in tempo reale + result streaming onStatus("Delegazione al backend (smolagents — task ad alta complessità)…"); let _sseResultText = ""; let _sseSuccess = false; const _vfsUpdates: Array<{ path: string; content: string }> = []; // GAP-1: file scritti dal backend const _toolOutputs: Array<{ tool: string; result: string; path?: string }> = []; // GAP-4: partial tool outputs per graph seed let _bgTaskId: string | null = null; try { const _sseCtrl = new AbortController(); const _sseTid = setTimeout(() => _sseCtrl.abort(), 120_000); const _projCtx = getProjectContext(); const _learnHints = getLearnHints(); // S-PERSIST step 1: crea task persistente con UUID stabile — sopravvive alla chiusura del browser const _taskPayload = JSON.stringify({ goal: lastUserMsg, context: initialMessages.slice(-20).map(m => ({ role: m.role, content: String(m.content).slice(0, 400), })), max_steps: 8, project_context: _projCtx, learning_hints: _learnHints, }); const _taskCreateRes = await fetch(BACKEND_BASE + "/api/agent/tasks", { method: "POST", headers: { "Content-Type": "application/json", ...(BACKEND_TOKEN ? { "X-Internal-Token": BACKEND_TOKEN } : {}), }, body: _taskPayload, }).catch(() => null); if (_taskCreateRes?.ok) { try { const _td = await _taskCreateRes.json() as { taskId?: string }; _bgTaskId = _td.taskId ?? null; } catch { /* non-fatal */ } } // S-PERSIST step 2: salva task_id in localStorage — recovery al boot se il telefono si spegne if (_bgTaskId) { try { const _pending: Array<{ taskId: string; goal: string; timestamp: number }> = JSON.parse(localStorage.getItem("agente:pending_bg_tasks") ?? "[]"); _pending.push({ taskId: _bgTaskId, goal: lastUserMsg.slice(0, 200), timestamp: Date.now() }); localStorage.setItem("agente:pending_bg_tasks", JSON.stringify(_pending.slice(-5))); } catch { /* non-fatal — Safari Private Mode */ } } // S-PERSIST step 3: apre SSE stream del task creato (fallback a run-stream se tasks API non disponibile) const _streamUrl = _bgTaskId ? `${BACKEND_BASE}/api/agent/tasks/${_bgTaskId}/stream` : BACKEND_BASE + "/api/agent/run-stream"; const _streamMethod = _bgTaskId ? "GET" : "POST"; const _streamBody = _bgTaskId ? undefined : _taskPayload; const _sseRes = await fetch(_streamUrl, { method: _streamMethod, headers: { ...(_streamBody ? { "Content-Type": "application/json" } : {}), ...(BACKEND_TOKEN ? { "X-Internal-Token": BACKEND_TOKEN } : {}), }, body: _streamBody, signal: _sseCtrl.signal, }).catch(() => null); if (_sseRes?.ok && _sseRes.body) { const _sseReader = _sseRes.body.getReader(); const _sseDec = new TextDecoder(); let _sseBuf = ""; const _stepLabels: Record = { llm: "Ragionamento AI in corso…", fallback: "Generazione risposta…", smolagents: "Agente multi-step…", tool: "Uso tool…", search: "Ricerca…", code: "Esecuzione codice…", }; outer: while (true) { const { done, value } = await _sseReader.read(); if (done || signal?.aborted) break; _sseBuf += _sseDec.decode(value, { stream: true }); const _sseLines = _sseBuf.split("\n"); _sseBuf = _sseLines.pop() ?? ""; for (const _sseLine of _sseLines) { const _sseData = _sseLine.startsWith("data: ") ? _sseLine.slice(6).trim() : ""; if (!_sseData || _sseData === "[DONE]") continue; try { const _sseEvt = JSON.parse(_sseData) as Record; const _evType = _sseEvt.type as string; if (_evType === "step_done") { const _step = _sseEvt.step as Record | undefined; const _action = String(_step?.action ?? ""); // GAP-1: file scritto dal backend → sync VFS locale if (_action === "file_written" && _step?.path && _step?.content !== undefined) { const _fPath = String(_step.path); const _fContent = String(_step.content); _vfsUpdates.push({ path: _fPath, content: _fContent }); // GAP-4: cattura come tool output per seed del grafo _toolOutputs.push({ tool: "write_file", result: `✅ File scritto: ${_fPath}`, path: _fPath }); // GAP-2: usa vfsAsync.write che chiama _notifyVfsChanged → aggiorna FileExplorer su Safari iOS // vfsDb.files.put() bypassava il listener del file tree su tab in background vfsAsync.write(_fPath, _fContent) .catch(() => { /* non-blocking — VFS sync best-effort */ }); } else { // GAP-4: cattura output strutturati se presenti nel step event (backend tool_name + result) const _backendToolName = String(_step?.tool_name ?? ""); const _backendResult = String(_step?.result ?? _step?.output ?? ""); if (_backendToolName && _backendResult) { _toolOutputs.push({ tool: _backendToolName, result: _backendResult.slice(0, 600) }); } const _label = _stepLabels[_action] ?? `Step ${_step?.loop ?? ""}…`; onStatus(_label); } } else if (_evType === "task_done") { const _res = _sseEvt.result; const _txt: string = typeof _res === "string" ? _res : ((_res as Record)?.output as string ?? JSON.stringify(_res ?? "")); if (_txt && _txt.length > 10 && _sseEvt.success) { _sseResultText = _txt; _sseSuccess = true; } break outer; } else if (_evType === "task_error") { break outer; } else if (_evType === "plan_update") { // GAP-1: plan_update SSE → StreamingPlanBubble in real-time (Replit-style) // Prima: entrambi frontend e backend credevano che l'altro gestisse il piano — nessuno lo faceva. // Ora: dispatcha custom events che StreamingPlanBubble ascolta per il piano live. const _su = _sseEvt.subtasks as Array<{id: number|string, description: string, tool: string}> | undefined; if (_su?.length) { window.dispatchEvent(new CustomEvent("agente-ai:plan-clear", {})); _su.forEach((_item, _i) => { window.dispatchEvent(new CustomEvent("agente-ai:plan-reveal", { detail: { id: _i, total: _su.length, item: { title: _item.description, tool: _item.tool } } })); }); // P22-NE: emetti agent:plan-update nativo (completa migrazione P21-F2) window.dispatchEvent(new CustomEvent("agent:plan-update", { detail: { taskId: undefined, goal: "", subtasks: _su.map((_s, _i) => ({ id: _i, description: _s.description, tool: _s.tool })), }, })); } } // S367/GAP-1: plan_update events now wired — prima dead-end, ora dispatched } catch { /* malformed SSE line — skip */ } } } try { _sseReader.cancel(); } catch { /* non-blocking */ } } clearTimeout(_sseTid); recordBackendResult(_sseSuccess); } catch { /* SSE failed — fall through to browser loop */ recordBackendResult(false); } if ( _sseSuccess && _sseResultText && !_sseResultText.includes("non disponibile") && !_sseResultText.includes("Backend reasoning") ) { onStatus(""); for (let i = 0; i < _sseResultText.length; i += 12) { if (signal?.aborted) break; onChunk(_sseResultText.slice(i, i + 12)); } // S-PERSIST: task completato → rimuovi dal pending (recovery non più necessaria) if (_bgTaskId) { try { const _p: Array<{ taskId: string }> = JSON.parse(localStorage.getItem("agente:pending_bg_tasks") ?? "[]"); localStorage.setItem("agente:pending_bg_tasks", JSON.stringify(_p.filter(t => t.taskId !== _bgTaskId))); } catch { /* non-fatal */ } } return { handled: true, failed: false, updatedFiles: _vfsUpdates, partialToolOutputs: [] }; // S638: successo } onStatus(""); return { handled: false, failed: true, updatedFiles: _vfsUpdates, partialToolOutputs: _toolOutputs }; // S638+GAP-4: tentato ma fallito → graph fallback con stato parziale }