File size: 6,820 Bytes
cc11e77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * apiCallRetryBlock.ts — S508: extracted from agentLoop.ts
 *
 * Esegue la chiamata API LLM con retry esponenziale (3×) e gestisce:
 *   1. LLM call timeout (60s per tentativo) via AbortController
 *   2. Rate-limit (429) + unavailable (503): retry con backoff
 *   3. Network/timeout failure: streamFallback + return
 *
 * Ritorna:
 *   - { action: "ok", response: Response }  → il caller prosegue con la risposta
 *   - { action: "return" }                  → streamFallback già eseguito, caller deve return
 *
 * @module apiCallRetryBlock
 */

import { callWithFallback } from "../providerChain";
import { streamFallback } from "./streamHelpers";
import type { ApiMsg } from "./networkTools";
import { recordOutcome as recordTaskOutcome } from "../agent/SuccessRateMonitor";
import { AGENT_TOOLS } from "./toolDefinitions";
import type { StreamOptions as AgentOptions } from "../types";
import type { TaskType } from "../taskClassifier";

// ─── Interfaces ────────────────────────────────────────────────────────────────

export interface ApiCallRetryCtx {
  token: string;
  safeMsgs: ApiMsg[];
  options: AgentOptions | undefined;
  signal: AbortSignal | undefined;
  loopMessages: ApiMsg[];
  initialMessages: Array<{ role: string; content: string }>;
  realDataInjected: boolean;
  preferredModel: string | undefined;
  maxTokensBudget: number;
  preCls: { type: string } | null;
  onStatus: (msg: string) => void;
  onChunk: (chunk: string) => void;
}

export type ApiCallRetryResult =
  | { action: "ok";     response: Response }
  | { action: "return" };

// ─── Main export ───────────────────────────────────────────────────────────────

/**
 * Chiama il provider LLM con retry esponenziale (max 3 tentativi).
 * Se tutte le retry falliscono → streamFallback e ritorna action="return".
 * Se la chiamata riesce → ritorna action="ok" con la Response grezza.
 */
export async function callLlmWithRetry(ctx: ApiCallRetryCtx): Promise<ApiCallRetryResult> {
  const {
    token, safeMsgs, options, signal, loopMessages, initialMessages,
    realDataInjected, preferredModel, maxTokensBudget, preCls,
    onStatus, onChunk,
  } = ctx;

  const _API_DELAYS = [600, 1_500, 3_500]; // ms — 600 · 1500 · 3500
  let _apiAttempt = 0;
  let res!: Response;

  // eslint-disable-next-line no-constant-condition
  while (true) {
    try {
      // LLM call timeout: 60s per attempt — prevents hanging on frozen providers
      const _llmCtrl = new AbortController();
      const _llmTimer = setTimeout(() => _llmCtrl.abort(), 60_000);
      const _llmSig: AbortSignal = (typeof AbortSignal !== "undefined" && "any" in AbortSignal)
        ? (AbortSignal as { any(s: AbortSignal[]): AbortSignal }).any([...(signal ? [signal] : []), _llmCtrl.signal])
        : _llmCtrl.signal;
      try {
        res = await callWithFallback(token, {
          messages: safeMsgs as unknown as import("../api").ChatMessage[],
          tools: AGENT_TOOLS,
          tool_choice: "auto",
          max_tokens: Math.min(options?.maxTokens ?? 8192, 8192),
          // Fix A (S380): temperatura task-aware — deterministico per code/math, espressivo per chat
          temperature: (() => {
            const _TASK_TEMP: Record<string, number> = {
              code_generation: 0.15, code_fix: 0.15, debug: 0.15,
              math: 0.10, data: 0.30, search_and_report: 0.40,
              text: 0.55, fetch: 0.45,
            };
            const _taskDefault = _TASK_TEMP[preCls?.type ?? ""] ?? 0.50;
            return options?.temperature !== undefined ? Math.min(options.temperature, 0.90) : _taskDefault;
          })(),
          stream: false,
          // S694-fix: passa preferredModel al payload — era ignorato da callWithFallback (HF routing)
          ...(preferredModel ? { preferredModel } : {}),
        }, _llmSig);
      } finally {
        clearTimeout(_llmTimer);
      }
    } catch (err) {
      if (err instanceof Error && err.name === "AbortError" && signal?.aborted) throw err; // user Stop
      if (err instanceof Error && err.name === "AbortError") { onStatus("LLM timeout (60s) — riprovo…"); }
      if (_apiAttempt < _API_DELAYS.length) {
        const delay = _API_DELAYS[_apiAttempt++];
        onStatus(`Riconnessione API… (tentativo ${_apiAttempt}/${_API_DELAYS.length + 1})`);
        await new Promise(r => setTimeout(r, delay));
        continue;
      }
      // All retries exhausted → streamFallback
      console.warn("[AgentLoop] callWithFallback esaurita dopo", _apiAttempt, "tentativi:", err);
      onStatus("Risposta diretta…");
      // S255: usa loopMessages se ha dati pre-iniettati (pre-exec riuscito), altrimenti initialMessages
      // S375: usa loopMessages (ha il format directive + system prompt assemblato) quando pre-exec ha iniettato dati
      if (realDataInjected) {
        await streamFallback(token, loopMessages as Array<{ role: string; content: string }>, onChunk, signal, { ...options, ...(preferredModel ? { model: preferredModel } : {}), maxTokens: maxTokensBudget });
      } else {
        await streamFallback(token, initialMessages, onChunk, signal, { ...options, ...(preferredModel ? { model: preferredModel } : {}), maxTokens: maxTokensBudget });
      }
      // S-GAP5: retry chain esaurita → streamFallback è degradazione del servizio, NON success.
      // "success" qui infiava SuccessRateMonitor → iterBudget sbagliato per quel task type.
      try { recordTaskOutcome((preCls?.type ?? "unknown") as TaskType, "failure"); } catch { /* non-blocking */ }
      return { action: "return" };
    }
    // ── Rate-limit (429) and unavailable (503): wait and retry ────────
    if (!res.ok && [429, 503].includes(res.status) && _apiAttempt < _API_DELAYS.length) {
      // S-LIVE-3: 503 sintetico da callWithFallback ("All providers exhausted") → retry inutile, vai subito a streamFallback
      if (res.status === 503) {
        try { const _b503 = await res.clone().text(); if (_b503.includes("providers exhausted") || _b503.includes("All providers")) break; } catch { /* ignore */ }
      }
      const delay = res.status === 429
        ? _API_DELAYS[_apiAttempt++] * 3 // longer backoff for rate-limit
        : _API_DELAYS[_apiAttempt++];
      onStatus(`API ${res.status} — riprovo tra ${Math.round(delay / 1000)}s (${_apiAttempt}/3)…`);
      await new Promise(r => setTimeout(r, delay));
      continue;
    }
    break; // success or non-retryable error — exit retry loop
  }

  return { action: "ok", response: res };
}