kerneva-runtime-trust 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kerneva_runtime_trust/__init__.py +43 -0
- kerneva_runtime_trust/client.py +509 -0
- kerneva_runtime_trust/context.py +106 -0
- kerneva_runtime_trust/exceptions.py +38 -0
- kerneva_runtime_trust/models.py +121 -0
- kerneva_runtime_trust/py.typed +0 -0
- kerneva_runtime_trust/runtime_trust.py +490 -0
- kerneva_runtime_trust-0.3.0.dist-info/METADATA +121 -0
- kerneva_runtime_trust-0.3.0.dist-info/RECORD +12 -0
- kerneva_runtime_trust-0.3.0.dist-info/WHEEL +5 -0
- kerneva_runtime_trust-0.3.0.dist-info/licenses/LICENSE +21 -0
- kerneva_runtime_trust-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from .client import KernevaClient
|
|
2
|
+
from .context import (
|
|
3
|
+
current_agent_id,
|
|
4
|
+
current_customer_id,
|
|
5
|
+
current_metadata,
|
|
6
|
+
current_session_id,
|
|
7
|
+
new_session_id,
|
|
8
|
+
session,
|
|
9
|
+
)
|
|
10
|
+
from .exceptions import KernevaBlocked, KernevaError
|
|
11
|
+
from .models import (
|
|
12
|
+
AgentSummary,
|
|
13
|
+
EvaluationDetail,
|
|
14
|
+
EvaluationResult,
|
|
15
|
+
ExecutionOutcome,
|
|
16
|
+
HistoryEntry,
|
|
17
|
+
SignalEntry,
|
|
18
|
+
TraceEntry,
|
|
19
|
+
)
|
|
20
|
+
from .runtime_trust import ReviewContext, RuntimeTrustBlock, guard, with_runtime_trust
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"with_runtime_trust",
|
|
24
|
+
"guard",
|
|
25
|
+
"RuntimeTrustBlock",
|
|
26
|
+
"ReviewContext",
|
|
27
|
+
"session",
|
|
28
|
+
"new_session_id",
|
|
29
|
+
"current_agent_id",
|
|
30
|
+
"current_session_id",
|
|
31
|
+
"current_customer_id",
|
|
32
|
+
"current_metadata",
|
|
33
|
+
"KernevaClient",
|
|
34
|
+
"KernevaError",
|
|
35
|
+
"KernevaBlocked",
|
|
36
|
+
"EvaluationResult",
|
|
37
|
+
"ExecutionOutcome",
|
|
38
|
+
"EvaluationDetail",
|
|
39
|
+
"HistoryEntry",
|
|
40
|
+
"AgentSummary",
|
|
41
|
+
"SignalEntry",
|
|
42
|
+
"TraceEntry",
|
|
43
|
+
]
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
KernevaAuthError, KernevaBlocked, KernevaError,
|
|
12
|
+
KernevaFatalError, KernevaRetryableError, KernevaTimeoutError,
|
|
13
|
+
KernevaValidationError,
|
|
14
|
+
)
|
|
15
|
+
from .models import (
|
|
16
|
+
AgentSummary,
|
|
17
|
+
EvaluationDetail,
|
|
18
|
+
EvaluationResult,
|
|
19
|
+
HistoryEntry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("kerneva_runtime_trust")
|
|
23
|
+
|
|
24
|
+
ExecutionStatus = Literal[
|
|
25
|
+
"completed",
|
|
26
|
+
"failed",
|
|
27
|
+
"blocked",
|
|
28
|
+
"abandoned",
|
|
29
|
+
"timed_out",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_EXECUTION_TO_REVIEW: Dict[str, Dict[str, str]] = {
|
|
33
|
+
"completed": {"status": "resolved", "resolution": "completed"},
|
|
34
|
+
"failed": {"status": "resolved", "resolution": "failed"},
|
|
35
|
+
"blocked": {"status": "resolved", "resolution": "blocked_by_external"},
|
|
36
|
+
"abandoned": {"status": "dismissed", "resolution": "abandoned"},
|
|
37
|
+
"timed_out": {"status": "dismissed", "resolution": "timed_out"},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_DEFAULT_API_URL = "https://api.kerneva.com"
|
|
41
|
+
|
|
42
|
+
# Retry configuration
|
|
43
|
+
_RETRYABLE_STATUSES = {429, 503}
|
|
44
|
+
_MAX_RETRIES = 3
|
|
45
|
+
_BASE_DELAY = 1.0 # seconds
|
|
46
|
+
_MAX_DELAY = 10.0 # seconds
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_url(base: str, path: str, query: Optional[Dict[str, Any]] = None) -> str:
|
|
50
|
+
base = base.rstrip("/")
|
|
51
|
+
url = f"{base}{path}"
|
|
52
|
+
if query:
|
|
53
|
+
params = {k: v for k, v in query.items() if v is not None}
|
|
54
|
+
if params:
|
|
55
|
+
url = f"{url}?{urlencode(params)}"
|
|
56
|
+
return url
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _retry_delay(attempt: int) -> float:
|
|
60
|
+
"""Exponential backoff with jitter."""
|
|
61
|
+
delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY)
|
|
62
|
+
# Add ±25% jitter
|
|
63
|
+
import random
|
|
64
|
+
jitter = delay * 0.25 * (2 * random.random() - 1)
|
|
65
|
+
return delay + jitter
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class KernevaClient:
|
|
69
|
+
"""Low-level client for the Kerneva API.
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
client = KernevaClient(api_key="krv_test_...")
|
|
73
|
+
result = client.evaluate(
|
|
74
|
+
agent_id="bot-1",
|
|
75
|
+
session_id="ses-abc",
|
|
76
|
+
action_type="refund",
|
|
77
|
+
amount=150.0,
|
|
78
|
+
)
|
|
79
|
+
client.confirm_execution(result.evaluation_id, status="completed")
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: Optional[str] = None,
|
|
85
|
+
base_url: Optional[str] = None,
|
|
86
|
+
timeout: int = 30,
|
|
87
|
+
endpoint_timeouts: Optional[dict[str, int]] = None,
|
|
88
|
+
):
|
|
89
|
+
from .exceptions import KernevaConfigError
|
|
90
|
+
|
|
91
|
+
self.api_key = (
|
|
92
|
+
api_key
|
|
93
|
+
or os.environ.get("KERNEVA_API_KEY")
|
|
94
|
+
or os.environ.get("RUNTIME_TRUST_API_KEY", "")
|
|
95
|
+
)
|
|
96
|
+
if not self.api_key:
|
|
97
|
+
raise KernevaConfigError(
|
|
98
|
+
"api_key is required. Pass it explicitly or set KERNEVA_API_KEY"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.base_url = (
|
|
102
|
+
base_url
|
|
103
|
+
or os.environ.get("KERNEVA_API_URL")
|
|
104
|
+
or os.environ.get("RUNTIME_TRUST_API_URL")
|
|
105
|
+
or _DEFAULT_API_URL
|
|
106
|
+
).rstrip("/")
|
|
107
|
+
|
|
108
|
+
self.timeout = timeout
|
|
109
|
+
self.endpoint_timeouts = endpoint_timeouts or {}
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Internal request helpers
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
body: Optional[dict] = None,
|
|
120
|
+
query: Optional[Dict[str, Any]] = None,
|
|
121
|
+
idempotency_key: Optional[str] = None,
|
|
122
|
+
endpoint_timeout: Optional[int] = None,
|
|
123
|
+
) -> Any:
|
|
124
|
+
url = _build_url(self.base_url, path, query)
|
|
125
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
126
|
+
timeout = endpoint_timeout or self.timeout
|
|
127
|
+
|
|
128
|
+
headers = {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
131
|
+
}
|
|
132
|
+
if idempotency_key:
|
|
133
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
134
|
+
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Sending %s %s (body=%s, query=%s, timeout=%ds)",
|
|
137
|
+
method, url,
|
|
138
|
+
json.dumps(body) if body else None,
|
|
139
|
+
query, timeout,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
last_exc: Optional[Exception] = None
|
|
143
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
144
|
+
if attempt > 0:
|
|
145
|
+
delay = _retry_delay(attempt - 1)
|
|
146
|
+
logger.debug(
|
|
147
|
+
"Retry %d/%d after %.2fs for %s %s",
|
|
148
|
+
attempt, _MAX_RETRIES, delay, method, url,
|
|
149
|
+
)
|
|
150
|
+
time.sleep(delay)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
req = urllib.request.Request(
|
|
154
|
+
url, data=data, headers=headers, method=method,
|
|
155
|
+
)
|
|
156
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
157
|
+
response_data = json.loads(resp.read().decode())
|
|
158
|
+
logger.debug(
|
|
159
|
+
"Response %d for %s %s: %s",
|
|
160
|
+
resp.status, method, url,
|
|
161
|
+
json.dumps(response_data)[:500],
|
|
162
|
+
)
|
|
163
|
+
return response_data
|
|
164
|
+
except urllib.error.HTTPError as e:
|
|
165
|
+
body_text = e.read().decode(errors="replace")
|
|
166
|
+
logger.debug(
|
|
167
|
+
"HTTP error %d for %s %s: %s",
|
|
168
|
+
e.code, method, url, body_text,
|
|
169
|
+
)
|
|
170
|
+
if e.code == 401 or e.code == 403:
|
|
171
|
+
raise KernevaAuthError(
|
|
172
|
+
f"Authentication failed ({e.code}) for {method} {path}: {body_text}"
|
|
173
|
+
) from e
|
|
174
|
+
if e.code == 422:
|
|
175
|
+
raise KernevaValidationError(
|
|
176
|
+
f"Validation error for {method} {path}: {body_text}"
|
|
177
|
+
) from e
|
|
178
|
+
if e.code in _RETRYABLE_STATUSES and attempt < _MAX_RETRIES:
|
|
179
|
+
last_exc = KernevaRetryableError(
|
|
180
|
+
f"Retryable API error {e.code} for {method} {path}: {body_text}"
|
|
181
|
+
)
|
|
182
|
+
continue
|
|
183
|
+
if 500 <= e.code < 600 and e.code not in _RETRYABLE_STATUSES:
|
|
184
|
+
raise KernevaFatalError(
|
|
185
|
+
f"Non-retryable server error {e.code} for {method} {path}: {body_text}"
|
|
186
|
+
) from e
|
|
187
|
+
raise KernevaError(
|
|
188
|
+
f"API error {e.code} for {method} {path}: {body_text}"
|
|
189
|
+
) from e
|
|
190
|
+
except urllib.error.URLError as e:
|
|
191
|
+
logger.debug("URL error for %s %s: %s", method, url, e.reason)
|
|
192
|
+
raise KernevaRetryableError(
|
|
193
|
+
f"Connection error for {method} {url}: {e.reason}"
|
|
194
|
+
) from e
|
|
195
|
+
except TimeoutError as e:
|
|
196
|
+
raise KernevaTimeoutError(
|
|
197
|
+
f"Request timed out after {timeout}s for {method} {url}"
|
|
198
|
+
) from e
|
|
199
|
+
except OSError as e:
|
|
200
|
+
logger.debug("OS error for %s %s: %s", method, url, e)
|
|
201
|
+
raise KernevaError(
|
|
202
|
+
f"Request failed for {method} {url}: {e}"
|
|
203
|
+
) from e
|
|
204
|
+
|
|
205
|
+
if last_exc:
|
|
206
|
+
raise last_exc
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Core API methods
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def evaluate(
|
|
213
|
+
self,
|
|
214
|
+
agent_id: str,
|
|
215
|
+
session_id: str,
|
|
216
|
+
action_type: str,
|
|
217
|
+
amount: float,
|
|
218
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
219
|
+
idempotency_key: Optional[str] = None,
|
|
220
|
+
customer_id: Optional[str] = None,
|
|
221
|
+
) -> EvaluationResult:
|
|
222
|
+
"""Evaluate a single agent action.
|
|
223
|
+
|
|
224
|
+
Returns EvaluationResult. Raises KernevaBlocked if decision is BLOCK.
|
|
225
|
+
Pass an idempotency_key to safely retry without creating duplicate evaluations.
|
|
226
|
+
|
|
227
|
+
Pass customer_id to identify the *end-customer* this action concerns
|
|
228
|
+
(e.g. "cust_123"). It segments recent-activity lookups so one
|
|
229
|
+
end-customer's trajectory never bleeds into another's — omit it and all
|
|
230
|
+
end-customers collapse into a single trajectory, which produces spurious
|
|
231
|
+
escalation/clustering signals.
|
|
232
|
+
|
|
233
|
+
The caller should check result.decision and handle REVIEW/ALLOW
|
|
234
|
+
appropriately.
|
|
235
|
+
"""
|
|
236
|
+
payload = {
|
|
237
|
+
"agent_id": agent_id,
|
|
238
|
+
"session_id": session_id,
|
|
239
|
+
"action_type": action_type,
|
|
240
|
+
"amount": amount,
|
|
241
|
+
"metadata": metadata or {},
|
|
242
|
+
}
|
|
243
|
+
if customer_id is not None:
|
|
244
|
+
payload["customer_id"] = customer_id
|
|
245
|
+
data = self._request(
|
|
246
|
+
"POST", "/evaluate", body=payload,
|
|
247
|
+
idempotency_key=idempotency_key,
|
|
248
|
+
endpoint_timeout=self.endpoint_timeouts.get("evaluate"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if data.get("decision") == "BLOCK":
|
|
252
|
+
evaluation_id = data.get("evaluation_id", "")
|
|
253
|
+
raise KernevaBlocked(
|
|
254
|
+
f"Kerneva blocked {action_type} for ${amount:.2f}: "
|
|
255
|
+
f"{data.get('reason', 'No reason provided')}",
|
|
256
|
+
evaluation_id=evaluation_id,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return EvaluationResult(
|
|
260
|
+
decision=data.get("decision", "ALLOW"),
|
|
261
|
+
risk_score=float(data.get("risk_score", 0)),
|
|
262
|
+
trust_score=float(data.get("trust_score", 0)),
|
|
263
|
+
reason=data.get("reason", ""),
|
|
264
|
+
signals=data.get("signals", []),
|
|
265
|
+
failure_classes=data.get("failure_classes_detected", []),
|
|
266
|
+
evaluation_id=data.get("evaluation_id"),
|
|
267
|
+
observation_mode=bool(data.get("observation_mode", True)),
|
|
268
|
+
versions=data.get("versions", {}),
|
|
269
|
+
warnings=data.get("warnings") or [],
|
|
270
|
+
recommended_decision=data.get("recommended_decision"),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def confirm_execution(
|
|
274
|
+
self,
|
|
275
|
+
evaluation_id: str,
|
|
276
|
+
status: ExecutionStatus,
|
|
277
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
278
|
+
) -> dict:
|
|
279
|
+
"""Report the outcome of an evaluated action.
|
|
280
|
+
|
|
281
|
+
Maps SDK-level execution statuses to the backend review system:
|
|
282
|
+
completed -> resolved/completed
|
|
283
|
+
failed -> resolved/failed
|
|
284
|
+
blocked -> resolved/blocked_by_external
|
|
285
|
+
abandoned -> dismissed/abandoned
|
|
286
|
+
timed_out -> dismissed/timed_out
|
|
287
|
+
"""
|
|
288
|
+
mapping = _EXECUTION_TO_REVIEW.get(status)
|
|
289
|
+
if not mapping:
|
|
290
|
+
raise KernevaError(
|
|
291
|
+
f"Invalid execution status: {status}. "
|
|
292
|
+
f"Must be one of: completed, failed, blocked, abandoned, timed_out"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
payload = {
|
|
296
|
+
"status": mapping["status"],
|
|
297
|
+
"resolution": mapping["resolution"],
|
|
298
|
+
}
|
|
299
|
+
if metadata:
|
|
300
|
+
payload["impact"] = json.dumps(metadata)
|
|
301
|
+
|
|
302
|
+
return self._request(
|
|
303
|
+
"PUT",
|
|
304
|
+
f"/evaluations/{evaluation_id}/review",
|
|
305
|
+
body=payload,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def fetch_evaluation(self, evaluation_id: str) -> EvaluationDetail:
|
|
309
|
+
"""Fetch the full trace for a single evaluation.
|
|
310
|
+
|
|
311
|
+
Returns EvaluationDetail containing all entries, signals, and
|
|
312
|
+
metadata for the evaluation.
|
|
313
|
+
"""
|
|
314
|
+
data = self._request("GET", f"/trace/{evaluation_id}")
|
|
315
|
+
if isinstance(data, list):
|
|
316
|
+
return EvaluationDetail.from_api(data)
|
|
317
|
+
return EvaluationDetail.from_api([data])
|
|
318
|
+
|
|
319
|
+
def fetch_history(
|
|
320
|
+
self,
|
|
321
|
+
agent: Optional[str] = None,
|
|
322
|
+
decision: Optional[str] = None,
|
|
323
|
+
review_status: Optional[str] = None,
|
|
324
|
+
start: Optional[str] = None,
|
|
325
|
+
end: Optional[str] = None,
|
|
326
|
+
limit: int = 50,
|
|
327
|
+
cursor: Optional[str] = None,
|
|
328
|
+
offset: Optional[int] = None,
|
|
329
|
+
) -> List[HistoryEntry]:
|
|
330
|
+
"""Fetch investigation history with optional filters.
|
|
331
|
+
|
|
332
|
+
Supports cursor-based and offset-based pagination via the
|
|
333
|
+
*cursor* and *offset* parameters.
|
|
334
|
+
|
|
335
|
+
Returns a list of HistoryEntry objects representing recent
|
|
336
|
+
non-ALLOW evaluations.
|
|
337
|
+
"""
|
|
338
|
+
query: Dict[str, Any] = {"limit": limit}
|
|
339
|
+
if agent:
|
|
340
|
+
query["agent"] = agent
|
|
341
|
+
if decision:
|
|
342
|
+
query["decision"] = decision
|
|
343
|
+
if review_status:
|
|
344
|
+
query["review_status"] = review_status
|
|
345
|
+
if start:
|
|
346
|
+
query["start"] = start
|
|
347
|
+
if end:
|
|
348
|
+
query["end"] = end
|
|
349
|
+
if cursor is not None:
|
|
350
|
+
query["cursor"] = cursor
|
|
351
|
+
if offset is not None:
|
|
352
|
+
query["offset"] = offset
|
|
353
|
+
|
|
354
|
+
data = self._request("GET", "/investigations", query=query)
|
|
355
|
+
entries: List[HistoryEntry] = []
|
|
356
|
+
for item in data if isinstance(data, list) else []:
|
|
357
|
+
entries.append(
|
|
358
|
+
HistoryEntry(
|
|
359
|
+
id=item.get("id", ""),
|
|
360
|
+
timestamp=item.get("timestamp", ""),
|
|
361
|
+
agent=item.get("agent", ""),
|
|
362
|
+
session_id=item.get("session_id", ""),
|
|
363
|
+
decision=item.get("decision", ""),
|
|
364
|
+
amount=float(item.get("amount", 0)),
|
|
365
|
+
trust_score=_safe_float(item.get("trust_score")),
|
|
366
|
+
risk_score=_safe_float(item.get("risk_score")),
|
|
367
|
+
failure_class=item.get("failure_class"),
|
|
368
|
+
review_status=item.get("review_status", "unreviewed"),
|
|
369
|
+
resolution=item.get("resolution"),
|
|
370
|
+
impact=item.get("impact"),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
return entries
|
|
374
|
+
|
|
375
|
+
def list_agents(self) -> List[AgentSummary]:
|
|
376
|
+
"""List all registered agents for this account."""
|
|
377
|
+
data = self._request("GET", "/agents")
|
|
378
|
+
agents: List[AgentSummary] = []
|
|
379
|
+
for item in data if isinstance(data, list) else []:
|
|
380
|
+
agents.append(
|
|
381
|
+
AgentSummary(
|
|
382
|
+
agent_id=item.get("agent_id", ""),
|
|
383
|
+
agent_name=item.get("agent_name", ""),
|
|
384
|
+
total_decisions=int(item.get("total_decisions", 0)),
|
|
385
|
+
review_count=int(item.get("review_count", 0)),
|
|
386
|
+
block_count=int(item.get("block_count", 0)),
|
|
387
|
+
avg_trust=_safe_float(item.get("avg_trust")),
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
return agents
|
|
391
|
+
|
|
392
|
+
def health(self) -> dict:
|
|
393
|
+
"""Check API health.
|
|
394
|
+
|
|
395
|
+
Returns the raw health response dict, e.g. {"status": "ok"}.
|
|
396
|
+
Raises KernevaError if the API is unreachable.
|
|
397
|
+
"""
|
|
398
|
+
return self._request("GET", "/health")
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# Config endpoints
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def get_config(self) -> dict:
|
|
405
|
+
"""Fetch the current runtime configuration.
|
|
406
|
+
|
|
407
|
+
Returns the full configuration dict, e.g.:
|
|
408
|
+
{"thresholds": {"refund": 75.0}, "observation_mode": True}
|
|
409
|
+
"""
|
|
410
|
+
return self._request("GET", "/config")
|
|
411
|
+
|
|
412
|
+
def update_config(self, config: dict) -> dict:
|
|
413
|
+
"""Update the runtime configuration.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
config: A dictionary of configuration values to set.
|
|
417
|
+
This replaces the entire configuration on the server.
|
|
418
|
+
|
|
419
|
+
Returns the updated configuration dict from the server.
|
|
420
|
+
"""
|
|
421
|
+
return self._request("PUT", "/config", body=config)
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# Async convenience wrappers
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
async def evaluate_async(
|
|
428
|
+
self,
|
|
429
|
+
agent_id: str,
|
|
430
|
+
session_id: str,
|
|
431
|
+
action_type: str,
|
|
432
|
+
amount: float,
|
|
433
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
434
|
+
customer_id: Optional[str] = None,
|
|
435
|
+
) -> EvaluationResult:
|
|
436
|
+
"""Async version of evaluate()."""
|
|
437
|
+
return self.evaluate(
|
|
438
|
+
agent_id=agent_id,
|
|
439
|
+
session_id=session_id,
|
|
440
|
+
action_type=action_type,
|
|
441
|
+
amount=amount,
|
|
442
|
+
metadata=metadata,
|
|
443
|
+
customer_id=customer_id,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def confirm_execution_async(
|
|
447
|
+
self,
|
|
448
|
+
evaluation_id: str,
|
|
449
|
+
status: ExecutionStatus,
|
|
450
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
451
|
+
) -> dict:
|
|
452
|
+
"""Async version of confirm_execution()."""
|
|
453
|
+
return self.confirm_execution(
|
|
454
|
+
evaluation_id=evaluation_id,
|
|
455
|
+
status=status,
|
|
456
|
+
metadata=metadata,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def fetch_evaluation_async(self, evaluation_id: str) -> EvaluationDetail:
|
|
460
|
+
"""Async version of fetch_evaluation()."""
|
|
461
|
+
return self.fetch_evaluation(evaluation_id)
|
|
462
|
+
|
|
463
|
+
async def fetch_history_async(
|
|
464
|
+
self,
|
|
465
|
+
agent: Optional[str] = None,
|
|
466
|
+
decision: Optional[str] = None,
|
|
467
|
+
review_status: Optional[str] = None,
|
|
468
|
+
start: Optional[str] = None,
|
|
469
|
+
end: Optional[str] = None,
|
|
470
|
+
limit: int = 50,
|
|
471
|
+
cursor: Optional[str] = None,
|
|
472
|
+
offset: Optional[int] = None,
|
|
473
|
+
) -> List[HistoryEntry]:
|
|
474
|
+
"""Async version of fetch_history()."""
|
|
475
|
+
return self.fetch_history(
|
|
476
|
+
agent=agent,
|
|
477
|
+
decision=decision,
|
|
478
|
+
review_status=review_status,
|
|
479
|
+
start=start,
|
|
480
|
+
end=end,
|
|
481
|
+
limit=limit,
|
|
482
|
+
cursor=cursor,
|
|
483
|
+
offset=offset,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
async def list_agents_async(self) -> List[AgentSummary]:
|
|
487
|
+
"""Async version of list_agents()."""
|
|
488
|
+
return self.list_agents()
|
|
489
|
+
|
|
490
|
+
async def health_async(self) -> dict:
|
|
491
|
+
"""Async version of health()."""
|
|
492
|
+
return self.health()
|
|
493
|
+
|
|
494
|
+
async def get_config_async(self) -> dict:
|
|
495
|
+
"""Async version of get_config()."""
|
|
496
|
+
return self.get_config()
|
|
497
|
+
|
|
498
|
+
async def update_config_async(self, config: dict) -> dict:
|
|
499
|
+
"""Async version of update_config()."""
|
|
500
|
+
return self.update_config(config)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _safe_float(value: Any) -> Optional[float]:
|
|
504
|
+
if value is None:
|
|
505
|
+
return None
|
|
506
|
+
try:
|
|
507
|
+
return float(value)
|
|
508
|
+
except (TypeError, ValueError):
|
|
509
|
+
return None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Ambient interaction context for the Kerneva SDK.
|
|
2
|
+
|
|
3
|
+
The engine's whole value is *longitudinal* — it reasons over an agent's
|
|
4
|
+
trajectory within a session and across a tenant's end-customers. Those three
|
|
5
|
+
identifiers (agent, session, end-customer) are business/temporal facts that a
|
|
6
|
+
single function call does not carry, so the SDK cannot reliably infer them by
|
|
7
|
+
reflection. Instead of forcing them into every function signature, this module
|
|
8
|
+
lets you declare them once for a logical interaction:
|
|
9
|
+
|
|
10
|
+
import kerneva_runtime_trust as kerneva
|
|
11
|
+
|
|
12
|
+
with kerneva.session(session_id=ticket_id, customer_id="cust_123",
|
|
13
|
+
agent_id="refund-bot"):
|
|
14
|
+
process_refund(amount=40) # shares one trajectory
|
|
15
|
+
process_refund(amount=75)
|
|
16
|
+
|
|
17
|
+
Anything guarded by ``@with_runtime_trust`` (aka ``@guard``) inside the block
|
|
18
|
+
picks these up automatically. ``ContextVar`` makes this correct under asyncio
|
|
19
|
+
and threads. If no session is active, each guarded call falls back to its own
|
|
20
|
+
fresh session id (so unrelated calls never collapse into one trajectory).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import contextvars
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Any, Dict, Iterator, Optional
|
|
27
|
+
|
|
28
|
+
_agent_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
|
|
29
|
+
"kerneva_agent_id", default=None
|
|
30
|
+
)
|
|
31
|
+
_session_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
|
|
32
|
+
"kerneva_session_id", default=None
|
|
33
|
+
)
|
|
34
|
+
_customer_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
|
|
35
|
+
"kerneva_customer_id", default=None
|
|
36
|
+
)
|
|
37
|
+
_metadata: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
|
|
38
|
+
"kerneva_metadata", default={}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def new_session_id() -> str:
|
|
43
|
+
"""A fresh, opaque session identifier."""
|
|
44
|
+
return str(uuid.uuid4())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current_agent_id() -> Optional[str]:
|
|
48
|
+
return _agent_id.get()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def current_session_id() -> Optional[str]:
|
|
52
|
+
return _session_id.get()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def current_customer_id() -> Optional[str]:
|
|
56
|
+
return _customer_id.get()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def current_metadata() -> Dict[str, Any]:
|
|
60
|
+
return dict(_metadata.get())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextlib.contextmanager
|
|
64
|
+
def session(
|
|
65
|
+
session_id: Optional[str] = None,
|
|
66
|
+
*,
|
|
67
|
+
agent_id: Optional[str] = None,
|
|
68
|
+
customer_id: Optional[str] = None,
|
|
69
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
70
|
+
) -> Iterator[str]:
|
|
71
|
+
"""Bind an interaction context for the duration of the ``with`` block.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
session_id: Identifier for this logical interaction (a support
|
|
75
|
+
conversation, one agent task-run, one request). If omitted, a fresh
|
|
76
|
+
id is generated so the block is still a single, bounded session.
|
|
77
|
+
agent_id: The logical AI actor making the calls (e.g. ``"refund-bot"``).
|
|
78
|
+
customer_id: The end-customer this interaction concerns. This is what
|
|
79
|
+
keeps one end-customer's trajectory from bleeding into another's.
|
|
80
|
+
metadata: Extra context merged into every evaluation inside the block
|
|
81
|
+
(shallow-merged over any enclosing session's metadata).
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
The resolved ``session_id``.
|
|
85
|
+
|
|
86
|
+
Nesting is supported; on exit each value is restored to what it was before
|
|
87
|
+
the block, so contexts compose cleanly.
|
|
88
|
+
"""
|
|
89
|
+
if session_id is None:
|
|
90
|
+
session_id = new_session_id()
|
|
91
|
+
|
|
92
|
+
tokens = [(_session_id, _session_id.set(session_id))]
|
|
93
|
+
if agent_id is not None:
|
|
94
|
+
tokens.append((_agent_id, _agent_id.set(agent_id)))
|
|
95
|
+
if customer_id is not None:
|
|
96
|
+
tokens.append((_customer_id, _customer_id.set(customer_id)))
|
|
97
|
+
if metadata is not None:
|
|
98
|
+
merged = {**_metadata.get(), **metadata}
|
|
99
|
+
tokens.append((_metadata, _metadata.set(merged)))
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
yield session_id
|
|
103
|
+
finally:
|
|
104
|
+
# Restore in reverse order so nested contexts unwind correctly.
|
|
105
|
+
for var, token in reversed(tokens):
|
|
106
|
+
var.reset(token)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class KernevaError(Exception):
|
|
2
|
+
"""Base exception for all Kerneva SDK errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class KernevaRetryableError(KernevaError):
|
|
6
|
+
"""Raised when a request failed but may succeed on retry (e.g., 429, 503)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KernevaFatalError(KernevaError):
|
|
10
|
+
"""Raised when a request failed with a non-retryable server error (e.g., 500, 502, 504)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KernevaBlocked(KernevaError):
|
|
14
|
+
"""Raised when Kerneva blocks an action (decision = BLOCK).
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
evaluation_id: The ID of the evaluation that was blocked, if available.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str = "", evaluation_id: str = ""):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.evaluation_id = evaluation_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KernevaAuthError(KernevaError):
|
|
26
|
+
"""Raised when authentication fails (401, 403)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KernevaTimeoutError(KernevaRetryableError):
|
|
30
|
+
"""Raised when a request times out."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class KernevaValidationError(KernevaError):
|
|
34
|
+
"""Raised when the API returns a validation error (422)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KernevaConfigError(KernevaError):
|
|
38
|
+
"""Raised when the SDK is misconfigured (e.g., missing API key)."""
|