vantageai 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vantage
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include vantageai *.py
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: vantageai
3
+ Version: 0.2.0
4
+ Summary: Vendor-neutral governance SDK for enterprise AI agents
5
+ Author-email: Vantage <sdk@vantage.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://vantage.ai
8
+ Project-URL: Repository, https://github.com/vantageai/vantage
9
+ Keywords: ai,agents,governance,audit,observability
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx>=0.27.0
23
+ Provides-Extra: langchain
24
+ Requires-Dist: langchain-core>=0.3.0; extra == "langchain"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
28
+ Requires-Dist: respx>=0.21; extra == "dev"
29
+ Requires-Dist: build>=1.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # vantageai — Python SDK
33
+
34
+ > **Vendor-neutral governance SDK for enterprise AI agents.**
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install vantageai
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ import asyncio
46
+ from vantageai import VantageClient
47
+
48
+ async def main():
49
+ client = VantageClient(api_key="your-api-key", agent_id="your-agent-id")
50
+
51
+ # Log an event
52
+ await client.log_event(type="llm_call", input={"model": "gpt-4o"})
53
+
54
+ # Execution trace
55
+ trace = await client.start_execution(session_id="session-123")
56
+ await client.log_step(trace.id, step={
57
+ "stepNumber": 1, "type": "tool_call", "toolName": "search"
58
+ })
59
+ await client.complete_execution(trace.id, status="complete")
60
+
61
+ # Policy evaluation
62
+ result = await client.evaluate(
63
+ action_type="send_email",
64
+ action_payload={"to": "user@example.com"},
65
+ )
66
+ if result.decision == "block":
67
+ print("Action blocked:", result.reason)
68
+ elif result.decision == "pending_approval":
69
+ approval = await client.wait_for_approval(result.approval_request_id)
70
+ print("Approved by:", approval.resolved_by)
71
+
72
+ # Intent shift enforcement
73
+ intent = await client.start_intent(declared_intent="Reconcile Q1 invoices")
74
+ shift = await client.log_shift(
75
+ intent.id,
76
+ observed_intent="Approve duplicate payment of $28,000",
77
+ severity="critical",
78
+ )
79
+ if shift.decision == "block":
80
+ print("Blocked by policy")
81
+ elif shift.decision == "pending_approval":
82
+ approval = await client.wait_for_approval(shift.approval_request_id)
83
+ print("Approved by:", approval.resolved_by)
84
+
85
+ asyncio.run(main())
86
+ ```
87
+
88
+ ## Sync Usage
89
+
90
+ ```python
91
+ from vantageai import VantageClientSync
92
+
93
+ client = VantageClientSync(api_key="your-api-key", agent_id="your-agent-id")
94
+ client.log_event(type="llm_call")
95
+ ```
96
+
97
+ ## LangChain Integration
98
+
99
+ ```python
100
+ from vantageai import VantageClient
101
+ from vantageai.integrations.langchain import VantageCallbackHandler
102
+
103
+ client = VantageClient(api_key="your-api-key", agent_id="your-agent-id")
104
+ handler = VantageCallbackHandler(client)
105
+
106
+ # Pass handler to any LangChain chain/agent
107
+ chain.invoke({"input": "..."}, config={"callbacks": [handler]})
108
+ ```
@@ -0,0 +1,77 @@
1
+ # vantageai — Python SDK
2
+
3
+ > **Vendor-neutral governance SDK for enterprise AI agents.**
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vantageai
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ import asyncio
15
+ from vantageai import VantageClient
16
+
17
+ async def main():
18
+ client = VantageClient(api_key="your-api-key", agent_id="your-agent-id")
19
+
20
+ # Log an event
21
+ await client.log_event(type="llm_call", input={"model": "gpt-4o"})
22
+
23
+ # Execution trace
24
+ trace = await client.start_execution(session_id="session-123")
25
+ await client.log_step(trace.id, step={
26
+ "stepNumber": 1, "type": "tool_call", "toolName": "search"
27
+ })
28
+ await client.complete_execution(trace.id, status="complete")
29
+
30
+ # Policy evaluation
31
+ result = await client.evaluate(
32
+ action_type="send_email",
33
+ action_payload={"to": "user@example.com"},
34
+ )
35
+ if result.decision == "block":
36
+ print("Action blocked:", result.reason)
37
+ elif result.decision == "pending_approval":
38
+ approval = await client.wait_for_approval(result.approval_request_id)
39
+ print("Approved by:", approval.resolved_by)
40
+
41
+ # Intent shift enforcement
42
+ intent = await client.start_intent(declared_intent="Reconcile Q1 invoices")
43
+ shift = await client.log_shift(
44
+ intent.id,
45
+ observed_intent="Approve duplicate payment of $28,000",
46
+ severity="critical",
47
+ )
48
+ if shift.decision == "block":
49
+ print("Blocked by policy")
50
+ elif shift.decision == "pending_approval":
51
+ approval = await client.wait_for_approval(shift.approval_request_id)
52
+ print("Approved by:", approval.resolved_by)
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ ## Sync Usage
58
+
59
+ ```python
60
+ from vantageai import VantageClientSync
61
+
62
+ client = VantageClientSync(api_key="your-api-key", agent_id="your-agent-id")
63
+ client.log_event(type="llm_call")
64
+ ```
65
+
66
+ ## LangChain Integration
67
+
68
+ ```python
69
+ from vantageai import VantageClient
70
+ from vantageai.integrations.langchain import VantageCallbackHandler
71
+
72
+ client = VantageClient(api_key="your-api-key", agent_id="your-agent-id")
73
+ handler = VantageCallbackHandler(client)
74
+
75
+ # Pass handler to any LangChain chain/agent
76
+ chain.invoke({"input": "..."}, config={"callbacks": [handler]})
77
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vantageai"
7
+ version = "0.2.0"
8
+ description = "Vendor-neutral governance SDK for enterprise AI agents"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Vantage", email = "sdk@vantage.ai" }]
13
+ keywords = ["ai", "agents", "governance", "audit", "observability"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ langchain = ["langchain-core>=0.3.0"]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-asyncio>=0.23",
34
+ "respx>=0.21",
35
+ "build>=1.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://vantage.ai"
40
+ Repository = "https://github.com/vantageai/vantage"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["."]
44
+ include = ["vantageai*"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,412 @@
1
+ """
2
+ Tests for VantageClient using respx to mock httpx.
3
+ """
4
+ import pytest
5
+ import respx
6
+ import httpx
7
+
8
+ from vantageai import VantageClient
9
+ from vantageai.types import (
10
+ ExecutionTrace,
11
+ IntentTrace,
12
+ IntentShift,
13
+ IntentCloseResult,
14
+ EvaluationResult,
15
+ ApprovalRequest,
16
+ )
17
+
18
+ BASE_URL = "https://agentosapi-production.up.railway.app"
19
+ TEST_API_KEY = "test-key-123"
20
+ TEST_AGENT_ID = "agent-abc"
21
+
22
+
23
+ def make_client() -> VantageClient:
24
+ return VantageClient(api_key=TEST_API_KEY, agent_id=TEST_AGENT_ID, base_url=BASE_URL)
25
+
26
+
27
+ # ── log_event ─────────────────────────────────────────────────
28
+
29
+ @respx.mock
30
+ @pytest.mark.asyncio
31
+ async def test_log_event_post_correct_body():
32
+ route = respx.post(f"{BASE_URL}/v1/events").mock(
33
+ return_value=httpx.Response(200, json={"id": "evt-1", "timestamp": "2026-01-01T00:00:00Z"})
34
+ )
35
+ client = make_client()
36
+ result = await client.log_event(type="llm_call", input={"model": "gpt-4o"})
37
+
38
+ assert route.called
39
+ body = route.calls[0].request.content
40
+ import json
41
+ parsed = json.loads(body)
42
+ assert parsed["type"] == "llm_call"
43
+ assert parsed["agentId"] == TEST_AGENT_ID
44
+ assert parsed["input"] == {"model": "gpt-4o"}
45
+ assert result["id"] == "evt-1"
46
+
47
+
48
+ @respx.mock
49
+ @pytest.mark.asyncio
50
+ async def test_log_event_auth_header():
51
+ route = respx.post(f"{BASE_URL}/v1/events").mock(
52
+ return_value=httpx.Response(200, json={"id": "evt-1", "timestamp": "2026-01-01T00:00:00Z"})
53
+ )
54
+ client = make_client()
55
+ await client.log_event(type="llm_call")
56
+
57
+ assert route.calls[0].request.headers["Authorization"] == f"Bearer {TEST_API_KEY}"
58
+
59
+
60
+ @respx.mock
61
+ @pytest.mark.asyncio
62
+ async def test_log_event_never_throws_on_network_error():
63
+ respx.post(f"{BASE_URL}/v1/events").mock(side_effect=httpx.ConnectError("Network failure"))
64
+ client = make_client()
65
+ # Must not raise — returns fallback
66
+ result = await client.log_event(type="llm_call")
67
+ assert result["id"] == ""
68
+
69
+
70
+ # ── Execution Trace ────────────────────────────────────────────
71
+
72
+ MOCK_TRACE = {
73
+ "id": "trace-1",
74
+ "orgId": "org-1",
75
+ "agentId": TEST_AGENT_ID,
76
+ "sessionId": "session-abc",
77
+ "status": "open",
78
+ "stepCount": 0,
79
+ "toolCalls": [],
80
+ "startedAt": "2026-01-01T00:00:00Z",
81
+ }
82
+
83
+
84
+ @respx.mock
85
+ @pytest.mark.asyncio
86
+ async def test_start_execution():
87
+ route = respx.post(f"{BASE_URL}/v1/execution-traces").mock(
88
+ return_value=httpx.Response(200, json=MOCK_TRACE)
89
+ )
90
+ client = make_client()
91
+ trace = await client.start_execution(session_id="session-abc")
92
+
93
+ assert route.called
94
+ import json
95
+ body = json.loads(route.calls[0].request.content)
96
+ assert body["agentId"] == TEST_AGENT_ID
97
+ assert body["sessionId"] == "session-abc"
98
+ assert isinstance(trace, ExecutionTrace)
99
+ assert trace.id == "trace-1"
100
+
101
+
102
+ @respx.mock
103
+ @pytest.mark.asyncio
104
+ async def test_log_step():
105
+ updated = {**MOCK_TRACE, "stepCount": 1}
106
+ route = respx.patch(f"{BASE_URL}/v1/execution-traces/trace-1").mock(
107
+ return_value=httpx.Response(200, json=updated)
108
+ )
109
+ client = make_client()
110
+ step = {
111
+ "stepNumber": 1,
112
+ "type": "tool_call",
113
+ "toolName": "send_email",
114
+ "toolArgs": {"to": "customer@acme.com"},
115
+ "toolResult": {"sent": True},
116
+ "latencyMs": 340,
117
+ }
118
+ result = await client.log_step("trace-1", step)
119
+
120
+ assert route.called
121
+ import json
122
+ body = json.loads(route.calls[0].request.content)
123
+ assert body["step"]["stepNumber"] == 1
124
+ assert body["step"]["toolName"] == "send_email"
125
+ assert isinstance(result, ExecutionTrace)
126
+ assert result.stepCount == 1
127
+
128
+
129
+ @respx.mock
130
+ @pytest.mark.asyncio
131
+ async def test_complete_execution():
132
+ completed = {**MOCK_TRACE, "status": "complete", "durationMs": 1200}
133
+ route = respx.post(f"{BASE_URL}/v1/execution-traces/trace-1/complete").mock(
134
+ return_value=httpx.Response(200, json=completed)
135
+ )
136
+ client = make_client()
137
+ result = await client.complete_execution("trace-1", status="complete")
138
+
139
+ assert route.called
140
+ import json
141
+ body = json.loads(route.calls[0].request.content)
142
+ assert body["status"] == "complete"
143
+ assert isinstance(result, ExecutionTrace)
144
+ assert result.status == "complete"
145
+ assert result.durationMs == 1200
146
+
147
+
148
+ @respx.mock
149
+ @pytest.mark.asyncio
150
+ async def test_full_execution_flow():
151
+ respx.post(f"{BASE_URL}/v1/execution-traces").mock(
152
+ return_value=httpx.Response(200, json={**MOCK_TRACE, "id": "trace-flow"})
153
+ )
154
+ respx.patch(f"{BASE_URL}/v1/execution-traces/trace-flow").mock(
155
+ return_value=httpx.Response(200, json={**MOCK_TRACE, "id": "trace-flow", "stepCount": 1})
156
+ )
157
+ respx.post(f"{BASE_URL}/v1/execution-traces/trace-flow/complete").mock(
158
+ return_value=httpx.Response(200, json={**MOCK_TRACE, "id": "trace-flow", "status": "complete", "durationMs": 800})
159
+ )
160
+
161
+ client = make_client()
162
+ trace = await client.start_execution(session_id="flow-session")
163
+ assert trace.id == "trace-flow"
164
+
165
+ updated = await client.log_step(trace.id, {"stepNumber": 1, "type": "tool_call", "toolName": "search", "latencyMs": 200})
166
+ assert updated.stepCount == 1
167
+
168
+ done = await client.complete_execution(trace.id, status="complete")
169
+ assert done.status == "complete"
170
+ assert done.durationMs == 800
171
+
172
+
173
+ # ── Intent Trace ───────────────────────────────────────────────
174
+
175
+ MOCK_INTENT = {
176
+ "id": "intent-1",
177
+ "orgId": "org-1",
178
+ "agentId": TEST_AGENT_ID,
179
+ "declaredIntent": "Draft a reply to the customer complaint email",
180
+ "status": "open",
181
+ "createdAt": "2026-01-01T00:00:00Z",
182
+ }
183
+
184
+
185
+ @respx.mock
186
+ @pytest.mark.asyncio
187
+ async def test_start_intent():
188
+ route = respx.post(f"{BASE_URL}/v1/intent-traces").mock(
189
+ return_value=httpx.Response(200, json=MOCK_INTENT)
190
+ )
191
+ client = make_client()
192
+ intent = await client.start_intent(
193
+ declared_intent="Draft a reply to the customer complaint email",
194
+ execution_trace_id="trace-1",
195
+ )
196
+
197
+ assert route.called
198
+ import json
199
+ body = json.loads(route.calls[0].request.content)
200
+ assert body["agentId"] == TEST_AGENT_ID
201
+ assert body["declaredIntent"] == "Draft a reply to the customer complaint email"
202
+ assert body["executionTraceId"] == "trace-1"
203
+ assert isinstance(intent, IntentTrace)
204
+ assert intent.id == "intent-1"
205
+
206
+
207
+ @respx.mock
208
+ @pytest.mark.asyncio
209
+ async def test_log_shift_returns_decision():
210
+ mock_shift = {
211
+ "id": "shift-blocked",
212
+ "intentTraceId": "intent-1",
213
+ "observedIntent": "Approve duplicate payment of $28,000",
214
+ "severity": "critical",
215
+ "timestamp": "2026-01-01T00:01:00Z",
216
+ "decision": "block",
217
+ "evaluationId": "eval-abc",
218
+ }
219
+ route = respx.post(f"{BASE_URL}/v1/intent-traces/intent-1/shifts").mock(
220
+ return_value=httpx.Response(200, json=mock_shift)
221
+ )
222
+ client = make_client()
223
+ shift = await client.log_shift(
224
+ "intent-1",
225
+ observed_intent="Approve duplicate payment of $28,000",
226
+ severity="critical",
227
+ )
228
+
229
+ assert route.called
230
+ assert isinstance(shift, IntentShift)
231
+ assert shift.decision == "block"
232
+ assert shift.evaluationId == "eval-abc"
233
+ assert shift.approvalRequestId is None
234
+
235
+
236
+ @respx.mock
237
+ @pytest.mark.asyncio
238
+ async def test_close_intent_returns_fidelity_score():
239
+ mock_result = {"fidelityScore": 0.3, "shifts": 1, "status": "flagged"}
240
+ route = respx.post(f"{BASE_URL}/v1/intent-traces/intent-1/close").mock(
241
+ return_value=httpx.Response(200, json=mock_result)
242
+ )
243
+ client = make_client()
244
+ result = await client.close_intent("intent-1", resolved_intent="Sent email directly")
245
+
246
+ assert route.called
247
+ assert isinstance(result, IntentCloseResult)
248
+ assert result.fidelityScore == 0.3
249
+ assert result.shifts == 1
250
+ assert result.status == "flagged"
251
+
252
+
253
+ @respx.mock
254
+ @pytest.mark.asyncio
255
+ async def test_full_intent_flow_critical_blocked():
256
+ respx.post(f"{BASE_URL}/v1/intent-traces").mock(
257
+ return_value=httpx.Response(200, json={**MOCK_INTENT, "id": "intent-flow"})
258
+ )
259
+ respx.post(f"{BASE_URL}/v1/intent-traces/intent-flow/shifts").mock(
260
+ return_value=httpx.Response(200, json={
261
+ "id": "shift-flow",
262
+ "intentTraceId": "intent-flow",
263
+ "observedIntent": "Send email directly without drafting first",
264
+ "severity": "critical",
265
+ "timestamp": "2026-01-01T00:01:00Z",
266
+ "decision": "block",
267
+ "evaluationId": "eval-flow",
268
+ })
269
+ )
270
+ respx.post(f"{BASE_URL}/v1/intent-traces/intent-flow/close").mock(
271
+ return_value=httpx.Response(200, json={"fidelityScore": 0.3, "shifts": 1, "status": "flagged"})
272
+ )
273
+
274
+ client = make_client()
275
+ intent = await client.start_intent(
276
+ declared_intent="Draft a reply to the customer complaint email",
277
+ execution_trace_id="trace-1",
278
+ )
279
+ assert intent.id == "intent-flow"
280
+
281
+ shift = await client.log_shift(
282
+ intent.id,
283
+ observed_intent="Send email directly without drafting first",
284
+ severity="critical",
285
+ shift_reason="draft_tool unavailable",
286
+ )
287
+ assert shift.decision == "block"
288
+ assert shift.severity == "critical"
289
+
290
+ close_result = await client.close_intent(intent.id, resolved_intent="Sent email directly without user review")
291
+ assert close_result.fidelityScore == 0.3
292
+ assert close_result.status == "flagged"
293
+
294
+
295
+ # ── Policy Engine ──────────────────────────────────────────────
296
+
297
+ @respx.mock
298
+ @pytest.mark.asyncio
299
+ async def test_evaluate_allow():
300
+ route = respx.post(f"{BASE_URL}/v1/evaluate").mock(
301
+ return_value=httpx.Response(200, json={"decision": "allow", "evaluationId": "eval-1"})
302
+ )
303
+ client = make_client()
304
+ result = await client.evaluate(
305
+ action_type="read_document",
306
+ action_payload={"docId": "doc-123"},
307
+ )
308
+
309
+ assert route.called
310
+ import json
311
+ body = json.loads(route.calls[0].request.content)
312
+ assert body["agentId"] == TEST_AGENT_ID
313
+ assert body["actionType"] == "read_document"
314
+ assert isinstance(result, EvaluationResult)
315
+ assert result.decision == "allow"
316
+
317
+
318
+ @respx.mock
319
+ @pytest.mark.asyncio
320
+ async def test_evaluate_block():
321
+ respx.post(f"{BASE_URL}/v1/evaluate").mock(
322
+ return_value=httpx.Response(200, json={
323
+ "decision": "block",
324
+ "reason": "Action blocked by policy: no external emails",
325
+ "evaluationId": "eval-2",
326
+ })
327
+ )
328
+ client = make_client()
329
+ result = await client.evaluate(
330
+ action_type="send_email",
331
+ action_payload={"to": "external@company.com"},
332
+ )
333
+
334
+ assert result.decision == "block"
335
+ assert result.reason == "Action blocked by policy: no external emails"
336
+
337
+
338
+ @respx.mock
339
+ @pytest.mark.asyncio
340
+ async def test_evaluate_pending_approval():
341
+ respx.post(f"{BASE_URL}/v1/evaluate").mock(
342
+ return_value=httpx.Response(200, json={
343
+ "decision": "pending_approval",
344
+ "evaluationId": "eval-3",
345
+ "approvalRequestId": "approval-99",
346
+ })
347
+ )
348
+ client = make_client()
349
+ result = await client.evaluate(
350
+ action_type="approve_payment",
351
+ action_payload={"amount": 50000},
352
+ )
353
+
354
+ assert result.decision == "pending_approval"
355
+ assert result.approvalRequestId == "approval-99"
356
+
357
+
358
+ @respx.mock
359
+ @pytest.mark.asyncio
360
+ async def test_wait_for_approval_resolves():
361
+ approved = {
362
+ "id": "approval-99",
363
+ "orgId": "org-1",
364
+ "policyEvaluationId": "eval-3",
365
+ "agentId": TEST_AGENT_ID,
366
+ "actionType": "approve_payment",
367
+ "actionPayload": {"amount": 50000},
368
+ "status": "approved",
369
+ "requestedAt": "2026-01-01T00:00:00Z",
370
+ "resolvedAt": "2026-01-01T00:01:00Z",
371
+ "resolvedBy": "human",
372
+ }
373
+ respx.get(f"{BASE_URL}/v1/approvals/approval-99").mock(
374
+ return_value=httpx.Response(200, json=approved)
375
+ )
376
+ client = make_client()
377
+ approval = await client.wait_for_approval("approval-99", timeout_ms=5000)
378
+
379
+ assert isinstance(approval, ApprovalRequest)
380
+ assert approval.status == "approved"
381
+ assert approval.id == "approval-99"
382
+ assert approval.resolvedBy == "human"
383
+
384
+
385
+ @respx.mock
386
+ @pytest.mark.asyncio
387
+ async def test_wait_for_approval_polls_until_resolved():
388
+ pending = {"id": "approval-88", "orgId": "org-1", "policyEvaluationId": "e", "agentId": TEST_AGENT_ID, "actionType": "a", "actionPayload": {}, "status": "pending", "requestedAt": "2026-01-01T00:00:00Z"}
389
+ approved = {**pending, "status": "approved", "resolvedAt": "2026-01-01T00:00:02Z"}
390
+
391
+ respx.get(f"{BASE_URL}/v1/approvals/approval-88").mock(
392
+ side_effect=[
393
+ httpx.Response(200, json=pending),
394
+ httpx.Response(200, json=approved),
395
+ ]
396
+ )
397
+ client = make_client()
398
+ result = await client.wait_for_approval("approval-88", timeout_ms=10_000, poll_interval_ms=10)
399
+
400
+ assert result.status == "approved"
401
+
402
+
403
+ @respx.mock
404
+ @pytest.mark.asyncio
405
+ async def test_wait_for_approval_timeout():
406
+ pending = {"id": "approval-77", "orgId": "org-1", "policyEvaluationId": "e", "agentId": TEST_AGENT_ID, "actionType": "a", "actionPayload": {}, "status": "pending", "requestedAt": "2026-01-01T00:00:00Z"}
407
+ respx.get(f"{BASE_URL}/v1/approvals/approval-77").mock(
408
+ return_value=httpx.Response(200, json=pending)
409
+ )
410
+ client = make_client()
411
+ with pytest.raises(TimeoutError, match="timed out"):
412
+ await client.wait_for_approval("approval-77", timeout_ms=50, poll_interval_ms=10)