AUDIT / src /lib /agentLoop /exhaustionHandler.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
8.17 kB
/**
* 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<void> {
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<void> {
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);
}