File size: 7,908 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
// S536: emitFinalText — GAP-2: gestione hard block "no-proof"
//
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GAP-2 change vs versione precedente:
//   - runFinalOutputGate ora può restituire pass:false reason:"no-proof"
//     (claim critica senza prova verificata in proofLedger)
//   - Questo file gestisce "no-proof" emettendo NO_PROOF_RETRY_TEXT
//     (messaggio specifico, diverso da SAFE_FALLBACK_TEXT)
//   - "no-proof" != "garbage/empty": il testo era valido, ma le azioni
//     dichiarate non hanno prova. Il messaggio informa l'utente che
//     l'agente ritenterà e spiega perché.
//
// Zero regressioni: i path garbage/empty sono invariati.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
import { runFinalOutputGate, type GateVerdict } from "./finalOutputGate";
import { detectClaimedCategories, checkClaimsAgainstLedger } from "./actionClaimDetector";
import { PROOF_VERIFIED_TOOLS, TOOL_TO_CATEGORY_LABEL } from "./proofToolRegistry";
import { proofLedger } from "./proofLedger";
import type { AgentStep } from "../storage";

export const EMIT_CHUNK_SIZE = 100;

// S765 Gap 1: filtro garbage per chunk in-stream
const _STREAM_GARBAGE_RE = /[^\x09\x0A\x0D\x20-\x7E\u00A0-\u024F\u0370-\u03FF]{4,}/g;

// ─── Messaggi di fallback ─────────────────────────────────────────────────────

/** Emesso per pass:false reason:"garbage"|"empty" — output irrecuperabile */
const SAFE_FALLBACK_TEXT =
  "⚠️ Non sono riuscito a generare una risposta valida in questo turno " +
  "(output non leggibile o vuoto). Riprova oppure riformula la richiesta.";

/**
 * GAP-2: Emesso per pass:false reason:"no-proof".
 * L'agente ha DICHIARATO di aver completato un'azione critica
 * (scrittura file, push Git, deploy) ma proofLedger non ha prova verificata.
 * Il messaggio:
 *   1. Informa l'utente in modo trasparente
 *   2. Spiega il meccanismo di self-healing
 *   3. Chiede di riprovare (il ciclo agente può re-triggerare il tool)
 *
 * Design: testo markdown-friendly, visibile anche in plain text.
 */
const NO_PROOF_RETRY_TEXT =
  "🔄 **Verifica azione non superata — retry automatico richiesto.**\n\n" +
  "L'agente ha dichiarato di aver completato un'azione critica " +
  "(scrittura file / push Git / deploy), ma il sistema non ha trovato " +
  "conferma verificata nel registro prove.\n\n" +
  "**Cosa succede ora:** il sistema annullerà l'output e rieseguirà " +
  "l'azione dal principio per garantire che sia stata completata " +
  "con prova concreta.\n\n" +
  "_Se il problema persiste, verifica la connessione al backend-exec " +
  "e i permessi del tool._";

// ─── Unverified claims calculator (invariato da versione precedente) ──────────

function _computeUnverifiedClaims(text: string, steps?: AgentStep[]): string[] {
  const warnings = new Set<string>();

  try {
    const claimed = detectClaimedCategories(text);
    if (claimed.size > 0) {
      const snap = proofLedger.snapshot();
      for (const w of checkClaimsAgainstLedger(claimed, snap)) warnings.add(w);
    }
  } catch { /* best-effort — non-blocking */ }

  try {
    if (steps?.length) {
      const snap = proofLedger.snapshot();
      const seen = new Set<string>();
      for (const s of steps) {
        if (!PROOF_VERIFIED_TOOLS.has(s.tool) || seen.has(s.tool)) continue;
        seen.add(s.tool);
        const entries = snap.filter(p => p.action === s.tool);
        if (entries.length > 0 && !entries.some(e => e.verified)) {
          const _label = TOOL_TO_CATEGORY_LABEL.get(s.tool) ?? s.tool;
          warnings.add(`${_label} (eseguito ma non verificato)`);
        }
      }
    }
  } catch { /* best-effort — non-blocking */ }

  return [...warnings];
}

function _safeChunk(c: string, cb: (s: string) => void): void {
  const safe = c.replace(_STREAM_GARBAGE_RE, "");
  if (safe) cb(safe);
}

