File size: 19,197 Bytes
95eb75a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
// 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;
  };
}