File size: 9,877 Bytes
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269f107
 
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
269f107
efeb81d
 
 
 
 
 
 
 
 
 
 
 
269f107
 
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269f107
efeb81d
 
 
269f107
 
efeb81d
 
 
 
 
 
 
 
 
 
269f107
 
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269f107
 
 
efeb81d
 
 
 
 
269f107
efeb81d
 
 
 
 
 
 
 
269f107
 
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
269f107
 
efeb81d
 
 
 
 
269f107
 
efeb81d
269f107
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
269f107
efeb81d
 
 
269f107
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
269f107
 
 
 
efeb81d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
executor.py — Tool Executor con retry, adaptive timeout, circuit breaker e fallback routing.
Usa AIClient (multi-provider) al posto di OllamaClient (localhost).

Architettura adaptive (GAP-SKILL-SYNC v2):
  _AdaptiveTimeoutTracker — P90-based timeout adaptation (sliding window 5 call)
  Circuit Breaker         — Wilson score < CIRCUIT_OPEN_THRESHOLD → skip al miglior fallback
  Fallback Execution      — TOOL_REGISTRY["fallbacks"] ora eseguiti automaticamente (non solo metadata)
  Recovery Credit         — tool circuit-broken retentato ogni RECOVERY_INTERVAL chiamate
