aeonic-agentguard-sdk-python 0.1.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.
- aeonic/__init__.py +5 -0
- aeonic/adapters/__init__.py +1 -0
- aeonic/adapters/django.py +275 -0
- aeonic/adapters/fastapi.py +337 -0
- aeonic/core/__init__.py +4 -0
- aeonic/core/agent_registry.py +209 -0
- aeonic/core/agent_status_cache.py +28 -0
- aeonic/core/agentguard/__init__.py +8 -0
- aeonic/core/agentguard/registration.py +93 -0
- aeonic/core/agentguard/sse_client.py +210 -0
- aeonic/core/assessment/__init__.py +5 -0
- aeonic/core/assessment/executor.py +207 -0
- aeonic/core/buffer.py +27 -0
- aeonic/core/init.py +218 -0
- aeonic/core/introspection.py +468 -0
- aeonic/core/sender.py +234 -0
- aeonic/core/types.py +51 -0
- aeonic/utils/__init__.py +1 -0
- aeonic/utils/safe_serialize.py +22 -0
- aeonic_agentguard_sdk_python-0.1.0.dist-info/METADATA +770 -0
- aeonic_agentguard_sdk_python-0.1.0.dist-info/RECORD +24 -0
- aeonic_agentguard_sdk_python-0.1.0.dist-info/WHEEL +5 -0
- aeonic_agentguard_sdk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- aeonic_agentguard_sdk_python-0.1.0.dist-info/top_level.txt +1 -0
aeonic/core/sender.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.request
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("aeonic")
|
|
10
|
+
|
|
11
|
+
# Try to load .env file if python-dotenv is available (optional dependency)
|
|
12
|
+
try:
|
|
13
|
+
from dotenv import load_dotenv # type: ignore
|
|
14
|
+
|
|
15
|
+
# Load .env file from current working directory or project root
|
|
16
|
+
load_dotenv()
|
|
17
|
+
except ImportError:
|
|
18
|
+
# python-dotenv not installed, skip .env loading
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
# Global state matching TS implementation
|
|
22
|
+
queue: deque = deque()
|
|
23
|
+
sending = False
|
|
24
|
+
sending_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_endpoint() -> str:
|
|
28
|
+
"""
|
|
29
|
+
Get the Aeonic endpoint from environment variable.
|
|
30
|
+
Reads AEONIC_ENDPOINT, with fallback to production endpoint.
|
|
31
|
+
This is called lazily to ensure environment variables are loaded.
|
|
32
|
+
"""
|
|
33
|
+
return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_origin() -> Optional[str]:
|
|
37
|
+
"""
|
|
38
|
+
Detect the origin URL from environment variables
|
|
39
|
+
Checks ORIGIN, BASE_URL, or constructs from HOST/HOSTNAME and PORT
|
|
40
|
+
"""
|
|
41
|
+
# Check for explicit origin/base URL
|
|
42
|
+
if os.environ.get("ORIGIN"):
|
|
43
|
+
return os.environ["ORIGIN"]
|
|
44
|
+
if os.environ.get("BASE_URL"):
|
|
45
|
+
return os.environ["BASE_URL"]
|
|
46
|
+
|
|
47
|
+
# Construct from HOST/HOSTNAME and PORT
|
|
48
|
+
host = os.environ.get("HOST") or os.environ.get("HOSTNAME") or "localhost"
|
|
49
|
+
port = os.environ.get("PORT")
|
|
50
|
+
|
|
51
|
+
if port:
|
|
52
|
+
# Determine protocol (default to http, use https if port is 443 or HTTPS env is set)
|
|
53
|
+
is_https = os.environ.get("HTTPS") == "true" or port == "443"
|
|
54
|
+
protocol = "https" if is_https else "http"
|
|
55
|
+
return f"{protocol}://{host}:{port}"
|
|
56
|
+
|
|
57
|
+
# If no port, return just the host (might be a domain like api.abcd.com)
|
|
58
|
+
if host != "localhost":
|
|
59
|
+
is_https = os.environ.get("HTTPS") == "true"
|
|
60
|
+
protocol = "https" if is_https else "http"
|
|
61
|
+
return f"{protocol}://{host}"
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_2xx(status_code: int) -> bool:
|
|
67
|
+
"""Return True if status_code is in the 2xx range."""
|
|
68
|
+
return 200 <= status_code < 300
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def enqueue(payload: Dict[str, Any]) -> None:
|
|
72
|
+
"""Add payload to queue and trigger processing (for 2xx samples → /sdk/agent-samples)."""
|
|
73
|
+
queue.append(payload)
|
|
74
|
+
_process_queue()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def emit_error_sample(payload: Dict[str, Any]) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Emit a single error sample (non-2xx response) to /sdk/agent-error-samples in real time.
|
|
80
|
+
Does not buffer; sends immediately in a background thread so the request is not blocked.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def _send() -> None:
|
|
84
|
+
try:
|
|
85
|
+
origin = detect_origin()
|
|
86
|
+
headers = {"Content-Type": "application/json"}
|
|
87
|
+
if origin:
|
|
88
|
+
headers["origin"] = origin
|
|
89
|
+
headers["referer"] = origin
|
|
90
|
+
url = f"{_get_endpoint()}/sdk/agent-error-samples"
|
|
91
|
+
data = json.dumps(payload).encode("utf-8")
|
|
92
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
93
|
+
with urllib.request.urlopen(req, timeout=100) as response:
|
|
94
|
+
response_data = json.loads(response.read())
|
|
95
|
+
if response_data.get("success"):
|
|
96
|
+
if os.environ.get("AEONIC_DEBUG"):
|
|
97
|
+
print("[Aeonic] Agent error sample emitted successfully")
|
|
98
|
+
else:
|
|
99
|
+
if os.environ.get("AEONIC_DEBUG"):
|
|
100
|
+
print(
|
|
101
|
+
f"[Aeonic] Failed to emit agent error sample: {response_data.get('message')}"
|
|
102
|
+
)
|
|
103
|
+
except Exception as err:
|
|
104
|
+
if os.environ.get("AEONIC_DEBUG"):
|
|
105
|
+
print(f"[Aeonic] Failed to emit agent error sample: {err}")
|
|
106
|
+
|
|
107
|
+
t = threading.Thread(target=_send)
|
|
108
|
+
t.daemon = True
|
|
109
|
+
t.start()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _process_queue() -> None:
|
|
113
|
+
"""
|
|
114
|
+
Trigger the background processing loop.
|
|
115
|
+
Mimics the recursive/async processQueue in TS.
|
|
116
|
+
"""
|
|
117
|
+
global sending
|
|
118
|
+
|
|
119
|
+
# Check if already sending or queue empty
|
|
120
|
+
# We use a lock to ensure thread-safety of the 'sending' flag check/set sequence
|
|
121
|
+
# if we were strictly launching threads, but here we just check if we need to start a worker.
|
|
122
|
+
if sending or not queue:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Start a background thread to process the queue
|
|
126
|
+
# This prevents blocking the main thread (like async usually implies)
|
|
127
|
+
t = threading.Thread(target=_process_queue_worker)
|
|
128
|
+
t.daemon = True
|
|
129
|
+
t.start()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _process_queue_worker() -> None:
|
|
133
|
+
"""Background worker to drain the queue."""
|
|
134
|
+
global sending
|
|
135
|
+
|
|
136
|
+
# helper to acquire lock and set sending
|
|
137
|
+
with sending_lock:
|
|
138
|
+
if sending:
|
|
139
|
+
return
|
|
140
|
+
sending = True
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
while True:
|
|
144
|
+
# Get next payload
|
|
145
|
+
try:
|
|
146
|
+
# TS: const payload = queue.shift();
|
|
147
|
+
payload = queue.popleft()
|
|
148
|
+
except IndexError:
|
|
149
|
+
# Queue is empty
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
# Detect origin and referer from environment
|
|
154
|
+
origin = detect_origin()
|
|
155
|
+
headers = {"Content-Type": "application/json"}
|
|
156
|
+
|
|
157
|
+
if origin:
|
|
158
|
+
headers["origin"] = origin
|
|
159
|
+
headers["referer"] = origin
|
|
160
|
+
|
|
161
|
+
url = f"{_get_endpoint()}/sdk/agent-samples"
|
|
162
|
+
data = json.dumps(payload).encode("utf-8")
|
|
163
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
164
|
+
|
|
165
|
+
# TS: await axios.post(...)
|
|
166
|
+
# 10s timeout to avoid hanging indefinitely
|
|
167
|
+
with urllib.request.urlopen(req, timeout=100) as response:
|
|
168
|
+
response_data = json.loads(response.read())
|
|
169
|
+
if response_data.get("success"):
|
|
170
|
+
print("[Aeonic] Agent samples emitted successfully")
|
|
171
|
+
else:
|
|
172
|
+
print(
|
|
173
|
+
f"[Aeonic] Failed to emit agent samples: {response_data.get('message')}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
except Exception as err:
|
|
177
|
+
if os.environ.get("AEONIC_DEBUG"):
|
|
178
|
+
# TS: console.error(...)
|
|
179
|
+
print(f"[Aeonic] Failed to emit agent samples: {err}")
|
|
180
|
+
|
|
181
|
+
# retry later (simple strategy for v1)
|
|
182
|
+
# TS: queue.push(payload!)
|
|
183
|
+
queue.append(payload)
|
|
184
|
+
# Prevent tight loop in case of persistent error?
|
|
185
|
+
# TS implementation doesn't sleep, but in Python thread it might consume CPU.
|
|
186
|
+
# Use a small sleep? User asked for "behhave same... nothing else".
|
|
187
|
+
# I will trust the "same" requirement strictly.
|
|
188
|
+
|
|
189
|
+
finally:
|
|
190
|
+
with sending_lock:
|
|
191
|
+
sending = False
|
|
192
|
+
|
|
193
|
+
# Determine if we need to restart (if items added while exiting)
|
|
194
|
+
if queue:
|
|
195
|
+
_process_queue()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def emit_agent_inventory(payload: Dict[str, Any]) -> bool:
|
|
199
|
+
"""
|
|
200
|
+
Emit agent inventory to the backend
|
|
201
|
+
This is sent ONCE per process lifecycle
|
|
202
|
+
Does NOT block startup and does NOT throw on failure
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
# Detect origin and referer from environment
|
|
206
|
+
origin = detect_origin()
|
|
207
|
+
headers = {"Content-Type": "application/json"}
|
|
208
|
+
|
|
209
|
+
if origin:
|
|
210
|
+
headers["origin"] = origin
|
|
211
|
+
headers["referer"] = origin
|
|
212
|
+
|
|
213
|
+
url = f"{_get_endpoint()}/sdk/agent-inventory"
|
|
214
|
+
data = json.dumps(payload).encode("utf-8")
|
|
215
|
+
|
|
216
|
+
print(f"[Aeonic] Emitting agent inventory to {url}")
|
|
217
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
218
|
+
|
|
219
|
+
with urllib.request.urlopen(req, timeout=100) as response:
|
|
220
|
+
response_data = json.loads(response.read())
|
|
221
|
+
if response_data.get("success"):
|
|
222
|
+
print("[Aeonic] Agent inventory emitted successfully")
|
|
223
|
+
return True
|
|
224
|
+
else:
|
|
225
|
+
print(f"[Aeonic] Failed to emit agent inventory: {response_data.get('message')}")
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
except Exception as err:
|
|
229
|
+
# Silently fail - do NOT break startup
|
|
230
|
+
# Log for debugging purposes only
|
|
231
|
+
if os.environ.get("AEONIC_DEBUG"):
|
|
232
|
+
print(f"[Aeonic] Failed to emit agent inventory: {err}")
|
|
233
|
+
|
|
234
|
+
raise err
|
aeonic/core/types.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentContext(TypedDict, total=False):
|
|
5
|
+
name: str
|
|
6
|
+
type: str
|
|
7
|
+
model: List[str]
|
|
8
|
+
source: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RouteContext(TypedDict):
|
|
12
|
+
path: str
|
|
13
|
+
method: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Sample(TypedDict, total=False):
|
|
17
|
+
req: Optional[Dict[str, Any]]
|
|
18
|
+
res: Optional[Dict[str, Any]]
|
|
19
|
+
timestamp: int
|
|
20
|
+
status_code: Optional[int] # HTTP status; included for error samples, optional for 2xx
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlushPayload(TypedDict):
|
|
24
|
+
api_key: str
|
|
25
|
+
service_name: Optional[str]
|
|
26
|
+
agent: AgentContext
|
|
27
|
+
route: RouteContext
|
|
28
|
+
samples: List[Sample]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AgentDescriptor(TypedDict, total=False):
|
|
32
|
+
"""Describes a registered agent with metadata."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
type: Optional[str]
|
|
36
|
+
model: Optional[List[str]]
|
|
37
|
+
route_path: Optional[str]
|
|
38
|
+
http_method: Optional[str]
|
|
39
|
+
framework: str
|
|
40
|
+
service_name: Optional[str]
|
|
41
|
+
declared_at: float
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InventoryPayload(TypedDict):
|
|
45
|
+
"""Payload for agent inventory emission."""
|
|
46
|
+
|
|
47
|
+
event: str
|
|
48
|
+
api_key: str
|
|
49
|
+
service_name: Optional[str]
|
|
50
|
+
framework: str
|
|
51
|
+
agents: List[AgentDescriptor]
|
aeonic/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utility helpers for the Aeonic SDK."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# aeonic/utils/safe_serialize.py
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def safe_serialize(value: Any) -> Any:
|
|
7
|
+
try:
|
|
8
|
+
if value is None:
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
if isinstance(value, (str, int, float, bool)):
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
if isinstance(value, dict):
|
|
15
|
+
return {k: safe_serialize(v) for k, v in value.items()}
|
|
16
|
+
|
|
17
|
+
if isinstance(value, list):
|
|
18
|
+
return [safe_serialize(v) for v in value]
|
|
19
|
+
|
|
20
|
+
return str(value)
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|