icycache 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,655 @@
1
+ #!/usr/bin/env python3
2
+ """IcyCache - proxy d'outils MCP avec cache + garde anti-boucle.
3
+
4
+ Routage: Hermes -> IcyCache (proxy_tool) -> serveur MCP cible (stdio) -> resultat
5
+ Cache: table tool_cache (tc_get/tc_set). Cle = sha256(server, tool, args) -> reponses
6
+ re-utilisables telles quelles. Aucune estimation de tokens: seule la latence
7
+ economisee est mesuree (agent_events.saved_latency_ms).
8
+ Garde: ToolLoopGuard - bloque un agent qui rappelle le meme outil avec les memes args
9
+ ET le meme resultat (boucle infinie). Detecte aussi dans l'historique LLM
10
+ (llm_loop_check).
11
+ Metriques: agent_events (tool_call / tool_hit / tool_error / tool_blocked).
12
+
13
+ Points de robustesse (verifies en production) :
14
+ - Sessions persistantes par serveur : un appel d'outil ne respawn plus un processus
15
+ a chaque fois (20s+ de latence observee avec npx/uvx).
16
+ - Les erreurs MCP (isError=true) ne sont JAMAIS mises en cache et renvoient ok=false.
17
+ - Les outils d'ecriture (create_/write_/delete_/...) ne sont pas mis en cache par
18
+ defaut : une reponse en cache doit pouvoir etre rejouee sans effet de bord.
19
+ - Les commandes Windows (.cmd comme npx) sont resolues : CreateProcess ne lance
20
+ pas un script .cmd/.ps1 sans shell.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import os
29
+ import shutil
30
+ import sys
31
+ import threading
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ logger = logging.getLogger("IcyCache.tool_proxy")
37
+
38
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
39
+
40
+
41
+ def _resolve_config_file() -> Path:
42
+ """Fichier de config du proxy d'outils.
43
+
44
+ Le proxy d'outils a SON propre fichier (config/icycache.json). Il ne doit pas
45
+ suivre aveuglement ICYCACHE_CONFIG: mcp_gateway.py positionne cette variable
46
+ sur icycache.gateway.json (qui n'a pas de section "tool_proxy"), ce qui
47
+ vidait silencieusement la liste des serveurs routables.
48
+ """
49
+ explicit = os.environ.get("ICYCACHE_TOOLPROXY_CONFIG")
50
+ if explicit:
51
+ return Path(explicit)
52
+ default = PROJECT_ROOT / "config" / "icycache.json"
53
+ env_cfg = os.environ.get("ICYCACHE_CONFIG")
54
+ if env_cfg:
55
+ p = Path(env_cfg)
56
+ try:
57
+ if "tool_proxy" in json.loads(p.read_text(encoding="utf-8")):
58
+ return p
59
+ except Exception:
60
+ pass
61
+ return default
62
+
63
+
64
+ CONFIG_FILE = _resolve_config_file()
65
+
66
+
67
+ def _load_config() -> Dict[str, Any]:
68
+ try:
69
+ with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
70
+ return json.load(fh)
71
+ except Exception:
72
+ logger.exception("tool_proxy: impossible de lire %s", CONFIG_FILE)
73
+ return {}
74
+
75
+
76
+ CONFIG = _load_config()
77
+ TOOL_CACHE_CFG: Dict[str, Any] = CONFIG.get("tool_cache") or {}
78
+ TOOL_PROXY_CFG: Dict[str, Any] = CONFIG.get("tool_proxy") or {}
79
+ TOOL_SERVERS: Dict[str, Dict[str, Any]] = TOOL_PROXY_CFG.get("servers") or {}
80
+ DEFAULT_TTL = int(TOOL_CACHE_CFG.get("ttl_seconds", 3600))
81
+ CACHE_ENABLED = bool(TOOL_CACHE_CFG.get("enabled", True))
82
+
83
+ HERMES_CONFIG = Path(os.environ.get("LOCALAPPDATA") or ".") / "hermes" / "config.yaml"
84
+
85
+ # Heuristique "outil d'ecriture" : un resultat mis en cache doit pouvoir etre rejoue
86
+ # sans effet de bord. Un create_/delete_/push_ ne peut pas l'etre.
87
+ _WRITE_HINTS = (
88
+ "create", "write", "update", "delete", "remove", "destroy", "drop", "purge",
89
+ "push", "commit", "merge", "send", "post", "put", "patch", "edit", "save",
90
+ "upload", "download", "install", "uninstall", "publish", "apply", "move",
91
+ "rename", "rebuild", "reset", "clear", "flush", "exec", "execute", "run_",
92
+ "add_", "set_", "new_", "insert", "deploy", "build", "start_", "stop_", "kill",
93
+ )
94
+
95
+
96
+ def _is_write_tool(tool: str) -> bool:
97
+ t = (tool or "").lower()
98
+ return any(h in t for h in _WRITE_HINTS)
99
+
100
+
101
+ def _resolve_env_value(val: str) -> str:
102
+ """Resolution paresseuse d'un secret vers la config Hermes.
103
+
104
+ Format: "@hermes:mcp_servers.<nom>" -> valeur lue dans %LOCALAPPDATA%\\hermes\\config.yaml.
105
+ Si le noeud est un dict avec 'args', on extrait la valeur du premier arg contenant '='.
106
+ Evite d'ecrire un token dans la config IcyCache.
107
+ """
108
+ if not isinstance(val, str) or not val.startswith("@hermes:"):
109
+ return val
110
+ try:
111
+ import yaml
112
+ except ImportError as exc:
113
+ raise RuntimeError(
114
+ "PyYAML est requis pour resoudre '@hermes:' (pip install pyyaml). "
115
+ "Sinon, mettez la valeur en clair dans la config IcyCache."
116
+ ) from exc
117
+ path = val[len("@hermes:"):]
118
+ with open(HERMES_CONFIG, "r", encoding="utf-8") as fh:
119
+ cfg = yaml.safe_load(fh)
120
+ node: Any = cfg
121
+ for part in path.split("."):
122
+ node = node[part]
123
+ if isinstance(node, dict):
124
+ for a in node.get("args") or []:
125
+ if isinstance(a, str) and "=" in a:
126
+ return a.split("=", 1)[1]
127
+ raise KeyError(f"aucun secret resolvable dans {path}")
128
+ return str(node)
129
+
130
+
131
+ def _env_for(cfg: Dict[str, Any]) -> Optional[Dict[str, str]]:
132
+ raw = cfg.get("env")
133
+ if not raw:
134
+ return None
135
+ return {k: _resolve_env_value(v) for k, v in raw.items()}
136
+
137
+
138
+ # Import apres la lecture de la config: icy_core lit le meme fichier et initialise la DB.
139
+ from icy_core import ( # noqa: E402
140
+ tc_get,
141
+ tc_set,
142
+ tc_flush,
143
+ tc_clear,
144
+ tc_stats,
145
+ log_agent_event,
146
+ llm_loop_check,
147
+ ToolLoopGuard,
148
+ )
149
+
150
+ GUARD = ToolLoopGuard(TOOL_CACHE_CFG)
151
+
152
+ _TOOL_LIST_CACHE: Dict[str, tuple] = {}
153
+ _TOOL_LIST_TTL = float(TOOL_PROXY_CFG.get("list_cache_seconds", 30))
154
+
155
+
156
+ # =============================================================================
157
+ # RESOLUTION DE COMMANDE (Windows)
158
+ # =============================================================================
159
+ def _resolve_command(cmd: str) -> str:
160
+ """Resout une commande d'executable de façon compatible CreateProcess.
161
+
162
+ Sous Windows, `npx` seul pointe vers le script sans extension (bash) et
163
+ `npx.ps1` n'est pas executable sans shell: CreateProcess echoue avec
164
+ WinError 2. On prefere donc `<cmd>.cmd`, puis `<cmd>.exe`, puis `<cmd>.bat`.
165
+ """
166
+ if not cmd or os.name != "nt":
167
+ return cmd
168
+ if os.path.isabs(cmd) or (os.path.altsep and os.path.altsep in cmd):
169
+ return cmd
170
+ if os.path.splitext(cmd)[1]:
171
+ return cmd
172
+ for ext in (".cmd", ".exe", ".bat", ".com"):
173
+ found = shutil.which(cmd + ext)
174
+ if found:
175
+ return found
176
+ return cmd
177
+
178
+
179
+ def _venv_python() -> str:
180
+ """Interpreteur Python du venv du projet (le seul qui a les dependances)."""
181
+ if os.name == "nt":
182
+ cand = PROJECT_ROOT / "venv" / "Scripts" / "python.exe"
183
+ else:
184
+ cand = PROJECT_ROOT / "venv" / "bin" / "python"
185
+ return str(cand) if cand.exists() else sys.executable
186
+
187
+
188
+ def _expand(value: str) -> str:
189
+ """Remplace les jetons de portabilite dans la config.
190
+
191
+ {PROJECT_ROOT} -> racine du projet {PYTHON} -> interpreteur du venv
192
+ Permet de deplacer IcyCache sans editer la config a la main.
193
+ """
194
+ if not isinstance(value, str):
195
+ return value
196
+ return (value
197
+ .replace("{PROJECT_ROOT}", str(PROJECT_ROOT))
198
+ .replace("{PYTHON}", _venv_python()))
199
+
200
+
201
+ def _stdio_params(cfg: Dict[str, Any]):
202
+ from mcp import StdioServerParameters
203
+ return StdioServerParameters(
204
+ command=_resolve_command(_expand(str(cfg["command"]))),
205
+ args=[_expand(a) for a in (cfg.get("args") or [])],
206
+ env=_env_for(cfg),
207
+ )
208
+
209
+
210
+ def _describe_exc(exc: BaseException) -> str:
211
+ """Message d'erreur utile: anyio encapsule la vraie erreur dans un
212
+ ExceptionGroup ('unhandled errors in a TaskGroup'), ce qui masque la cause."""
213
+ parts: List[str] = []
214
+
215
+ def walk(e: BaseException, depth: int = 0) -> None:
216
+ if isinstance(e, BaseExceptionGroup):
217
+ for sub in e.exceptions:
218
+ walk(sub, depth + 1)
219
+ else:
220
+ msg = str(e).strip()
221
+ parts.append(f"{type(e).__name__}: {msg}" if msg else type(e).__name__)
222
+
223
+ try:
224
+ walk(exc)
225
+ except Exception:
226
+ parts = []
227
+ if not parts:
228
+ parts = [f"{type(exc).__name__}: {exc}"]
229
+ return " | ".join(parts)
230
+
231
+
232
+ # =============================================================================
233
+ # SESSION PERSISTANTE PAR SERVEUR
234
+ # =============================================================================
235
+ class _ServerConn:
236
+ """Session MCP vivante pour un serveur cible.
237
+
238
+ `_exec()` ouvrait un processus + un handshake `initialize` a CHAQUE appel
239
+ d'outil (mesure: 21s pour npx, 12s pour uvx qui installe a chaque fois).
240
+ On garde donc une session par serveur, dans son propre thread + event loop,
241
+ et on y planifie les appels via run_coroutine_threadsafe. Le exit du task
242
+ group anyio reste dans la tache qui l'a ouvert, comme l'exige le SDK.
243
+ """
244
+
245
+ def __init__(self, name: str, cfg: Dict[str, Any]):
246
+ self.name = name
247
+ self.cfg = cfg
248
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
249
+ self._thread: Optional[threading.Thread] = None
250
+ self._session = None
251
+ self._ready = threading.Event()
252
+ self._error: Optional[BaseException] = None
253
+ self._lock = threading.Lock()
254
+
255
+ # -- demarrage ---------------------------------------------------------
256
+ def start(self, timeout: float = 60.0) -> None:
257
+ with self._lock:
258
+ if self._thread is not None and self._thread.is_alive() and self._session is not None:
259
+ return
260
+ self._ready.clear()
261
+ self._error = None
262
+ self._thread = threading.Thread(
263
+ target=self._run, daemon=True, name="icy-mcp-%s" % self.name)
264
+ self._thread.start()
265
+ if not self._ready.wait(timeout):
266
+ raise TimeoutError(f"demarrage du serveur MCP {self.name!r} trop long (> {timeout:.0f}s)")
267
+ if self._error is not None:
268
+ raise self._error
269
+
270
+ def _run(self) -> None:
271
+ loop = asyncio.new_event_loop()
272
+ asyncio.set_event_loop(loop)
273
+ self._loop = loop
274
+ try:
275
+ loop.run_until_complete(self._serve())
276
+ except BaseException as exc: # noqa: BLE001 - remonte a start()
277
+ self._error = exc
278
+ finally:
279
+ self._ready.set()
280
+ try:
281
+ loop.run_until_complete(loop.shutdown_asyncgens())
282
+ except Exception:
283
+ pass
284
+ loop.close()
285
+
286
+ async def _serve(self) -> None:
287
+ from mcp import ClientSession
288
+ from mcp.client.stdio import stdio_client
289
+
290
+ params = _stdio_params(self.cfg)
291
+ async with stdio_client(params) as (reader, writer):
292
+ async with ClientSession(reader, writer) as session:
293
+ await session.initialize()
294
+ self._session = session
295
+ self._ready.set()
296
+ while self._session is session:
297
+ await asyncio.sleep(0.25)
298
+
299
+ # -- appels ------------------------------------------------------------
300
+ def _submit(self, coro_factory, timeout: float):
301
+ loop = self._loop
302
+ session = self._session
303
+ if loop is None or session is None:
304
+ raise RuntimeError(f"session MCP {self.name!r} non prete")
305
+ fut = asyncio.run_coroutine_threadsafe(coro_factory(session), loop)
306
+ return fut.result(timeout)
307
+
308
+ def call_tool(self, tool: str, args: Dict[str, Any], timeout: float = 300.0):
309
+ return self._submit(lambda s: s.call_tool(tool, args or {}), timeout)
310
+
311
+ def list_tools(self, timeout: float = 60.0):
312
+ return self._submit(lambda s: s.list_tools(), timeout)
313
+
314
+ def alive(self) -> bool:
315
+ return self._session is not None and self._thread is not None and self._thread.is_alive()
316
+
317
+ def stop(self) -> None:
318
+ # Pas de loop.stop() ni de join (stop() est appele depuis l'event loop principal):
319
+ # _serve() sort seul via `while self._session is session` et ferme
320
+ # ClientSession/stdio_client dans la tache qui les a ouverts (contrat anyio).
321
+ self._session = None
322
+
323
+
324
+ _CONNS: Dict[str, _ServerConn] = {}
325
+ _CONNS_LOCK = threading.Lock()
326
+
327
+
328
+ def _conn(server: str) -> _ServerConn:
329
+ with _CONNS_LOCK:
330
+ c = _CONNS.get(server)
331
+ if c is None:
332
+ c = _ServerConn(server, TOOL_SERVERS[server])
333
+ _CONNS[server] = c
334
+ return c
335
+
336
+
337
+ def _drop_conn(server: str) -> None:
338
+ with _CONNS_LOCK:
339
+ c = _CONNS.pop(server, None)
340
+ if c is not None:
341
+ c.stop()
342
+
343
+
344
+ # =============================================================================
345
+ # API PUBLIQUE
346
+ # =============================================================================
347
+ def server_names() -> List[str]:
348
+ return sorted(TOOL_SERVERS)
349
+
350
+
351
+ def tool_cache_key(server: str, tool: str, args: Any) -> str:
352
+ """Cle de cache = hash exact de (serveur, outil, arguments). Deterministe."""
353
+ raw = json.dumps([server, tool, args if args is not None else {}],
354
+ sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
355
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
356
+
357
+
358
+ def _args_hash(args: Any) -> str:
359
+ raw = json.dumps(args if args is not None else {},
360
+ sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
361
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
362
+
363
+
364
+ def _content_to_json(res) -> Any:
365
+ """Resultat MCP -> structure serialisable (json-able)."""
366
+ if res is None:
367
+ return None
368
+ dump = getattr(res, "model_dump", None)
369
+ if callable(dump):
370
+ try:
371
+ return dump(mode="json")
372
+ except Exception:
373
+ pass
374
+ out: List[Any] = []
375
+ for blk in getattr(res, "content", []) or []:
376
+ if isinstance(blk, dict):
377
+ out.append(blk)
378
+ else:
379
+ out.append({k: v for k, v in vars(blk).items() if not k.startswith("_")})
380
+ return out
381
+
382
+
383
+ def _is_error_result(payload: Any) -> bool:
384
+ """Detecte un resultat MCP en erreur (isError=true) dans le payload serialise."""
385
+ if isinstance(payload, dict):
386
+ if payload.get("isError") is True:
387
+ return True
388
+ if payload.get("is_error") is True:
389
+ return True
390
+ return False
391
+
392
+
393
+ def _error_text(payload: Any) -> str:
394
+ """Extrait un texte lisible d'un resultat d'erreur MCP."""
395
+ if isinstance(payload, dict):
396
+ chunks: List[str] = []
397
+ for blk in payload.get("content") or []:
398
+ if isinstance(blk, dict) and blk.get("text"):
399
+ chunks.append(str(blk["text"]))
400
+ if chunks:
401
+ return " ".join(chunks)[:800]
402
+ if payload.get("error"):
403
+ return str(payload["error"])[:800]
404
+ return "erreur MCP"
405
+
406
+
407
+ def _cache_decision(cfg: Dict[str, Any], tool: str) -> bool:
408
+ """Le cache d'outils est OPT-IN par serveur, et les outils d'ecriture sont
409
+ exclus par defaut: rejouer une reponse en cache doit etre sans effet de bord.
410
+
411
+ Regles (dans l'ordre):
412
+ 1. `cache_tools` present (dict) -> ALLOWLIST stricte: seuls les outils
413
+ listes a true sont caches, tout le reste est execute a chaque fois.
414
+ 2. `cache: true` -> heuristique anti-ecriture (les outils create_/write_/
415
+ delete_/push_... ne sont pas caches).
416
+ 3. sinon -> pas de cache.
417
+ """
418
+ if not CACHE_ENABLED:
419
+ return False
420
+ per = cfg.get("cache_tools")
421
+ if isinstance(per, dict):
422
+ return bool(per.get(tool, False))
423
+ if not cfg.get("cache", False):
424
+ return False
425
+ if _is_write_tool(tool):
426
+ allow = cfg.get("cache_write_tools") or []
427
+ return tool in allow
428
+ return True
429
+
430
+
431
+ async def _exec(server: str, cfg: Dict[str, Any], tool: str, args: Dict[str, Any]):
432
+ """Execute l'outil. Session persistante si possible, processus jetable sinon."""
433
+ from mcp import ClientSession
434
+ from mcp.client.stdio import stdio_client
435
+
436
+ from mcp.shared.exceptions import McpError
437
+
438
+ conn = _conn(server)
439
+ started = False
440
+ try:
441
+ await asyncio.to_thread(conn.start)
442
+ if not conn.alive():
443
+ raise RuntimeError("session persistante indisponible")
444
+ started = True
445
+ res = await asyncio.to_thread(conn.call_tool, tool, args)
446
+ return _content_to_json(res)
447
+ except McpError:
448
+ # Erreur applicative renvoyee par le serveur (outil inconnu, args invalides...):
449
+ # la session est saine. Ni drop ni rejeu (evite double execution + respawn).
450
+ raise
451
+ except Exception as exc:
452
+ if started and isinstance(exc, TimeoutError):
453
+ # Timeout de l'appel: l'outil peut encore tourner cote serveur -> pas de rejeu.
454
+ raise
455
+ # Session cassee ou demarrage impossible -> on retente en jetable.
456
+ _drop_conn(server)
457
+ logger.warning("tool_proxy: session persistante %s KO (%s), repli jetable", server,
458
+ _describe_exc(exc))
459
+ params = _stdio_params(cfg)
460
+ async with stdio_client(params) as (reader, writer):
461
+ async with ClientSession(reader, writer) as session:
462
+ await session.initialize()
463
+ res = await session.call_tool(tool, args or {})
464
+ return _content_to_json(res)
465
+
466
+
467
+ async def _list_tools(server: str) -> List[str]:
468
+ """Liste des outils exposes par le serveur cible (avec cache memoire TTL)."""
469
+ now = time.time()
470
+ hit = _TOOL_LIST_CACHE.get(server)
471
+ if hit and now - hit[0] < _TOOL_LIST_TTL:
472
+ return hit[1]
473
+
474
+ from mcp import ClientSession
475
+ from mcp.client.stdio import stdio_client
476
+
477
+ conn = _conn(server)
478
+ names: Optional[List[str]] = None
479
+ try:
480
+ await asyncio.to_thread(conn.start)
481
+ if conn.alive():
482
+ listing = await asyncio.to_thread(conn.list_tools)
483
+ names = [t.name for t in (getattr(listing, "tools", None) or [])]
484
+ except Exception as exc:
485
+ logger.warning("tool_proxy: liste persistante %s KO (%s), repli jetable", server,
486
+ _describe_exc(exc))
487
+ _drop_conn(server)
488
+
489
+ if names is None:
490
+ params = _stdio_params(TOOL_SERVERS[server])
491
+ async with stdio_client(params) as (reader, writer):
492
+ async with ClientSession(reader, writer) as session:
493
+ await session.initialize()
494
+ listing = await session.list_tools()
495
+ names = [t.name for t in (getattr(listing, "tools", None) or [])]
496
+
497
+ _TOOL_LIST_CACHE[server] = (now, names)
498
+ return names
499
+
500
+
501
+ async def list_server_tools(server: str) -> Dict[str, Any]:
502
+ if server not in TOOL_SERVERS:
503
+ return {"ok": False, "error": f"serveur inconnu: {server!r}", "servers": server_names()}
504
+ try:
505
+ names = await _list_tools(server)
506
+ except Exception as exc:
507
+ return {"ok": False, "error": _describe_exc(exc)}
508
+ return {"ok": True, "server": server, "tools": names, "count": len(names)}
509
+
510
+
511
+ async def call_tool(server: str, tool: str, args: Any = None,
512
+ client: Optional[str] = None) -> Dict[str, Any]:
513
+ """Appelle un outil MCP via le proxy: cache -> loop guard -> execution -> stockage."""
514
+ args = args if isinstance(args, dict) else {}
515
+ if server not in TOOL_SERVERS:
516
+ return {"ok": False, "error": f"serveur inconnu: {server!r}", "servers": server_names()}
517
+
518
+ cfg = TOOL_SERVERS[server]
519
+ t0 = time.perf_counter()
520
+ cacheable = _cache_decision(cfg, tool)
521
+ key = tool_cache_key(server, tool, args)
522
+ ahash = _args_hash(args)
523
+
524
+ # --- HIT: reponse reutilisable telle quelle ---
525
+ if cacheable:
526
+ row = tc_get(key)
527
+ if row:
528
+ result_json, latency_ms, _bytes = row
529
+ log_agent_event("tool_hit", server=server, tool=tool, client=client,
530
+ args_hash=key, latency_ms=0, saved_latency_ms=latency_ms or 0)
531
+ try:
532
+ payload = json.loads(result_json)
533
+ except Exception:
534
+ payload = result_json
535
+ return {"ok": True, "cached": True, "latency_ms": 0,
536
+ "saved_latency_ms": latency_ms or 0, "result": payload}
537
+
538
+ # --- GARDE ANTI-BOUcle: meme tool + memes args deja bloques ---
539
+ # La cle inclut le serveur: deux serveurs peuvent exposer le meme nom d'outil.
540
+ sess = "%s|%s" % (server, client or "default")
541
+ if GUARD.check_before(sess, tool, ahash):
542
+ GUARD.record_blocked(sess, tool, ahash)
543
+ log_agent_event("tool_blocked", server=server, tool=tool, client=client,
544
+ args_hash=key, detail={"reason": "loop_guard"})
545
+ return {"ok": False,
546
+ "error": "BOUCLE BLOQUEE: meme outil + memes args + memes resultats (ToolLoopGuard)."}
547
+
548
+ # --- MISS: execution reelle ---
549
+ try:
550
+ payload = await _exec(server, cfg, tool, args)
551
+ except Exception as exc:
552
+ log_agent_event("tool_error", server=server, tool=tool, client=client,
553
+ args_hash=key,
554
+ latency_ms=int((time.perf_counter() - t0) * 1000),
555
+ detail={"error": _describe_exc(exc)})
556
+ return {"ok": False, "error": _describe_exc(exc)}
557
+ latency_ms = int((time.perf_counter() - t0) * 1000)
558
+
559
+ # --- Erreur MCP: jamais de cache, jamais de faux "ok" ---
560
+ if _is_error_result(payload):
561
+ msg = _error_text(payload)
562
+ log_agent_event("tool_error", server=server, tool=tool, client=client,
563
+ args_hash=key, latency_ms=latency_ms,
564
+ detail={"error": msg, "source": "mcp_isError"})
565
+ return {"ok": False, "cached": False, "latency_ms": latency_ms,
566
+ "error": msg, "result": payload}
567
+
568
+ result_json = json.dumps(payload, ensure_ascii=False, default=str)
569
+ rhash = hashlib.sha256(result_json.encode("utf-8")).hexdigest()[:16]
570
+ level, cycle_len, reps = GUARD.after(sess, tool, ahash, rhash)
571
+
572
+ if cacheable:
573
+ ttl = int(cfg.get("ttl_seconds", DEFAULT_TTL))
574
+ try:
575
+ tc_set(key, server, tool, result_json, len(result_json.encode("utf-8")), latency_ms, ttl)
576
+ except Exception:
577
+ logger.exception("tc_set a echoue pour %s/%s", server, tool)
578
+
579
+ log_agent_event("tool_call", server=server, tool=tool, client=client,
580
+ args_hash=key, nbytes=len(result_json.encode("utf-8")),
581
+ latency_ms=latency_ms, detail={"cached": False, "cacheable": cacheable})
582
+
583
+ out: Dict[str, Any] = {"ok": True, "cached": False, "latency_ms": latency_ms, "result": payload}
584
+ if level in ("warn", "block"):
585
+ out["loop_warning"] = {"level": level, "cycle_len": cycle_len, "reps": reps}
586
+ return out
587
+
588
+
589
+ async def batch_call(calls: List[Dict[str, Any]],
590
+ client: Optional[str] = None) -> List[Dict[str, Any]]:
591
+ """Appel batch: [{'server':..,'tool':..,'args':{..}}, ...]."""
592
+ out: List[Dict[str, Any]] = []
593
+ calls = calls or []
594
+ if calls and all(isinstance(c, dict) for c in calls):
595
+ # Les appels vers des serveurs differents sont independants -> en parallele.
596
+ # Un meme serveur reste serialise (session MCP partagee, pas de concurrence).
597
+ groups: Dict[str, List[Dict[str, Any]]] = {}
598
+ for c in calls:
599
+ groups.setdefault(str(c.get("server")), []).append(c)
600
+ if len(groups) > 1:
601
+ results = await asyncio.gather(
602
+ *[call_tool(c.get("server"), c.get("tool"), c.get("args") or {}, client)
603
+ for c in calls],
604
+ return_exceptions=True)
605
+ for c, r in zip(calls, results):
606
+ if isinstance(r, BaseException):
607
+ out.append({"ok": False, "error": _describe_exc(r)})
608
+ else:
609
+ out.append(r)
610
+ return out
611
+
612
+ for call in calls:
613
+ if not isinstance(call, dict):
614
+ out.append({"ok": False, "error": "call invalide (dict attendu)"})
615
+ continue
616
+ out.append(await call_tool(call.get("server"), call.get("tool"),
617
+ call.get("args") or {}, client))
618
+ return out
619
+
620
+
621
+ def llm_messages_guard(messages: Any, warn_after: int = 3,
622
+ max_cycle: int = 3) -> Optional[Dict[str, Any]]:
623
+ """Boucle d'agent detectee dans l'historique LLM (memes tool calls + memes resultats)."""
624
+ return llm_loop_check(messages, warn_after=warn_after, max_cycle=max_cycle)
625
+
626
+
627
+ def clear_cache(server: Optional[str] = None) -> Dict[str, Any]:
628
+ try:
629
+ n = tc_clear(server)
630
+ except Exception as exc:
631
+ return {"ok": False, "error": _describe_exc(exc)}
632
+ return {"ok": True, "cleared": n, "server": server or "all"}
633
+
634
+
635
+ def flush_server(server: str) -> Dict[str, Any]:
636
+ try:
637
+ n = tc_flush(server)
638
+ except Exception as exc:
639
+ return {"ok": False, "error": _describe_exc(exc)}
640
+ return {"ok": True, "cleared": n, "server": server}
641
+
642
+
643
+ def stats() -> Dict[str, Any]:
644
+ try:
645
+ return {"ok": True, **tc_stats()}
646
+ except Exception as exc:
647
+ return {"ok": False, "error": _describe_exc(exc)}
648
+
649
+
650
+ def connection_status() -> Dict[str, Any]:
651
+ """Etat des sessions persistantes (diagnostic)."""
652
+ with _CONNS_LOCK:
653
+ items = list(_CONNS.values())
654
+ return {c.name: {"alive": c.alive(), "error": _describe_exc(c._error) if c._error else None}
655
+ for c in items}