glimind 0.2.0__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.
- glimind/__init__.py +6 -0
- glimind/_shape.py +54 -0
- glimind/client.py +283 -0
- glimind/langchain.py +86 -0
- glimind/openai_agents.py +76 -0
- glimind-0.2.0.dist-info/METADATA +64 -0
- glimind-0.2.0.dist-info/RECORD +8 -0
- glimind-0.2.0.dist-info/WHEEL +4 -0
glimind/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Glimind — live reliability oracle for AI agent tools (Python client)."""
|
|
2
|
+
from ._shape import derive_shape, scrub_error
|
|
3
|
+
from .client import SDK_VERSION, Glimind
|
|
4
|
+
|
|
5
|
+
__all__ = ["Glimind", "SDK_VERSION", "derive_shape", "scrub_error"]
|
|
6
|
+
Sentinel = Glimind # back-compat alias
|
glimind/_shape.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Local, value-free shape derivation. Caller values NEVER survive — every leaf
|
|
2
|
+
becomes a type tag; only dict keys (tool-schema field names) are kept. Mirrors the
|
|
3
|
+
TypeScript SDK so shapes hash identically across languages."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_TYPE_TAGS = {"<string>", "<number>", "<boolean>", "<null>", "<array>", "<object>", "<empty>"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def derive_shape(value: Any, depth: int = 0) -> Any:
|
|
13
|
+
if depth > 6:
|
|
14
|
+
return "<object>"
|
|
15
|
+
if value is None:
|
|
16
|
+
return "<null>"
|
|
17
|
+
# bool must be checked before int (bool is an int subclass in Python).
|
|
18
|
+
if isinstance(value, bool):
|
|
19
|
+
return "<boolean>"
|
|
20
|
+
if isinstance(value, (int, float)):
|
|
21
|
+
return "<number>"
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
return "<string>"
|
|
24
|
+
if isinstance(value, (list, tuple)):
|
|
25
|
+
return [derive_shape(value[0], depth + 1)] if value else "<empty>"
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
out: dict[str, Any] = {}
|
|
28
|
+
for k in sorted(str(x) for x in value.keys()):
|
|
29
|
+
if len(k) > 64:
|
|
30
|
+
continue
|
|
31
|
+
out[k] = derive_shape(value[k], depth + 1)
|
|
32
|
+
return out
|
|
33
|
+
return "<object>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SCRUBBERS = [
|
|
37
|
+
(re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "<email>"),
|
|
38
|
+
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "<ip>"),
|
|
39
|
+
(re.compile(r"\bBearer\s+[A-Za-z0-9._-]+", re.I), "Bearer <token>"),
|
|
40
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{10,}\b"), "<token>"),
|
|
41
|
+
(re.compile(r"\b[A-Fa-f0-9]{32,}\b"), "<hex>"),
|
|
42
|
+
(re.compile(r"https?://[^\s\"']+"), "<url>"),
|
|
43
|
+
(re.compile(r"\"[^\"]{40,}\""), '"<str>"'),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scrub_error(err: Any) -> str | None:
|
|
48
|
+
if err is None:
|
|
49
|
+
return None
|
|
50
|
+
s = f"{type(err).__name__}: {err}" if isinstance(err, BaseException) else str(err)
|
|
51
|
+
for rx, rep in _SCRUBBERS:
|
|
52
|
+
s = rx.sub(rep, s)
|
|
53
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
54
|
+
return s[:160] or None
|
glimind/client.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Glimind Python client — zero third-party dependencies (stdlib only).
|
|
2
|
+
|
|
3
|
+
Ask if a tool works before calling it, and report your own outcomes
|
|
4
|
+
(privacy-preserving) so the data gets better for everyone. Reporting is batched
|
|
5
|
+
on a background daemon thread and flushed at exit, so it adds no latency to your
|
|
6
|
+
tool calls.
|
|
7
|
+
|
|
8
|
+
from glimind import Glimind
|
|
9
|
+
s = Glimind(endpoint="https://glimind.com")
|
|
10
|
+
with s.wrap("acme/search", input={"q": "..."}, task="search"):
|
|
11
|
+
result = acme.search(q) # outcome auto-reported (success/failure/latency)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import json
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from typing import Any, Callable, Iterable
|
|
23
|
+
|
|
24
|
+
from ._shape import derive_shape, scrub_error
|
|
25
|
+
|
|
26
|
+
SDK_VERSION = "0.1.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Glimind:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
endpoint: str = "https://glimind.com",
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
reporter_id: str | None = None,
|
|
35
|
+
region: str | None = None,
|
|
36
|
+
report: bool = True,
|
|
37
|
+
flush_interval: float = 5.0,
|
|
38
|
+
batch_size: int = 50,
|
|
39
|
+
timeout: float = 5.0,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.endpoint = endpoint.rstrip("/")
|
|
42
|
+
self.api_key = api_key
|
|
43
|
+
self.reporter_id = reporter_id
|
|
44
|
+
self.region = region
|
|
45
|
+
self._report = report
|
|
46
|
+
self._batch_size = batch_size
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._queue: list[dict] = []
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
self._stop = threading.Event()
|
|
51
|
+
if report and flush_interval > 0:
|
|
52
|
+
self._thread = threading.Thread(target=self._loop, args=(flush_interval,), daemon=True)
|
|
53
|
+
self._thread.start()
|
|
54
|
+
atexit.register(self.close)
|
|
55
|
+
|
|
56
|
+
# --- query --------------------------------------------------------------
|
|
57
|
+
def check(self, tool_id: str) -> dict:
|
|
58
|
+
"""Live health verdict for a tool (call before invoking it)."""
|
|
59
|
+
return self._get(f"/v1/health/{urllib.parse.quote(tool_id, safe='')}")
|
|
60
|
+
|
|
61
|
+
def score(self, tool_id: str) -> dict:
|
|
62
|
+
"""Neutral reliability score + grade for a tool."""
|
|
63
|
+
return self._get(f"/v1/score/{urllib.parse.quote(tool_id, safe='')}")
|
|
64
|
+
|
|
65
|
+
def discover(self, query: str, limit: int | None = None) -> dict:
|
|
66
|
+
"""Find live tools for a task described in plain language."""
|
|
67
|
+
q = urllib.parse.urlencode({"q": query, **({"limit": limit} if limit else {})})
|
|
68
|
+
return self._get(f"/v1/search?{q}")
|
|
69
|
+
|
|
70
|
+
# --- alerts -------------------------------------------------------------
|
|
71
|
+
def watch(self, tools=None, webhook=None, all_critical=None, min_severity=None) -> dict:
|
|
72
|
+
"""Subscribe to reliability alerts. Pass webhook for push, or omit for a
|
|
73
|
+
pull token you poll with notifications(). Returns {id, token, channel}."""
|
|
74
|
+
if isinstance(tools, str):
|
|
75
|
+
tools = [tools]
|
|
76
|
+
payload = {
|
|
77
|
+
"url": webhook,
|
|
78
|
+
"tools": tools or [],
|
|
79
|
+
"allCritical": all_critical,
|
|
80
|
+
"minSeverity": min_severity,
|
|
81
|
+
}
|
|
82
|
+
return self._post("/v1/subscriptions", payload)
|
|
83
|
+
|
|
84
|
+
def notifications(self, token: str, limit: int | None = None) -> dict:
|
|
85
|
+
q = urllib.parse.urlencode({"token": token, **({"limit": limit} if limit else {})})
|
|
86
|
+
return self._get(f"/v1/notifications?{q}")
|
|
87
|
+
|
|
88
|
+
def poll_alerts(self, token: str, on_event, interval: float = 15.0):
|
|
89
|
+
"""Block-polling loop: calls on_event(event) for each new alert. Returns a
|
|
90
|
+
threading.Event; set it to stop. Runs on a daemon thread."""
|
|
91
|
+
stop = threading.Event()
|
|
92
|
+
|
|
93
|
+
def loop():
|
|
94
|
+
while not stop.wait(0 if stop.is_set() else 0.01):
|
|
95
|
+
try:
|
|
96
|
+
for e in self.notifications(token).get("events", []):
|
|
97
|
+
on_event(e)
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
pass
|
|
100
|
+
if stop.wait(interval):
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
104
|
+
return stop
|
|
105
|
+
|
|
106
|
+
def unwatch(self, token: str) -> dict:
|
|
107
|
+
q = urllib.parse.urlencode({"token": token})
|
|
108
|
+
req = urllib.request.Request(self.endpoint + f"/v1/subscriptions?{q}", headers=self._headers(), method="DELETE")
|
|
109
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as r: # noqa: S310
|
|
110
|
+
return json.loads(r.read().decode("utf-8"))
|
|
111
|
+
|
|
112
|
+
# --- report -------------------------------------------------------------
|
|
113
|
+
def report(
|
|
114
|
+
self,
|
|
115
|
+
tool_id: str,
|
|
116
|
+
success: bool,
|
|
117
|
+
input: Any = None,
|
|
118
|
+
error: Any = None,
|
|
119
|
+
latency_ms: int | None = None,
|
|
120
|
+
task: str | None = None,
|
|
121
|
+
routed: bool | None = None,
|
|
122
|
+
via_failover: bool | None = None,
|
|
123
|
+
attempts: int | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
if not self._report:
|
|
126
|
+
return
|
|
127
|
+
ev = {
|
|
128
|
+
"toolId": tool_id,
|
|
129
|
+
"shape": derive_shape(input) if input is not None else None,
|
|
130
|
+
"success": success,
|
|
131
|
+
"error": None if success else scrub_error(error),
|
|
132
|
+
"latencyMs": latency_ms,
|
|
133
|
+
"taskLabel": task,
|
|
134
|
+
"routed": routed,
|
|
135
|
+
"viaFailover": via_failover,
|
|
136
|
+
"attempts": attempts,
|
|
137
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + "Z",
|
|
138
|
+
}
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._queue.append(ev)
|
|
141
|
+
n = len(self._queue)
|
|
142
|
+
if n >= self._batch_size:
|
|
143
|
+
self.flush()
|
|
144
|
+
|
|
145
|
+
@contextmanager
|
|
146
|
+
def wrap(self, tool_id: str, input: Any = None, task: str | None = None):
|
|
147
|
+
"""Context manager: times the block and reports success/failure."""
|
|
148
|
+
start = time.monotonic()
|
|
149
|
+
try:
|
|
150
|
+
yield
|
|
151
|
+
except BaseException as e: # noqa: BLE001
|
|
152
|
+
self.report(tool_id, False, input=input, error=e, latency_ms=int((time.monotonic() - start) * 1000), task=task)
|
|
153
|
+
raise
|
|
154
|
+
else:
|
|
155
|
+
self.report(tool_id, True, input=input, latency_ms=int((time.monotonic() - start) * 1000), task=task)
|
|
156
|
+
|
|
157
|
+
def prepare(self, tool_id: str, task: str | None = None) -> dict:
|
|
158
|
+
"""Health + recipe + alternatives + call hints for a tool, in one call."""
|
|
159
|
+
path = f"/v1/prepare/{urllib.parse.quote(tool_id, safe='')}"
|
|
160
|
+
if task:
|
|
161
|
+
path += "?task=" + urllib.parse.quote(task, safe="")
|
|
162
|
+
return self._get(path)
|
|
163
|
+
|
|
164
|
+
def execute(
|
|
165
|
+
self,
|
|
166
|
+
tool_id: str,
|
|
167
|
+
call: Callable[[str], Any],
|
|
168
|
+
task: str | None = None,
|
|
169
|
+
input: Any = None,
|
|
170
|
+
allow_failover: bool = True,
|
|
171
|
+
max_retries: int | None = None,
|
|
172
|
+
max_failovers: int = 2,
|
|
173
|
+
) -> Any:
|
|
174
|
+
"""Reliability-aware execution: run call(tool_id) with learned retry and,
|
|
175
|
+
if it keeps failing, automatic failover to a healthy alternative tool.
|
|
176
|
+
Reports every attempt (privacy-clean), marked as execution-driven."""
|
|
177
|
+
prep: dict = {}
|
|
178
|
+
try:
|
|
179
|
+
prep = self.prepare(tool_id, task)
|
|
180
|
+
except Exception: # noqa: BLE001
|
|
181
|
+
prep = {}
|
|
182
|
+
alts = [a for a in (prep.get("alternatives") or []) if a.get("toolId") and a.get("recommendation") != "avoid"]
|
|
183
|
+
if prep.get("recommendation") == "avoid":
|
|
184
|
+
ordered = [a["toolId"] for a in alts] + [tool_id]
|
|
185
|
+
else:
|
|
186
|
+
ordered = [tool_id] + ([a["toolId"] for a in alts] if allow_failover else [])
|
|
187
|
+
# de-dup preserving order, bound failover breadth
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
deduped: list[str] = []
|
|
190
|
+
for t in ordered:
|
|
191
|
+
if t not in seen:
|
|
192
|
+
seen.add(t)
|
|
193
|
+
deduped.append(t)
|
|
194
|
+
deduped = deduped[: 1 + (max_failovers if allow_failover else 0)] or [tool_id]
|
|
195
|
+
|
|
196
|
+
hints = prep.get("callHints") or {}
|
|
197
|
+
retryable = (hints.get("retry") or {}).get("retryable") is not False
|
|
198
|
+
retries = max_retries if max_retries is not None else (2 if retryable else 0)
|
|
199
|
+
|
|
200
|
+
last_err: Exception | None = None
|
|
201
|
+
global_attempt = 0
|
|
202
|
+
for ci, chosen in enumerate(deduped):
|
|
203
|
+
via_failover = ci > 0
|
|
204
|
+
for attempt in range(retries + 1):
|
|
205
|
+
global_attempt += 1
|
|
206
|
+
start = time.monotonic()
|
|
207
|
+
try:
|
|
208
|
+
out = call(chosen)
|
|
209
|
+
self.report(chosen, True, input=input, latency_ms=int((time.monotonic() - start) * 1000),
|
|
210
|
+
task=task, routed=True, via_failover=via_failover, attempts=global_attempt)
|
|
211
|
+
return out
|
|
212
|
+
except Exception as e: # noqa: BLE001
|
|
213
|
+
last_err = e
|
|
214
|
+
self.report(chosen, False, input=input, error=e, latency_ms=int((time.monotonic() - start) * 1000),
|
|
215
|
+
task=task, routed=True, via_failover=via_failover, attempts=global_attempt)
|
|
216
|
+
if attempt < retries:
|
|
217
|
+
time.sleep(min(2.0, 0.15 * (2 ** attempt)))
|
|
218
|
+
if last_err:
|
|
219
|
+
raise last_err
|
|
220
|
+
raise RuntimeError("glimind.execute: no candidate tools")
|
|
221
|
+
|
|
222
|
+
def instrument(self, tool_id: str, task: str | None = None) -> Callable:
|
|
223
|
+
"""Decorator form of wrap() for a function tool."""
|
|
224
|
+
def deco(fn: Callable) -> Callable:
|
|
225
|
+
def inner(*args, **kwargs):
|
|
226
|
+
with self.wrap(tool_id, input={"args": args, "kwargs": kwargs}, task=task):
|
|
227
|
+
return fn(*args, **kwargs)
|
|
228
|
+
inner.__name__ = getattr(fn, "__name__", "tool")
|
|
229
|
+
return inner
|
|
230
|
+
return deco
|
|
231
|
+
|
|
232
|
+
# --- internals ----------------------------------------------------------
|
|
233
|
+
def _headers(self) -> dict[str, str]:
|
|
234
|
+
h = {"content-type": "application/json"}
|
|
235
|
+
if self.api_key:
|
|
236
|
+
h["authorization"] = f"Bearer {self.api_key}"
|
|
237
|
+
return h
|
|
238
|
+
|
|
239
|
+
def _get(self, path: str) -> dict:
|
|
240
|
+
req = urllib.request.Request(self.endpoint + path, headers=self._headers(), method="GET")
|
|
241
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as r: # noqa: S310
|
|
242
|
+
return json.loads(r.read().decode("utf-8"))
|
|
243
|
+
|
|
244
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
245
|
+
body = json.dumps(payload).encode("utf-8")
|
|
246
|
+
req = urllib.request.Request(self.endpoint + path, data=body, headers=self._headers(), method="POST")
|
|
247
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as r: # noqa: S310
|
|
248
|
+
return json.loads(r.read().decode("utf-8"))
|
|
249
|
+
|
|
250
|
+
def _loop(self, interval: float) -> None:
|
|
251
|
+
while not self._stop.wait(interval):
|
|
252
|
+
try:
|
|
253
|
+
self.flush()
|
|
254
|
+
except Exception: # noqa: BLE001
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def flush(self) -> None:
|
|
258
|
+
with self._lock:
|
|
259
|
+
if not self._queue:
|
|
260
|
+
return
|
|
261
|
+
batch = self._queue[: self._batch_size]
|
|
262
|
+
self._queue = self._queue[self._batch_size :]
|
|
263
|
+
events: Iterable[dict] = [
|
|
264
|
+
{**e, "region": self.region, "reporterId": self.reporter_id, "sdkVersion": SDK_VERSION}
|
|
265
|
+
for e in batch
|
|
266
|
+
]
|
|
267
|
+
body = json.dumps({"events": list(events)}).encode("utf-8")
|
|
268
|
+
req = urllib.request.Request(self.endpoint + "/v1/ingest", data=body, headers=self._headers(), method="POST")
|
|
269
|
+
try:
|
|
270
|
+
urllib.request.urlopen(req, timeout=self._timeout).close() # noqa: S310
|
|
271
|
+
except Exception: # noqa: BLE001
|
|
272
|
+
# Best-effort: reliability telemetry must never destabilize the host app.
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
self._stop.set()
|
|
277
|
+
try:
|
|
278
|
+
self.flush()
|
|
279
|
+
except Exception: # noqa: BLE001
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
# Back-compat alias (formerly 'Sentinel').
|
|
283
|
+
Sentinel = Glimind
|
glimind/langchain.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""LangChain / LangGraph (Python) middleware.
|
|
2
|
+
|
|
3
|
+
from glimind import Glimind
|
|
4
|
+
from glimind.langchain import GlimindCallbackHandler
|
|
5
|
+
s = Glimind(endpoint="https://glimind.com")
|
|
6
|
+
agent.invoke(input, config={"callbacks": [GlimindCallbackHandler(s)]})
|
|
7
|
+
|
|
8
|
+
Reports the outcome of every tool the agent runs — one line, no per-tool wrapping.
|
|
9
|
+
Subclasses BaseCallbackHandler when available, else falls back to a plain object
|
|
10
|
+
(duck-typed) so importing this module never hard-requires langchain.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from .client import Glimind
|
|
19
|
+
|
|
20
|
+
try: # pragma: no cover - import shim
|
|
21
|
+
from langchain_core.callbacks import BaseCallbackHandler as _Base # type: ignore
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
try:
|
|
24
|
+
from langchain.callbacks.base import BaseCallbackHandler as _Base # type: ignore
|
|
25
|
+
except Exception: # noqa: BLE001
|
|
26
|
+
class _Base: # minimal fallback
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GlimindCallbackHandler(_Base): # type: ignore[misc]
|
|
31
|
+
def __init__(self, glimind: Glimind, server_id: str = "langchain", task: str | None = None) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self._s = glimind
|
|
34
|
+
self._server_id = server_id
|
|
35
|
+
self._task = task
|
|
36
|
+
self._runs: dict[str, dict] = {}
|
|
37
|
+
|
|
38
|
+
def on_tool_start(self, serialized: dict, input_str: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
39
|
+
name = (serialized or {}).get("name") or kwargs.get("name") or "tool"
|
|
40
|
+
self._runs[str(run_id)] = {
|
|
41
|
+
"tool_id": f"{self._server_id}/{name}",
|
|
42
|
+
"input": input_str,
|
|
43
|
+
"start": time.monotonic(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
47
|
+
r = self._runs.pop(str(run_id), None)
|
|
48
|
+
if not r:
|
|
49
|
+
return
|
|
50
|
+
self._s.report(r["tool_id"], True, input=r["input"],
|
|
51
|
+
latency_ms=int((time.monotonic() - r["start"]) * 1000), task=self._task)
|
|
52
|
+
|
|
53
|
+
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
54
|
+
r = self._runs.pop(str(run_id), None)
|
|
55
|
+
if not r:
|
|
56
|
+
return
|
|
57
|
+
self._s.report(r["tool_id"], False, input=r["input"], error=error,
|
|
58
|
+
latency_ms=int((time.monotonic() - r["start"]) * 1000), task=self._task)
|
|
59
|
+
|
|
60
|
+
def wrap_langchain_tool(glimind: Glimind, tool: Any, server_id: str = "langchain",
|
|
61
|
+
task: str | None = None, max_retries: int | None = None) -> Any:
|
|
62
|
+
"""Reliability-wrap a LangChain tool (in place). The callback handler only
|
|
63
|
+
OBSERVES; wrap the tool to actually retry transient failures via Glimind's
|
|
64
|
+
reliability-aware executor (learned backoff; failover off — schemas differ)."""
|
|
65
|
+
name = getattr(tool, "name", "tool")
|
|
66
|
+
tool_id = f"{server_id}/{name}"
|
|
67
|
+
for method in ("invoke", "run"):
|
|
68
|
+
orig = getattr(tool, method, None)
|
|
69
|
+
if not callable(orig):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
def make(bound):
|
|
73
|
+
def wrapped(input: Any, config: Any = None, **kw: Any):
|
|
74
|
+
call = (lambda _id: bound(input, config, **kw)) if config is not None else (lambda _id: bound(input, **kw))
|
|
75
|
+
return glimind.execute(tool_id, call, task=task, input=input,
|
|
76
|
+
allow_failover=False, max_retries=max_retries)
|
|
77
|
+
return wrapped
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
setattr(tool, method, make(orig))
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass # some tools have read-only attrs; skip silently
|
|
83
|
+
return tool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SentinelCallbackHandler = GlimindCallbackHandler # back-compat alias
|
glimind/openai_agents.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""OpenAI Agents SDK (Python) middleware.
|
|
2
|
+
|
|
3
|
+
from glimind import Glimind
|
|
4
|
+
from glimind.openai_agents import instrument_agent
|
|
5
|
+
s = Glimind(endpoint="https://glimind.com")
|
|
6
|
+
agent = instrument_agent(s, agent) # every function tool now auto-reports
|
|
7
|
+
|
|
8
|
+
Duck-typed: wraps each tool's async `on_invoke_tool`; no hard dependency on the
|
|
9
|
+
`agents` package. Reporting is fire-and-forget.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .client import Glimind
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def instrument_function_tool(glimind: Glimind, tool: Any, server_id: str = "openai",
|
|
22
|
+
task: str | None = None, reliability: bool = False,
|
|
23
|
+
max_retries: int = 2) -> Any:
|
|
24
|
+
if not hasattr(tool, "on_invoke_tool"):
|
|
25
|
+
return tool
|
|
26
|
+
name = getattr(tool, "name", "tool")
|
|
27
|
+
tool_id = f"{server_id}/{name}"
|
|
28
|
+
original = tool.on_invoke_tool
|
|
29
|
+
|
|
30
|
+
async def wrapped(ctx: Any, input_json: Any):
|
|
31
|
+
shape_input = None
|
|
32
|
+
try:
|
|
33
|
+
shape_input = json.loads(input_json) if isinstance(input_json, str) else input_json
|
|
34
|
+
except Exception: # noqa: BLE001
|
|
35
|
+
shape_input = None
|
|
36
|
+
|
|
37
|
+
# Reliability mode: retry transient failures with backoff (non-blocking),
|
|
38
|
+
# reporting each attempt as execution-driven. Failover stays off here.
|
|
39
|
+
if reliability:
|
|
40
|
+
last_err: BaseException | None = None
|
|
41
|
+
for attempt in range(max_retries + 1):
|
|
42
|
+
start = time.monotonic()
|
|
43
|
+
try:
|
|
44
|
+
res = await original(ctx, input_json)
|
|
45
|
+
glimind.report(tool_id, True, input=shape_input,
|
|
46
|
+
latency_ms=int((time.monotonic() - start) * 1000), task=task,
|
|
47
|
+
routed=True, via_failover=False, attempts=attempt + 1)
|
|
48
|
+
return res
|
|
49
|
+
except BaseException as e: # noqa: BLE001
|
|
50
|
+
last_err = e
|
|
51
|
+
glimind.report(tool_id, False, input=None, error=e,
|
|
52
|
+
latency_ms=int((time.monotonic() - start) * 1000), task=task,
|
|
53
|
+
routed=True, via_failover=False, attempts=attempt + 1)
|
|
54
|
+
if attempt < max_retries:
|
|
55
|
+
await asyncio.sleep(min(2.0, 0.15 * (2 ** attempt)))
|
|
56
|
+
raise last_err # type: ignore[misc]
|
|
57
|
+
|
|
58
|
+
start = time.monotonic()
|
|
59
|
+
try:
|
|
60
|
+
res = await original(ctx, input_json)
|
|
61
|
+
glimind.report(tool_id, True, input=shape_input, latency_ms=int((time.monotonic() - start) * 1000), task=task)
|
|
62
|
+
return res
|
|
63
|
+
except BaseException as e: # noqa: BLE001
|
|
64
|
+
glimind.report(tool_id, False, input=None, error=e, latency_ms=int((time.monotonic() - start) * 1000), task=task)
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
tool.on_invoke_tool = wrapped
|
|
68
|
+
return tool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def instrument_agent(glimind: Glimind, agent: Any, server_id: str = "openai", task: str | None = None,
|
|
72
|
+
reliability: bool = False, max_retries: int = 2) -> Any:
|
|
73
|
+
for t in (getattr(agent, "tools", None) or []):
|
|
74
|
+
instrument_function_tool(glimind, t, server_id=server_id, task=task,
|
|
75
|
+
reliability=reliability, max_retries=max_retries)
|
|
76
|
+
return agent
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glimind
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Live reliability oracle for AI agent tools — ask if a tool works before calling it, and report outcomes (privacy-preserving). Zero dependencies.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ai-agents,langchain,mcp,observability,openai-agents,reliability
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Provides-Extra: langchain
|
|
9
|
+
Requires-Dist: langchain-core; extra == 'langchain'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# glimind (Python)
|
|
13
|
+
|
|
14
|
+
Live reliability oracle for AI agent tools. Ask if a tool works before calling it,
|
|
15
|
+
and report your own outcomes (privacy-preserving) so the data gets better for
|
|
16
|
+
everyone. **Zero third-party dependencies** (stdlib only); reporting is batched on
|
|
17
|
+
a background daemon thread and flushed at exit, so it adds no latency to your calls.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install glimind
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from glimind import Glimind
|
|
25
|
+
|
|
26
|
+
s = Glimind(endpoint="https://glimind.com", api_key="sk_live_...") # api_key optional
|
|
27
|
+
|
|
28
|
+
# 1) ask before acting
|
|
29
|
+
h = s.check("acme/search")
|
|
30
|
+
if h["verdict"] == "down":
|
|
31
|
+
... # pick a fallback
|
|
32
|
+
|
|
33
|
+
# 2) wrap a call — success/failure/latency reported automatically (privacy-clean)
|
|
34
|
+
with s.wrap("acme/search", input={"q": query}, task="search"):
|
|
35
|
+
result = acme.search(query)
|
|
36
|
+
|
|
37
|
+
# or decorate a function tool
|
|
38
|
+
@s.instrument("acme/search", task="search")
|
|
39
|
+
def search(q): ...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Framework middleware — one line
|
|
43
|
+
|
|
44
|
+
**LangChain / LangGraph:**
|
|
45
|
+
```python
|
|
46
|
+
from glimind.langchain import GlimindCallbackHandler
|
|
47
|
+
agent.invoke(input, config={"callbacks": [GlimindCallbackHandler(s)]})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**OpenAI Agents SDK:**
|
|
51
|
+
```python
|
|
52
|
+
from glimind.openai_agents import instrument_agent
|
|
53
|
+
agent = instrument_agent(s, agent) # every function tool now auto-reports
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Privacy
|
|
57
|
+
|
|
58
|
+
This client **never sends your arguments, payloads, or tool outputs.** Before anything
|
|
59
|
+
leaves your process it is reduced to a value-free *shape skeleton* (every leaf value
|
|
60
|
+
becomes a type tag), errors are scrubbed, and only: tool id, input shape, success/failure,
|
|
61
|
+
normalized error, latency, coarse region are transmitted. The derivation is identical to
|
|
62
|
+
the TypeScript SDK, so shapes hash consistently across languages.
|
|
63
|
+
|
|
64
|
+
MIT licensed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
glimind/__init__.py,sha256=-JKf3TvxahytfD7kyNE-FO5viYQ-BVJGCX4EPfyQYQ0,274
|
|
2
|
+
glimind/_shape.py,sha256=ee0-P67lAv2FCN-2XKm1GQsPF9wz43CetBoXpaRJ4L4,1913
|
|
3
|
+
glimind/client.py,sha256=lgUFIKQzO47x-o4t2nAejB9e2XiS3_G1NyKB7BaePtc,11638
|
|
4
|
+
glimind/langchain.py,sha256=thULD-El64XttVI6g2Sr2TNvVpbbBdwKUrekOzYG3kw,3608
|
|
5
|
+
glimind/openai_agents.py,sha256=wVWZpyVlM5FcXhABv32NRv4DKxZ6HcvAD1mvY2t3TEo,3299
|
|
6
|
+
glimind-0.2.0.dist-info/METADATA,sha256=bQPRz855Z4oLZ6yD9D6Be_zkxAX7Iyh1SwR02wvkE5g,2143
|
|
7
|
+
glimind-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
glimind-0.2.0.dist-info/RECORD,,
|