AUDIT / src /lib /agentLoop /executeToolGatedFactory.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
19.2 kB
// S535: executeToolGated factory — estratto da agentLoop.ts
// Riceve tutte le dipendenze chiuse e ritorna la funzione executeToolGated.
import { useTaskStore } from "@/store/taskStore";
import { AGENT_TOOLS } from "./toolDefinitions";
import { executeToolWithFallback } from "./toolExecutor";
import { CB_ALT } from "./circuitBreaker";
import {
CACHEABLE_TOOLS,
NETWORK_TOOLS,
HIGH_RISK_TOOLS as HIGH_RISK_TOOLS_M,
TOOL_TIMEOUTS,
toolCacheKey,
} from "./toolGate";
import { TOOL_STATUS_LABELS, toolNameToActionType as _toolNameToActionType } from "./toolStatusLabels";
import { TOOL_CONTRACTS } from "./toolOutputValidators"; // P2-6: renamed from toolContracts
import { recordRoutingEvent } from "../agent/SuccessRateMonitor"; // S686
// GAP-TRUTH-3: PROOF_VERIFIED_TOOLS sostituisce il set _CRITICAL_VERIFY locale
import { PROOF_VERIFIED_TOOLS } from "./proofToolRegistry";
// GAP-2: simboli dichiarati → cross-check semantico in verifyToolResult
import { detectClaimedSymbols } from "./symbolClaimDetector";
// V7-4: efficiency critic — LLM check prima di git_push/deploy_trigger/execute_shell
import { isEfficiencyCriticTool, runEfficiencyCritic } from "./efficiencyCritic";
// TG-WEBHOOK: notifica Telegram con confirmKey prima di onNeedConfirm → bottone "🚫 Rifiuta" su iPhone
import { tgNotify } from "../agentTelegram";
export interface ExecuteToolGatedDeps {
signal: AbortSignal | undefined;
onStatus: (msg: string) => void;
preExecTools: Set<string>;
preExecCritic: (name: string, args: Record<string, unknown>) => { blocked: boolean; reason: string; alt: string };
onNeedConfirm?: ((req: { tool: string; label: string; preview: string; risk: "medium" | "high" }) => Promise<boolean>) | undefined;
skillRegistry: { requiresConfirm: (n: string) => boolean; recordUse: (n: string, ok: boolean) => void; userApprove: (n: string) => void };
toolCache: Map<string, string>;
persistentToolCache: Map<string, { result: string; at: number }>;
persistentTtl: Record<string, number>;
prunePersistentCache: () => void;
incrementPersistentCacheCallCount: () => number;
maxPersistentCacheSize: number;
convId: string;
loopTaskId: string | null;
contextBridge: { recordToolResult: (name: string, args: Record<string, unknown>, result: string) => void };
semanticSearch: { invalidateCache: () => void; cacheTs: number; contextForAsync: (q: string) => Promise<string> };
invalidateRepoMap: () => void;
lastUserMsg: string;
onSemanticCtxUpdate: (ctx: string, ts: number) => void;
getSemanticCtxTs: () => number;
}
export function createExecuteToolGated(deps: ExecuteToolGatedDeps): (name: string, args: Record<string, unknown>) => Promise<string> {
const {
signal, onStatus, preExecTools, preExecCritic, onNeedConfirm, skillRegistry,
toolCache, persistentToolCache, persistentTtl, prunePersistentCache,
incrementPersistentCacheCallCount, maxPersistentCacheSize, convId, loopTaskId,
contextBridge, semanticSearch, invalidateRepoMap, lastUserMsg,
onSemanticCtxUpdate, getSemanticCtxTs,
} = deps;
const HIGH_RISK_TOOLS = HIGH_RISK_TOOLS_M;
const _CACHEABLE_TOOLS = CACHEABLE_TOOLS;
const _NETWORK_TOOLS = NETWORK_TOOLS;
const _TOOL_TIMEOUTS = TOOL_TIMEOUTS;
const _toolCacheKey = toolCacheKey;
const _PERSISTENT_TTL = persistentTtl;
const _TOOL_CONTRACTS = TOOL_CONTRACTS;
return async function executeToolGated(name: string, args: Record<string, unknown>): Promise<string> {
// AbortSignal early-exit
if (signal?.aborted) { onStatus("🛑 Operazione annullata"); return "❌ [ABORT] Operazione annullata dall'utente."; }
// Step6: Pre-execution critic for high-impact tools
if (preExecTools.has(name)) {
const _criticism = preExecCritic(name, args);
if (_criticism.blocked) {
return `❌ [PRE-EXEC CRITIC] ${_criticism.reason} — Alternativa: ${_criticism.alt}`;
}
}
// V7-4: Efficiency critic — check LLM leggero (100tok, 4s, 30s cooldown)
// su git_push/git_push_multiple/deploy_trigger/execute_shell prima dell'esecuzione.
// Fail-open: se timeout/cooldown → proceed:true → nessun impatto sul loop.
if (isEfficiencyCriticTool(name)) {
const _recentCtx = (() => {
if (!loopTaskId) return "";
try {
const _task = useTaskStore.getState().tasks.find(t => t.id === loopTaskId);
return _task?.actions.slice(-3).map(a => `${a.label}: ${a.done ? "✓" : "…"}`).join(", ") ?? "";
} catch { return ""; }
})();
const _ec = await runEfficiencyCritic(name, args, lastUserMsg, _recentCtx);
if (!_ec.proceed && !_ec.skipped) {
onStatus(`🛑 Efficiency critic: ${_ec.reason.slice(0, 60)}`);
return `❌ [EFFICIENCY CRITIC] Azione bloccata: ${_ec.reason}. Verifica il piano e riprova.`;
}
}
if (HIGH_RISK_TOOLS.includes(name) && onNeedConfirm && skillRegistry.requiresConfirm(name)) {
const preview = name === 'run_code'
? (args.code as string ?? '').slice(0, 320)
: String(args.path ?? '') + (args.content ? ' (' + String(args.content).length + ' chars)' : '');
const label = name === 'write_file'
? 'Salva ' + String(args.path ?? 'file')
: (args.code as string ?? '').slice(0, 90);
// REJECT-WIRE: corto-circuita confirm se questo tool è già stato rifiutato (decisionMemory)
// — chiude il cerchio: _dmIsRejected() in agentLoop era sempre false perché rejectProposal() non veniva mai chiamata
const _confirmKey = `confirm_${name}_${lastUserMsg.slice(0, 40).replace(/\W+/g, "_")}`;
let _alreadyRejected = false;
try { const { isRejected: _isDmRejected } = await import("../decisionMemory"); _alreadyRejected = _isDmRejected(_confirmKey); } catch { /* non-blocking */ }
if (_alreadyRejected) { skillRegistry.recordUse(name, false); return '⏭️ Azione già rifiutata in precedenza (memoria sessione) — usa un approccio diverso.'; }
// TG-WEBHOOK: manda notifica Telegram con bottone "🚫 Rifiuta" (confirm_key → Realtime → rejectProposal)
try { tgNotify.taskApproval(loopTaskId, label, 85, 'medio', undefined, undefined, _confirmKey); } catch { /* non-blocking */ }
const allowed = await onNeedConfirm({ tool: name, label, preview, risk: 'medium' });
if (!allowed) {
skillRegistry.recordUse(name, false);
// REJECT-WIRE: registra rifiuto → future iterazioni non riproporranno lo stesso tool in questo task
try { const { rejectProposal } = await import("../decisionMemory"); rejectProposal(_confirmKey, `utente ha annullato: ${label}`); } catch { /* non-blocking */ }
return '⏭️ Azione annullata dall\'utente.';
}
skillRegistry.userApprove(name);
}
// ── Offline detection ──────────────────────────────────────────────────────
if (
_NETWORK_TOOLS.has(name) &&
typeof navigator !== "undefined" &&
!navigator.onLine
) {
onStatus("📵 Offline — " + name + " saltato");
return `❌ [OFFLINE] Connessione internet non disponibile — "${name}" richiede rete. Basati sulle tue conoscenze o usa tool locali (read_file, recall, math_eval).`;
}
// ── Required arg validation ────────────────────────────────────────────────
{
const _toolDef = (typeof AGENT_TOOLS !== "undefined" ? (AGENT_TOOLS as any[]) : [])
.find((t: { type: string; function: { name: string; parameters?: { required?: string[] } } }) => t.function.name === name);
const _required: string[] = (_toolDef?.function?.parameters?.required as string[] | undefined) ?? [];
const _missing = _required.filter(k => {
const v = args[k];
return v === undefined || v === null || v === "";
});
if (_missing.length > 0) {
return `❌ [ARG VALIDATION] Tool "${name}" richiede: ${_missing.map(k => `"${k}"`).join(", ")}. Fornisci i campi mancanti e riprova.`;
}
}
// ── Duplicate call cache ───────────────────────────────────────────────────
if (_CACHEABLE_TOOLS.has(name)) {
const _ck = _toolCacheKey(name, args);
const _cv = toolCache.get(_ck);
if (_cv !== undefined) {
onStatus(`♻️ ${name} (da cache)`);
return `${_cv}\n\n[CACHE: risultato identico già ottenuto in questo task]`;
}
}
// ── Per-tool timeout ───────────────────────────────────────────────────────
const _toolMs = _TOOL_TIMEOUTS[name] ?? 35_000;
let _toolTimeoutId: ReturnType<typeof setTimeout>;
const _toolTimeout = new Promise<string>(resolve => {
_toolTimeoutId = setTimeout(
() => resolve(
`❌ [TIMEOUT] "${name}" non ha risposto in ${Math.round(_toolMs / 1000)}s — server troppo lento.` +
(CB_ALT[name] ? ` Usa invece: ${CB_ALT[name]}.` : ""),
),
_toolMs,
);
});
// S274 Fix#7: add action card to taskStore before tool execution
const _tsActStart = Date.now();
const _tsActId: string | null = (() => {
if (!loopTaskId) return null;
try {
const _tsLocal = useTaskStore.getState();
_tsLocal.addAction(loopTaskId, {
type: _toolNameToActionType(name),
label: TOOL_STATUS_LABELS[name] ?? name,
detail: (() => {
const _v0 = Object.values(args)[0];
return _v0 != null ? String(_v0).slice(0, 120) : undefined;
})(),
done: false,
});
return _tsLocal.tasks.find(t => t.id === loopTaskId)?.actions.at(-1)?.id ?? null;
} catch { return null; }
})();
const result = await Promise.race([
executeToolWithFallback(name, args, signal).finally(() => clearTimeout(_toolTimeoutId)),
_toolTimeout,
]);
// S274 Fix#7: complete action card after tool returns
if (_tsActId && loopTaskId) {
try { useTaskStore.getState().completeAction(loopTaskId, _tsActId, Date.now() - _tsActStart); } catch { /* non-blocking */ }
}
// Context overflow: cap result to 12KB
// GAP-SAFARI2 v2: smart size-guard — hard limit 300KB + troncatura intelligente.
// v1 tagliava raw a metà stringa → JSON invalido, righe spezzate, indentazione rotta.
// v2: (1) rileva binary/base64 → skip payload, ritorna solo metadati
// (2) tronca all'ultimo newline (testi/codice) o ',' (JSON array)
// così il risultato resta leggibile e parsabile dal LLM.
const _SAFARI_MAX_RAW = 300_000;
// Rilevamento binary/base64: >97% chars alfanumerici + varietà reale o data URI.
// Requisiti: (1) data URI prefix, oppure (2) upper+lower+digit+special misti
// Evita falsi positivi su stringhe alfanumeriche monotone (es. rep('A', 300_000)).
const _isBinaryPayload = (() => {
if (result.length < 50_000) return false;
const _sample = result.slice(0, 600);
const _b64Count = _sample.split('').filter((c: string) => /[A-Za-z0-9+/=\n]/.test(c)).length;
if ((_b64Count / _sample.length) <= 0.97) return false;
// data URI prefix → certamente base64
if (/^data:[a-z]+\/[a-z+]+;base64,/i.test(_sample.trim())) return true;
// Base64 reale ha varietà: uppercase + lowercase + digit + almeno un char speciale (+/=)
const _hasUpper = /[A-Z]/.test(_sample);
const _hasLower = /[a-z]/.test(_sample);
const _hasDigit = /[0-9]/.test(_sample);
const _hasSpec = /[+/=]/.test(_sample);
return _hasUpper && _hasLower && _hasDigit && _hasSpec;
})();
let _resultSafe: string;
if (_isBinaryPayload) {
// Payload binario/base64 → non inviare dati raw (inutili e pesanti per l'LLM)
_resultSafe = `[GAP-SAFARI2: payload binario/base64 (${Math.round(result.length / 1024)}KB) — contenuto non testuale non inviato al LLM. Usa il path del file invece del contenuto.]`;
} else if (result.length > _SAFARI_MAX_RAW) {
// Tronca al confine intelligente: ultimo newline o ',' entro il limite.
// FIX R2-P1: usa _isLikelyJson300 per decidere se la virgola è un confine valido.
// Stesso pattern del guard 12KB (bug: il 300KB mancava di questo check).
// Scenario senza fix: log >300KB con virgole ma senza newlines → taglio a metà riga.
const _rawCut = result.slice(0, _SAFARI_MAX_RAW);
const _isLikelyJson300 = /^\s*(\{|\[\s*[\[{"\]])/.test(result.slice(0, 60));
const _lastNL = _rawCut.lastIndexOf('\n');
const _lastCm = _isLikelyJson300 ? _rawCut.lastIndexOf(',') : -1;
const _boundary = _lastNL > _SAFARI_MAX_RAW * 0.85 ? _lastNL
: _lastCm > _SAFARI_MAX_RAW * 0.85 ? _lastCm
: _SAFARI_MAX_RAW;
_resultSafe = result.slice(0, _boundary)
+ `\n[GAP-SAFARI2: troncato al confine di testo (${Math.round(_boundary / 1024)}KB) — originale ${Math.round(result.length / 1024)}KB]`;
} else {
_resultSafe = result;
}
const MAX_TOOL_RESULT = 12_000;
// Troncatura finale a 12KB: cerca confine pulito (newline → virgola-JSON → raw cut)
// NOTA: virgola usata SOLO se il contenuto è probabilmente JSON (inizia con { o [{)
// Scenari non-JSON (log, CSV con virgole, codice) usano il newline o il raw cut.
// isLikelyJson esclude log prefix "[2026-..." ([ seguito da digit) e plain text.
const _isLikelyJson = /^\s*(\{|\[\s*[\[{"\]])/.test(_resultSafe.slice(0, 60));
const _resultFinal = (() => {
if (_resultSafe.length <= MAX_TOOL_RESULT) return _resultSafe;
const _cut = _resultSafe.slice(0, MAX_TOOL_RESULT);
const _nl = _cut.lastIndexOf('\n');
// Usa confine virgola solo per JSON reale (evita tagli a metà-riga in log/CSV)
const _cm = _isLikelyJson ? _cut.lastIndexOf(',') : -1;
const _smartCut = _nl > MAX_TOOL_RESULT * 0.80 ? _nl
: _cm > MAX_TOOL_RESULT * 0.80 ? _cm
: MAX_TOOL_RESULT;
return _resultSafe.slice(0, _smartCut)
+ `\n\n[TRONCATO: risposta originale ${Math.round(_resultSafe.length / 1024)}KB — mostrati primi ${Math.round(_smartCut / 1024)}KB]`;
})();
// Cache the result
if (_CACHEABLE_TOOLS.has(name) && !_resultFinal.startsWith("❌") && !_resultFinal.includes("[CACHE:")) {
const _ck2 = _toolCacheKey(name, args);
try { toolCache.set(_ck2, _resultFinal); } catch { /* non-blocking */ }
const _ttl2 = _PERSISTENT_TTL[name] ?? 0;
if (_ttl2 > 0) {
try {
if (persistentToolCache.size >= maxPersistentCacheSize) prunePersistentCache();
persistentToolCache.set(convId + "::" + _ck2, { result: _resultFinal, at: Date.now() });
if (incrementPersistentCacheCallCount() % 50 === 0) prunePersistentCache();
} catch { /* non-blocking */ }
}
}
try { skillRegistry.recordUse(name, !_resultFinal.startsWith('❌')); } catch { /* non-blocking */ }
try { contextBridge.recordToolResult(name, args, _resultFinal); } catch { /* non-blocking */ }
try { semanticSearch.invalidateCache(); } catch { /* non-blocking */ }
try { invalidateRepoMap(); } catch { /* non-blocking */ }
// S686: wire recordRoutingEvent — misura quante volte l'LLM sceglie i tool suggeriti
// da AgentDispatcher (S680/S681 routing accuracy). Non-blocking, solo su successo.
try {
if (!_resultFinal.startsWith("❌")) recordRoutingEvent(lastUserMsg, name);
} catch { /* non-blocking */ }
// S167-Fix8: ricalcola ctx se la memoria è cambiata
if (semanticSearch.cacheTs !== getSemanticCtxTs()) {
semanticSearch.contextForAsync(lastUserMsg)
.then(ctx => { onSemanticCtxUpdate(ctx, semanticSearch.cacheTs); })
.catch(() => {});
}
// Quality filter: ghost success detection
if (!_resultFinal.startsWith("❌")) {
const _qt = _resultFinal.trim();
const _isGhost =
_qt.length < 8 ||
_qt === "null" || _qt === "undefined" ||
_qt === "{}" || _qt === "[]" ||
/^Error:/i.test(_qt) ||
/^(no results?|nessun risultato|nothing found|0 results?)/i.test(_qt);
if (_isGhost) {
return "❌ [QUALITY] " + name + ": risultato vuoto/non valido (" + _qt.slice(0, 80) + "). Prova approccio alternativo.";
}
// T005: Per-tool semantic contract validators
const _contractCheck = _TOOL_CONTRACTS[name];
if (_contractCheck) {
const _contractErr = _contractCheck(_qt, args);
if (_contractErr) {
return "⚠️ [CONTRACT] " + _contractErr;
}
}
}
// S304-A: stale cache fallback su errore
if (_resultFinal.startsWith("❌") && _CACHEABLE_TOOLS.has(name)) {
const _staleKey = _toolCacheKey(name, args);
let _bestStale: { result: string; at: number } | undefined;
for (const [_pk, _pv] of persistentToolCache) {
const _pkCore = _pk.includes("::") ? _pk.slice(_pk.indexOf("::") + 2) : _pk;
if (_pkCore === _staleKey && _pv.result && !_pv.result.startsWith("❌") && !_pv.result.includes("Cache locale")) {
if (!_bestStale || _pv.at > _bestStale.at) _bestStale = _pv;
}
}
if (_bestStale) {
const _ageMin = Math.round((Date.now() - _bestStale.at) / 60_000);
const _ageStr = _ageMin > 60 ? `${Math.round(_ageMin / 60)}h` : `${_ageMin}min`;
return _bestStale.result + `\n\n_(⚠️ Cache locale [${_ageStr} fa] — connessione temporaneamente non disponibile)_`;
}
}
// Gap 2 — verifier come autorità: ogni tool critico viene verificato prima di ritornare
// il risultato. L'OBSERVATION che il modello riceve include prova di verifica.
// Ledger recording delegato ad annotateWithProof (verifier.ts) — unico writer per case1/case2 path.
if (PROOF_VERIFIED_TOOLS.has(name) && !_resultFinal.startsWith("❌")) {
try {
const { verifyToolResult } = await import("../agent/verifier");
// GAP-2: estrai simboli dichiarati dal testo del task/args per cross-check semantico
const _claimedSyms = detectClaimedSymbols(String(args.description ?? args.goal ?? args.path ?? ""));
const _vr = verifyToolResult(name, args, _resultFinal, _claimedSyms.length > 0 ? _claimedSyms : undefined);
if (!_vr.verified) {
onStatus(`⚠️ ${name}: verifica fallita`);
// Gap 5: trustScore basso codificato nell'OBSERVATION per AgentStepCards
return `${_resultFinal}
⚠️ VERIFICA: non confermato — ${_vr.proof}`;
}
// Gap 5: trustScore alto codificato nell'OBSERVATION
return `${_resultFinal}
✅ VERIFICATO: ${_vr.proof}`;
} catch { /* non-blocking: verifier errore non blocca il tool */ }
}
return _resultFinal;
};
}