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 ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from aeonic.core.init import init
4
+
5
+ __all__ = ["init", "__version__"]
@@ -0,0 +1 @@
1
+ """Framework adapters for FastAPI and Django."""
@@ -0,0 +1,275 @@
1
+ from functools import wraps
2
+ from time import time
3
+ from typing import Any, Callable, Dict, Optional, cast
4
+
5
+ from django.http import HttpRequest, HttpResponse
6
+
7
+ from aeonic.core.buffer import SampleBuffer
8
+ from aeonic.core.init import get_config
9
+ from aeonic.core.sender import _is_2xx, emit_error_sample, enqueue
10
+ from aeonic.core.types import AgentContext, Sample
11
+ from aeonic.utils.safe_serialize import safe_serialize
12
+
13
+ # In-memory buffers (per process)
14
+ _BUFFERS: Dict[str, SampleBuffer] = {}
15
+ _MAX_AGENT_BUFFERS = 100
16
+
17
+ # Agent metadata storage for introspection
18
+ # Maps function ID to agent context
19
+ _AGENT_METADATA_MAP: Dict[int, AgentContext] = {}
20
+
21
+
22
+ class agent_middleware:
23
+ """
24
+ Django middleware to observe responses and capture samples
25
+ ONLY for agent-marked routes.
26
+ """
27
+
28
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
29
+ self.get_response = get_response
30
+
31
+ def __call__(self, request: HttpRequest) -> HttpResponse:
32
+ response = self.get_response(request)
33
+
34
+ aeonic = getattr(request, "aeonic", None)
35
+ if not aeonic or not aeonic.get("is_agent"):
36
+ return response # Ignore non-agent routes
37
+
38
+ # ⚠️ Skip sample collection for assessment calls
39
+ # Assessment calls are synthetic test scenarios and should not be tracked as production samples
40
+ # Django converts headers: X-Aeonic-Assessment -> HTTP_X_AEONIC_ASSESSMENT
41
+ assessment_header = request.META.get("HTTP_X_AEONIC_ASSESSMENT")
42
+ is_assessment = assessment_header is not None and str(assessment_header).lower() == "true"
43
+ if is_assessment:
44
+ # Log for debugging (optional)
45
+ import os
46
+
47
+ if os.environ.get("AEONIC_DEBUG"):
48
+ job_id = request.META.get("HTTP_X_AEONIC_JOB_ID")
49
+ job_id_str = str(job_id) if job_id else ""
50
+ print(
51
+ f"[Aeonic] Skipping sample collection for assessment"
52
+ f"{f' (job: {job_id_str})' if job_id_str else ''}"
53
+ )
54
+ return response # Skip sample collection
55
+
56
+ agent: AgentContext = aeonic["agent"]
57
+ path: str = request.path
58
+ method: str = request.method
59
+
60
+ # ⚠️ Skip sample collection for blocked/quarantined agents
61
+ from aeonic.core.agent_status_cache import should_collect_samples
62
+
63
+ agent_key: str = agent.get("name") or f"{method}:{path}"
64
+ if not should_collect_samples(agent_key):
65
+ import os
66
+
67
+ if os.environ.get("AEONIC_DEBUG"):
68
+ print(f"[Aeonic] Skipping sample collection for blocked agent: {agent_key}")
69
+ return response
70
+
71
+ req_payload = safe_serialize(aeonic.get("req_payload"))
72
+
73
+ res_payload = None
74
+ try:
75
+ content_type = response.get("Content-Type", "")
76
+ if "json" in content_type.lower():
77
+ import json
78
+
79
+ try:
80
+ content = response.content.decode("utf-8")
81
+ if content:
82
+ res_json = json.loads(content)
83
+ res_payload = safe_serialize(res_json)
84
+ except Exception:
85
+ pass
86
+ except Exception:
87
+ pass
88
+
89
+ status_code: int = getattr(response, "status_code", 200)
90
+ sample: Sample = {
91
+ "req": req_payload,
92
+ "res": res_payload,
93
+ "timestamp": int(time()),
94
+ "status_code": status_code,
95
+ }
96
+
97
+ config = get_config()
98
+ max_samples: int = config["max_samples"]
99
+
100
+ if _is_2xx(status_code):
101
+ buffer = _BUFFERS.get(agent_key)
102
+ if buffer is None:
103
+ if len(_BUFFERS) >= _MAX_AGENT_BUFFERS:
104
+ first_key = next(iter(_BUFFERS))
105
+ _BUFFERS.pop(first_key, None)
106
+
107
+ buffer = SampleBuffer(
108
+ agent_key=agent_key,
109
+ agent_context=agent,
110
+ route={"path": path, "method": method},
111
+ max_samples=max_samples,
112
+ )
113
+ _BUFFERS[agent_key] = buffer
114
+
115
+ ready: bool = buffer.add(sample)
116
+ if ready:
117
+ enqueue(
118
+ {
119
+ "api_key": config["api_key"],
120
+ "service_name": config["service_name"],
121
+ "agent": buffer.agent_context,
122
+ "route": buffer.route,
123
+ "samples": buffer.flush(),
124
+ }
125
+ )
126
+ else:
127
+ emit_error_sample(
128
+ {
129
+ "api_key": config["api_key"],
130
+ "service_name": config["service_name"],
131
+ "agent": agent,
132
+ "route": {"path": path, "method": method},
133
+ "samples": [sample],
134
+ }
135
+ )
136
+
137
+ return response
138
+
139
+
140
+ def with_agent(agent: AgentContext) -> Callable[..., HttpResponse]:
141
+ """
142
+ Decorator to mark a Django view as an agent route.
143
+
144
+ This function:
145
+ - Stores agent metadata for later introspection
146
+ - Registers agent at declaration time (if SDK is initialized)
147
+ - Returns a decorator that attaches agent context to requests
148
+
149
+ Args:
150
+ agent: Agent context with name, type, model, etc.
151
+
152
+ Returns:
153
+ Decorator function for Django views
154
+ """
155
+
156
+ # Register agent at declaration time
157
+ _register_agent_declaration(agent, framework="django")
158
+
159
+ def decorator(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
160
+ @wraps(view_func)
161
+ def wrapped_view(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
162
+ try:
163
+ req_payload = None
164
+
165
+ # For POST/PUT/PATCH, try to parse JSON body
166
+ if request.method in ["POST", "PUT", "PATCH"]:
167
+ content_type = request.META.get("CONTENT_TYPE", "")
168
+ if "json" in content_type.lower() and request.body:
169
+ import json
170
+
171
+ try:
172
+ body_data = json.loads(request.body.decode("utf-8"))
173
+ req_payload = safe_serialize(body_data)
174
+ except Exception:
175
+ pass
176
+ elif request.body:
177
+ # Non-JSON body, serialize as string
178
+ req_payload = safe_serialize(request.body.decode("utf-8"))
179
+
180
+ # For GET requests, capture query parameters
181
+ elif request.method == "GET":
182
+ if request.GET:
183
+ req_payload = safe_serialize(dict(request.GET))
184
+ else:
185
+ # No query params, set empty dict instead of None
186
+ req_payload = {}
187
+
188
+ except Exception:
189
+ req_payload = None
190
+
191
+ request.aeonic = {
192
+ "is_agent": True,
193
+ "agent": {**agent, "source": "manual"},
194
+ "req_payload": req_payload,
195
+ }
196
+
197
+ return view_func(request, *args, **kwargs)
198
+
199
+ # Store metadata for introspection (key by wrapped view ID)
200
+ agent_metadata: AgentContext = cast(AgentContext, {**agent, "source": "manual"})
201
+ _AGENT_METADATA_MAP[id(wrapped_view)] = agent_metadata
202
+
203
+ # Also store as function attribute for easier retrieval
204
+ wrapped_view.__aeonic_metadata__ = agent_metadata # type: ignore[attr-defined]
205
+
206
+ return wrapped_view
207
+
208
+ return decorator
209
+
210
+
211
+ def get_agent_metadata(func: Callable) -> Optional[AgentContext]:
212
+ """
213
+ Retrieve agent metadata from a function.
214
+
215
+ Used during route introspection to match views with agent metadata.
216
+
217
+ Args:
218
+ func: Function to check (view function)
219
+
220
+ Returns:
221
+ Agent context if found, None otherwise
222
+ """
223
+ # Try direct lookup first
224
+ metadata = _AGENT_METADATA_MAP.get(id(func))
225
+ if metadata:
226
+ return metadata
227
+
228
+ # Try checking if function has metadata attribute (for wrapped functions)
229
+ if hasattr(func, "__aeonic_metadata__"):
230
+ return getattr(func, "__aeonic_metadata__") # type: ignore[no-any-return]
231
+
232
+ return None
233
+
234
+
235
+ def _register_agent_declaration(agent: AgentContext, framework: str) -> None:
236
+ """
237
+ Register agent in the global registry at declaration time.
238
+
239
+ This function:
240
+ - Registers the agent with None route_path/http_method (will be updated by introspection)
241
+ - Does NOT emit inventory (introspection will do that after finding routes)
242
+ - Never throws
243
+
244
+ Args:
245
+ agent: Agent context
246
+ framework: Framework identifier
247
+ """
248
+ try:
249
+ from aeonic.core.agent_registry import register_agent
250
+ from aeonic.core.init import get_config
251
+
252
+ # Get config (may not be initialized yet)
253
+ try:
254
+ config = get_config()
255
+ service_name = config.get("service_name")
256
+ except RuntimeError:
257
+ service_name = None
258
+
259
+ # Register agent (with None values - introspection will update these)
260
+ register_agent(
261
+ name=agent.get("name", ""),
262
+ agent_type=agent.get("type"),
263
+ model=agent.get("model"),
264
+ route_path=None, # Not available at declaration time - introspection will update
265
+ http_method=None, # Not available at declaration time - introspection will update
266
+ framework=framework,
267
+ service_name=service_name,
268
+ )
269
+
270
+ # DO NOT emit inventory here - wait for introspection to complete
271
+ # Introspection will emit inventory after updating route_path and http_method
272
+
273
+ except Exception:
274
+ # Never throw - silent failure
275
+ pass
@@ -0,0 +1,337 @@
1
+ from time import time
2
+ from typing import Awaitable, Callable, Dict, Optional, cast
3
+
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+ from starlette.requests import Request
6
+ from starlette.responses import Response
7
+
8
+ from aeonic.core.buffer import SampleBuffer
9
+ from aeonic.core.init import get_config
10
+ from aeonic.core.sender import _is_2xx, emit_error_sample, enqueue
11
+ from aeonic.core.types import AgentContext, Sample
12
+ from aeonic.utils.safe_serialize import safe_serialize
13
+
14
+ # In-memory buffers (per process)
15
+ _BUFFERS: Dict[str, SampleBuffer] = {}
16
+ _MAX_AGENT_BUFFERS = 100
17
+
18
+ # Agent metadata storage for introspection
19
+ # Maps function ID to agent context
20
+ _AGENT_METADATA_MAP: Dict[int, AgentContext] = {}
21
+ # Also store by agent name as fallback (for cases where function IDs don't match)
22
+ _AGENT_METADATA_BY_NAME: Dict[str, AgentContext] = {}
23
+ # Store all metadata for fallback matching
24
+ _ALL_METADATA: list = [] # List of (func_id, metadata) tuples
25
+
26
+
27
+ class agent_middleware(BaseHTTPMiddleware):
28
+ """
29
+ Global middleware.
30
+ Observes responses and captures samples ONLY for agent routes.
31
+ """
32
+
33
+ async def dispatch(
34
+ self,
35
+ request: Request,
36
+ call_next: Callable[[Request], Awaitable[Response]],
37
+ ) -> Response:
38
+ # Request payload (best-effort) - must be captured BEFORE processing request
39
+ # Note: consuming request.stream() or .json() here would break the app if not careful.
40
+ # FastAPI request.json() caches the result, so it might be safe if the app also uses .json().
41
+ # However, checking `request.state` (populated by dependency) is safer.
42
+
43
+ # We process the request first
44
+ response: Response = await call_next(request)
45
+
46
+ aeonic = getattr(request.state, "aeonic", None)
47
+ if not aeonic or not aeonic.get("is_agent"):
48
+ return response # Ignore non-agent routes
49
+
50
+ # ⚠️ Skip sample collection for assessment calls
51
+ # Assessment calls are synthetic test scenarios and should not be tracked as production samples
52
+ assessment_header = request.headers.get("x-aeonic-assessment")
53
+ is_assessment = assessment_header is not None and str(assessment_header).lower() == "true"
54
+ if is_assessment:
55
+ # Log for debugging (optional)
56
+ import os
57
+
58
+ if os.environ.get("AEONIC_DEBUG"):
59
+ job_id = request.headers.get("x-aeonic-job-id")
60
+ job_id_str = str(job_id) if job_id else ""
61
+ print(
62
+ f"[Aeonic] Skipping sample collection for assessment"
63
+ f"{f' (job: {job_id_str})' if job_id_str else ''}"
64
+ )
65
+ return response # Skip sample collection
66
+
67
+ agent: AgentContext = aeonic["agent"]
68
+ path: str = request.url.path
69
+ method: str = request.method
70
+
71
+ # ⚠️ Skip sample collection for blocked/quarantined agents
72
+ from aeonic.core.agent_status_cache import should_collect_samples
73
+
74
+ agent_key: str = agent.get("name") or f"{method}:{path}"
75
+ if not should_collect_samples(agent_key):
76
+ if os.environ.get("AEONIC_DEBUG"):
77
+ print(f"[Aeonic] Skipping sample collection for blocked agent: {agent_key}")
78
+ return response
79
+
80
+ # Request payload (attempt to get from state first, which is populated by dependency)
81
+ req_payload = safe_serialize(aeonic.get("req_payload"))
82
+
83
+ # Response payload capture
84
+ res_payload = None
85
+ response_body = b""
86
+
87
+ # We need to capture the response body.
88
+ # Strategy: Try .body first (works for non-streaming responses), then body_iterator
89
+ try:
90
+ # First, try to get the body directly (for pre-rendered responses like JSONResponse)
91
+ if hasattr(response, "body") and response.body:
92
+ response_body = response.body
93
+ elif hasattr(response, "body_iterator"):
94
+ # Fallback: iterate through body_iterator (for streaming responses)
95
+ # CAUTION: This consumes the iterator. We must reconstruct the response.
96
+ chunks = []
97
+ async for chunk in response.body_iterator:
98
+ chunks.append(chunk)
99
+ response_body = b"".join(chunks)
100
+
101
+ # Reconstruct the response with fresh content
102
+ response = Response(
103
+ content=response_body,
104
+ status_code=response.status_code,
105
+ headers=dict(response.headers),
106
+ media_type=response.media_type,
107
+ )
108
+
109
+ # Parse JSON if applicable
110
+ # Check Content-Type header instead of media_type (which may be None)
111
+ content_type = response.headers.get("content-type", "")
112
+
113
+ if "json" in content_type.lower() and response_body:
114
+ import json
115
+
116
+ try:
117
+ body_json = json.loads(response_body)
118
+ res_payload = safe_serialize(body_json)
119
+ except Exception:
120
+ pass
121
+
122
+ except Exception:
123
+ # Fallback if anything goes wrong with body capture
124
+ pass
125
+
126
+ status_code: int = getattr(response, "status_code", 200)
127
+ sample: Sample = {
128
+ "req": req_payload,
129
+ "res": res_payload,
130
+ "timestamp": int(time()),
131
+ "status_code": status_code,
132
+ }
133
+
134
+ config = get_config()
135
+ max_samples: int = config["max_samples"]
136
+
137
+ if _is_2xx(status_code):
138
+ buffer = _BUFFERS.get(agent_key)
139
+ if buffer is None:
140
+ if len(_BUFFERS) >= _MAX_AGENT_BUFFERS:
141
+ first_key = next(iter(_BUFFERS))
142
+ _BUFFERS.pop(first_key, None)
143
+
144
+ buffer = SampleBuffer(
145
+ agent_key=agent_key,
146
+ agent_context=agent,
147
+ route={"path": path, "method": method},
148
+ max_samples=max_samples,
149
+ )
150
+ _BUFFERS[agent_key] = buffer
151
+
152
+ ready: bool = buffer.add(sample)
153
+ if ready:
154
+ enqueue(
155
+ {
156
+ "api_key": config["api_key"],
157
+ "service_name": config["service_name"],
158
+ "agent": buffer.agent_context,
159
+ "route": buffer.route,
160
+ "samples": buffer.flush(),
161
+ }
162
+ )
163
+ else:
164
+ emit_error_sample(
165
+ {
166
+ "api_key": config["api_key"],
167
+ "service_name": config["service_name"],
168
+ "agent": agent,
169
+ "route": {"path": path, "method": method},
170
+ "samples": [sample],
171
+ }
172
+ )
173
+
174
+ return response
175
+
176
+
177
+ def with_agent(agent: AgentContext) -> Callable[[Request], Awaitable[None]]:
178
+ """
179
+ Dependency to mark a route as an agent route.
180
+
181
+ This function:
182
+ - Stores agent metadata for later introspection
183
+ - Registers agent at declaration time (if SDK is initialized)
184
+ - Returns a FastAPI dependency that attaches agent context to requests
185
+
186
+ Args:
187
+ agent: Agent context with name, type, model, etc.
188
+
189
+ Returns:
190
+ FastAPI dependency function
191
+ """
192
+
193
+ # Register agent at declaration time
194
+ _register_agent_declaration(agent, framework="fastapi")
195
+
196
+ async def dependency(request: Request) -> None:
197
+ try:
198
+ body = await request.json()
199
+ except Exception:
200
+ body = None
201
+
202
+ request.state.aeonic = {
203
+ "is_agent": True,
204
+ "agent": {**agent, "source": "manual"},
205
+ "req_payload": body,
206
+ }
207
+
208
+ # Store metadata for introspection (key by dependency function ID)
209
+ agent_metadata: AgentContext = cast(AgentContext, {**agent, "source": "manual"})
210
+ func_id = id(dependency)
211
+ _AGENT_METADATA_MAP[func_id] = agent_metadata
212
+
213
+ # Also store as function attribute for easier retrieval (handles wrapped functions)
214
+ dependency.__aeonic_metadata__ = agent_metadata # type: ignore[attr-defined]
215
+
216
+ # Also store by agent name as fallback
217
+ agent_name = agent.get("name")
218
+ if agent_name:
219
+ _AGENT_METADATA_BY_NAME[agent_name] = agent_metadata
220
+
221
+ # Store in list for fallback matching
222
+ _ALL_METADATA.append((func_id, agent_metadata))
223
+
224
+ return dependency
225
+
226
+
227
+ def get_agent_metadata(func: Callable) -> Optional[AgentContext]:
228
+ """
229
+ Retrieve agent metadata from a function.
230
+
231
+ Used during route introspection to match routes with agent metadata.
232
+
233
+ Args:
234
+ func: Function to check (route handler or dependency)
235
+
236
+ Returns:
237
+ Agent context if found, None otherwise
238
+ """
239
+ if func is None:
240
+ return None
241
+
242
+ # Try direct lookup first
243
+ func_id = id(func)
244
+ metadata = _AGENT_METADATA_MAP.get(func_id)
245
+ if metadata:
246
+ return metadata
247
+
248
+ # Try checking if function has metadata attribute (for wrapped functions)
249
+ if hasattr(func, "__aeonic_metadata__"):
250
+ return getattr(func, "__aeonic_metadata__") # type: ignore[no-any-return]
251
+
252
+ # Try checking closure variables (for nested functions)
253
+ if hasattr(func, "__closure__") and func.__closure__:
254
+ # Check if any closure variable contains our metadata
255
+ for cell in func.__closure__:
256
+ try:
257
+ value = cell.cell_contents
258
+ if isinstance(value, dict) and value.get("source") == "manual":
259
+ return cast(AgentContext, value)
260
+ except (ValueError, AttributeError):
261
+ continue
262
+
263
+ # For FastAPI dependencies wrapped in Depends, try to get the underlying callable
264
+ # FastAPI's DependencyInfo has a 'call' attribute
265
+ if hasattr(func, "call"):
266
+ return get_agent_metadata(func.call)
267
+
268
+ # Try checking __wrapped__ for decorated functions
269
+ if hasattr(func, "__wrapped__"):
270
+ wrapped_metadata = get_agent_metadata(func.__wrapped__)
271
+ if wrapped_metadata:
272
+ return wrapped_metadata
273
+
274
+ # Last resort: try to match by inspecting function closure/code
275
+ # FastAPI might wrap the dependency, so function IDs won't match
276
+ # But the closure might contain our agent context
277
+ if hasattr(func, "__closure__") and func.__closure__:
278
+ for cell in func.__closure__:
279
+ try:
280
+ value = cell.cell_contents
281
+ # Check if this is our dependency function
282
+ if callable(value) and hasattr(value, "__aeonic_metadata__"):
283
+ return getattr(value, "__aeonic_metadata__") # type: ignore[no-any-return]
284
+ # Check if closure contains agent context directly
285
+ if isinstance(value, dict) and value.get("source") == "manual":
286
+ return cast(AgentContext, value)
287
+ except (ValueError, AttributeError):
288
+ continue
289
+
290
+ # Final fallback: check all stored metadata to see if any function matches
291
+ # This handles cases where FastAPI creates a wrapper
292
+ # Note: We can't easily match by function ID here, so we skip this fallback
293
+
294
+ return None
295
+
296
+
297
+ def _register_agent_declaration(agent: AgentContext, framework: str) -> None:
298
+ """
299
+ Register agent in the global registry at declaration time.
300
+
301
+ This function:
302
+ - Registers the agent with None route_path/http_method (will be updated by introspection)
303
+ - Does NOT emit inventory (introspection will do that after finding routes)
304
+ - Never throws
305
+
306
+ Args:
307
+ agent: Agent context
308
+ framework: Framework identifier
309
+ """
310
+ try:
311
+ from aeonic.core.agent_registry import register_agent
312
+ from aeonic.core.init import get_config
313
+
314
+ # Get config (may not be initialized yet)
315
+ try:
316
+ config = get_config()
317
+ service_name = config.get("service_name")
318
+ except RuntimeError:
319
+ service_name = None
320
+
321
+ # Register agent (with None values - introspection will update these)
322
+ register_agent(
323
+ name=agent.get("name", ""),
324
+ agent_type=agent.get("type"),
325
+ model=agent.get("model"),
326
+ route_path=None, # Not available at declaration time - introspection will update
327
+ http_method=None, # Not available at declaration time - introspection will update
328
+ framework=framework,
329
+ service_name=service_name,
330
+ )
331
+
332
+ # DO NOT emit inventory here - wait for introspection to complete
333
+ # Introspection will emit inventory after updating route_path and http_method
334
+
335
+ except Exception:
336
+ # Never throw - silent failure
337
+ pass
@@ -0,0 +1,4 @@
1
+ from aeonic.core.buffer import SampleBuffer
2
+ from aeonic.core.init import get_config, init
3
+
4
+ __all__ = ["init", "get_config", "SampleBuffer"]