agentrust-py 0.0.3__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.
agentrust_sdk/auto.py ADDED
@@ -0,0 +1,397 @@
1
+ """
2
+ AgentTrust transparent auto-instrumentation.
3
+
4
+ Drop-in patching for OpenAI, LangChain, and Anthropic SDKs — no changes
5
+ required in user code.
6
+
7
+ Quick start::
8
+
9
+ import agentrust_sdk.auto as at_auto
10
+ at_auto.install(agent_id="my-agent", api_key="at-...")
11
+
12
+ Or use the convenience alias::
13
+
14
+ from agentrust_sdk.auto import auto_install
15
+ auto_install(agent_id="my-agent")
16
+
17
+ After calling ``install()`` every call to:
18
+
19
+ * ``openai.OpenAI().chat.completions.create`` / ``AsyncOpenAI`` variant
20
+ * ``anthropic.Anthropic().messages.create`` / ``AsyncAnthropic`` variant
21
+ * Any LangChain LLM / chain / tool invocation (via ``BaseCallbackHandler``)
22
+
23
+ …will be transparently wrapped with ``AgentTrustClient.validate()``.
24
+
25
+ The original return value is **always** preserved. Validation errors are
26
+ logged and swallowed (or raise, depending on ``failure_mode``).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import logging
32
+ from typing import Any
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Module-level set that tracks which patches have been applied so that
37
+ # calling install() multiple times is a no-op.
38
+ _INSTALLED: set[str] = set()
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Helpers
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def _make_client(agent_id: str, api_key: str | None, gateway_url: str | None, failure_mode: str):
46
+ """Lazily import and construct an AgentTrustClient."""
47
+ from .client import AgentTrustClient # local import to avoid circular deps
48
+
49
+ kwargs: dict[str, Any] = {"failure_mode": failure_mode}
50
+ if api_key:
51
+ kwargs["api_key"] = api_key
52
+ if gateway_url:
53
+ kwargs["gateway_url"] = gateway_url
54
+ return AgentTrustClient(**kwargs), agent_id
55
+
56
+
57
+ def _safe_validate(client, agent_id: str, user: str, input_text: str, output_text: str) -> None:
58
+ """Call validate() and swallow any exception so the caller is unaffected."""
59
+ try:
60
+ client.validate(
61
+ agent_id=agent_id,
62
+ user=user,
63
+ input=input_text,
64
+ output=output_text,
65
+ )
66
+ except Exception as exc: # noqa: BLE001
67
+ logger.debug("AgentTrust validation error (non-fatal): %s", exc)
68
+
69
+
70
+ async def _safe_validate_async(client, agent_id: str, user: str, input_text: str, output_text: str) -> None:
71
+ """Async version of _safe_validate."""
72
+ try:
73
+ await client.validate_async(
74
+ agent_id=agent_id,
75
+ user=user,
76
+ input=input_text,
77
+ output=output_text,
78
+ )
79
+ except Exception as exc: # noqa: BLE001
80
+ logger.debug("AgentTrust async validation error (non-fatal): %s", exc)
81
+
82
+
83
+ def _extract_openai_prompt(kwargs: dict[str, Any]) -> str:
84
+ """Best-effort extraction of a human-readable prompt from OpenAI messages."""
85
+ messages = kwargs.get("messages", [])
86
+ if not messages:
87
+ return ""
88
+ parts = []
89
+ for m in messages:
90
+ role = m.get("role", "")
91
+ content = m.get("content", "")
92
+ if isinstance(content, list):
93
+ # vision / multi-part content
94
+ content = " ".join(
95
+ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
96
+ )
97
+ parts.append(f"{role}: {content}")
98
+ return "\n".join(parts)
99
+
100
+
101
+ def _extract_openai_response(response: Any) -> str:
102
+ """Extract text from an OpenAI ChatCompletion response object."""
103
+ try:
104
+ return response.choices[0].message.content or ""
105
+ except Exception:
106
+ return str(response)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Patch 1 — OpenAI SDK (openai >= 1.0)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def _patch_openai(client_at, agent_id: str) -> None:
114
+ """Wrap openai.OpenAI and openai.AsyncOpenAI completions.create."""
115
+ if "openai" in _INSTALLED:
116
+ return
117
+ try:
118
+ import openai # noqa: PLC0415
119
+ except ImportError:
120
+ logger.debug("openai not installed — skipping AgentTrust OpenAI patch")
121
+ return
122
+
123
+ # --- sync ---
124
+ _orig_sync_create = openai.resources.chat.completions.Completions.create
125
+
126
+ @functools.wraps(_orig_sync_create)
127
+ def _sync_create(self, *args: Any, **kwargs: Any) -> Any:
128
+ result = _orig_sync_create(self, *args, **kwargs)
129
+ prompt = _extract_openai_prompt(kwargs)
130
+ output = _extract_openai_response(result)
131
+ _safe_validate(client_at, agent_id, user="openai-sync", input_text=prompt, output_text=output)
132
+ return result
133
+
134
+ openai.resources.chat.completions.Completions.create = _sync_create # type: ignore[method-assign]
135
+
136
+ # --- async ---
137
+ try:
138
+ _orig_async_create = openai.resources.chat.completions.AsyncCompletions.create
139
+
140
+ @functools.wraps(_orig_async_create)
141
+ async def _async_create(self, *args: Any, **kwargs: Any) -> Any:
142
+ result = await _orig_async_create(self, *args, **kwargs)
143
+ prompt = _extract_openai_prompt(kwargs)
144
+ output = _extract_openai_response(result)
145
+ await _safe_validate_async(client_at, agent_id, user="openai-async", input_text=prompt, output_text=output)
146
+ return result
147
+
148
+ openai.resources.chat.completions.AsyncCompletions.create = _async_create # type: ignore[method-assign]
149
+ except AttributeError:
150
+ logger.debug("AsyncCompletions not found in openai — async patch skipped")
151
+
152
+ _INSTALLED.add("openai")
153
+ logger.info("AgentTrust: OpenAI patch installed")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Patch 2 — LangChain (langchain >= 0.1)
158
+ # ---------------------------------------------------------------------------
159
+
160
+ class AgentTrustCallbackHandler:
161
+ """
162
+ LangChain BaseCallbackHandler that validates LLM / chain / tool outputs
163
+ via AgentTrust.
164
+
165
+ This class inherits from ``langchain_core.callbacks.BaseCallbackHandler``
166
+ only when LangChain is available. When LangChain is absent the class is
167
+ defined as a plain Python class so that the module can still be imported.
168
+ """
169
+
170
+ # Filled in by _patch_langchain() after the real base class is resolved.
171
+ _client_at: Any = None
172
+ _agent_id: str = "auto"
173
+
174
+ def on_llm_end(self, response: Any, **kwargs: Any) -> None: # type: ignore[override]
175
+ try:
176
+ # LLMResult stores generations as a list-of-list of Generation objects
177
+ for gen_list in getattr(response, "generations", []):
178
+ for gen in gen_list:
179
+ text = getattr(gen, "text", "") or ""
180
+ _safe_validate(
181
+ self._client_at,
182
+ self._agent_id,
183
+ user="langchain-llm",
184
+ input_text="",
185
+ output_text=text,
186
+ )
187
+ except Exception as exc: # noqa: BLE001
188
+ logger.debug("AgentTrust LangChain on_llm_end error: %s", exc)
189
+
190
+ def on_chain_end(self, outputs: Any, **kwargs: Any) -> None: # type: ignore[override]
191
+ try:
192
+ output_text = str(outputs) if outputs is not None else ""
193
+ _safe_validate(
194
+ self._client_at,
195
+ self._agent_id,
196
+ user="langchain-chain",
197
+ input_text="",
198
+ output_text=output_text,
199
+ )
200
+ except Exception as exc: # noqa: BLE001
201
+ logger.debug("AgentTrust LangChain on_chain_end error: %s", exc)
202
+
203
+ def on_tool_end(self, output: Any, **kwargs: Any) -> None: # type: ignore[override]
204
+ try:
205
+ output_text = str(output) if output is not None else ""
206
+ _safe_validate(
207
+ self._client_at,
208
+ self._agent_id,
209
+ user="langchain-tool",
210
+ input_text="",
211
+ output_text=output_text,
212
+ )
213
+ except Exception as exc: # noqa: BLE001
214
+ logger.debug("AgentTrust LangChain on_tool_end error: %s", exc)
215
+
216
+
217
+ def _patch_langchain(client_at, agent_id: str) -> None:
218
+ """Register AgentTrustCallbackHandler as a global LangChain callback."""
219
+ if "langchain" in _INSTALLED:
220
+ return
221
+ try:
222
+ from langchain_core.callbacks import BaseCallbackHandler # noqa: PLC0415
223
+ except ImportError:
224
+ try:
225
+ from langchain.callbacks import BaseCallbackHandler # type: ignore[no-redef] # noqa: PLC0415
226
+ except ImportError:
227
+ logger.debug("langchain not installed — skipping AgentTrust LangChain patch")
228
+ return
229
+
230
+ # Re-define the handler as a proper subclass now that we have the base.
231
+ class _Handler(BaseCallbackHandler): # type: ignore[misc]
232
+ def on_llm_end(self, response: Any, **kwargs: Any) -> None:
233
+ AgentTrustCallbackHandler.on_llm_end(self, response, **kwargs)
234
+
235
+ def on_chain_end(self, outputs: Any, **kwargs: Any) -> None:
236
+ AgentTrustCallbackHandler.on_chain_end(self, outputs, **kwargs)
237
+
238
+ def on_tool_end(self, output: Any, **kwargs: Any) -> None:
239
+ AgentTrustCallbackHandler.on_tool_end(self, output, **kwargs)
240
+
241
+ handler = _Handler()
242
+ handler._client_at = client_at # type: ignore[attr-defined]
243
+ handler._agent_id = agent_id # type: ignore[attr-defined]
244
+
245
+ # Expose the concrete subclass so users can import it if needed.
246
+ AgentTrustCallbackHandler.__bases__ = (BaseCallbackHandler,) # type: ignore[misc]
247
+
248
+ # Try to register globally via the callback manager.
249
+ _registered = False
250
+ try:
251
+ from langchain.callbacks import set_handler # noqa: PLC0415
252
+ set_handler(handler)
253
+ _registered = True
254
+ except (ImportError, AttributeError):
255
+ pass
256
+
257
+ if not _registered:
258
+ try:
259
+ from langchain_core.callbacks import get_callback_manager # noqa: PLC0415
260
+ get_callback_manager().add_handler(handler)
261
+ _registered = True
262
+ except (ImportError, AttributeError):
263
+ pass
264
+
265
+ if not _registered:
266
+ logger.debug(
267
+ "AgentTrust: could not find a global LangChain callback registry; "
268
+ "add AgentTrustCallbackHandler() manually to your chain's callbacks."
269
+ )
270
+
271
+ _INSTALLED.add("langchain")
272
+ logger.info("AgentTrust: LangChain patch installed (global registration=%s)", _registered)
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Patch 3 — Anthropic SDK (anthropic >= 0.20)
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def _extract_anthropic_prompt(kwargs: dict[str, Any]) -> str:
280
+ """Best-effort extraction of prompt text from anthropic.messages.create kwargs."""
281
+ messages = kwargs.get("messages", [])
282
+ parts = []
283
+ for m in messages:
284
+ role = m.get("role", "")
285
+ content = m.get("content", "")
286
+ if isinstance(content, list):
287
+ content = " ".join(
288
+ block.get("text", "") for block in content
289
+ if isinstance(block, dict) and block.get("type") == "text"
290
+ )
291
+ parts.append(f"{role}: {content}")
292
+ system = kwargs.get("system", "")
293
+ if system:
294
+ parts.insert(0, f"system: {system}")
295
+ return "\n".join(parts)
296
+
297
+
298
+ def _extract_anthropic_response(response: Any) -> str:
299
+ """Extract text from an anthropic Message response."""
300
+ try:
301
+ parts = []
302
+ for block in response.content:
303
+ if hasattr(block, "text"):
304
+ parts.append(block.text)
305
+ return "\n".join(parts)
306
+ except Exception:
307
+ return str(response)
308
+
309
+
310
+ def _patch_anthropic(client_at, agent_id: str) -> None:
311
+ """Wrap anthropic.Anthropic().messages.create and AsyncAnthropic variant."""
312
+ if "anthropic" in _INSTALLED:
313
+ return
314
+ try:
315
+ import anthropic # noqa: PLC0415
316
+ except ImportError:
317
+ logger.debug("anthropic not installed — skipping AgentTrust Anthropic patch")
318
+ return
319
+
320
+ # --- sync ---
321
+ try:
322
+ _orig_sync = anthropic.resources.messages.Messages.create
323
+
324
+ @functools.wraps(_orig_sync)
325
+ def _sync_create(self, *args: Any, **kwargs: Any) -> Any:
326
+ result = _orig_sync(self, *args, **kwargs)
327
+ prompt = _extract_anthropic_prompt(kwargs)
328
+ output = _extract_anthropic_response(result)
329
+ _safe_validate(client_at, agent_id, user="anthropic-sync", input_text=prompt, output_text=output)
330
+ return result
331
+
332
+ anthropic.resources.messages.Messages.create = _sync_create # type: ignore[method-assign]
333
+ except AttributeError:
334
+ logger.debug("anthropic.resources.messages.Messages not found — sync patch skipped")
335
+
336
+ # --- async ---
337
+ try:
338
+ _orig_async = anthropic.resources.messages.AsyncMessages.create
339
+
340
+ @functools.wraps(_orig_async)
341
+ async def _async_create(self, *args: Any, **kwargs: Any) -> Any:
342
+ result = await _orig_async(self, *args, **kwargs)
343
+ prompt = _extract_anthropic_prompt(kwargs)
344
+ output = _extract_anthropic_response(result)
345
+ await _safe_validate_async(client_at, agent_id, user="anthropic-async", input_text=prompt, output_text=output)
346
+ return result
347
+
348
+ anthropic.resources.messages.AsyncMessages.create = _async_create # type: ignore[method-assign]
349
+ except AttributeError:
350
+ logger.debug("anthropic.resources.messages.AsyncMessages not found — async patch skipped")
351
+
352
+ _INSTALLED.add("anthropic")
353
+ logger.info("AgentTrust: Anthropic patch installed")
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Public API
358
+ # ---------------------------------------------------------------------------
359
+
360
+ def install(
361
+ agent_id: str = "auto",
362
+ api_key: str | None = None,
363
+ gateway_url: str | None = None,
364
+ failure_mode: str = "open",
365
+ ) -> None:
366
+ """Install all available AgentTrust auto-instrumentation patches.
367
+
368
+ This function is **idempotent** — calling it more than once has no effect.
369
+
370
+ Parameters
371
+ ----------
372
+ agent_id:
373
+ The agent identifier reported to AgentTrust for every validation call.
374
+ Defaults to ``"auto"``.
375
+ api_key:
376
+ AgentTrust API key. When *None* the key is resolved from the
377
+ environment variable ``AGENTRUST_API_KEY`` or ``~/.agentrust/config.yaml``.
378
+ gateway_url:
379
+ Override the AgentTrust gateway URL (useful for on-premise deployments).
380
+ failure_mode:
381
+ One of ``"open"`` (default — allow on error), ``"closed"`` (deny on
382
+ error), or ``"queue"`` (buffer calls for later retry).
383
+ """
384
+ if _INSTALLED == {"openai", "langchain", "anthropic"}:
385
+ # All known patches already applied — fast-path exit.
386
+ return
387
+
388
+ client_at, resolved_agent_id = _make_client(agent_id, api_key, gateway_url, failure_mode)
389
+
390
+ _patch_openai(client_at, resolved_agent_id)
391
+ _patch_langchain(client_at, resolved_agent_id)
392
+ _patch_anthropic(client_at, resolved_agent_id)
393
+
394
+ logger.info("AgentTrust auto-instrumentation complete. Installed: %s", _INSTALLED)
395
+
396
+
397
+ auto_install = install # alias for convenience
@@ -0,0 +1,95 @@
1
+ """
2
+ AgentTrust autoload bootstrap — zero-code instrumentation entry point.
3
+
4
+ Three ways to activate without editing application code
5
+ -------------------------------------------------------
6
+
7
+ **1. PYTHONSTARTUP (interactive / script startup):**
8
+
9
+ export PYTHONSTARTUP=/path/to/agentrust_autoload.py
10
+ # or point to this module:
11
+ export PYTHONSTARTUP=$(python -c "import agentrust_sdk.autoload; print(agentrust_sdk.autoload.__file__)")
12
+
13
+ **2. sitecustomize / usercustomize:**
14
+
15
+ # /path/to/site-packages/sitecustomize.py (system) or usercustomize.py (user)
16
+ import agentrust_sdk.autoload # noqa: F401
17
+
18
+ **3. PYTHONPATH + sitecustomize trick:**
19
+
20
+ # Create a sitecustomize.py in a directory and prepend to PYTHONPATH:
21
+ echo "import agentrust_sdk.autoload" > /tmp/agentrust_site/sitecustomize.py
22
+ export PYTHONPATH=/tmp/agentrust_site:$PYTHONPATH
23
+
24
+ **4. Framework entry point (gunicorn/uvicorn pre-exec):**
25
+
26
+ gunicorn myapp:app --preload --worker-class uvicorn.workers.UvicornWorker \\
27
+ --config agentrust_sdk.autoload.gunicorn_conf
28
+
29
+ **5. `[autoload]` setuptools extra:**
30
+
31
+ pip install agentrust-sdk[autoload]
32
+
33
+ Then in your app's own `sitecustomize.py` or startup:
34
+ import agentrust_sdk.autoload
35
+
36
+ Environment controls
37
+ --------------------
38
+ ``AGENTRUST_ENABLED=false`` → skip all instrumentation
39
+ ``AGENTRUST_AUTO_INSTRUMENT=false`` → skip hook patching
40
+ ``AGENTRUST_AUTOLOAD_LOG_LEVEL=DEBUG`` → verbose bootstrap logging
41
+ ``AGENTRUST_AUTOLOAD_EMBED=true`` → also start embedded gateway
42
+ ``AGENTRUST_AUTOLOAD_AGENT_ID=<string>`` → agent_id label for auto patches
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ import os
48
+
49
+ _log_level = os.environ.get("AGENTRUST_AUTOLOAD_LOG_LEVEL", "INFO").upper()
50
+ logging.basicConfig(level=getattr(logging, _log_level, logging.INFO))
51
+ logger = logging.getLogger("agentrust.autoload")
52
+
53
+ # Respect kill-switch — do nothing if disabled
54
+ if os.environ.get("AGENTRUST_ENABLED", "true").lower() in ("0", "false", "no"):
55
+ logger.debug("[AgentTrust autoload] AGENTRUST_ENABLED=false — skipping bootstrap")
56
+ else:
57
+ try:
58
+ from agentrust_sdk.hooks import auto_instrument
59
+ from agentrust_sdk.config import SDK_CONFIG
60
+
61
+ _agent_id = os.environ.get("AGENTRUST_AUTOLOAD_AGENT_ID", "autoload")
62
+
63
+ patched = auto_instrument(agent_id=_agent_id)
64
+ if patched:
65
+ logger.info("[AgentTrust autoload] Patched frameworks: %s", ", ".join(patched))
66
+ else:
67
+ logger.debug("[AgentTrust autoload] No frameworks to patch (none installed or AGENTRUST_AUTO_INSTRUMENT=false)")
68
+
69
+ # Optionally start embedded gateway (SQLite, no external deps)
70
+ if os.environ.get("AGENTRUST_AUTOLOAD_EMBED", "false").lower() in ("1", "true", "yes"):
71
+ from agentrust_sdk.embedded import embed_gateway
72
+ gw = embed_gateway()
73
+ logger.info("[AgentTrust autoload] Embedded gateway started on %s", SDK_CONFIG.gateway_url)
74
+
75
+ except Exception as exc:
76
+ # Bootstrap must never crash the host application
77
+ logger.warning("[AgentTrust autoload] Bootstrap error (non-fatal): %s", exc)
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # gunicorn config helper
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def _post_fork(server, worker): # noqa: ANN001
85
+ """gunicorn post_fork hook — re-apply patches after fork."""
86
+ try:
87
+ from agentrust_sdk.hooks import auto_instrument
88
+ auto_instrument(agent_id=os.environ.get("AGENTRUST_AUTOLOAD_AGENT_ID", "autoload"))
89
+ except Exception as exc:
90
+ import logging as _log
91
+ _log.getLogger("agentrust.autoload").warning("post_fork patch error: %s", exc)
92
+
93
+
94
+ # gunicorn config module surface (used via --config agentrust_sdk.autoload)
95
+ post_fork = _post_fork