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.
@@ -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()