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/__init__.py +72 -0
- agentrust_py-0.0.3.dist-info/METADATA +193 -0
- agentrust_py-0.0.3.dist-info/RECORD +29 -0
- agentrust_py-0.0.3.dist-info/WHEEL +4 -0
- agentrust_py-0.0.3.dist-info/entry_points.txt +2 -0
- agentrust_py-0.0.3.dist-info/licenses/LICENSE +177 -0
- agentrust_sdk/__init__.py +124 -0
- agentrust_sdk/adapters/__init__.py +1 -0
- agentrust_sdk/adapters/autogen.py +235 -0
- agentrust_sdk/adapters/claude_agents.py +225 -0
- agentrust_sdk/adapters/crewai.py +98 -0
- agentrust_sdk/adapters/langgraph.py +109 -0
- agentrust_sdk/adapters/mcp.py +193 -0
- agentrust_sdk/adapters/openai_agents.py +263 -0
- agentrust_sdk/auth.py +192 -0
- agentrust_sdk/auto.py +397 -0
- agentrust_sdk/autoload.py +95 -0
- agentrust_sdk/cli.py +736 -0
- agentrust_sdk/client.py +790 -0
- agentrust_sdk/config.py +192 -0
- agentrust_sdk/decorator.py +276 -0
- agentrust_sdk/embedded.py +428 -0
- agentrust_sdk/hooks.py +461 -0
- agentrust_sdk/models.py +81 -0
- agentrust_sdk/py.typed +0 -0
- agentrust_sdk/queue_replay.py +204 -0
- agentrust_sdk/tiers.py +180 -0
- agentrust_sdk/version_negotiation.py +290 -0
- agentrust_sdk/webhooks.py +782 -0
agentrust_sdk/client.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentTrust client — sync and async, tier-gated, backed by httpx.
|
|
3
|
+
|
|
4
|
+
Key features added per CRITIC_AUDIT_REPORT.md:
|
|
5
|
+
- Centralized config (SDK_CONFIG) for all tunables
|
|
6
|
+
- Exponential-backoff retry via tenacity (AGENTRUST_RETRY_ATTEMPTS)
|
|
7
|
+
- Configurable failure mode: open | closed | queue (AGENTRUST_FAILURE_MODE)
|
|
8
|
+
- Kill-switch: AGENTRUST_ENABLED=false → no-op ValidateResponse
|
|
9
|
+
- Version negotiation headers (X-AgentTrust-SDK-Version)
|
|
10
|
+
- OpenTelemetry spans when opentelemetry-sdk is installed (optional)
|
|
11
|
+
- AGENTRUST_TIMEOUT_SEC, AGENTRUST_RETRY_BACKOFF env vars
|
|
12
|
+
- Webhook dispatcher integration (Team tier and above)
|
|
13
|
+
Attach a WebhookDispatcher to receive POST notifications after each
|
|
14
|
+
validate() call. Set AGENTRUST_WEBHOOK_URL for zero-code setup.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import sqlite3
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .auth import KeyInfo, resolve_key
|
|
27
|
+
from .config import SDK_CONFIG
|
|
28
|
+
from .models import ValidateRequest, ValidateResponse
|
|
29
|
+
from .tiers import Capability, Tier, is_allowed, UPGRADE_MESSAGES
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_VALIDATE_PATH = "/v1/runtime/validate"
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Optional OpenTelemetry — soft dependency; no error if not installed
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from opentelemetry import trace as _otel_trace
|
|
41
|
+
from opentelemetry import metrics as _otel_metrics
|
|
42
|
+
_tracer = _otel_trace.get_tracer("agentrust.sdk")
|
|
43
|
+
_meter = _otel_metrics.get_meter("agentrust.sdk")
|
|
44
|
+
_validation_latency = _meter.create_histogram(
|
|
45
|
+
"agentrust.validation_latency_ms",
|
|
46
|
+
description="AgentTrust validate() round-trip latency in milliseconds",
|
|
47
|
+
unit="ms",
|
|
48
|
+
)
|
|
49
|
+
_validation_counter = _meter.create_counter(
|
|
50
|
+
"agentrust.validations_total",
|
|
51
|
+
description="Total number of AgentTrust validation calls",
|
|
52
|
+
)
|
|
53
|
+
_HAS_OTEL = True
|
|
54
|
+
except ImportError:
|
|
55
|
+
_HAS_OTEL = False
|
|
56
|
+
_tracer = None # type: ignore[assignment]
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Optional tenacity — soft dependency for retry/backoff
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from tenacity import (
|
|
64
|
+
retry,
|
|
65
|
+
retry_if_exception_type,
|
|
66
|
+
stop_after_attempt,
|
|
67
|
+
wait_exponential,
|
|
68
|
+
)
|
|
69
|
+
_HAS_TENACITY = True
|
|
70
|
+
except ImportError:
|
|
71
|
+
_HAS_TENACITY = False
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Capabilities required per request field
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_FIELD_CAPABILITIES: dict[str, Capability] = {
|
|
78
|
+
"confidence": Capability.CONFIDENCE_ENGINE,
|
|
79
|
+
"risk": Capability.RISK_SCORING,
|
|
80
|
+
"decision": Capability.AUTO_DECISION,
|
|
81
|
+
"policy": Capability.BUILTIN_POLICY_PACKS,
|
|
82
|
+
"trust_chain": Capability.TRUST_CHAIN,
|
|
83
|
+
"audit": Capability.LOCAL_AUDIT,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_gateway_payload(req: ValidateRequest) -> dict[str, Any]:
|
|
88
|
+
payload: dict[str, Any] = {
|
|
89
|
+
"agent_id": req.agent_id,
|
|
90
|
+
"framework": req.framework,
|
|
91
|
+
"version": req.version,
|
|
92
|
+
"parent_envelope_id": req.parent_envelope_id,
|
|
93
|
+
"request": {
|
|
94
|
+
"user": req.user,
|
|
95
|
+
"input": req.input,
|
|
96
|
+
"session_id": req.session_id,
|
|
97
|
+
"metadata": req.metadata,
|
|
98
|
+
},
|
|
99
|
+
"execution": {
|
|
100
|
+
"model": req.model,
|
|
101
|
+
"tools_called": [t.model_dump() for t in req.tools_called],
|
|
102
|
+
"latency_ms": req.latency_ms,
|
|
103
|
+
"tokens": req.tokens,
|
|
104
|
+
},
|
|
105
|
+
"output": req.output,
|
|
106
|
+
# Version negotiation (P1 — version skew detection)
|
|
107
|
+
"sdk_version": SDK_CONFIG.sdk_version,
|
|
108
|
+
"sdk_min_gateway_version": SDK_CONFIG.min_gateway_version,
|
|
109
|
+
}
|
|
110
|
+
return payload
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _warn_tier(capability: Capability) -> None:
|
|
114
|
+
msg = UPGRADE_MESSAGES.get(capability, f"{capability.value} not available on this tier.")
|
|
115
|
+
logger.warning("[AgentTrust] %s", msg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _noop_response() -> ValidateResponse:
|
|
119
|
+
"""Return a pass-through ValidateResponse used when SDK is disabled."""
|
|
120
|
+
from .models import DecisionResult, RiskResult, ValidationResult
|
|
121
|
+
return ValidateResponse(
|
|
122
|
+
envelope_id="disabled",
|
|
123
|
+
validation=ValidationResult(schema_score=100.0, final_confidence=100.0, failures=[]),
|
|
124
|
+
risk=RiskResult(),
|
|
125
|
+
decision=DecisionResult(outcome="approve", reason="AgentTrust disabled via AGENTRUST_ENABLED=false"),
|
|
126
|
+
latency_ms=0.0,
|
|
127
|
+
tier_info="disabled",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Local queue for failure_mode=queue
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _enqueue_failed_request(payload: dict[str, Any]) -> None:
|
|
136
|
+
"""Buffer a failed validate payload to local SQLite for later replay."""
|
|
137
|
+
try:
|
|
138
|
+
db_path = SDK_CONFIG.queue_db
|
|
139
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
con = sqlite3.connect(str(db_path))
|
|
141
|
+
con.execute("PRAGMA journal_mode=WAL")
|
|
142
|
+
con.execute("PRAGMA synchronous=NORMAL")
|
|
143
|
+
con.execute(
|
|
144
|
+
"CREATE TABLE IF NOT EXISTS queue ("
|
|
145
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
146
|
+
" payload TEXT NOT NULL,"
|
|
147
|
+
" queued_at TEXT NOT NULL DEFAULT (datetime('now')),"
|
|
148
|
+
" attempts INTEGER NOT NULL DEFAULT 0"
|
|
149
|
+
")"
|
|
150
|
+
)
|
|
151
|
+
import json
|
|
152
|
+
con.execute("INSERT INTO queue (payload) VALUES (?)", (json.dumps(payload),))
|
|
153
|
+
con.commit()
|
|
154
|
+
con.close()
|
|
155
|
+
logger.info(
|
|
156
|
+
"[AgentTrust] Validation queued locally (gateway unavailable). "
|
|
157
|
+
"Run `agentrust queue replay` to flush when gateway returns. "
|
|
158
|
+
"Buffered at: %s", db_path
|
|
159
|
+
)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logger.error("[AgentTrust] Failed to queue validation locally: %s", exc)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def drain_queue(
|
|
165
|
+
base_url: str | None = None,
|
|
166
|
+
api_key: str | None = None,
|
|
167
|
+
timeout: float | None = None,
|
|
168
|
+
) -> tuple[int, int]:
|
|
169
|
+
"""Replay locally buffered validations against the gateway.
|
|
170
|
+
|
|
171
|
+
Returns (sent, failed) counts. Use after gateway recovers::
|
|
172
|
+
|
|
173
|
+
from agentrust_sdk.client import drain_queue
|
|
174
|
+
sent, failed = drain_queue()
|
|
175
|
+
|
|
176
|
+
Or via CLI: ``agentrust queue replay``
|
|
177
|
+
"""
|
|
178
|
+
import json
|
|
179
|
+
|
|
180
|
+
db_path = SDK_CONFIG.queue_db
|
|
181
|
+
if not db_path.exists():
|
|
182
|
+
return 0, 0
|
|
183
|
+
|
|
184
|
+
_url = (base_url or SDK_CONFIG.gateway_url).rstrip("/")
|
|
185
|
+
_key = api_key or SDK_CONFIG.api_key
|
|
186
|
+
_timeout = timeout if timeout is not None else SDK_CONFIG.timeout_sec
|
|
187
|
+
|
|
188
|
+
headers: dict[str, str] = {
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"X-AgentTrust-SDK-Version": SDK_CONFIG.sdk_version,
|
|
191
|
+
}
|
|
192
|
+
if _key:
|
|
193
|
+
headers["X-AgentTrust-Token"] = _key
|
|
194
|
+
|
|
195
|
+
con = sqlite3.connect(str(db_path))
|
|
196
|
+
rows = con.execute("SELECT id, payload FROM queue ORDER BY id").fetchall()
|
|
197
|
+
sent = 0
|
|
198
|
+
failed = 0
|
|
199
|
+
for row_id, raw in rows:
|
|
200
|
+
try:
|
|
201
|
+
payload = json.loads(raw)
|
|
202
|
+
with httpx.Client(base_url=_url, headers=headers, timeout=_timeout) as http:
|
|
203
|
+
resp = http.post(_VALIDATE_PATH, json=payload)
|
|
204
|
+
resp.raise_for_status()
|
|
205
|
+
con.execute("DELETE FROM queue WHERE id = ?", (row_id,))
|
|
206
|
+
con.commit()
|
|
207
|
+
sent += 1
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
logger.warning("[AgentTrust] Queue replay failed for record %d: %s", row_id, exc)
|
|
210
|
+
failed += 1
|
|
211
|
+
con.close()
|
|
212
|
+
return sent, failed
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Retry helper
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _make_sync_retry():
|
|
220
|
+
"""Return a tenacity retry decorator for sync HTTP calls, or identity if tenacity missing."""
|
|
221
|
+
if not _HAS_TENACITY:
|
|
222
|
+
return lambda f: f
|
|
223
|
+
return retry(
|
|
224
|
+
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
|
|
225
|
+
stop=stop_after_attempt(SDK_CONFIG.retry_attempts),
|
|
226
|
+
wait=wait_exponential(multiplier=SDK_CONFIG.retry_backoff_sec, min=0.1, max=30),
|
|
227
|
+
reraise=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _make_async_retry():
|
|
232
|
+
if not _HAS_TENACITY:
|
|
233
|
+
return lambda f: f
|
|
234
|
+
return retry(
|
|
235
|
+
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
|
|
236
|
+
stop=stop_after_attempt(SDK_CONFIG.retry_attempts),
|
|
237
|
+
wait=wait_exponential(multiplier=SDK_CONFIG.retry_backoff_sec, min=0.1, max=30),
|
|
238
|
+
reraise=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Sync client
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
class AgentTrustClient:
|
|
247
|
+
"""
|
|
248
|
+
Synchronous AgentTrust client with:
|
|
249
|
+
- kill-switch (AGENTRUST_ENABLED=false → no-op)
|
|
250
|
+
- retry/backoff (AGENTRUST_RETRY_ATTEMPTS, AGENTRUST_RETRY_BACKOFF)
|
|
251
|
+
- failure modes (AGENTRUST_FAILURE_MODE=open|closed|queue)
|
|
252
|
+
- OTel spans when opentelemetry-sdk installed
|
|
253
|
+
- version negotiation headers
|
|
254
|
+
- webhook dispatcher (Team tier and above)
|
|
255
|
+
|
|
256
|
+
Usage::
|
|
257
|
+
|
|
258
|
+
client = AgentTrustClient()
|
|
259
|
+
result = client.validate(agent_id="payment-agent", user="alice",
|
|
260
|
+
input="Pay invoice #42", output={"amount": 500})
|
|
261
|
+
print(result.decision.outcome)
|
|
262
|
+
|
|
263
|
+
With webhooks (Team tier)::
|
|
264
|
+
|
|
265
|
+
from agentrust_sdk.webhooks import WebhookDispatcher
|
|
266
|
+
dispatcher = WebhookDispatcher(tier=client.tier)
|
|
267
|
+
dispatcher.register(
|
|
268
|
+
url="https://discord.com/api/webhooks/...",
|
|
269
|
+
events=["block", "escalate"],
|
|
270
|
+
)
|
|
271
|
+
client = AgentTrustClient(webhook_dispatcher=dispatcher)
|
|
272
|
+
# webhook fires automatically after every validate()
|
|
273
|
+
|
|
274
|
+
Or via env vars (zero code)::
|
|
275
|
+
|
|
276
|
+
AGENTRUST_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
|
277
|
+
AGENTRUST_WEBHOOK_EVENTS=block,escalate
|
|
278
|
+
# client auto-creates a dispatcher on __init__
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
base_url: str | None = None,
|
|
284
|
+
api_key: str | None = None,
|
|
285
|
+
timeout: float | None = None,
|
|
286
|
+
raise_on_tier_gate: bool = False,
|
|
287
|
+
control_plane_url: str | None = None,
|
|
288
|
+
webhook_dispatcher: "Any | None" = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
self._key_info: KeyInfo = resolve_key(api_key or SDK_CONFIG.api_key)
|
|
291
|
+
_url = control_plane_url or base_url or SDK_CONFIG.gateway_url
|
|
292
|
+
self._base_url = _url.rstrip("/")
|
|
293
|
+
self._timeout = timeout if timeout is not None else SDK_CONFIG.timeout_sec
|
|
294
|
+
self._raise_on_tier_gate = raise_on_tier_gate
|
|
295
|
+
|
|
296
|
+
headers = {
|
|
297
|
+
"Content-Type": "application/json",
|
|
298
|
+
"X-AgentTrust-SDK-Version": SDK_CONFIG.sdk_version,
|
|
299
|
+
}
|
|
300
|
+
if self._key_info.key:
|
|
301
|
+
headers["X-AgentTrust-Token"] = self._key_info.key
|
|
302
|
+
|
|
303
|
+
self._http = httpx.Client(
|
|
304
|
+
base_url=self._base_url, headers=headers, timeout=self._timeout
|
|
305
|
+
)
|
|
306
|
+
logger.debug(
|
|
307
|
+
"[AgentTrust] Client init: tier=%s org=%s failure_mode=%s retries=%d",
|
|
308
|
+
self._key_info.tier.value, self._key_info.org_id,
|
|
309
|
+
SDK_CONFIG.failure_mode, SDK_CONFIG.retry_attempts,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ── Webhook dispatcher ────────────────────────────────────────────────
|
|
313
|
+
# Accept an explicit dispatcher; otherwise auto-create one when
|
|
314
|
+
# AGENTRUST_WEBHOOK_URL is set (mirrors Adrian's zero-config setup).
|
|
315
|
+
self._webhook_dispatcher = webhook_dispatcher
|
|
316
|
+
if self._webhook_dispatcher is None and SDK_CONFIG.webhook_url:
|
|
317
|
+
try:
|
|
318
|
+
from .webhooks import WebhookDispatcher as _WD
|
|
319
|
+
self._webhook_dispatcher = _WD(tier=self._key_info.tier)
|
|
320
|
+
logger.debug(
|
|
321
|
+
"[AgentTrust] Webhook dispatcher auto-created from env var"
|
|
322
|
+
)
|
|
323
|
+
except Exception as exc:
|
|
324
|
+
logger.warning(
|
|
325
|
+
"[AgentTrust] Failed to auto-create webhook dispatcher: %s", exc
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# If failure_mode=queue, drain any buffered records in the background
|
|
329
|
+
# so they replay automatically when the gateway becomes reachable again.
|
|
330
|
+
if SDK_CONFIG.failure_mode == "queue" and SDK_CONFIG.queue_db.exists():
|
|
331
|
+
import threading as _threading
|
|
332
|
+
|
|
333
|
+
def _background_drain() -> None:
|
|
334
|
+
try:
|
|
335
|
+
sent, failed = drain_queue(
|
|
336
|
+
base_url=self._base_url,
|
|
337
|
+
api_key=self._key_info.key,
|
|
338
|
+
timeout=self._timeout,
|
|
339
|
+
)
|
|
340
|
+
if sent:
|
|
341
|
+
logger.info(
|
|
342
|
+
"[AgentTrust] Background queue drain: replayed %d, failed %d",
|
|
343
|
+
sent, failed,
|
|
344
|
+
)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
logger.debug("[AgentTrust] Background drain skipped: %s", exc)
|
|
347
|
+
|
|
348
|
+
_threading.Thread(target=_background_drain, daemon=True).start()
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def tier(self) -> Tier:
|
|
352
|
+
return self._key_info.tier
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def webhook_dispatcher(self) -> "Any | None":
|
|
356
|
+
"""The attached WebhookDispatcher, or None if not configured."""
|
|
357
|
+
return self._webhook_dispatcher
|
|
358
|
+
|
|
359
|
+
@webhook_dispatcher.setter
|
|
360
|
+
def webhook_dispatcher(self, dispatcher: "Any | None") -> None:
|
|
361
|
+
"""Attach or replace the WebhookDispatcher after construction."""
|
|
362
|
+
self._webhook_dispatcher = dispatcher
|
|
363
|
+
|
|
364
|
+
def can(self, capability: Capability) -> bool:
|
|
365
|
+
return is_allowed(capability, self._key_info.tier)
|
|
366
|
+
|
|
367
|
+
def validate(
|
|
368
|
+
self,
|
|
369
|
+
agent_id: str,
|
|
370
|
+
user: str,
|
|
371
|
+
input: str,
|
|
372
|
+
output: dict[str, Any] | None = None,
|
|
373
|
+
*,
|
|
374
|
+
framework: str = "REST",
|
|
375
|
+
model: str = "unknown",
|
|
376
|
+
tools_called: list[dict] | None = None,
|
|
377
|
+
latency_ms: float = 0.0,
|
|
378
|
+
tokens: int = 0,
|
|
379
|
+
parent_envelope_id: str | None = None,
|
|
380
|
+
session_id: str | None = None,
|
|
381
|
+
metadata: dict[str, Any] | None = None,
|
|
382
|
+
) -> ValidateResponse:
|
|
383
|
+
# ── kill-switch ──────────────────────────────────────────────────────
|
|
384
|
+
if not SDK_CONFIG.enabled:
|
|
385
|
+
return _noop_response()
|
|
386
|
+
|
|
387
|
+
from .models import ToolCall
|
|
388
|
+
|
|
389
|
+
if parent_envelope_id and not self.can(Capability.TRUST_CHAIN):
|
|
390
|
+
_warn_tier(Capability.TRUST_CHAIN)
|
|
391
|
+
if self._raise_on_tier_gate:
|
|
392
|
+
raise TierGateError(Capability.TRUST_CHAIN, self._key_info.tier)
|
|
393
|
+
parent_envelope_id = None
|
|
394
|
+
|
|
395
|
+
req = ValidateRequest(
|
|
396
|
+
agent_id=agent_id, framework=framework, user=user, input=input,
|
|
397
|
+
output=output or {}, model=model,
|
|
398
|
+
tools_called=[ToolCall(**t) for t in (tools_called or [])],
|
|
399
|
+
latency_ms=latency_ms, tokens=tokens,
|
|
400
|
+
parent_envelope_id=parent_envelope_id,
|
|
401
|
+
session_id=session_id, metadata=metadata or {},
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if self._key_info.tier == Tier.OSS:
|
|
405
|
+
return _oss_schema_only(req)
|
|
406
|
+
|
|
407
|
+
payload = _build_gateway_payload(req)
|
|
408
|
+
result = self._call_with_resilience(payload, req)
|
|
409
|
+
|
|
410
|
+
# ── Webhook fan-out (Team tier and above) ────────────────────────────
|
|
411
|
+
if (
|
|
412
|
+
self._webhook_dispatcher is not None
|
|
413
|
+
and SDK_CONFIG.webhook_auto_dispatch
|
|
414
|
+
):
|
|
415
|
+
try:
|
|
416
|
+
from .webhooks import event_from_response
|
|
417
|
+
event = event_from_response(
|
|
418
|
+
result,
|
|
419
|
+
agent_id=agent_id,
|
|
420
|
+
framework=framework,
|
|
421
|
+
session_id=session_id,
|
|
422
|
+
)
|
|
423
|
+
self._webhook_dispatcher.dispatch(event)
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
logger.debug(
|
|
426
|
+
"[AgentTrust] Webhook dispatch skipped after validate: %s", exc
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return result
|
|
430
|
+
|
|
431
|
+
def _call_with_resilience(
|
|
432
|
+
self, payload: dict[str, Any], req: ValidateRequest
|
|
433
|
+
) -> ValidateResponse:
|
|
434
|
+
"""Execute HTTP call with retry, OTel, and failure-mode handling."""
|
|
435
|
+
t0 = time.perf_counter()
|
|
436
|
+
|
|
437
|
+
def _do_request() -> ValidateResponse:
|
|
438
|
+
@_make_sync_retry()
|
|
439
|
+
def _inner():
|
|
440
|
+
resp = self._http.post(_VALIDATE_PATH, json=payload)
|
|
441
|
+
# 426 = version mismatch
|
|
442
|
+
if resp.status_code == 426:
|
|
443
|
+
raise GatewayVersionError(
|
|
444
|
+
f"Gateway requires SDK upgrade. "
|
|
445
|
+
f"Current SDK: {SDK_CONFIG.sdk_version}. "
|
|
446
|
+
f"Upgrade: pip install --upgrade agentrust-sdk"
|
|
447
|
+
)
|
|
448
|
+
resp.raise_for_status()
|
|
449
|
+
return ValidateResponse.model_validate(resp.json())
|
|
450
|
+
return _inner()
|
|
451
|
+
|
|
452
|
+
span_ctx = (
|
|
453
|
+
_tracer.start_as_current_span(
|
|
454
|
+
"agentrust.validate",
|
|
455
|
+
attributes={"agent_id": req.agent_id, "framework": req.framework,
|
|
456
|
+
"sdk_version": SDK_CONFIG.sdk_version},
|
|
457
|
+
) if _HAS_OTEL else _nullspan()
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
with span_ctx as span:
|
|
461
|
+
try:
|
|
462
|
+
result = _do_request()
|
|
463
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
464
|
+
if _HAS_OTEL:
|
|
465
|
+
span.set_attribute("decision", result.decision.outcome)
|
|
466
|
+
span.set_attribute("risk_tier", result.risk.tier if result.risk else "unknown")
|
|
467
|
+
_validation_latency.record(latency, {"agent_id": req.agent_id})
|
|
468
|
+
_validation_counter.add(1, {"decision": result.decision.outcome})
|
|
469
|
+
_apply_tier_mask(result, self._key_info.tier)
|
|
470
|
+
return result
|
|
471
|
+
except GatewayVersionError:
|
|
472
|
+
raise
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
if _HAS_OTEL:
|
|
475
|
+
span.record_exception(exc)
|
|
476
|
+
return self._handle_gateway_failure(exc, payload)
|
|
477
|
+
|
|
478
|
+
def _handle_gateway_failure(
|
|
479
|
+
self, exc: Exception, payload: dict[str, Any]
|
|
480
|
+
) -> ValidateResponse:
|
|
481
|
+
mode = SDK_CONFIG.failure_mode
|
|
482
|
+
if mode == "closed":
|
|
483
|
+
raise GatewayUnavailableError(
|
|
484
|
+
f"AgentTrust gateway unreachable and AGENTRUST_FAILURE_MODE=closed. "
|
|
485
|
+
f"Original error: {exc}"
|
|
486
|
+
) from exc
|
|
487
|
+
if mode == "queue":
|
|
488
|
+
_enqueue_failed_request(payload)
|
|
489
|
+
else: # open (default)
|
|
490
|
+
logger.warning("[AgentTrust] Gateway unreachable (fail-open): %s", exc)
|
|
491
|
+
return _noop_response()
|
|
492
|
+
|
|
493
|
+
def close(self) -> None:
|
|
494
|
+
self._http.close()
|
|
495
|
+
|
|
496
|
+
def __enter__(self) -> "AgentTrustClient":
|
|
497
|
+
return self
|
|
498
|
+
|
|
499
|
+
def __exit__(self, *args: Any) -> None:
|
|
500
|
+
self.close()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Async client
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
class AsyncAgentTrustClient:
|
|
508
|
+
"""
|
|
509
|
+
Async AgentTrust client — same resilience features as the sync client,
|
|
510
|
+
plus async webhook dispatch (Team tier and above).
|
|
511
|
+
|
|
512
|
+
Usage::
|
|
513
|
+
|
|
514
|
+
async with AsyncAgentTrustClient() as client:
|
|
515
|
+
result = await client.validate(agent_id="...", user="...", input="...")
|
|
516
|
+
|
|
517
|
+
With webhooks (Team tier)::
|
|
518
|
+
|
|
519
|
+
from agentrust_sdk.webhooks import WebhookDispatcher
|
|
520
|
+
dispatcher = WebhookDispatcher(tier=Tier.TEAM)
|
|
521
|
+
dispatcher.register(url="https://discord.com/api/webhooks/...",
|
|
522
|
+
events=["block"])
|
|
523
|
+
async with AsyncAgentTrustClient(webhook_dispatcher=dispatcher) as client:
|
|
524
|
+
result = await client.validate(...)
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
def __init__(
|
|
528
|
+
self,
|
|
529
|
+
base_url: str | None = None,
|
|
530
|
+
api_key: str | None = None,
|
|
531
|
+
timeout: float | None = None,
|
|
532
|
+
raise_on_tier_gate: bool = False,
|
|
533
|
+
control_plane_url: str | None = None,
|
|
534
|
+
webhook_dispatcher: "Any | None" = None,
|
|
535
|
+
) -> None:
|
|
536
|
+
self._key_info: KeyInfo = resolve_key(api_key or SDK_CONFIG.api_key)
|
|
537
|
+
_url = control_plane_url or base_url or SDK_CONFIG.gateway_url
|
|
538
|
+
self._base_url = _url.rstrip("/")
|
|
539
|
+
self._timeout = timeout if timeout is not None else SDK_CONFIG.timeout_sec
|
|
540
|
+
self._raise_on_tier_gate = raise_on_tier_gate
|
|
541
|
+
|
|
542
|
+
headers = {
|
|
543
|
+
"Content-Type": "application/json",
|
|
544
|
+
"X-AgentTrust-SDK-Version": SDK_CONFIG.sdk_version,
|
|
545
|
+
}
|
|
546
|
+
if self._key_info.key:
|
|
547
|
+
headers["X-AgentTrust-Token"] = self._key_info.key
|
|
548
|
+
|
|
549
|
+
self._http = httpx.AsyncClient(
|
|
550
|
+
base_url=self._base_url, headers=headers, timeout=self._timeout
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# ── Webhook dispatcher ────────────────────────────────────────────────
|
|
554
|
+
self._webhook_dispatcher = webhook_dispatcher
|
|
555
|
+
if self._webhook_dispatcher is None and SDK_CONFIG.webhook_url:
|
|
556
|
+
try:
|
|
557
|
+
from .webhooks import WebhookDispatcher as _WD
|
|
558
|
+
self._webhook_dispatcher = _WD(tier=self._key_info.tier)
|
|
559
|
+
except Exception as exc:
|
|
560
|
+
logger.warning(
|
|
561
|
+
"[AgentTrust] Failed to auto-create async webhook dispatcher: %s",
|
|
562
|
+
exc,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
@property
|
|
566
|
+
def tier(self) -> Tier:
|
|
567
|
+
return self._key_info.tier
|
|
568
|
+
|
|
569
|
+
@property
|
|
570
|
+
def webhook_dispatcher(self) -> "Any | None":
|
|
571
|
+
"""The attached WebhookDispatcher, or None if not configured."""
|
|
572
|
+
return self._webhook_dispatcher
|
|
573
|
+
|
|
574
|
+
@webhook_dispatcher.setter
|
|
575
|
+
def webhook_dispatcher(self, dispatcher: "Any | None") -> None:
|
|
576
|
+
self._webhook_dispatcher = dispatcher
|
|
577
|
+
|
|
578
|
+
def can(self, capability: Capability) -> bool:
|
|
579
|
+
return is_allowed(capability, self._key_info.tier)
|
|
580
|
+
|
|
581
|
+
async def validate(
|
|
582
|
+
self,
|
|
583
|
+
agent_id: str,
|
|
584
|
+
user: str,
|
|
585
|
+
input: str,
|
|
586
|
+
output: dict[str, Any] | None = None,
|
|
587
|
+
*,
|
|
588
|
+
framework: str = "REST",
|
|
589
|
+
model: str = "unknown",
|
|
590
|
+
tools_called: list[dict] | None = None,
|
|
591
|
+
latency_ms: float = 0.0,
|
|
592
|
+
tokens: int = 0,
|
|
593
|
+
parent_envelope_id: str | None = None,
|
|
594
|
+
session_id: str | None = None,
|
|
595
|
+
metadata: dict[str, Any] | None = None,
|
|
596
|
+
) -> ValidateResponse:
|
|
597
|
+
if not SDK_CONFIG.enabled:
|
|
598
|
+
return _noop_response()
|
|
599
|
+
|
|
600
|
+
from .models import ToolCall
|
|
601
|
+
|
|
602
|
+
if parent_envelope_id and not self.can(Capability.TRUST_CHAIN):
|
|
603
|
+
_warn_tier(Capability.TRUST_CHAIN)
|
|
604
|
+
if self._raise_on_tier_gate:
|
|
605
|
+
raise TierGateError(Capability.TRUST_CHAIN, self._key_info.tier)
|
|
606
|
+
parent_envelope_id = None
|
|
607
|
+
|
|
608
|
+
req = ValidateRequest(
|
|
609
|
+
agent_id=agent_id, framework=framework, user=user, input=input,
|
|
610
|
+
output=output or {}, model=model,
|
|
611
|
+
tools_called=[ToolCall(**t) for t in (tools_called or [])],
|
|
612
|
+
latency_ms=latency_ms, tokens=tokens,
|
|
613
|
+
parent_envelope_id=parent_envelope_id,
|
|
614
|
+
session_id=session_id, metadata=metadata or {},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if self._key_info.tier == Tier.OSS:
|
|
618
|
+
return _oss_schema_only(req)
|
|
619
|
+
|
|
620
|
+
payload = _build_gateway_payload(req)
|
|
621
|
+
result = await self._call_with_resilience(payload, req)
|
|
622
|
+
|
|
623
|
+
# ── Async webhook fan-out (Team tier and above) ───────────────────────
|
|
624
|
+
if (
|
|
625
|
+
self._webhook_dispatcher is not None
|
|
626
|
+
and SDK_CONFIG.webhook_auto_dispatch
|
|
627
|
+
):
|
|
628
|
+
try:
|
|
629
|
+
from .webhooks import event_from_response
|
|
630
|
+
event = event_from_response(
|
|
631
|
+
result,
|
|
632
|
+
agent_id=agent_id,
|
|
633
|
+
framework=framework,
|
|
634
|
+
session_id=session_id,
|
|
635
|
+
)
|
|
636
|
+
await self._webhook_dispatcher.async_dispatch(event)
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
logger.debug(
|
|
639
|
+
"[AgentTrust] Async webhook dispatch skipped after validate: %s",
|
|
640
|
+
exc,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return result
|
|
644
|
+
|
|
645
|
+
async def _call_with_resilience(
|
|
646
|
+
self, payload: dict[str, Any], req: ValidateRequest
|
|
647
|
+
) -> ValidateResponse:
|
|
648
|
+
t0 = time.perf_counter()
|
|
649
|
+
|
|
650
|
+
async def _do_request() -> ValidateResponse:
|
|
651
|
+
@_make_async_retry()
|
|
652
|
+
async def _inner():
|
|
653
|
+
resp = await self._http.post(_VALIDATE_PATH, json=payload)
|
|
654
|
+
if resp.status_code == 426:
|
|
655
|
+
raise GatewayVersionError(
|
|
656
|
+
f"Gateway requires SDK upgrade. Current: {SDK_CONFIG.sdk_version}. "
|
|
657
|
+
f"Upgrade: pip install --upgrade agentrust-sdk"
|
|
658
|
+
)
|
|
659
|
+
resp.raise_for_status()
|
|
660
|
+
return ValidateResponse.model_validate(resp.json())
|
|
661
|
+
return await _inner()
|
|
662
|
+
|
|
663
|
+
span_ctx = (
|
|
664
|
+
_tracer.start_as_current_span(
|
|
665
|
+
"agentrust.validate",
|
|
666
|
+
attributes={"agent_id": req.agent_id, "framework": req.framework,
|
|
667
|
+
"sdk_version": SDK_CONFIG.sdk_version},
|
|
668
|
+
) if _HAS_OTEL else _nullspan()
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
with span_ctx as span:
|
|
672
|
+
try:
|
|
673
|
+
result = await _do_request()
|
|
674
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
675
|
+
if _HAS_OTEL:
|
|
676
|
+
span.set_attribute("decision", result.decision.outcome)
|
|
677
|
+
_validation_latency.record(latency, {"agent_id": req.agent_id})
|
|
678
|
+
_validation_counter.add(1, {"decision": result.decision.outcome})
|
|
679
|
+
_apply_tier_mask(result, self._key_info.tier)
|
|
680
|
+
return result
|
|
681
|
+
except GatewayVersionError:
|
|
682
|
+
raise
|
|
683
|
+
except Exception as exc:
|
|
684
|
+
if _HAS_OTEL:
|
|
685
|
+
span.record_exception(exc)
|
|
686
|
+
return self._handle_gateway_failure(exc, payload)
|
|
687
|
+
|
|
688
|
+
def _handle_gateway_failure(
|
|
689
|
+
self, exc: Exception, payload: dict[str, Any]
|
|
690
|
+
) -> ValidateResponse:
|
|
691
|
+
mode = SDK_CONFIG.failure_mode
|
|
692
|
+
if mode == "closed":
|
|
693
|
+
raise GatewayUnavailableError(
|
|
694
|
+
f"AgentTrust gateway unreachable and AGENTRUST_FAILURE_MODE=closed. "
|
|
695
|
+
f"Original error: {exc}"
|
|
696
|
+
) from exc
|
|
697
|
+
if mode == "queue":
|
|
698
|
+
_enqueue_failed_request(payload)
|
|
699
|
+
else:
|
|
700
|
+
logger.warning("[AgentTrust] Async gateway unreachable (fail-open): %s", exc)
|
|
701
|
+
return _noop_response()
|
|
702
|
+
|
|
703
|
+
async def close(self) -> None:
|
|
704
|
+
await self._http.aclose()
|
|
705
|
+
|
|
706
|
+
async def __aenter__(self) -> "AsyncAgentTrustClient":
|
|
707
|
+
return self
|
|
708
|
+
|
|
709
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
710
|
+
await self.close()
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ---------------------------------------------------------------------------
|
|
714
|
+
# Exceptions
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
class TierGateError(RuntimeError):
|
|
718
|
+
def __init__(self, capability: Capability, current_tier: Tier) -> None:
|
|
719
|
+
msg = UPGRADE_MESSAGES.get(
|
|
720
|
+
capability, f"{capability.value} not available on {current_tier.value} tier."
|
|
721
|
+
)
|
|
722
|
+
super().__init__(msg)
|
|
723
|
+
self.capability = capability
|
|
724
|
+
self.current_tier = current_tier
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class GatewayUnavailableError(RuntimeError):
|
|
728
|
+
"""Raised when AGENTRUST_FAILURE_MODE=closed and gateway is unreachable."""
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
class GatewayVersionError(RuntimeError):
|
|
732
|
+
"""Raised when gateway returns 426 — SDK version too old."""
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
# OSS schema-only path
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
def _oss_schema_only(req: ValidateRequest) -> ValidateResponse:
|
|
740
|
+
"""Run schema validation in-process for OSS tier. No gateway call."""
|
|
741
|
+
from .models import DecisionResult, RiskResult, ValidationResult
|
|
742
|
+
|
|
743
|
+
failures: list[str] = []
|
|
744
|
+
if not isinstance(req.output, dict):
|
|
745
|
+
failures.append("output must be a JSON object")
|
|
746
|
+
schema_score = 0.0
|
|
747
|
+
elif not req.output:
|
|
748
|
+
failures.append("output is empty")
|
|
749
|
+
schema_score = 40.0
|
|
750
|
+
else:
|
|
751
|
+
schema_score = 100.0
|
|
752
|
+
|
|
753
|
+
return ValidateResponse(
|
|
754
|
+
envelope_id="oss-local",
|
|
755
|
+
validation=ValidationResult(
|
|
756
|
+
schema_score=schema_score, final_confidence=schema_score, failures=failures,
|
|
757
|
+
),
|
|
758
|
+
risk=RiskResult(),
|
|
759
|
+
decision=DecisionResult(),
|
|
760
|
+
latency_ms=0.0,
|
|
761
|
+
tier_info=Tier.OSS.value,
|
|
762
|
+
upgrade_hint="Upgrade to Free tier for full validation: agentrust.io/signup",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _apply_tier_mask(result: ValidateResponse, tier: Tier) -> None:
|
|
767
|
+
from .models import DecisionResult, RiskResult
|
|
768
|
+
if not is_allowed(Capability.CONFIDENCE_ENGINE, tier):
|
|
769
|
+
result.validation.final_confidence = result.validation.schema_score
|
|
770
|
+
if not is_allowed(Capability.RISK_SCORING, tier):
|
|
771
|
+
result.risk = RiskResult()
|
|
772
|
+
if not is_allowed(Capability.AUTO_DECISION, tier):
|
|
773
|
+
result.decision = DecisionResult()
|
|
774
|
+
if not is_allowed(Capability.TRUST_CHAIN, tier):
|
|
775
|
+
result.trust_chain = None
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
# OTel null-span context manager (when OTel not installed)
|
|
780
|
+
# ---------------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
class _NullSpan:
|
|
783
|
+
def set_attribute(self, *a: Any, **kw: Any) -> None: ...
|
|
784
|
+
def record_exception(self, *a: Any, **kw: Any) -> None: ...
|
|
785
|
+
def __enter__(self): return self
|
|
786
|
+
def __exit__(self, *a: Any): ...
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _nullspan() -> _NullSpan:
|
|
790
|
+
return _NullSpan()
|