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.
- vantageai-0.2.0/LICENSE +21 -0
- vantageai-0.2.0/MANIFEST.in +3 -0
- vantageai-0.2.0/PKG-INFO +108 -0
- vantageai-0.2.0/README.md +77 -0
- vantageai-0.2.0/pyproject.toml +48 -0
- vantageai-0.2.0/setup.cfg +4 -0
- vantageai-0.2.0/tests/test_client.py +412 -0
- vantageai-0.2.0/vantageai/__init__.py +23 -0
- vantageai-0.2.0/vantageai/client.py +314 -0
- vantageai-0.2.0/vantageai/integrations/__init__.py +0 -0
- vantageai-0.2.0/vantageai/integrations/langchain.py +132 -0
- vantageai-0.2.0/vantageai/types.py +160 -0
- vantageai-0.2.0/vantageai.egg-info/PKG-INFO +108 -0
- vantageai-0.2.0/vantageai.egg-info/SOURCES.txt +15 -0
- vantageai-0.2.0/vantageai.egg-info/dependency_links.txt +1 -0
- vantageai-0.2.0/vantageai.egg-info/requires.txt +10 -0
- vantageai-0.2.0/vantageai.egg-info/top_level.txt +1 -0
vantageai-0.2.0/LICENSE
ADDED
|
@@ -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.
|
vantageai-0.2.0/PKG-INFO
ADDED
|
@@ -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,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)
|