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,8 @@
1
+ {
2
+ "semantic_enabled": false,
3
+ "semantic_threshold": 0.95,
4
+ "semantic_relax_threshold": 0.97,
5
+ "semantic_embed_url": "http://127.0.0.1:1234",
6
+ "semantic_embed_model": null,
7
+ "qdrant_url": "http://127.0.0.1:6333"
8
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "total": 100,
4
+ "verified_anchors": [
5
+ {"name": "ha-mcp", "version": "8.7.0", "transport": "stdio"},
6
+ {"name": "serena-agent", "version": "2.1.0", "cmd": "serena start-mcp-server", "transport": "stdio"},
7
+ {"name": "browser-use", "version": "0.13.11", "flag": "--mcp", "transport": "stdio"}
8
+ ],
9
+ "groups": {"A": 15, "B": 22, "C1": 3, "C2": 60}
10
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """IcyCache - store de cache LLM (compatible avec la table `cache` de mcp_server.py).
3
+
4
+ La table `cache` existe deja en base (creee par mcp_server.py). Ce module y accede
5
+ sans la recreer ni l'alterer, a part une colonne additive `raw_hash` qui permet
6
+ de savoir si un hit vient d'un payload identique ou d'un payload normalise
7
+ equivalent (transparence pour le dashboard).
8
+
9
+ Format de stockage de `response` (v3) :
10
+ {"_v": 3, "kind": "openai", "message": {...}, "usage": {...}, "model": "...",
11
+ "finish_reason": "stop", "sse": "data: ...\\n\\n", "gen_time_ms": 123}
12
+
13
+ Les entrees v2 (mcp_server.py) restent lisibles.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sqlite3
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+
24
+ __all__ = ["CacheStore", "decode_entry", "encode_entry"]
25
+
26
+
27
+ def encode_entry(kind: str, message: Any, usage: Dict[str, Any], model: str,
28
+ finish_reason: str, sse: str, gen_time_ms: int) -> str:
29
+ return json.dumps(
30
+ {
31
+ "_v": 3,
32
+ "kind": kind,
33
+ "message": message,
34
+ "usage": usage or {},
35
+ "model": model,
36
+ "finish_reason": finish_reason or "stop",
37
+ "sse": sse,
38
+ "gen_time_ms": gen_time_ms,
39
+ },
40
+ ensure_ascii=False,
41
+ )
42
+
43
+
44
+ def decode_entry(raw: str) -> Dict[str, Any]:
45
+ """Decode une entree cache (v3 ou v2 ou texte brut) en structure exploitable."""
46
+ out: Dict[str, Any] = {"message": None, "usage": {}, "model": None, "finish_reason": "stop",
47
+ "sse": None, "gen_time_ms": None, "version": 0}
48
+ if not raw:
49
+ return out
50
+ try:
51
+ data = json.loads(raw)
52
+ except (json.JSONDecodeError, TypeError):
53
+ out["message"] = {"role": "assistant", "content": raw}
54
+ return out
55
+ if not isinstance(data, dict):
56
+ out["message"] = data
57
+ return out
58
+ v = data.get("_v")
59
+ if v == 3:
60
+ out.update({
61
+ "message": data.get("message"),
62
+ "usage": data.get("usage") or {},
63
+ "model": data.get("model"),
64
+ "finish_reason": data.get("finish_reason") or "stop",
65
+ "sse": data.get("sse"),
66
+ "gen_time_ms": data.get("gen_time_ms"),
67
+ "version": 3,
68
+ })
69
+ elif v == 2:
70
+ out.update({
71
+ "message": data.get("message"),
72
+ "usage": data.get("usage") or {},
73
+ "model": data.get("model"),
74
+ "finish_reason": data.get("finish_reason") or "stop",
75
+ "sse": None,
76
+ "gen_time_ms": data.get("gen_time_ms"),
77
+ "version": 2,
78
+ })
79
+ else:
80
+ out["message"] = data
81
+ out["version"] = 1
82
+ return out
83
+
84
+
85
+ class CacheStore:
86
+ """Acces thread-safe (un lock par operation) au cache LLM."""
87
+
88
+ def __init__(self, db_path: Path, ttl_seconds: int = 86400, max_entries: int = 10000):
89
+ self.db_path = Path(db_path)
90
+ self.ttl_seconds = int(ttl_seconds)
91
+ self.max_entries = int(max_entries)
92
+ self._lock = threading.Lock()
93
+ self._migrate()
94
+
95
+ def _connect(self) -> sqlite3.Connection:
96
+ conn = sqlite3.connect(self.db_path, timeout=10)
97
+ conn.execute("PRAGMA busy_timeout=10000")
98
+ return conn
99
+
100
+ def _migrate(self) -> None:
101
+ """Colonnes additives uniquement (jamais de DROP / recreation)."""
102
+ with self._lock:
103
+ conn = self._connect()
104
+ try:
105
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)")}
106
+ if "raw_hash" not in cols:
107
+ conn.execute("ALTER TABLE cache ADD COLUMN raw_hash TEXT")
108
+ conn.commit()
109
+ except sqlite3.OperationalError:
110
+ pass
111
+ finally:
112
+ conn.close()
113
+
114
+ def get(self, prompt_hash: str) -> Optional[Dict[str, Any]]:
115
+ with self._lock:
116
+ conn = self._connect()
117
+ try:
118
+ row = conn.execute(
119
+ "SELECT normalized_prompt, original_prompt, response, is_stream, provider, model, "
120
+ "created_at, access_count, raw_hash FROM cache "
121
+ "WHERE prompt_hash = ? AND created_at >= datetime('now', '-' || ? || ' seconds')",
122
+ (prompt_hash, self.ttl_seconds),
123
+ ).fetchone()
124
+ if not row:
125
+ return None
126
+ conn.execute(
127
+ "UPDATE cache SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP "
128
+ "WHERE prompt_hash = ?", (prompt_hash,))
129
+ conn.commit()
130
+ return {
131
+ "prompt_hash": prompt_hash,
132
+ "normalized_prompt": row[0],
133
+ "original_prompt": row[1],
134
+ "response": row[2],
135
+ "is_stream": bool(row[3]),
136
+ "provider": row[4],
137
+ "model": row[5],
138
+ "created_at": row[6],
139
+ "access_count": (row[7] or 1) + 1,
140
+ "raw_hash": row[8],
141
+ }
142
+ finally:
143
+ conn.close()
144
+
145
+ def set(self, prompt_hash: str, normalized: str, original: str, response: str,
146
+ is_stream: bool, provider: str = None, model: str = None, raw_hash: str = None) -> None:
147
+ with self._lock:
148
+ conn = self._connect()
149
+ try:
150
+ conn.execute(
151
+ "INSERT OR REPLACE INTO cache "
152
+ "(prompt_hash, normalized_prompt, original_prompt, response, is_stream, provider, model, "
153
+ " created_at, last_accessed, access_count, raw_hash) "
154
+ "VALUES (?,?,?,?,?,?,?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, ?)",
155
+ (prompt_hash, normalized, original, response, int(bool(is_stream)), provider, model, raw_hash),
156
+ )
157
+ conn.execute("DELETE FROM cache WHERE created_at < datetime('now', '-' || ? || ' seconds')",
158
+ (self.ttl_seconds,))
159
+ count = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
160
+ if count >= self.max_entries:
161
+ excess = count - self.max_entries + 1
162
+ conn.execute(
163
+ "DELETE FROM cache WHERE prompt_hash IN "
164
+ "(SELECT prompt_hash FROM cache ORDER BY last_accessed ASC LIMIT ?)", (excess,))
165
+ conn.commit()
166
+ finally:
167
+ conn.close()
168
+
169
+ def stats(self) -> Dict[str, Any]:
170
+ with self._lock:
171
+ conn = self._connect()
172
+ try:
173
+ total = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
174
+ hits = conn.execute("SELECT COALESCE(SUM(access_count - 1), 0) FROM cache").fetchone()[0] or 0
175
+ by_provider = dict(conn.execute(
176
+ "SELECT provider, COUNT(*) FROM cache GROUP BY provider").fetchall())
177
+ by_model = dict(conn.execute(
178
+ "SELECT model, COUNT(*) FROM cache GROUP BY model ORDER BY 2 DESC LIMIT 20").fetchall())
179
+ return {
180
+ "total_entries": total,
181
+ "total_hits": hits,
182
+ "by_provider": by_provider,
183
+ "by_model": by_model,
184
+ "db_path": str(self.db_path),
185
+ }
186
+ finally:
187
+ conn.close()
188
+
189
+ def clear(self, prompt_hash: Optional[str] = None) -> int:
190
+ with self._lock:
191
+ conn = self._connect()
192
+ try:
193
+ if prompt_hash:
194
+ n = conn.execute("DELETE FROM cache WHERE prompt_hash = ?", (prompt_hash,)).rowcount
195
+ else:
196
+ n = conn.execute("DELETE FROM cache").rowcount
197
+ conn.commit()
198
+ return n
199
+ finally:
200
+ conn.close()
201
+
202
+ def search(self, query: str, limit: int = 10) -> list:
203
+ limit = max(1, min(int(limit), 200))
204
+ with self._lock:
205
+ conn = self._connect()
206
+ try:
207
+ rows = conn.execute(
208
+ "SELECT prompt_hash, normalized_prompt, provider, model, created_at, access_count "
209
+ "FROM cache WHERE normalized_prompt LIKE ? ORDER BY access_count DESC LIMIT ?",
210
+ ("%" + query + "%", limit)).fetchall()
211
+ return [
212
+ {"hash": r[0][:8] + "...", "normalized": r[1], "provider": r[2],
213
+ "model": r[3], "created_at": r[4], "access_count": r[5]}
214
+ for r in rows
215
+ ]
216
+ finally:
217
+ conn.close()
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """IcyCache core: config, DB (events / agent_events / tool_cache), hub WebSocket, loop guard.
3
+
4
+ Module partage par server_new.py (gateway HTTP) et mcp_gateway.py (gateway MCP).
5
+ Aucune dependance a FastAPI. Les evenements "agent" (tools, boucles) vont dans une table
6
+ SEPARÉE (agent_events) pour ne pas fausser les compteurs de la table events (/api/metrics).
7
+ """
8
+ import os
9
+ import json
10
+ import time
11
+ import sqlite3
12
+ import asyncio
13
+ import hashlib
14
+ import logging
15
+ import threading
16
+ from pathlib import Path
17
+ from collections import deque
18
+ from datetime import datetime, timezone
19
+
20
+ logger = logging.getLogger("IcyCache.core")
21
+
22
+ from icy_paths import PROJECT_ROOT, resolve_data_path # noqa: E402
23
+
24
+ CONFIG_FILE = Path(os.environ.get("ICYCACHE_CONFIG") or (PROJECT_ROOT / "config" / "icycache.json"))
25
+ with open(CONFIG_FILE, "r", encoding="utf-8") as _f:
26
+ CONFIG = json.load(_f)
27
+ # Priorite: variable d'env > chemin declare dans la config > chemin par defaut.
28
+ # (mcp_gateway/server_new lisaient "cache.db_path" ; on aligne icy_core sur la
29
+ # meme regle pour que tous les processus pointent sur la meme base.)
30
+ # Un chemin relatif est resolu contre DATA_DIR (racine du depot en mode source,
31
+ # repertoire utilisateur en mode installe) -> portable d'une machine a l'autre.
32
+ DB_FILE = resolve_data_path(
33
+ os.environ.get("ICYCACHE_DB")
34
+ or os.environ.get("CACHEFORGE_DB")
35
+ or CONFIG.get("cache", {}).get("db_path"),
36
+ "db/cache.db",
37
+ )
38
+ DB_FILE.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+
41
+ def utc_iso():
42
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
43
+
44
+
45
+ def canonical(obj):
46
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
47
+
48
+
49
+ def short_hash(s, n=16):
50
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()[:n]
51
+
52
+
53
+ def _db():
54
+ return sqlite3.connect(DB_FILE, timeout=10)
55
+
56
+
57
+ # === HUB WEBSOCKET (thread-safe publish, fan-out dans la boucle asyncio du proxy) ===
58
+ class EventHub:
59
+ def __init__(self):
60
+ self.loop = None
61
+ self.subs = set()
62
+
63
+ def bind(self, loop):
64
+ self.loop = loop
65
+
66
+ def subscribe(self):
67
+ q = asyncio.Queue(maxsize=2000)
68
+ self.subs.add(q)
69
+ return q
70
+
71
+ def unsubscribe(self, q):
72
+ self.subs.discard(q)
73
+
74
+ def publish(self, ev):
75
+ loop = self.loop
76
+ if loop is None or not self.subs:
77
+ return
78
+
79
+ def _fan():
80
+ for q in list(self.subs):
81
+ try:
82
+ q.put_nowait(ev)
83
+ except asyncio.QueueFull:
84
+ pass
85
+
86
+ try:
87
+ loop.call_soon_threadsafe(_fan)
88
+ except RuntimeError:
89
+ pass
90
+
91
+
92
+ hub = EventHub()
93
+
94
+
95
+ # === DB ===
96
+ EVENT_NEW_COLS = {"upstream": "TEXT", "client": "TEXT", "cached_tokens": "INTEGER", "cache_write_tokens": "INTEGER"}
97
+
98
+
99
+ def init_db():
100
+ conn = _db()
101
+ conn.execute("""CREATE TABLE IF NOT EXISTS cache (
102
+ prompt_hash TEXT PRIMARY KEY, normalized_prompt TEXT, original_prompt TEXT, response TEXT,
103
+ is_stream INTEGER, provider TEXT, model TEXT,
104
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
105
+ access_count INTEGER DEFAULT 1)""")
106
+ conn.execute("""CREATE TABLE IF NOT EXISTS events (
107
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
108
+ kind TEXT NOT NULL, hash TEXT, model_req TEXT, model_real TEXT, stream INTEGER,
109
+ prompt_tokens INTEGER, completion_tokens INTEGER,
110
+ saved_prompt INTEGER DEFAULT 0, saved_completion INTEGER DEFAULT 0,
111
+ latency_ms INTEGER, ttft_ms INTEGER)""")
112
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(events)")}
113
+ for c, t in EVENT_NEW_COLS.items():
114
+ if c not in cols:
115
+ conn.execute("ALTER TABLE events ADD COLUMN %s %s" % (c, t))
116
+ try:
117
+ conn.execute("PRAGMA journal_mode=WAL")
118
+ except sqlite3.OperationalError:
119
+ logger.warning("WAL non active (base verrouillee par un autre process)")
120
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_created ON cache(created_at)")
121
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache(last_accessed)")
122
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)")
123
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_events_hash ON events(hash, kind)")
124
+ conn.execute("""CREATE TABLE IF NOT EXISTS agent_events (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT DEFAULT CURRENT_TIMESTAMP,
126
+ kind TEXT NOT NULL, server TEXT, tool TEXT, client TEXT, args_hash TEXT,
127
+ bytes INTEGER, latency_ms INTEGER, saved_latency_ms INTEGER, detail TEXT)""")
128
+ conn.execute("""CREATE TABLE IF NOT EXISTS tool_cache (
129
+ key TEXT PRIMARY KEY, server TEXT, tool TEXT, result_json TEXT, bytes INTEGER,
130
+ latency_ms INTEGER, ttl INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
131
+ last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, hits INTEGER DEFAULT 0)""")
132
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_tool_cache_server ON tool_cache(server)")
133
+ conn.commit()
134
+ conn.close()
135
+
136
+
137
+ init_db()
138
+
139
+
140
+ def log_event(kind, h, model_req, model_real, stream, pt=None, ct=None, sp=0, sc=0,
141
+ latency_ms=None, ttft_ms=None, upstream=None, client=None,
142
+ cached_tokens=None, cache_write_tokens=None, req_id=None):
143
+ """Ligne dans events (tokens = valeurs upstream reelles ou NULL, jamais estimees) + push WS."""
144
+ try:
145
+ conn = _db()
146
+ conn.execute(
147
+ "INSERT INTO events (kind,hash,model_req,model_real,stream,prompt_tokens,completion_tokens,"
148
+ "saved_prompt,saved_completion,latency_ms,ttft_ms,upstream,client,cached_tokens,cache_write_tokens) "
149
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
150
+ (kind, h, model_req, model_real, int(bool(stream)), pt, ct, sp, sc, latency_ms, ttft_ms,
151
+ upstream, client, cached_tokens, cache_write_tokens))
152
+ conn.commit()
153
+ conn.close()
154
+ except Exception:
155
+ logger.exception("events insert failed")
156
+ hub.publish({"type": "final", "id": req_id, "ts": utc_iso(), "kind": kind, "upstream": upstream,
157
+ "client": client, "model_req": model_req, "model": model_real, "stream": bool(stream),
158
+ "prompt_tokens": pt, "completion_tokens": ct, "cached_tokens": cached_tokens,
159
+ "cache_write_tokens": cache_write_tokens, "saved_prompt": sp, "saved_completion": sc,
160
+ "latency_ms": latency_ms, "ttft_ms": ttft_ms, "hash": (h or "")[:8],
161
+ "tokens_source": "upstream" if (pt is not None or ct is not None) else "n/a"})
162
+
163
+
164
+ def log_agent_event(kind, server=None, tool=None, client=None, args_hash=None, nbytes=None,
165
+ latency_ms=None, saved_latency_ms=None, detail=None):
166
+ try:
167
+ conn = _db()
168
+ conn.execute(
169
+ "INSERT INTO agent_events (kind,server,tool,client,args_hash,bytes,latency_ms,saved_latency_ms,detail) "
170
+ "VALUES (?,?,?,?,?,?,?,?,?)",
171
+ (kind, server, tool, client, args_hash, nbytes, latency_ms, saved_latency_ms,
172
+ canonical(detail) if detail is not None else None))
173
+ conn.commit()
174
+ conn.close()
175
+ except Exception:
176
+ logger.exception("agent_events insert failed")
177
+ hub.publish({"type": "agent", "ts": utc_iso(), "kind": kind, "server": server, "tool": tool,
178
+ "client": client, "bytes": nbytes, "latency_ms": latency_ms,
179
+ "saved_latency_ms": saved_latency_ms, "detail": detail})
180
+
181
+
182
+ # === TOOL CACHE (stockage) ===
183
+ def tc_get(key):
184
+ conn = _db()
185
+ row = conn.execute(
186
+ "SELECT result_json, latency_ms, bytes FROM tool_cache WHERE key=? "
187
+ "AND datetime(created_at, '+' || ttl || ' seconds') >= datetime('now')", (key,)).fetchone()
188
+ if row:
189
+ conn.execute("UPDATE tool_cache SET hits=hits+1, last_accessed=CURRENT_TIMESTAMP WHERE key=?", (key,))
190
+ conn.commit()
191
+ conn.close()
192
+ return row
193
+
194
+
195
+ def tc_set(key, server, tool, result_json, nbytes, latency_ms, ttl):
196
+ conn = _db()
197
+ conn.execute("INSERT OR REPLACE INTO tool_cache (key,server,tool,result_json,bytes,latency_ms,ttl,hits) "
198
+ "VALUES (?,?,?,?,?,?,?,0)", (key, server, tool, result_json, nbytes, latency_ms, int(ttl)))
199
+ conn.execute("DELETE FROM tool_cache WHERE datetime(created_at, '+' || ttl || ' seconds') < datetime('now')")
200
+ conn.commit()
201
+ conn.close()
202
+
203
+
204
+ def tc_flush(server):
205
+ conn = _db()
206
+ n = conn.execute("DELETE FROM tool_cache WHERE server=?", (server,)).rowcount
207
+ conn.commit()
208
+ conn.close()
209
+ return n
210
+
211
+
212
+ def tc_clear(server=None):
213
+ conn = _db()
214
+ if server:
215
+ n = conn.execute("DELETE FROM tool_cache WHERE server=?", (server,)).rowcount
216
+ else:
217
+ n = conn.execute("DELETE FROM tool_cache").rowcount
218
+ conn.commit()
219
+ conn.close()
220
+ return n
221
+
222
+
223
+ def tc_stats():
224
+ conn = _db()
225
+ entries = conn.execute("SELECT server, tool, COUNT(*), COALESCE(SUM(hits),0), COALESCE(SUM(bytes),0) "
226
+ "FROM tool_cache GROUP BY server, tool").fetchall()
227
+ ev = conn.execute("SELECT kind, COUNT(*), COALESCE(SUM(saved_latency_ms),0), COALESCE(SUM(bytes),0) "
228
+ "FROM agent_events GROUP BY kind").fetchall()
229
+ conn.close()
230
+ return {"entries": [{"server": e[0], "tool": e[1], "entries": e[2], "hits": e[3], "bytes": e[4]} for e in entries],
231
+ "events": {k: {"count": c, "saved_latency_ms": s, "bytes": b} for k, c, s, b in ev},
232
+ "note": "tokens economises par tool-cache = n/a (non mesurables sans estimation); latence economisee mesuree."}
233
+
234
+
235
+ def agent_events_recent(limit=50, kinds=None):
236
+ limit = max(1, min(int(limit), 1000))
237
+ conn = _db()
238
+ conn.row_factory = sqlite3.Row
239
+ q = "SELECT id, ts, kind, server, tool, client, args_hash, bytes, latency_ms, saved_latency_ms, detail FROM agent_events"
240
+ args = []
241
+ if kinds:
242
+ q += " WHERE kind IN (%s)" % ",".join("?" * len(kinds))
243
+ args += list(kinds)
244
+ q += " ORDER BY id DESC LIMIT ?"
245
+ args.append(limit)
246
+ rows = [dict(r) for r in conn.execute(q, args).fetchall()]
247
+ conn.close()
248
+ return rows
249
+
250
+
251
+ # === LOOP GUARD ===
252
+ def trailing_cycle(seq, max_cycle=3):
253
+ """Motif repete en fin de sequence. Retourne (cycle_len, reps) avec reps>=2 maximal, sinon None."""
254
+ n = len(seq)
255
+ best = None
256
+ for c in range(1, max_cycle + 1):
257
+ if n < 2 * c:
258
+ continue
259
+ pat = seq[-c:]
260
+ reps, i = 1, n - 2 * c
261
+ while i >= 0 and seq[i:i + c] == pat:
262
+ reps += 1
263
+ i -= c
264
+ if reps >= 2 and (best is None or reps > best[1]):
265
+ best = (c, reps)
266
+ return best
267
+
268
+
269
+ def _args_hash(a):
270
+ if isinstance(a, str):
271
+ try:
272
+ a = json.loads(a)
273
+ except Exception:
274
+ pass
275
+ return short_hash(canonical(a))
276
+
277
+
278
+ def llm_tool_sigs(messages):
279
+ """Signatures (nom, args, resultat) des tool calls deja faits dans l'historique d'une requete LLM."""
280
+ results = {}
281
+ for m in messages:
282
+ if not isinstance(m, dict):
283
+ continue
284
+ if m.get("role") == "tool" and m.get("tool_call_id"):
285
+ results[m["tool_call_id"]] = short_hash(canonical(m.get("content")))
286
+ c = m.get("content")
287
+ if isinstance(c, list):
288
+ for b in c:
289
+ if isinstance(b, dict) and b.get("type") == "tool_result":
290
+ results[b.get("tool_use_id")] = short_hash(canonical(b.get("content")))
291
+ sigs = []
292
+ for m in messages:
293
+ if not isinstance(m, dict) or m.get("role") != "assistant":
294
+ continue
295
+ for tc in (m.get("tool_calls") or []):
296
+ fn = (tc or {}).get("function") or {}
297
+ sigs.append((fn.get("name"), _args_hash(fn.get("arguments")), results.get((tc or {}).get("id"))))
298
+ c = m.get("content")
299
+ if isinstance(c, list):
300
+ for b in c:
301
+ if isinstance(b, dict) and b.get("type") == "tool_use":
302
+ sigs.append((b.get("name"), _args_hash(b.get("input")), results.get(b.get("id"))))
303
+ return sigs
304
+
305
+
306
+ def llm_loop_check(messages, warn_after=3, max_cycle=3):
307
+ """Boucle d'agent vue dans l'historique: meme(s) tool call(s) + memes resultats, repetes en fin."""
308
+ if not isinstance(messages, list):
309
+ return None
310
+ sigs = llm_tool_sigs(messages)
311
+ cyc = trailing_cycle(sigs, max_cycle)
312
+ if cyc and cyc[1] >= warn_after:
313
+ tail = sigs[-cyc[0]:]
314
+ return {"cycle_len": cyc[0], "reps": cyc[1], "tools": [t[0] for t in tail]}
315
+ return None
316
+
317
+
318
+ class ToolLoopGuard:
319
+ """Detecte un agent qui rappelle les memes tools avec les memes args ET les memes resultats."""
320
+
321
+ def __init__(self, cfg):
322
+ c = (cfg or {})
323
+ self.enabled = c.get("enabled", True)
324
+ self.window = c.get("window_seconds", 300)
325
+ t = c.get("tools", {})
326
+ self.warn_after = t.get("warn_after", 3)
327
+ self.block_after = t.get("block_after", 5)
328
+ self.max_cycle = t.get("max_cycle_len", 3)
329
+ self.sessions = {}
330
+ self.lock = threading.Lock()
331
+
332
+ def _s(self, key):
333
+ s = self.sessions.get(key)
334
+ if s is None:
335
+ s = {"hist": deque(maxlen=128), "blocked": None}
336
+ self.sessions[key] = s
337
+ return s
338
+
339
+ def check_before(self, key, tool, args_hash):
340
+ """True si cet appel (meme tool + memes args) est bloque parce que la boucle persiste."""
341
+ if not self.enabled:
342
+ return False
343
+ with self.lock:
344
+ s = self._s(key)
345
+ b = s["blocked"]
346
+ if b is None:
347
+ return False
348
+ if b == (tool, args_hash):
349
+ return True
350
+ s["blocked"] = None # l'agent a change d'approche
351
+ return False
352
+
353
+ def after(self, key, tool, args_hash, result_hash):
354
+ """Enregistre l'appel. Retourne ('none'|'warn'|'block', cycle_len, reps)."""
355
+ if not self.enabled:
356
+ return ("none", 0, 0)
357
+ now = time.time()
358
+ with self.lock:
359
+ s = self._s(key)
360
+ hist = s["hist"]
361
+ hist.append((now, (tool, args_hash, result_hash)))
362
+ while hist and now - hist[0][0] > self.window:
363
+ hist.popleft()
364
+ seq = [h[1] for h in hist]
365
+ cyc = trailing_cycle(seq, self.max_cycle)
366
+ if not cyc:
367
+ return ("none", 0, 0)
368
+ c, reps = cyc
369
+ if reps >= self.block_after:
370
+ s["blocked"] = (tool, args_hash)
371
+ return ("block", c, reps)
372
+ if reps >= self.warn_after:
373
+ return ("warn", c, reps)
374
+ return ("none", c, reps)
375
+
376
+ def record_blocked(self, key, tool, args_hash):
377
+ """Un appel bloque reste dans l'historique pour que le blocage tienne tant que l'agent insiste."""
378
+ with self.lock:
379
+ s = self._s(key)
380
+ s["hist"].append((time.time(), (tool, args_hash, "BLOCKED")))