AUDIT / src /lib /agentLoop /backendDelegationBlock.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
12 kB
/**
* 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
}