Spaces:
Sleeping
Sleeping
| /** | |
| * 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<BackendDelegationResult> { | |
| 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<string, string> = { | |
| 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<string, unknown>; | |
| const _evType = _sseEvt.type as string; | |
| if (_evType === "step_done") { | |
| const _step = _sseEvt.step as Record<string, unknown> | 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<string, unknown>)?.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 | |
| } | |