/** * exhaustionHandler.ts — S502: extracted from agentLoop.ts * * Gestisce i due casi di esaurimento del loop: * 1. handleEmptyOrNoText — risposta vuota (no rawText) con o senza steps precedenti * 2. handleMaxIterReached — iterazioni massime raggiunte (post-loop) * * Entrambe le funzioni sono terminali: il caller deve sempre fare return dopo. * * @module exhaustionHandler */ import type { AgentStep } from "../storage"; import type { StreamOptions } from "../types"; import type { GoalContract } from "../_goalVerifier"; import type { TaskType } from "../taskClassifier"; import { verifyGoal } from "../_goalVerifier"; import { recordErrorAsync } from "../selfLearningAsync"; // S649 import { streamFallback, truncateMessages } from "./streamHelpers"; import { recordOutcome as recordTaskOutcome } from "../agent/SuccessRateMonitor"; import { liveGuidanceStore } from "@/lib/liveGuidanceStore"; import type { ApiMsg } from "./networkTools"; // GAP-TRUTH: emitFinalText (gate fail-closed) e utility per streaming incrementale import { buildClaimWarningBanner } from "./emitFinalText"; // ─── handleEmptyOrNoText ────────────────────────────────────────────────────── export interface EmptyOrNoTextCtx { hasSteps: boolean; emptyCount: number; steps: AgentStep[]; loopMessages: ApiMsg[]; initialMessages: Array<{ role: string; content: string }>; iter: number; preCls: { type: string } | null; token: string; options: StreamOptions; signal?: AbortSignal; onChunk: (chunk: string) => void; /** GAP-TRUTH: unico chokepoint pre-UI — usato per testo finale (non per status/marker) */ emitFinalText: (text: string, signal?: AbortSignal) => void; onStatus: (msg: string) => void; writeSess: () => void; cleanupSseBridge: () => void; onReasoningUpdate: (update: { error: string }) => void; recordRouteFailure?: () => void; } /** * Gestisce risposta vuota (no rawText). Sempre terminale. */ export async function handleEmptyOrNoText(ctx: EmptyOrNoTextCtx): Promise { const { hasSteps, emptyCount, steps, loopMessages, initialMessages, iter, preCls: _preCls, token, options, signal, onChunk, emitFinalText, onStatus: _onStatus, writeSess: _writeSess, cleanupSseBridge: _cleanupSseBridge, onReasoningUpdate, recordRouteFailure, } = ctx; if (hasSteps) { const newEmptyCount = emptyCount + 1; onReasoningUpdate({ error: `iter ${iter}: risposta vuota` }); if (newEmptyCount >= 2) { // Seconda risposta vuota consecutiva → errore definitivo const _doneTools = steps.filter(s => s.status === "done").map(s => s.tool); const _progressNote = _doneTools.length > 0 ? `Ho eseguito: ${_doneTools.join(", ")}. ` : ""; // GAP-TRUTH: usa emitFinalText invece di onChunk diretto — gate + proof check emitFinalText(`_(${_progressNote}Il modello non ha generato una risposta — a volte capita con richieste complesse o provider sotto carico. Riprova tra un momento o riformula la domanda.)_`, signal); try { recordTaskOutcome(_preCls!.type as TaskType, "failure"); } catch { /* non-blocking */ } try { recordRouteFailure?.(); } catch { /* non-blocking */ } _writeSess(); return; } // Prima risposta vuota: inietta richiesta di sintesi con contesto degli step const _doneSteps = steps.filter(s => s.status === "done"); const _stepSummary = _doneSteps.length > 0 ? `Ho già eseguito: ${_doneSteps.map(s => s.tool + "→" + s.result.slice(0, 60).replace(/\n/g, " ")).join(" | ")}.` : ""; const sumMsgs: ApiMsg[] = [ ...truncateMessages(loopMessages), { role: "user", content: `${_stepSummary} Riassumi in italiano i risultati trovati. Sii diretto e conciso.` } as ApiMsg, ]; // GAP-TRUTH: accumula il testo streamato per il claim check post-stream let _accumulated1 = ""; const _gatedChunk1 = (c: string) => { _accumulated1 += c; onChunk(c); }; await streamFallback( token || "", sumMsgs as Array<{ role: string; content: string }>, _gatedChunk1, signal, { ...options, maxTokens: 2048 }, ); const _banner1 = buildClaimWarningBanner(_accumulated1, steps); if (_banner1) onChunk(_banner1); } else { // Nessuno step eseguito, testo vuoto → fallback diretto (no claim risk, no steps) await streamFallback(token || "", initialMessages, onChunk, signal, options); } // Common tail — S499-FIX: path emptyCount=1 + no-steps convergono qui try { recordTaskOutcome((_preCls?.type ?? "unknown") as TaskType, "success"); } catch { /* non-blocking */ } _writeSess(); liveGuidanceStore.clear(); _cleanupSseBridge(); signal?.removeEventListener("abort", _cleanupSseBridge); } // ─── handleMaxIterReached ───────────────────────────────────────────────────── export interface MaxIterCtx { formalGoal: GoalContract | null; steps: AgentStep[]; signal?: AbortSignal; loopMessages: ApiMsg[]; options: StreamOptions; preCls: { type: string } | null; token: string; onStatus: (msg: string) => void; onSteps: (steps: AgentStep[]) => void; onChunk: (chunk: string) => void; /** GAP-TRUTH: unico chokepoint pre-UI — usato per testo finale (non per status/marker) */ emitFinalText: (text: string, signal?: AbortSignal) => void; writeSess: () => void; cleanupSseBridge: () => void; recordRouteFailure?: () => void; } /** * Gestisce iterazioni massime raggiunte (post-loop). Sempre terminale. */ export async function handleMaxIterReached(ctx: MaxIterCtx): Promise { const { formalGoal, steps, signal, loopMessages, options, preCls: _preCls, token, onStatus, onSteps, onChunk, writeSess: _writeSess, cleanupSseBridge: _cleanupSseBridge, recordRouteFailure, } = ctx; onStatus("Ho raggiunto il limite — sintetizzo quello che ho trovato…"); // S455-P6: verifyGoal anche su iter cap — logga failure per selfLearning, NON blocca if (formalGoal && steps.length > 0 && !signal?.aborted) { try { const _vrCap = verifyGoal(formalGoal, steps, "", signal); steps.push(_vrCap.step); onSteps([...steps]); if (!_vrCap.satisfied) { try { recordErrorAsync( // S649 `ITER_CAP_VERIFY_FAIL: score=${Math.round(_vrCap.score * 100)}%`, `verify:goal="${formalGoal.goal.slice(0, 60)}"`, _vrCap.failedDimensions .map((d: { name: string; detail: string }) => `[${d.name}] ${d.detail}`) .join("; "), ); } catch { /* non-blocking */ } } } catch { /* non-blocking — verifica mai interrompe il flusso */ } } const _goodSteps = steps.filter((s: AgentStep) => !s.result.startsWith("❌") && s.result.length > 30); const _stepsSummary = _goodSteps.length > 0 ? `\n\nDATI RACCOLTI DAI TOOL:\n${_goodSteps.map((s: AgentStep) => `[${s.tool}]: ${s.result.slice(0, 400)}`).join("\n\n---\n\n")}` : ""; const sumMsgs: ApiMsg[] = [ ...truncateMessages(loopMessages), { role: "user", content: `Raggiunto limite iterazioni. Riassumi brevemente tutto ciò che hai fatto e trovato.${_stepsSummary}` } as ApiMsg, ]; // GAP-TRUTH: accumula testo per claim check — LLM potrebbe dichiarare azioni // completate nel riepilogo iter-cap che non ha mai realmente eseguito. let _accumulated2 = ""; const _gatedChunk2 = (c: string) => { _accumulated2 += c; onChunk(c); }; await streamFallback( token || "", sumMsgs as Array<{ role: string; content: string }>, _gatedChunk2, signal, { ...options, maxTokens: 2048 }, ); const _banner2 = buildClaimWarningBanner(_accumulated2, steps); if (_banner2) onChunk(_banner2); try { recordTaskOutcome((_preCls?.type ?? "unknown") as TaskType, "failure"); } catch { /* non-blocking */ } try { recordRouteFailure?.(); } catch { /* non-blocking */ } _writeSess(); liveGuidanceStore.clear(); _cleanupSseBridge(); signal?.removeEventListener("abort", _cleanupSseBridge); }