glimind 0.2.0__tar.gz

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,13 @@
1
+ node_modules/
2
+ /package-lock.json
3
+ dist/
4
+ .next/
5
+ *.log
6
+ .env
7
+ .env.local
8
+ pgdata/
9
+ pglog/
10
+ .DS_Store
11
+ *.tsbuildinfo
12
+ __pycache__/
13
+ *.pyc
glimind-0.2.0/PKG-INFO ADDED
@@ -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,53 @@
1
+ # glimind (Python)
2
+
3
+ Live reliability oracle for AI agent tools. Ask if a tool works before calling it,
4
+ and report your own outcomes (privacy-preserving) so the data gets better for
5
+ everyone. **Zero third-party dependencies** (stdlib only); reporting is batched on
6
+ a background daemon thread and flushed at exit, so it adds no latency to your calls.
7
+
8
+ ```bash
9
+ pip install glimind
10
+ ```
11
+
12
+ ```python
13
+ from glimind import Glimind
14
+
15
+ s = Glimind(endpoint="https://glimind.com", api_key="sk_live_...") # api_key optional
16
+
17
+ # 1) ask before acting
18
+ h = s.check("acme/search")
19
+ if h["verdict"] == "down":
20
+ ... # pick a fallback
21
+
22
+ # 2) wrap a call — success/failure/latency reported automatically (privacy-clean)
23
+ with s.wrap("acme/search", input={"q": query}, task="search"):
24
+ result = acme.search(query)
25
+
26
+ # or decorate a function tool
27
+ @s.instrument("acme/search", task="search")
28
+ def search(q): ...
29
+ ```
30
+
31
+ ## Framework middleware — one line
32
+
33
+ **LangChain / LangGraph:**
34
+ ```python
35
+ from glimind.langchain import GlimindCallbackHandler
36
+ agent.invoke(input, config={"callbacks": [GlimindCallbackHandler(s)]})
37
+ ```
38
+
39
+ **OpenAI Agents SDK:**
40
+ ```python
41
+ from glimind.openai_agents import instrument_agent
42
+ agent = instrument_agent(s, agent) # every function tool now auto-reports
43
+ ```
44
+
45
+ ## Privacy
46
+
47
+ This client **never sends your arguments, payloads, or tool outputs.** Before anything
48
+ leaves your process it is reduced to a value-free *shape skeleton* (every leaf value
49
+ becomes a type tag), errors are scrubbed, and only: tool id, input shape, success/failure,
50
+ normalized error, latency, coarse region are transmitted. The derivation is identical to
51
+ the TypeScript SDK, so shapes hash consistently across languages.
52
+
53
+ MIT licensed.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "glimind"
7
+ version = "0.2.0"
8
+ description = "Live reliability oracle for AI agent tools — ask if a tool works before calling it, and report outcomes (privacy-preserving). Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["mcp", "ai-agents", "reliability", "observability", "langchain", "openai-agents"]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ langchain = ["langchain-core"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["glimind"]