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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Registry - Global in-memory registry for declared agents.
|
|
3
|
+
|
|
4
|
+
This module tracks all agents declared via SDK APIs (with_agent() / @with_agent)
|
|
5
|
+
at declaration time, NOT via static code scanning.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from time import time
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from aeonic.core.types import AgentDescriptor
|
|
13
|
+
|
|
14
|
+
# Global registry storage
|
|
15
|
+
_REGISTRY: Dict[str, AgentDescriptor] = {}
|
|
16
|
+
_REGISTRY_LOCK = threading.Lock()
|
|
17
|
+
_INVENTORY_EMITTED = False
|
|
18
|
+
_INVENTORY_LOCK = threading.Lock()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_agent(
|
|
22
|
+
name: str,
|
|
23
|
+
agent_type: Optional[str],
|
|
24
|
+
model: Optional[List[str]],
|
|
25
|
+
route_path: Optional[str],
|
|
26
|
+
http_method: Optional[str],
|
|
27
|
+
framework: str,
|
|
28
|
+
service_name: Optional[str],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Register an agent in the global registry.
|
|
32
|
+
|
|
33
|
+
This function is idempotent and never throws exceptions.
|
|
34
|
+
Called at agent declaration time (when with_agent() is invoked or @with_agent is applied).
|
|
35
|
+
|
|
36
|
+
If an agent with the same name already exists but has None route_path/http_method,
|
|
37
|
+
this will update that registration with the actual values.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
name: Agent name (unique identifier)
|
|
41
|
+
agent_type: Type of agent (e.g., "conversational", "task")
|
|
42
|
+
model: List of model identifiers
|
|
43
|
+
route_path: HTTP route path (if applicable)
|
|
44
|
+
http_method: HTTP method (if applicable)
|
|
45
|
+
framework: Framework identifier ("fastapi" or "django")
|
|
46
|
+
service_name: Service name from SDK config
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
agent_key = _generate_agent_key(name, route_path, http_method, framework)
|
|
50
|
+
|
|
51
|
+
with _REGISTRY_LOCK:
|
|
52
|
+
# Idempotent: skip if already registered with same key
|
|
53
|
+
if agent_key in _REGISTRY:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# If we have actual route_path/http_method, check if there's an existing
|
|
57
|
+
# registration with the same name but None values (from early registration)
|
|
58
|
+
if route_path and http_method:
|
|
59
|
+
# Look for existing registration with same name but None path/method
|
|
60
|
+
existing_key = _generate_agent_key(name, None, None, framework)
|
|
61
|
+
if existing_key in _REGISTRY:
|
|
62
|
+
# Update the existing registration
|
|
63
|
+
existing = _REGISTRY[existing_key]
|
|
64
|
+
# Preserve original declared_at
|
|
65
|
+
updated_descriptor: AgentDescriptor = {
|
|
66
|
+
"name": name,
|
|
67
|
+
"type": agent_type or existing.get("type"),
|
|
68
|
+
"model": model or existing.get("model"),
|
|
69
|
+
"route_path": route_path,
|
|
70
|
+
"http_method": http_method,
|
|
71
|
+
"framework": framework,
|
|
72
|
+
"service_name": service_name or existing.get("service_name"),
|
|
73
|
+
"declared_at": existing.get("declared_at", time()),
|
|
74
|
+
}
|
|
75
|
+
# Remove old key and add new one
|
|
76
|
+
del _REGISTRY[existing_key]
|
|
77
|
+
_REGISTRY[agent_key] = updated_descriptor
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Also check all existing agents for same name (in case key format differs)
|
|
81
|
+
for key, existing_agent in list(_REGISTRY.items()):
|
|
82
|
+
if (
|
|
83
|
+
existing_agent.get("name") == name
|
|
84
|
+
and existing_agent.get("framework") == framework
|
|
85
|
+
and existing_agent.get("route_path") is None
|
|
86
|
+
and existing_agent.get("http_method") is None
|
|
87
|
+
):
|
|
88
|
+
# Found a match - update it
|
|
89
|
+
matched_descriptor: AgentDescriptor = {
|
|
90
|
+
"name": name,
|
|
91
|
+
"type": agent_type or existing_agent.get("type"),
|
|
92
|
+
"model": model or existing_agent.get("model"),
|
|
93
|
+
"route_path": route_path,
|
|
94
|
+
"http_method": http_method,
|
|
95
|
+
"framework": framework,
|
|
96
|
+
"service_name": service_name or existing_agent.get("service_name"),
|
|
97
|
+
"declared_at": existing_agent.get("declared_at", time()),
|
|
98
|
+
}
|
|
99
|
+
# Remove old and add new
|
|
100
|
+
del _REGISTRY[key]
|
|
101
|
+
_REGISTRY[agent_key] = matched_descriptor
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Create new registration
|
|
105
|
+
new_descriptor: AgentDescriptor = {
|
|
106
|
+
"name": name,
|
|
107
|
+
"type": agent_type,
|
|
108
|
+
"model": model,
|
|
109
|
+
"route_path": route_path,
|
|
110
|
+
"http_method": http_method,
|
|
111
|
+
"framework": framework,
|
|
112
|
+
"service_name": service_name,
|
|
113
|
+
"declared_at": time(),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_REGISTRY[agent_key] = new_descriptor
|
|
117
|
+
|
|
118
|
+
except Exception:
|
|
119
|
+
# Never throw - silent failure
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_all_agents() -> List[AgentDescriptor]:
|
|
124
|
+
"""
|
|
125
|
+
Retrieve all registered agents.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of agent descriptors
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
with _REGISTRY_LOCK:
|
|
132
|
+
return list(_REGISTRY.values())
|
|
133
|
+
except Exception:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def should_emit_inventory() -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Check if inventory should be emitted.
|
|
140
|
+
|
|
141
|
+
This checks if inventory has been emitted, but does NOT set the flag.
|
|
142
|
+
The flag is only set when the API call succeeds (via mark_inventory_emitted()).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if inventory should be emitted (hasn't been emitted yet), False otherwise
|
|
146
|
+
"""
|
|
147
|
+
global _INVENTORY_EMITTED
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
with _INVENTORY_LOCK:
|
|
151
|
+
return not _INVENTORY_EMITTED
|
|
152
|
+
except Exception:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mark_inventory_emitted() -> None:
|
|
157
|
+
"""
|
|
158
|
+
Mark inventory as successfully emitted.
|
|
159
|
+
|
|
160
|
+
This should only be called after a successful API response.
|
|
161
|
+
"""
|
|
162
|
+
global _INVENTORY_EMITTED
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with _INVENTORY_LOCK:
|
|
166
|
+
_INVENTORY_EMITTED = True
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def reset_inventory_emission() -> None:
|
|
172
|
+
"""
|
|
173
|
+
Reset the inventory emission flag.
|
|
174
|
+
|
|
175
|
+
This is primarily used for testing purposes.
|
|
176
|
+
"""
|
|
177
|
+
global _INVENTORY_EMITTED
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with _INVENTORY_LOCK:
|
|
181
|
+
_INVENTORY_EMITTED = False
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _generate_agent_key(
|
|
187
|
+
name: str,
|
|
188
|
+
route_path: Optional[str],
|
|
189
|
+
http_method: Optional[str],
|
|
190
|
+
framework: str,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""
|
|
193
|
+
Generate a unique key for agent registration.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
name: Agent name
|
|
197
|
+
route_path: HTTP route path
|
|
198
|
+
http_method: HTTP method
|
|
199
|
+
framework: Framework identifier
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Unique agent key
|
|
203
|
+
"""
|
|
204
|
+
parts = [framework, name]
|
|
205
|
+
if route_path:
|
|
206
|
+
parts.append(route_path)
|
|
207
|
+
if http_method:
|
|
208
|
+
parts.append(http_method)
|
|
209
|
+
return ":".join(parts)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Agent status cache for blocking/quarantining agents."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
_AGENT_STATUS_CACHE: Dict[str, str] = {}
|
|
7
|
+
_CACHE_LOCK = threading.Lock()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_agent_status(agent_key: str) -> Optional[str]:
|
|
11
|
+
"""Get agent status from cache."""
|
|
12
|
+
with _CACHE_LOCK:
|
|
13
|
+
return _AGENT_STATUS_CACHE.get(agent_key)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_agent_status(agent_key: str, status: str) -> None:
|
|
17
|
+
"""Set agent status in cache."""
|
|
18
|
+
with _CACHE_LOCK:
|
|
19
|
+
_AGENT_STATUS_CACHE[agent_key] = status
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def should_collect_samples(agent_key: str) -> bool:
|
|
23
|
+
"""Check if samples should be collected for agent."""
|
|
24
|
+
status = get_agent_status(agent_key)
|
|
25
|
+
# If no status in cache, assume healthy (collect samples)
|
|
26
|
+
if status is None:
|
|
27
|
+
return True
|
|
28
|
+
return status not in ("blocked", "quarantined")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""AgentGuard registration with T-LAIOR backend."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from aeonic.core.agentguard.sse_client import start_sse_connection, stop_sse_connection
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("aeonic")
|
|
13
|
+
|
|
14
|
+
_AGENT_GUARD_CREDENTIALS: Optional[Dict[str, Any]] = None
|
|
15
|
+
_CREDENTIALS_LOCK = threading.Lock()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_endpoint() -> str:
|
|
19
|
+
"""Get the Aeonic endpoint from environment variable."""
|
|
20
|
+
return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_agent_guard() -> Dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Register SDK as AgentGuard instance with T-LAIOR.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dictionary containing agentguard_id, auth_token, and intervals.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
Exception: If registration fails.
|
|
32
|
+
"""
|
|
33
|
+
global _AGENT_GUARD_CREDENTIALS
|
|
34
|
+
|
|
35
|
+
from aeonic.core.init import get_config
|
|
36
|
+
|
|
37
|
+
config = get_config()
|
|
38
|
+
endpoint = _get_endpoint()
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
"api_key": config["api_key"],
|
|
42
|
+
"agentguard_version": "0.1.0",
|
|
43
|
+
"capabilities": ["red-team", "leakage", "safety"],
|
|
44
|
+
"service_name": config.get("service_name"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
url = f"{endpoint}/agentguard/register"
|
|
49
|
+
data = json.dumps(payload).encode("utf-8")
|
|
50
|
+
req = urllib.request.Request(
|
|
51
|
+
url,
|
|
52
|
+
data=data,
|
|
53
|
+
headers={"Content-Type": "application/json"},
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
58
|
+
response_data = json.loads(response.read())
|
|
59
|
+
|
|
60
|
+
with _CREDENTIALS_LOCK:
|
|
61
|
+
_AGENT_GUARD_CREDENTIALS = {
|
|
62
|
+
"agentguard_id": response_data["agentguard_id"],
|
|
63
|
+
"auth_token": response_data["auth_token"],
|
|
64
|
+
"heartbeat_interval": response_data.get("heartbeat_interval", 30),
|
|
65
|
+
"polling_interval": response_data.get("polling_interval", 5),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Start SSE connection for real-time events
|
|
69
|
+
start_sse_connection()
|
|
70
|
+
|
|
71
|
+
logger.info("[Aeonic] AgentGuard registered successfully")
|
|
72
|
+
return _AGENT_GUARD_CREDENTIALS
|
|
73
|
+
|
|
74
|
+
except Exception as error:
|
|
75
|
+
logger.error(f"[Aeonic] Failed to register AgentGuard: {error}")
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_agent_guard_credentials() -> Optional[Dict[str, Any]]:
|
|
80
|
+
"""Get current AgentGuard credentials."""
|
|
81
|
+
with _CREDENTIALS_LOCK:
|
|
82
|
+
return _AGENT_GUARD_CREDENTIALS.copy() if _AGENT_GUARD_CREDENTIALS else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stop_agent_guard() -> None:
|
|
86
|
+
"""Stop AgentGuard and SSE connection."""
|
|
87
|
+
global _AGENT_GUARD_CREDENTIALS
|
|
88
|
+
|
|
89
|
+
# Stop SSE connection
|
|
90
|
+
stop_sse_connection()
|
|
91
|
+
|
|
92
|
+
with _CREDENTIALS_LOCK:
|
|
93
|
+
_AGENT_GUARD_CREDENTIALS = None
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""SSE client for real-time event streaming."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from aeonic.core.agent_status_cache import set_agent_status
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("aeonic")
|
|
14
|
+
|
|
15
|
+
_SSE_CONNECTION: Optional[threading.Thread] = None
|
|
16
|
+
_STOP_SSE = threading.Event()
|
|
17
|
+
_RECONNECT_ATTEMPTS = 0
|
|
18
|
+
_MAX_RECONNECT_ATTEMPTS = 10
|
|
19
|
+
_INITIAL_RECONNECT_DELAY = 1.0 # 1 second
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_endpoint() -> str:
|
|
23
|
+
"""Get the Aeonic endpoint from environment variable."""
|
|
24
|
+
return os.environ.get("AEONIC_ENDPOINT", "http://45.79.111.106:3291")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def start_sse_connection() -> None:
|
|
28
|
+
"""Start SSE connection for real-time events."""
|
|
29
|
+
global _SSE_CONNECTION, _RECONNECT_ATTEMPTS
|
|
30
|
+
|
|
31
|
+
from aeonic.core.agentguard.registration import get_agent_guard_credentials
|
|
32
|
+
|
|
33
|
+
credentials = get_agent_guard_credentials()
|
|
34
|
+
if not credentials:
|
|
35
|
+
logger.warning("[Aeonic] Cannot start SSE: AgentGuard not registered")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if _SSE_CONNECTION and _SSE_CONNECTION.is_alive():
|
|
39
|
+
# Already connected
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
_STOP_SSE.clear()
|
|
43
|
+
_RECONNECT_ATTEMPTS = 0
|
|
44
|
+
|
|
45
|
+
def sse_worker() -> None:
|
|
46
|
+
"""SSE connection worker thread."""
|
|
47
|
+
global _RECONNECT_ATTEMPTS
|
|
48
|
+
|
|
49
|
+
endpoint = _get_endpoint()
|
|
50
|
+
url = f"{endpoint}/api/sdk/events/stream"
|
|
51
|
+
|
|
52
|
+
while not _STOP_SSE.is_set():
|
|
53
|
+
try:
|
|
54
|
+
headers = {
|
|
55
|
+
"Authorization": f"Bearer {credentials['auth_token']}",
|
|
56
|
+
"Accept": "text/event-stream",
|
|
57
|
+
"Cache-Control": "no-cache",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
response = requests.get(
|
|
61
|
+
url,
|
|
62
|
+
headers=headers,
|
|
63
|
+
stream=True,
|
|
64
|
+
timeout=None, # No timeout for SSE
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
|
|
69
|
+
logger.info("[Aeonic] SSE connection established")
|
|
70
|
+
_RECONNECT_ATTEMPTS = 0
|
|
71
|
+
|
|
72
|
+
# Process SSE stream
|
|
73
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
74
|
+
if _STOP_SSE.is_set():
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
if not line:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
if line.startswith("data: "):
|
|
81
|
+
try:
|
|
82
|
+
# Remove "data: " prefix and parse JSON
|
|
83
|
+
event_data = json.loads(line[6:])
|
|
84
|
+
_handle_sse_event(event_data)
|
|
85
|
+
except json.JSONDecodeError as e:
|
|
86
|
+
logger.error(f"[Aeonic] Failed to parse SSE event: {e}")
|
|
87
|
+
|
|
88
|
+
except requests.exceptions.RequestException as e:
|
|
89
|
+
if _STOP_SSE.is_set():
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
logger.warning(f"[Aeonic] SSE connection error: {e}")
|
|
93
|
+
|
|
94
|
+
# Reconnect with exponential backoff
|
|
95
|
+
if _RECONNECT_ATTEMPTS < _MAX_RECONNECT_ATTEMPTS:
|
|
96
|
+
delay = _INITIAL_RECONNECT_DELAY * (2**_RECONNECT_ATTEMPTS)
|
|
97
|
+
_RECONNECT_ATTEMPTS += 1
|
|
98
|
+
logger.info(
|
|
99
|
+
f"[Aeonic] Reconnecting SSE in {delay}s (attempt {_RECONNECT_ATTEMPTS})"
|
|
100
|
+
)
|
|
101
|
+
if _STOP_SSE.wait(delay):
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
logger.error("[Aeonic] Max SSE reconnection attempts reached")
|
|
105
|
+
break
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"[Aeonic] Unexpected SSE error: {e}")
|
|
108
|
+
if _STOP_SSE.wait(5): # Wait a bit before retrying unexpected errors
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
_SSE_CONNECTION = threading.Thread(target=sse_worker, name="AeonicSSEThread", daemon=True)
|
|
112
|
+
_SSE_CONNECTION.start()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _handle_sse_event(data: Dict[str, Any]) -> None:
|
|
116
|
+
"""Handle SSE event."""
|
|
117
|
+
event_type = data.get("type")
|
|
118
|
+
|
|
119
|
+
if event_type == "connected":
|
|
120
|
+
logger.info(f"[Aeonic] SSE connected: {data.get('message')}")
|
|
121
|
+
|
|
122
|
+
elif event_type == "heartbeat":
|
|
123
|
+
# Heartbeat received - connection is alive
|
|
124
|
+
logger.debug("[Aeonic] SSE heartbeat received")
|
|
125
|
+
|
|
126
|
+
elif event_type == "agent.status_changed":
|
|
127
|
+
_handle_agent_status_change(data)
|
|
128
|
+
|
|
129
|
+
elif event_type == "assessment.job_available":
|
|
130
|
+
_handle_assessment_job(data)
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
logger.debug(f"[Aeonic] Unknown SSE event type: {event_type}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _handle_agent_status_change(data: Dict[str, Any]) -> None:
|
|
137
|
+
"""Handle agent status change event."""
|
|
138
|
+
agent_id = data.get("agent_id")
|
|
139
|
+
agent_key = data.get("agent_key")
|
|
140
|
+
agent_name = data.get("agent_name")
|
|
141
|
+
new_status = data.get("new_status")
|
|
142
|
+
previous_status = data.get("previous_status")
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
f"[Aeonic] Agent status changed: {agent_name or agent_id} "
|
|
146
|
+
f"({agent_key or agent_id}) {previous_status} -> {new_status}"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not any([agent_key, agent_id, agent_name]):
|
|
150
|
+
logger.warning("[Aeonic] Received agent.status_changed event without any identifiers")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Update agent status cache for all identifiers provided
|
|
154
|
+
identifiers: list[str] = [x for x in (agent_key, agent_id, agent_name) if x]
|
|
155
|
+
for identifier in identifiers:
|
|
156
|
+
if new_status:
|
|
157
|
+
set_agent_status(identifier, new_status)
|
|
158
|
+
|
|
159
|
+
# Logging based on status
|
|
160
|
+
display_name = agent_name or agent_key or agent_id
|
|
161
|
+
if new_status in ("blocked", "quarantined"):
|
|
162
|
+
logger.warning(
|
|
163
|
+
f"[Aeonic] Agent {display_name} is {new_status} - stopping sample collection"
|
|
164
|
+
)
|
|
165
|
+
elif new_status == "healthy" and previous_status in ("blocked", "quarantined"):
|
|
166
|
+
# Agent was unblocked - resume sample collection
|
|
167
|
+
logger.info(f"[Aeonic] Agent {display_name} is now healthy - resuming sample collection")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _handle_assessment_job(data: Dict[str, Any]) -> None:
|
|
171
|
+
"""Handle assessment job available event."""
|
|
172
|
+
from aeonic.core.assessment.executor import execute_assessment
|
|
173
|
+
|
|
174
|
+
job = {
|
|
175
|
+
"job_id": data.get("job_id"),
|
|
176
|
+
"agent_id": data.get("agent_id"),
|
|
177
|
+
"scenario_id": data.get("scenario_id"),
|
|
178
|
+
"scenario_steps": data.get("scenario_steps"),
|
|
179
|
+
"playbook": data.get("playbook"),
|
|
180
|
+
"agent_endpoint": data.get("agent_endpoint"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
logger.info(f"[Aeonic] Assessment job received via SSE: {job.get('job_id')}")
|
|
184
|
+
|
|
185
|
+
# Execute assessment asynchronously (non-blocking) in a new thread
|
|
186
|
+
threading.Thread(
|
|
187
|
+
target=lambda: _run_assessment_safely(job, execute_assessment),
|
|
188
|
+
name=f"AeonicAssessment-{job.get('job_id')}",
|
|
189
|
+
daemon=True,
|
|
190
|
+
).start()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _run_assessment_safely(job: Dict[str, Any], executor_func: Any) -> None:
|
|
194
|
+
"""Run assessment and log errors."""
|
|
195
|
+
try:
|
|
196
|
+
executor_func(job)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"[Aeonic] Assessment {job.get('job_id', 'unknown')} failed: {e}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def stop_sse_connection() -> None:
|
|
202
|
+
"""Stop SSE connection."""
|
|
203
|
+
global _SSE_CONNECTION
|
|
204
|
+
|
|
205
|
+
_STOP_SSE.set()
|
|
206
|
+
|
|
207
|
+
if _SSE_CONNECTION and _SSE_CONNECTION.is_alive():
|
|
208
|
+
# Try to join briefly
|
|
209
|
+
_SSE_CONNECTION.join(timeout=2)
|
|
210
|
+
_SSE_CONNECTION = None
|