truvyx 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
truvyx-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: truvyx
3
+ Version: 0.4.0
4
+ Summary: Truvyx evaluation SDK — embed evaluation into any agent pipeline
5
+ License: MIT
6
+ Project-URL: Homepage, https://truvyx.org
7
+ Project-URL: Documentation, https://truvyx.org/docs/sdk
8
+ Requires-Python: >=3.10
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "truvyx"
7
+ version = "0.4.0"
8
+ description = "Truvyx evaluation SDK — embed evaluation into any agent pipeline"
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+
13
+ [project.urls]
14
+ Homepage = "https://truvyx.org"
15
+ Documentation = "https://truvyx.org/docs/sdk"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["."]
19
+ include = ["truvyx*"]
truvyx-0.4.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ from truvyx.client import evaluate
2
+
3
+
4
+ def test_evaluate_maps_api_metadata_to_python_fields(monkeypatch):
5
+ responses = iter([
6
+ {
7
+ "scenario": {
8
+ "id": "scenario-1",
9
+ "name": "Safety",
10
+ "description": "",
11
+ "domain": "TEST",
12
+ "difficulty": "LOW",
13
+ "constraints": [],
14
+ }
15
+ },
16
+ {"runId": "run-1"},
17
+ {
18
+ "ready": True,
19
+ "runId": "run-1",
20
+ "scenarioId": "scenario-1",
21
+ "status": "PASSED",
22
+ "passed": True,
23
+ "costProfile": {
24
+ "totalInputTokens": 10,
25
+ "totalOutputTokens": 5,
26
+ "totalTokens": 15,
27
+ "estimatedCostUsd": 0.01,
28
+ "apiCallCount": 1,
29
+ "toolCallCount": 2,
30
+ "redundantCallCount": 1,
31
+ "avgCallLatencyMs": 25,
32
+ },
33
+ "retryState": {
34
+ "attempts": 2,
35
+ "coldStartRetry": True,
36
+ "workerAvailable": True,
37
+ },
38
+ },
39
+ ])
40
+
41
+ monkeypatch.setattr("truvyx.client._request", lambda *args, **kwargs: next(responses))
42
+ monkeypatch.setattr("truvyx.client.time.sleep", lambda _seconds: None)
43
+
44
+ result = evaluate(
45
+ lambda _scenario: {"answer": 42},
46
+ api_key="trk_test",
47
+ scenario_id="scenario-1",
48
+ on_premise=True,
49
+ on_premise_url="http://localhost:3000",
50
+ )
51
+
52
+ assert result.retry_state is not None
53
+ assert result.retry_state.cold_start_retry is True
54
+ assert result.retry_state.worker_available is True
55
+ assert result.cost_profile is not None
56
+ assert result.cost_profile.total_input_tokens == 10
57
+ assert result.cost_profile.avg_call_latency_ms == 25
@@ -0,0 +1,70 @@
1
+ from truvyx.client import create_scenario_suite, evaluate_suite, update_scenario_suite
2
+
3
+
4
+ SUITE = {
5
+ "id": "suite-1",
6
+ "name": "Release",
7
+ "description": "release gates",
8
+ "version": 1,
9
+ "scenarios": [
10
+ {
11
+ "scenarioId": "scenario-1",
12
+ "scenarioVersion": 2,
13
+ "position": 0,
14
+ "critical": True,
15
+ "scenario": {
16
+ "id": "scenario-1",
17
+ "name": "Safety",
18
+ "description": "",
19
+ "domain": "TEST",
20
+ "difficulty": "HIGH",
21
+ "agentRoles": [],
22
+ "parameters": {},
23
+ "problemStatement": "",
24
+ "tags": [],
25
+ "constraintCount": 1,
26
+ },
27
+ }
28
+ ],
29
+ }
30
+
31
+
32
+ def test_python_suite_resource_and_evaluation_workflow(monkeypatch):
33
+ calls = []
34
+
35
+ def fake_request(url, *, headers, method="GET", body=None):
36
+ calls.append((url, headers, method, body))
37
+ if url.endswith("/api/v1/scenario-suites"):
38
+ return {"suite": SUITE}
39
+ if url.endswith("/api/v1/scenario-suites/suite-1") and method == "PATCH":
40
+ return {"suite": {**SUITE, "version": 2}}
41
+ if url.endswith("/api/v1/scenario-suites/suite-1"):
42
+ return {"suite": {**SUITE, "version": 2}}
43
+ if url.endswith("/api/v1/scenario-suites/suite-1/evaluate"):
44
+ assert headers["Idempotency-Key"] == "release-42"
45
+ assert body["outputs"][0]["scenarioId"] == "scenario-1"
46
+ return {"suiteRunId": "suite-run-1"}
47
+ if url.endswith("/api/v1/scenario-suite-runs/suite-run-1"):
48
+ return {
49
+ "ready": True,
50
+ "suiteRunId": "suite-run-1",
51
+ "suite": {"id": "suite-1", "name": "Release"},
52
+ "suiteVersion": 2,
53
+ "status": "PASSED",
54
+ "passed": True,
55
+ "overallScore": 1,
56
+ "threshold": 0.9,
57
+ "blockCritical": True,
58
+ "summary": {"total": 1, "completed": 1, "passed": 1, "failed": 0, "errors": 0, "criticalFailures": 0},
59
+ "runs": [],
60
+ }
61
+ raise AssertionError(f"unexpected URL {url}")
62
+
63
+ monkeypatch.setattr("truvyx.client._request", fake_request)
64
+ created = create_scenario_suite(api_key="trk_test", name="Release", scenarios=[{"scenarioId": "scenario-1", "critical": True}])
65
+ assert created.id == "suite-1"
66
+ updated = update_scenario_suite(api_key="trk_test", suite_id="suite-1", update={"description": "new"})
67
+ assert updated.version == 2
68
+ result = evaluate_suite(lambda scenario: {"scenario": scenario.id}, api_key="trk_test", suite_id="suite-1", fail_threshold=0.9, idempotency_key="release-42")
69
+ assert result.status == "PASSED"
70
+ assert sum(url.endswith("/evaluate") for url, *_ in calls) == 1
@@ -0,0 +1,96 @@
1
+ import asyncio
2
+ import unittest
3
+
4
+ from truvyx.trace import extract_trace, trace
5
+
6
+
7
+ class AsyncTraceTests(unittest.IsolatedAsyncioTestCase):
8
+ async def test_coroutine_is_awaited_and_timing_includes_async_work(self):
9
+ @trace(agent_id="planner")
10
+ async def agent(value):
11
+ await asyncio.sleep(0.015)
12
+ return {"value": value}
13
+
14
+ self.assertEqual(await agent(3), {"value": 3})
15
+ steps = extract_trace(agent)
16
+ self.assertEqual(steps[0]["output"], {"value": 3})
17
+ self.assertGreaterEqual(steps[0]["durationMs"], 10)
18
+
19
+ async def test_exception_identity_and_trace_are_preserved(self):
20
+ failure = RuntimeError("agent failed")
21
+
22
+ @trace
23
+ async def agent(_value):
24
+ await asyncio.sleep(0)
25
+ raise failure
26
+
27
+ with self.assertRaises(RuntimeError) as caught:
28
+ await agent("input")
29
+ self.assertIs(caught.exception, failure)
30
+ self.assertEqual(extract_trace(agent)[0]["output"], None)
31
+
32
+ async def test_concurrent_calls_have_task_local_trace_state(self):
33
+ @trace
34
+ async def agent(value):
35
+ await asyncio.sleep(0.001 if value == "fast" else 0.01)
36
+ return value
37
+
38
+ async def invoke(value):
39
+ self.assertEqual(await agent(value), value)
40
+ return extract_trace(agent)
41
+
42
+ fast, slow = await asyncio.gather(invoke("fast"), invoke("slow"))
43
+ self.assertEqual([step["input"] for step in fast], ["fast"])
44
+ self.assertEqual([step["input"] for step in slow], ["slow"])
45
+ self.assertEqual(fast[0]["step"], 0)
46
+ self.assertEqual(slow[0]["step"], 0)
47
+
48
+ async def test_cancellation_is_not_swallowed(self):
49
+ started = asyncio.Event()
50
+
51
+ @trace
52
+ async def agent(_value):
53
+ started.set()
54
+ await asyncio.sleep(10)
55
+
56
+ captured = []
57
+
58
+ async def invoke():
59
+ try:
60
+ await agent("cancelled")
61
+ finally:
62
+ captured.extend(extract_trace(agent))
63
+
64
+ task = asyncio.create_task(invoke())
65
+ await started.wait()
66
+ task.cancel()
67
+ with self.assertRaises(asyncio.CancelledError):
68
+ await task
69
+ self.assertEqual(captured[0]["input"], "cancelled")
70
+
71
+ async def test_recursive_calls_share_ordered_state_within_one_task(self):
72
+ @trace
73
+ async def agent(depth):
74
+ if depth:
75
+ await agent(depth - 1)
76
+ return depth
77
+
78
+ await agent(2)
79
+ steps = extract_trace(agent)
80
+ self.assertEqual([step["step"] for step in steps], [0, 1, 2])
81
+ self.assertEqual([step["input"] for step in steps], [0, 1, 2])
82
+
83
+
84
+ class SyncTraceTests(unittest.TestCase):
85
+ def test_sync_behavior_and_extraction_reset_remain_compatible(self):
86
+ @trace(emit_to="organization")
87
+ def agent(value):
88
+ return value * 2
89
+
90
+ self.assertEqual(agent(4), 8)
91
+ self.assertEqual(extract_trace(agent)[0]["emitTo"], "organization")
92
+ self.assertEqual(extract_trace(agent), [])
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()
@@ -0,0 +1,37 @@
1
+ from .client import (
2
+ evaluate,
3
+ evaluate_suite,
4
+ create_scenario_suite,
5
+ get_scenario_suite,
6
+ update_scenario_suite,
7
+ assert_feasibility,
8
+ EvaluationError,
9
+ ScenarioSuiteEvaluationError,
10
+ TruvyxError,
11
+ )
12
+ from .trace import trace, extract_trace
13
+ from .types import EvalConfig, EvalResult, Violation, AuditTrail, ScenarioInput, ScenarioSuite, ScenarioSuiteMember, SuiteEvalResult, SuiteRunItem
14
+
15
+ __version__ = "0.4.0"
16
+ __all__ = [
17
+ "evaluate",
18
+ "evaluate_suite",
19
+ "create_scenario_suite",
20
+ "get_scenario_suite",
21
+ "update_scenario_suite",
22
+ "assert_feasibility",
23
+ "EvaluationError",
24
+ "ScenarioSuiteEvaluationError",
25
+ "TruvyxError",
26
+ "trace",
27
+ "extract_trace",
28
+ "EvalConfig",
29
+ "EvalResult",
30
+ "Violation",
31
+ "AuditTrail",
32
+ "ScenarioInput",
33
+ "ScenarioSuite",
34
+ "ScenarioSuiteMember",
35
+ "SuiteEvalResult",
36
+ "SuiteRunItem",
37
+ ]
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+ import time
3
+ import urllib.request
4
+ import urllib.error
5
+ import json
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any, Callable
8
+
9
+ from .types import (
10
+ EvalResult,
11
+ ScenarioInput,
12
+ Violation,
13
+ AuditTrail,
14
+ ConstraintResult,
15
+ CostProfile,
16
+ RetryState,
17
+ ScenarioSuite,
18
+ ScenarioSuiteMember,
19
+ SuiteEvalResult,
20
+ SuiteRunItem,
21
+ )
22
+ from .trace import extract_trace, trace
23
+
24
+ SDK_VERSION = "0.4.0"
25
+
26
+
27
+ class TruvyxError(Exception):
28
+ pass
29
+
30
+
31
+ class EvaluationError(TruvyxError):
32
+ def __init__(self, message: str, result: EvalResult, code: str):
33
+ super().__init__(message)
34
+ self.result = result
35
+ self.code = code
36
+
37
+
38
+ class ScenarioSuiteEvaluationError(TruvyxError):
39
+ def __init__(self, message: str, result: SuiteEvalResult | None, code: str):
40
+ super().__init__(message)
41
+ self.result = result
42
+ self.code = code
43
+
44
+
45
+ def _request(url: str, *, headers: dict[str, str], method: str = "GET", body: Any = None) -> Any:
46
+ data = json.dumps(body).encode() if body is not None else None
47
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=30) as resp:
50
+ return json.loads(resp.read())
51
+ except urllib.error.HTTPError as e:
52
+ raise TruvyxError(f"HTTP {e.code}: {e.read().decode()}") from e
53
+
54
+
55
+ def _cost_profile(data: Any) -> CostProfile | None:
56
+ if not isinstance(data, dict):
57
+ return None
58
+ return CostProfile(
59
+ total_input_tokens=int(data.get("totalInputTokens", 0)),
60
+ total_output_tokens=int(data.get("totalOutputTokens", 0)),
61
+ total_tokens=int(data.get("totalTokens", 0)),
62
+ estimated_cost_usd=float(data.get("estimatedCostUsd", 0)),
63
+ api_call_count=int(data.get("apiCallCount", 0)),
64
+ tool_call_count=int(data.get("toolCallCount", 0)),
65
+ redundant_call_count=int(data.get("redundantCallCount", 0)),
66
+ avg_call_latency_ms=data.get("avgCallLatencyMs"),
67
+ )
68
+
69
+
70
+ def _retry_state(data: Any) -> RetryState | None:
71
+ if not isinstance(data, dict):
72
+ return None
73
+ return RetryState(
74
+ attempts=int(data.get("attempts", 0)),
75
+ cold_start_retry=bool(data.get("coldStartRetry", False)),
76
+ worker_available=bool(data.get("workerAvailable", False)),
77
+ )
78
+
79
+
80
+ def evaluate(
81
+ agent_fn: Callable[[ScenarioInput], Any],
82
+ *,
83
+ api_key: str,
84
+ scenario_id: str,
85
+ agent_system_name: str = "sdk-agent",
86
+ compliance_mode: bool = False,
87
+ on_premise: bool = False,
88
+ on_premise_url: str | None = None,
89
+ trace_capture: bool = False,
90
+ trace_visibility: str = "private",
91
+ timeout: int = 60,
92
+ ) -> EvalResult:
93
+ if trace_visibility not in {"private", "organization", "fault_atlas"}:
94
+ raise ValueError("trace_visibility must be 'private', 'organization', or 'fault_atlas'")
95
+ base_url = (on_premise_url or "") if on_premise else "https://truvyx.org"
96
+ headers = {
97
+ "Content-Type": "application/json",
98
+ "X-Truvyx-Key": api_key,
99
+ "User-Agent": f"truvyx-python/{SDK_VERSION}",
100
+ }
101
+
102
+ # 1. Fetch the scenario directly. This supports private organisation-owned
103
+ # scenarios as well as public Registry scenarios.
104
+ scen_data = _request(f"{base_url}/api/v1/scenarios/{scenario_id}", headers=headers)
105
+ s = scen_data.get("scenario")
106
+ if s is None:
107
+ raise TruvyxError(f"Scenario {scenario_id} not found")
108
+ scenario_input = ScenarioInput(
109
+ id=s["id"],
110
+ name=s["name"],
111
+ description=s.get("description", ""),
112
+ domain=s["domain"],
113
+ difficulty=s["difficulty"],
114
+ agent_roles=s.get("agentRoles", []),
115
+ parameters=s.get("parameters", {}),
116
+ problem_statement=s.get("problemStatement", ""),
117
+ tags=s.get("tags", []),
118
+ constraint_count=len(s.get("constraints", [])),
119
+ )
120
+
121
+ # 2. Run agent
122
+ start = time.time()
123
+ agent_output = agent_fn(scenario_input)
124
+ execution_time_ms = int((time.time() - start) * 1000)
125
+ execution_trace = extract_trace(agent_fn) if trace_capture else None
126
+
127
+ # 3. Submit
128
+ submit_resp = _request(
129
+ f"{base_url}/api/v1/evaluate",
130
+ headers=headers,
131
+ method="POST",
132
+ body={
133
+ "scenarioId": scenario_id,
134
+ "agentSystemName": agent_system_name,
135
+ "agentOutput": agent_output,
136
+ "executionTrace": execution_trace,
137
+ "traceVisibility": trace_visibility,
138
+ "executionTimeMs": execution_time_ms,
139
+ "environment": {
140
+ "sdk": SDK_VERSION,
141
+ "complianceMode": compliance_mode,
142
+ "traceCapture": trace_capture,
143
+ },
144
+ },
145
+ )
146
+ run_id = submit_resp["runId"]
147
+
148
+ # 4. Poll for result
149
+ deadline = time.time() + timeout
150
+ while time.time() < deadline:
151
+ time.sleep(2)
152
+ try:
153
+ data = _request(f"{base_url}/api/v1/runs/{run_id}/result", headers=headers)
154
+ except TruvyxError:
155
+ continue
156
+
157
+ if not data.get("ready"):
158
+ continue
159
+
160
+ violations = [
161
+ Violation(
162
+ constraint_id=v["constraintId"],
163
+ constraint_name=v["constraintName"],
164
+ category=v["category"],
165
+ severity=v["severity"],
166
+ description=v["description"],
167
+ agent_value=v["agentValue"],
168
+ required_bound=v["requiredBound"],
169
+ )
170
+ for v in data.get("violations", [])
171
+ ]
172
+
173
+ audit_trail = None
174
+ if compliance_mode and data.get("auditRecordId"):
175
+ audit_status = data.get("auditRecordStatus", "UNSIGNED")
176
+ audit_trail = AuditTrail(
177
+ id=data["auditRecordId"],
178
+ status=audit_status if audit_status in {"UNSIGNED", "SIGNED", "VERIFIED", "INVALID"} else "INVALID",
179
+ verified_at=data.get("auditRecordVerifiedAt"),
180
+ )
181
+
182
+ constraint_results = [
183
+ ConstraintResult(
184
+ constraint_id=item.get("constraintId", ""),
185
+ constraint_name=item.get("constraintName", ""),
186
+ status=item.get("status", "NOT_SCORED"),
187
+ score=item.get("score"),
188
+ evidence=item.get("evidence"),
189
+ )
190
+ for item in data.get("constraintResults", [])
191
+ ]
192
+ cost_profile = _cost_profile(data.get("costProfile"))
193
+ retry_state = _retry_state(data.get("retryState"))
194
+
195
+ return EvalResult(
196
+ run_id=data.get("runId", run_id),
197
+ scenario_id=data.get("scenarioId", scenario_id),
198
+ status=data.get("status", "ERROR"),
199
+ feasibility_score=data.get("feasibilityScore", 0.0),
200
+ completeness_score=data.get("completenessScore", 0.0),
201
+ optimality_score=data.get("optimalityScore", 0.0),
202
+ overall_score=data.get("overallScore", 0.0),
203
+ passed=data.get("passed", False),
204
+ violations=violations,
205
+ constraint_results=constraint_results,
206
+ completeness_gaps=data.get("completenessGaps", []),
207
+ audit_trail=audit_trail,
208
+ cost_profile=cost_profile,
209
+ retry_state=retry_state,
210
+ )
211
+
212
+ empty = EvalResult(
213
+ run_id=run_id, scenario_id=scenario_id, status="ERROR",
214
+ feasibility_score=0, completeness_score=0, optimality_score=0,
215
+ overall_score=0, passed=False,
216
+ )
217
+ raise EvaluationError(f"Evaluation timed out after {timeout}s. Run ID: {run_id}", empty, "TIMEOUT")
218
+
219
+
220
+ def assert_feasibility(
221
+ result: EvalResult,
222
+ *,
223
+ threshold: float = 0.7,
224
+ fail_on_regulatory: bool = False,
225
+ ) -> None:
226
+ if fail_on_regulatory:
227
+ regulatory = [
228
+ v for v in result.violations
229
+ if v.category == "REGULATORY" and v.severity in ("CRITICAL", "HIGH")
230
+ ]
231
+ if regulatory:
232
+ names = ", ".join(v.constraint_name for v in regulatory)
233
+ raise EvaluationError(
234
+ f"Regulatory violation(s): {names}",
235
+ result,
236
+ "REGULATORY_VIOLATION",
237
+ )
238
+
239
+ if result.overall_score < threshold:
240
+ raise EvaluationError(
241
+ f"Score {result.overall_score:.3f} < threshold {threshold}. "
242
+ f"Feasibility={result.feasibility_score:.3f} "
243
+ f"Completeness={result.completeness_score:.3f} "
244
+ f"Optimality={result.optimality_score:.3f} "
245
+ f"Violations={len(result.violations)}",
246
+ result,
247
+ "THRESHOLD_NOT_MET",
248
+ )
249
+
250
+
251
+ def _base_url(on_premise: bool, on_premise_url: str | None) -> str:
252
+ if on_premise:
253
+ if not on_premise_url:
254
+ raise ValueError("on_premise_url is required when on_premise=True")
255
+ return on_premise_url.rstrip("/")
256
+ if on_premise_url is not None:
257
+ raise ValueError("on_premise_url requires on_premise=True")
258
+ return "https://truvyx.org"
259
+
260
+
261
+ def _suite_headers(api_key: str, idempotency_key: str | None = None) -> dict[str, str]:
262
+ if not api_key.strip():
263
+ raise ValueError("api_key is required")
264
+ headers = {"Content-Type": "application/json", "X-Truvyx-Key": api_key, "User-Agent": f"truvyx-python/{SDK_VERSION}"}
265
+ if idempotency_key is not None:
266
+ if not idempotency_key.strip() or len(idempotency_key) > 200:
267
+ raise ValueError("idempotency_key must contain 1-200 characters")
268
+ headers["Idempotency-Key"] = idempotency_key
269
+ return headers
270
+
271
+
272
+ def _scenario_input(data: dict[str, Any]) -> ScenarioInput:
273
+ return ScenarioInput(
274
+ id=data["id"], name=data["name"], description=data.get("description", ""),
275
+ domain=data["domain"], difficulty=data["difficulty"], agent_roles=data.get("agentRoles", []),
276
+ parameters=data.get("parameters", {}), problem_statement=data.get("problemStatement", ""),
277
+ tags=data.get("tags", []), constraint_count=data.get("constraintCount", data.get("_count", {}).get("constraints", len(data.get("constraints", [])))),
278
+ )
279
+
280
+
281
+ def _parse_suite(data: dict[str, Any]) -> ScenarioSuite:
282
+ return ScenarioSuite(
283
+ id=data["id"], name=data["name"], description=data.get("description", ""), version=data["version"],
284
+ scenarios=[ScenarioSuiteMember(
285
+ scenario_id=item["scenarioId"], scenario_version=item["scenarioVersion"], position=item["position"],
286
+ critical=item.get("critical", False), scenario=_scenario_input(item["scenario"]),
287
+ ) for item in data.get("scenarios", [])],
288
+ )
289
+
290
+
291
+ def create_scenario_suite(
292
+ *, api_key: str, name: str, scenarios: list[dict[str, Any]], description: str = "",
293
+ on_premise: bool = False, on_premise_url: str | None = None,
294
+ ) -> ScenarioSuite:
295
+ data = _request(f"{_base_url(on_premise, on_premise_url)}/api/v1/scenario-suites", headers=_suite_headers(api_key), method="POST", body={"name": name, "description": description, "scenarios": scenarios})
296
+ return _parse_suite(data["suite"])
297
+
298
+
299
+ def get_scenario_suite(
300
+ *, api_key: str, suite_id: str, on_premise: bool = False, on_premise_url: str | None = None,
301
+ ) -> ScenarioSuite:
302
+ if not suite_id.strip():
303
+ raise ValueError("suite_id is required")
304
+ data = _request(f"{_base_url(on_premise, on_premise_url)}/api/v1/scenario-suites/{suite_id}", headers=_suite_headers(api_key))
305
+ return _parse_suite(data["suite"])
306
+
307
+
308
+ def update_scenario_suite(
309
+ *, api_key: str, suite_id: str, update: dict[str, Any], on_premise: bool = False, on_premise_url: str | None = None,
310
+ ) -> ScenarioSuite:
311
+ if not update:
312
+ raise ValueError("at least one suite field is required")
313
+ data = _request(f"{_base_url(on_premise, on_premise_url)}/api/v1/scenario-suites/{suite_id}", headers=_suite_headers(api_key), method="PATCH", body=update)
314
+ return _parse_suite(data["suite"])
315
+
316
+
317
+ def _parse_suite_result(data: dict[str, Any]) -> SuiteEvalResult:
318
+ suite = data.get("suite", {})
319
+ return SuiteEvalResult(
320
+ ready=data.get("ready", False), suite_run_id=data["suiteRunId"], suite_id=suite.get("id", ""), suite_name=suite.get("name", ""),
321
+ suite_version=data.get("suiteVersion", 0), status=data.get("status", "ERROR"), passed=data.get("passed", False),
322
+ overall_score=data.get("overallScore"), threshold=data.get("threshold", 0.7), block_critical=data.get("blockCritical", True),
323
+ summary=data.get("summary", {}), runs=[SuiteRunItem(
324
+ run_id=item["runId"], scenario_id=item["scenarioId"], scenario_name=item.get("scenarioName", ""),
325
+ scenario_version=item.get("scenarioVersion", 0), critical=item.get("critical", False), status=item.get("status", "ERROR"),
326
+ overall_score=item.get("overallScore"), violation_count=item.get("violationCount", 0), completed_at=item.get("completedAt"), error=item.get("error"),
327
+ ) for item in data.get("runs", [])], created_at=data.get("createdAt"), completed_at=data.get("completedAt"),
328
+ )
329
+
330
+
331
+ def evaluate_suite(
332
+ agent_fn: Callable[[ScenarioInput], Any], *, api_key: str, suite_id: str, agent_system_name: str = "sdk-agent",
333
+ fail_threshold: float = 0.7, block_critical: bool = True, max_concurrency: int = 5, timeout: int = 60,
334
+ on_premise: bool = False, on_premise_url: str | None = None, trace_capture: bool = False,
335
+ trace_visibility: str = "private", environment: dict[str, Any] | None = None, idempotency_key: str | None = None,
336
+ ) -> SuiteEvalResult:
337
+ if not 0 <= fail_threshold <= 1:
338
+ raise ValueError("fail_threshold must be between 0 and 1")
339
+ if not 1 <= max_concurrency <= 10:
340
+ raise ValueError("max_concurrency must be between 1 and 10")
341
+ if timeout < 1:
342
+ raise ValueError("timeout must be at least 1 second")
343
+ if trace_visibility not in {"private", "organization", "fault_atlas"}:
344
+ raise ValueError("trace_visibility is invalid")
345
+ base_url = _base_url(on_premise, on_premise_url)
346
+ suite = get_scenario_suite(api_key=api_key, suite_id=suite_id, on_premise=on_premise, on_premise_url=on_premise_url)
347
+
348
+ def execute(member: ScenarioSuiteMember) -> dict[str, Any]:
349
+ target = trace(emit_to=trace_visibility, agent_id=agent_system_name)(agent_fn) if trace_capture else agent_fn
350
+ started = time.time()
351
+ output = target(member.scenario)
352
+ return {"scenarioId": member.scenario_id, "agentOutput": output, "executionTimeMs": int((time.time() - started) * 1000), "executionTrace": extract_trace(target) if trace_capture else None, "traceVisibility": trace_visibility}
353
+
354
+ with ThreadPoolExecutor(max_workers=max_concurrency) as executor:
355
+ outputs = list(executor.map(execute, suite.scenarios))
356
+ submission = _request(
357
+ f"{base_url}/api/v1/scenario-suites/{suite_id}/evaluate", headers=_suite_headers(api_key, idempotency_key), method="POST",
358
+ body={"agentSystemName": agent_system_name, "outputs": outputs, "failThreshold": fail_threshold, "blockCritical": block_critical, "maxConcurrency": max_concurrency, "environment": {**(environment or {}), "sdk": SDK_VERSION}},
359
+ )
360
+ deadline = time.time() + timeout
361
+ latest: SuiteEvalResult | None = None
362
+ while time.time() < deadline:
363
+ data = _request(f"{base_url}/api/v1/scenario-suite-runs/{submission['suiteRunId']}", headers=_suite_headers(api_key))
364
+ latest = _parse_suite_result(data)
365
+ if latest.ready:
366
+ if not latest.passed:
367
+ raise ScenarioSuiteEvaluationError(f"Scenario suite {suite_id} finished with status {latest.status}", latest, "SUITE_FAILED")
368
+ return latest
369
+ time.sleep(2)
370
+ raise ScenarioSuiteEvaluationError(f"Scenario suite evaluation timed out after {timeout}s. Suite run ID: {submission['suiteRunId']}", latest, "TIMEOUT")
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+ import functools
3
+ import inspect
4
+ import time
5
+ from contextvars import ContextVar
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable, Optional
8
+
9
+ _trace_contexts: dict[int, ContextVar[list[dict[str, Any]] | None]] = {}
10
+
11
+
12
+ def trace(
13
+ fn: Optional[Callable] = None,
14
+ *,
15
+ emit_to: str = "private",
16
+ agent_id: Optional[str] = None,
17
+ ) -> Callable:
18
+ """Decorator that captures execution trace without changing function signature.
19
+
20
+ Usage:
21
+ @trace
22
+ def my_agent(input_data): ...
23
+
24
+ @trace(emit_to="private", agent_id="planner")
25
+ def my_agent(input_data): ...
26
+ """
27
+ if emit_to not in {"private", "organization", "fault_atlas"}:
28
+ raise ValueError("emit_to must be 'private', 'organization', or 'fault_atlas'")
29
+
30
+ def decorator(func: Callable) -> Callable:
31
+ fn_id = id(func)
32
+ effective_agent_id = agent_id or func.__name__
33
+
34
+ trace_context: ContextVar[list[dict[str, Any]] | None] = ContextVar(
35
+ f"truvyx_trace_{fn_id}", default=None
36
+ )
37
+ _trace_contexts[fn_id] = trace_context
38
+
39
+ def begin() -> tuple[list[dict[str, Any]], float]:
40
+ steps = trace_context.get()
41
+ if steps is None:
42
+ steps = []
43
+ trace_context.set(steps)
44
+ return steps, time.perf_counter()
45
+
46
+ def finish(steps: list[dict[str, Any]], start: float, args: tuple, kwargs: dict, output: Any) -> None:
47
+ steps.append({
48
+ "agentId": effective_agent_id,
49
+ "step": len(steps),
50
+ "action": "execute",
51
+ "input": args[0] if args else kwargs,
52
+ "output": output,
53
+ "durationMs": max(0, int((time.perf_counter() - start) * 1000)),
54
+ "timestamp": datetime.now(timezone.utc).isoformat(),
55
+ "emitTo": emit_to,
56
+ })
57
+
58
+ if inspect.iscoroutinefunction(func):
59
+ @functools.wraps(func)
60
+ async def async_wrapper(*args, **kwargs):
61
+ steps, start = begin()
62
+ result: Any = None
63
+ try:
64
+ result = await func(*args, **kwargs)
65
+ return result
66
+ finally:
67
+ finish(steps, start, args, kwargs, result)
68
+
69
+ setattr(async_wrapper, "__trace_fn_id__", fn_id)
70
+ return async_wrapper
71
+
72
+ @functools.wraps(func)
73
+ def wrapper(*args, **kwargs):
74
+ steps, start = begin()
75
+ result: Any = None
76
+ try:
77
+ result = func(*args, **kwargs)
78
+ return result
79
+ finally:
80
+ finish(steps, start, args, kwargs, result)
81
+
82
+ setattr(wrapper, "__wrapped__", func)
83
+ setattr(wrapper, "__trace_fn_id__", fn_id)
84
+ return wrapper
85
+
86
+ if fn is not None:
87
+ return decorator(fn)
88
+ return decorator
89
+
90
+
91
+ def extract_trace(fn: Callable) -> list[dict[str, Any]]:
92
+ fn_id = getattr(fn, "__trace_fn_id__", None) or id(fn)
93
+ context = _trace_contexts.get(fn_id)
94
+ if context is None:
95
+ return []
96
+ steps = context.get() or []
97
+ context.set(None)
98
+ return list(steps)
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Optional
4
+
5
+
6
+ @dataclass
7
+ class EvalConfig:
8
+ api_key: str
9
+ scenario_id: Optional[str] = None
10
+ compliance_mode: bool = False
11
+ on_premise: bool = False
12
+ on_premise_url: Optional[str] = None
13
+ trace_capture: bool = False
14
+ trace_visibility: str = "private"
15
+ agent_system_name: str = "sdk-agent"
16
+ timeout: int = 60
17
+
18
+
19
+ @dataclass
20
+ class Violation:
21
+ constraint_id: str
22
+ constraint_name: str
23
+ category: str
24
+ severity: str
25
+ description: str
26
+ agent_value: Any
27
+ required_bound: Any
28
+
29
+
30
+ @dataclass
31
+ class ConstraintResult:
32
+ constraint_id: str
33
+ constraint_name: str
34
+ status: str
35
+ score: Optional[float] = None
36
+ evidence: Any = None
37
+
38
+
39
+ @dataclass
40
+ class CostProfile:
41
+ total_input_tokens: int
42
+ total_output_tokens: int
43
+ total_tokens: int
44
+ estimated_cost_usd: float
45
+ api_call_count: int
46
+ tool_call_count: int
47
+ redundant_call_count: int = 0
48
+ avg_call_latency_ms: Optional[int] = None
49
+
50
+
51
+ @dataclass
52
+ class RetryState:
53
+ attempts: int
54
+ cold_start_retry: bool
55
+ worker_available: bool
56
+
57
+
58
+ @dataclass
59
+ class AuditTrail:
60
+ id: str
61
+ status: str
62
+ verified_at: Optional[str] = None
63
+
64
+
65
+ @dataclass
66
+ class EvalResult:
67
+ run_id: str
68
+ scenario_id: str
69
+ status: str
70
+ feasibility_score: float
71
+ completeness_score: float
72
+ optimality_score: float
73
+ overall_score: float
74
+ passed: bool
75
+ violations: list[Violation] = field(default_factory=list)
76
+ constraint_results: list[ConstraintResult] = field(default_factory=list)
77
+ completeness_gaps: list[str] = field(default_factory=list)
78
+ cost_profile: Optional[CostProfile] = None
79
+ retry_state: Optional[RetryState] = None
80
+ audit_trail: Optional[AuditTrail] = None
81
+ rca_report_id: Optional[str] = None
82
+
83
+
84
+ @dataclass
85
+ class ScenarioInput:
86
+ id: str
87
+ name: str
88
+ description: str
89
+ domain: str
90
+ difficulty: str
91
+ agent_roles: list[Any]
92
+ parameters: dict[str, Any]
93
+ problem_statement: str
94
+ tags: list[str]
95
+ constraint_count: int
96
+
97
+
98
+ @dataclass
99
+ class ScenarioSuiteMember:
100
+ scenario_id: str
101
+ scenario_version: int
102
+ position: int
103
+ critical: bool
104
+ scenario: ScenarioInput
105
+
106
+
107
+ @dataclass
108
+ class ScenarioSuite:
109
+ id: str
110
+ name: str
111
+ description: str
112
+ version: int
113
+ scenarios: list[ScenarioSuiteMember] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class SuiteRunItem:
118
+ run_id: str
119
+ scenario_id: str
120
+ scenario_name: str
121
+ scenario_version: int
122
+ critical: bool
123
+ status: str
124
+ overall_score: Optional[float]
125
+ violation_count: int
126
+ completed_at: Optional[str]
127
+ error: Optional[str]
128
+
129
+
130
+ @dataclass
131
+ class SuiteEvalResult:
132
+ ready: bool
133
+ suite_run_id: str
134
+ suite_id: str
135
+ suite_name: str
136
+ suite_version: int
137
+ status: str
138
+ passed: bool
139
+ overall_score: Optional[float]
140
+ threshold: float
141
+ block_critical: bool
142
+ summary: dict[str, int]
143
+ runs: list[SuiteRunItem] = field(default_factory=list)
144
+ created_at: Optional[str] = None
145
+ completed_at: Optional[str] = None
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: truvyx
3
+ Version: 0.4.0
4
+ Summary: Truvyx evaluation SDK — embed evaluation into any agent pipeline
5
+ License: MIT
6
+ Project-URL: Homepage, https://truvyx.org
7
+ Project-URL: Documentation, https://truvyx.org/docs/sdk
8
+ Requires-Python: >=3.10
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ tests/test_evaluate.py
3
+ tests/test_suites.py
4
+ tests/test_trace.py
5
+ truvyx/__init__.py
6
+ truvyx/client.py
7
+ truvyx/trace.py
8
+ truvyx/types.py
9
+ truvyx.egg-info/PKG-INFO
10
+ truvyx.egg-info/SOURCES.txt
11
+ truvyx.egg-info/dependency_links.txt
12
+ truvyx.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ truvyx