"""
import asyncio
import collections
import logging
import time as _time_mod

from models.ai_client import AIClient
from memory.manager import MemoryManager
from tools.registry import TOOL_REGISTRY

_logger = logging.getLogger("agente_ai.executor")

# ─── Costanti circuit breaker ────────────────────────────────────────────────
_CIRCUIT_OPEN_THRESHOLD = 0.15   # Wilson score < soglia AND >= min calls → circuit open
_MIN_CALLS_FOR_CIRCUIT  = 3      # minimo di chiamate prima che il circuit possa aprirsi
_RECOVERY_INTERVAL      = 5      # ogni N chiamate con circuit open → tenta il tool primario

# ─── S-ORCH-8GAP FIX-GAP2: Adaptive Timeout Tracker ─────────────────────────
class _AdaptiveTimeoutTracker:
    """Tracked P90 per-tool timeout con sliding window di 5 call."""
    _WINDOW     = 5
    _MIN        = 4.0   # mai sotto 4s
    _MAX        = 55.0  # mai sopra 55s
    _MULTIPLIER = 1.5   # P90 * 1.5 = headroom conservativo

    def __init__(self) -> None:
        self._times: dict[str, collections.deque] = {}

    def record(self, tool_name: str, elapsed: float) -> None:
        if tool_name not in self._times:
            self._times[tool_name] = collections.deque(maxlen=self._WINDOW)
        self._times[tool_name].append(elapsed)

    def adaptive_timeout(self, tool_name: str, base_timeout: float) -> float:
        times = self._times.get(tool_name)
        if not times or len(times) < 2:
            return base_timeout
        sorted_t = sorted(times)
        p90_idx  = min(int(len(sorted_t) * 0.9), len(sorted_t) - 1)
        adaptive = sorted_t[p90_idx] * self._MULTIPLIER
        return max(self._MIN, min(self._MAX, adaptive))

_timeout_tracker = _AdaptiveTimeoutTracker()


def _get_session_id() -> str:
    try:
        from tools.registry import _agent_session_id_var
        return _agent_session_id_var.get()
    except Exception as e:
        _logger.debug("[executor] _get_session_id failed: %s", e)
        return "default"


class Executor:
    def __init__(
        self,
        llm_client:  AIClient | None = None,
        memory:      MemoryManager | None = None,
        max_retries: int = 2,
    ):
        self.llm         = llm_client or AIClient()
        self.memory      = memory
        self.max_retries = max_retries
        self._circuit_recovery_counts: dict[str, int] = {}

    @classmethod
    def from_ollama(cls, ollama=None, memory=None, max_retries: int = 2) -> "Executor":
        return cls(memory=memory, max_retries=max_retries)

    def _is_circuit_open(self, tool_name: str, session_id: str) -> bool:
        tool = TOOL_REGISTRY.get(tool_name, {})
        if not tool.get("fallbacks"):
            return False
        try:
            from agents.skill_tracker import get_skill_tracker
            stats = get_skill_tracker().get_stats(session_id).get(tool_name)
        except Exception as e:
            _logger.debug("[executor] _is_circuit_open failed to get skill tracker: %s", e)
            return False
        if not stats:
            return False
        if stats["total_count"] < _MIN_CALLS_FOR_CIRCUIT:
            return False
        if stats["wilson_score"] >= _CIRCUIT_OPEN_THRESHOLD:
            return False
        count = self._circuit_recovery_counts.get(tool_name, 0) + 1
        self._circuit_recovery_counts[tool_name] = count
        if count % _RECOVERY_INTERVAL == 0:
            _logger.info("[executor] recovery credit: riprovo %s (circuit call #%d)", tool_name, count)
            return False
        return True

    async def _try_fallbacks(
        self,
        primary_name: str,
        inputs:       dict,
        timeout:      float,
        session_id:   str,
    ) -> "dict | None":
        tool      = TOOL_REGISTRY.get(primary_name, {})
        fallbacks = tool.get("fallbacks", [])
        if not fallbacks:
            return None

        try:
            from agents.skill_tracker import get_skill_tracker
            sorted_fbs = get_skill_tracker().get_sorted_fallbacks(session_id, fallbacks)
        except Exception as e:
            _logger.debug("[executor] _try_fallbacks failed to sort: %s", e)
            sorted_fbs = fallbacks

        for fb_name in sorted_fbs:
            fb_tool = TOOL_REGISTRY.get(fb_name)
            if not fb_tool or not fb_tool.get("_fn"):
                continue
            _logger.info("[executor] %s fallita — provo fallback %s", primary_name, fb_name)
            try:
                _t0     = _time_mod.monotonic()
                _fb_to  = _timeout_tracker.adaptive_timeout(fb_name, timeout)
                result  = await asyncio.wait_for(fb_tool["_fn"](**inputs), timeout=_fb_to)
                _timeout_tracker.record(fb_name, _time_mod.monotonic() - _t0)
                try:
                    from agents.skill_tracker import get_skill_tracker
                    get_skill_tracker().record(session_id, fb_name, True)
                except Exception as e:
                    _logger.debug("[executor] record success failed: %s", e)
                return {
                    "success":            True,
                    "tool":               fb_name,
                    "output":             result,
                    "via_fallback_from":  primary_name,
                    "attempt":            1,
                }
            except asyncio.TimeoutError:
                _timeout_tracker.record(fb_name, timeout * 1.2)
                _logger.debug("[executor] fallback %s timeout", fb_name)
                try:
                    from agents.skill_tracker import get_skill_tracker
                    get_skill_tracker().record(session_id, fb_name, False)
                except Exception as e:
                    _logger.debug("[executor] record timeout failed: %s", e)
            except Exception as fb_exc:
                _logger.debug("[executor] fallback %s errore: %s", fb_name, str(fb_exc)[:80])
                try:
                    from agents.skill_tracker import get_skill_tracker
                    get_skill_tracker().record(session_id, fb_name, False)
                except Exception as e:
                    _logger.debug("[executor] record error failed: %s", e)

        return None

    async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
        tool = TOOL_REGISTRY.get(tool_name)
        if not tool:
            return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}

        missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
        if missing:
            return {"success": False, "error": f"Input mancanti: {missing}", "output": None}

        session_id = _get_session_id()

        if self._is_circuit_open(tool_name, session_id):
            _logger.info("[executor] circuit OPEN per %s — routing a fallback", tool_name)
            fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
            if fb_result:
                return fb_result
            _logger.warning("[executor] tutti i fallback di %s falliti — provo primario", tool_name)

        fn = tool.get("_fn")
        if fn is None:
            return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}

        last_error: str = "max_retries"
        for attempt in range(self.max_retries + 1):
            try:
                _adaptive_to = _timeout_tracker.adaptive_timeout(tool_name, timeout)
                _t0          = _time_mod.monotonic()
                result       = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
                _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
                if self.memory:
                    try:
                        await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:500]}", str(result)[:500], True)
                    except Exception as e:
                        _logger.debug("[executor] memory save failed: %s", e)
                return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}

            except asyncio.TimeoutError:
                _timeout_tracker.record(tool_name, timeout * 1.2)
                last_error = f"Timeout dopo {timeout}s (tentativo {attempt + 1})"
                if attempt == self.max_retries:
                    fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
                    if fb_result:
                        return fb_result
                    return {"success": False, "error": last_error, "output": None}
                await asyncio.sleep(0.5)

            except Exception as e:
                last_error = str(e)
                if attempt == self.max_retries:
                    fb_result = await self._try_fallbacks(tool_name, inputs, timeout, session_id)
                    if fb_result:
                        return fb_result
                    return {"success": False, "error": last_error, "output": None}
                await asyncio.sleep(0.5)

        return {"success": False, "error": f"Max retries raggiunti: {last_error}", "output": None}