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/__init__.py
ADDED
|
@@ -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
|
aeonic/core/__init__.py
ADDED