function _emitChunks(
  text: string,
  onChunk: (c: string) => void,
  abort?: AbortSignal,
  chunkSize = EMIT_CHUNK_SIZE,
): void {
  const stepRe = /(\u0060{3}step\n[\w_]+\n\u0060{3})/g;
  let last = 0, m: RegExpExecArray | null;
  while ((m = stepRe.exec(text)) !== null) {
    if (abort?.aborted) return;
    const before = text.slice(last, m.index);
    for (let i = 0; i < before.length; i += chunkSize) {
      if (abort?.aborted) return;
      _safeChunk(before.slice(i, i + chunkSize), onChunk);
    }
    _safeChunk(m[1], onChunk);
    last = m.index + m[0].length;
  }
  const tail = text.slice(last);
  for (let i = 0; i < tail.length; i += chunkSize) {
    if (abort?.aborted) return;
    _safeChunk(tail.slice(i, i + chunkSize), onChunk);
  }
}

/**
 * GAP-2: emitFinalText con hard block "no-proof".
 *
 * Nuova gestione pass:false:
 *   - reason "garbage" | "empty" → SAFE_FALLBACK_TEXT (invariato)
 *   - reason "no-proof" [GAP-2] → NO_PROOF_RETRY_TEXT (nuovo)
 *
 * Il caller (case3FinalBlock, doneSignalProcessor) riceverà:
 *   - SAFE_FALLBACK_TEXT: se l'output era garbage/vuoto
 *   - NO_PROOF_RETRY_TEXT: se l'azione dichiarata non ha prova
 *
 * In entrambi i casi il testo originale non viene MAI emesso (fail-closed).
 *
 * @param text      Testo grezzo da validare
 * @param onChunk   Callback per ogni chunk sanitizzato
 * @param abort     AbortSignal opzionale
 * @param steps     Step sessione per check "tool critico non verificato"
 * @param taskHint  Riservato per estensioni future
 * @param chunkSize Dimensione chunk (default EMIT_CHUNK_SIZE)
 */
export function emitFinalText(
  text:      string,
  onChunk:   (c: string) => void,
  abort?:    AbortSignal,
  steps?:    AgentStep[],
  taskHint?: string,
  chunkSize  = EMIT_CHUNK_SIZE,
): void {
  if (abort?.aborted) return;

  let _g: GateVerdict;
  try {
    _g = runFinalOutputGate(text, {
      unverifiedClaims: _computeUnverifiedClaims(text, steps),
      taskHint,
      // hardProofGating: true (default) — attiva GAP-2 hard block in produzione
      // Per disabilitare: passa hardProofGating: false nel GateCtx
    });
  } catch {
    _g = { pass: false, reason: "garbage", text: "" };
  }

  if (!_g.pass) {
    // GAP-2: distingue tra garbage/empty e no-proof
    const fallbackText =
      _g.reason === "no-proof"
        ? NO_PROOF_RETRY_TEXT
        : SAFE_FALLBACK_TEXT;
    _emitChunks(fallbackText, onChunk, abort, chunkSize);
    return;
  }

  let outText = _g.text;
  // Soft mode legacy (hardProofGating: false): appende warnings
  if (_g.warnings?.length) {
    const list   = _g.warnings.join("; ");
    const plural = _g.warnings.length === 1 ? "questa azione" : "queste azioni";
    outText +=
      `\n\n---\n⚠️ **Verifica automatica:** ${list}. ` +
      `Il sistema non ha trovato prove confermate per ${plural} — ` +
      `controlla manualmente prima di considerarle completate.`;
  }

  _emitChunks(outText, onChunk, abort, chunkSize);
}

export { detectClaimedCategories, checkClaimsAgainstLedger } from "./actionClaimDetector";

export function buildClaimWarningBanner(text: string, steps?: AgentStep[]): string {
  try {
    const warnings = _computeUnverifiedClaims(text, steps);
    if (!warnings.length) return "";
    const list   = warnings.join("; ");
    const plural = warnings.length === 1 ? "questa azione" : "queste azioni";
    return (
      `\n\n---\n⚠️ **Verifica automatica:** ${list}. ` +
      `Il sistema non ha trovato prove confermate per ${plural} — ` +
      `controlla manualmente prima di considerarle completate.`
    );
  } catch { return ""; }
}