asap-protocol 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.
- asap/__init__.py +7 -0
- asap/cli.py +220 -0
- asap/errors.py +150 -0
- asap/examples/README.md +25 -0
- asap/examples/__init__.py +1 -0
- asap/examples/coordinator.py +184 -0
- asap/examples/echo_agent.py +100 -0
- asap/examples/run_demo.py +120 -0
- asap/models/__init__.py +146 -0
- asap/models/base.py +55 -0
- asap/models/constants.py +14 -0
- asap/models/entities.py +410 -0
- asap/models/enums.py +71 -0
- asap/models/envelope.py +94 -0
- asap/models/ids.py +55 -0
- asap/models/parts.py +207 -0
- asap/models/payloads.py +423 -0
- asap/models/types.py +39 -0
- asap/observability/__init__.py +43 -0
- asap/observability/logging.py +216 -0
- asap/observability/metrics.py +399 -0
- asap/schemas.py +203 -0
- asap/state/__init__.py +22 -0
- asap/state/machine.py +86 -0
- asap/state/snapshot.py +265 -0
- asap/transport/__init__.py +84 -0
- asap/transport/client.py +399 -0
- asap/transport/handlers.py +444 -0
- asap/transport/jsonrpc.py +190 -0
- asap/transport/middleware.py +359 -0
- asap/transport/server.py +739 -0
- asap_protocol-0.1.0.dist-info/METADATA +251 -0
- asap_protocol-0.1.0.dist-info/RECORD +36 -0
- asap_protocol-0.1.0.dist-info/WHEEL +4 -0
- asap_protocol-0.1.0.dist-info/entry_points.txt +2 -0
- asap_protocol-0.1.0.dist-info/licenses/LICENSE +190 -0
asap/transport/server.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""FastAPI server implementation for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module provides a production-ready FastAPI server that:
|
|
4
|
+
- Exposes POST /asap endpoint for JSON-RPC 2.0 wrapped ASAP messages
|
|
5
|
+
- Exposes GET /.well-known/asap/manifest.json for agent discovery
|
|
6
|
+
- Exposes GET /asap/metrics for Prometheus-compatible metrics
|
|
7
|
+
- Handles errors with proper JSON-RPC error responses
|
|
8
|
+
- Validates all incoming requests against ASAP schemas
|
|
9
|
+
- Uses HandlerRegistry for extensible payload processing
|
|
10
|
+
- Provides structured logging for observability
|
|
11
|
+
- Supports authentication based on manifest configuration
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from asap.models.entities import Manifest, Capability, Endpoint, Skill, AuthScheme
|
|
15
|
+
>>> from asap.transport.server import create_app
|
|
16
|
+
>>> from asap.transport.handlers import HandlerRegistry
|
|
17
|
+
>>>
|
|
18
|
+
>>> manifest = Manifest(
|
|
19
|
+
... id="urn:asap:agent:my-agent",
|
|
20
|
+
... name="My Agent",
|
|
21
|
+
... version="1.0.0",
|
|
22
|
+
... description="Example agent",
|
|
23
|
+
... capabilities=Capability(
|
|
24
|
+
... asap_version="0.1",
|
|
25
|
+
... skills=[Skill(id="echo", description="Echo skill")],
|
|
26
|
+
... state_persistence=False
|
|
27
|
+
... ),
|
|
28
|
+
... endpoints=Endpoint(asap="http://localhost:8000/asap"),
|
|
29
|
+
... auth=AuthScheme(schemes=["bearer"]) # Optional authentication
|
|
30
|
+
... )
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Create app with default registry
|
|
33
|
+
>>> app = create_app(manifest)
|
|
34
|
+
>>>
|
|
35
|
+
>>> # Or with custom registry and auth
|
|
36
|
+
>>> registry = HandlerRegistry()
|
|
37
|
+
>>> registry.register("task.request", my_custom_handler)
|
|
38
|
+
>>> app = create_app(manifest, registry)
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Run with: uvicorn asap.transport.server:app --host 0.0.0.0 --port 8000
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import time
|
|
44
|
+
from typing import Any, Callable
|
|
45
|
+
|
|
46
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
47
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
48
|
+
from pydantic import ValidationError
|
|
49
|
+
|
|
50
|
+
from asap.models.entities import Capability, Endpoint, Manifest, Skill
|
|
51
|
+
from asap.models.envelope import Envelope
|
|
52
|
+
from asap.observability import get_logger, get_metrics
|
|
53
|
+
from asap.transport.middleware import AuthenticationMiddleware, BearerTokenValidator
|
|
54
|
+
from asap.observability.metrics import MetricsCollector
|
|
55
|
+
from asap.transport.handlers import (
|
|
56
|
+
HandlerNotFoundError,
|
|
57
|
+
HandlerRegistry,
|
|
58
|
+
create_default_registry,
|
|
59
|
+
)
|
|
60
|
+
from asap.transport.jsonrpc import (
|
|
61
|
+
ASAP_METHOD,
|
|
62
|
+
INTERNAL_ERROR,
|
|
63
|
+
INVALID_PARAMS,
|
|
64
|
+
INVALID_REQUEST,
|
|
65
|
+
METHOD_NOT_FOUND,
|
|
66
|
+
PARSE_ERROR,
|
|
67
|
+
JsonRpcError,
|
|
68
|
+
JsonRpcErrorResponse,
|
|
69
|
+
JsonRpcRequest,
|
|
70
|
+
JsonRpcResponse,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Module logger
|
|
74
|
+
logger = get_logger(__name__)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ASAPRequestHandler:
|
|
78
|
+
"""Handler for processing ASAP protocol requests.
|
|
79
|
+
|
|
80
|
+
Encapsulates the logic for:
|
|
81
|
+
- Parsing and validating JSON-RPC requests
|
|
82
|
+
- Authenticating requests based on manifest configuration
|
|
83
|
+
- Validating sender identity
|
|
84
|
+
- Dispatching to registered handlers
|
|
85
|
+
- Building error responses
|
|
86
|
+
- Recording metrics
|
|
87
|
+
|
|
88
|
+
This class is instantiated by create_app() and used to handle
|
|
89
|
+
incoming requests on the /asap endpoint.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
registry: Handler registry for payload dispatch
|
|
93
|
+
manifest: Agent manifest for context
|
|
94
|
+
auth_middleware: Optional authentication middleware
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> handler = ASAPRequestHandler(registry, manifest, auth_middleware)
|
|
98
|
+
>>> response = await handler.handle_message(request)
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
registry: HandlerRegistry,
|
|
104
|
+
manifest: Manifest,
|
|
105
|
+
auth_middleware: AuthenticationMiddleware | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Initialize the request handler.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
registry: Handler registry for dispatching payloads
|
|
111
|
+
manifest: Agent manifest describing capabilities
|
|
112
|
+
auth_middleware: Optional authentication middleware for request validation
|
|
113
|
+
"""
|
|
114
|
+
self.registry = registry
|
|
115
|
+
self.manifest = manifest
|
|
116
|
+
self.auth_middleware = auth_middleware
|
|
117
|
+
|
|
118
|
+
def build_error_response(
|
|
119
|
+
self,
|
|
120
|
+
code: int,
|
|
121
|
+
data: dict[str, Any] | None = None,
|
|
122
|
+
request_id: str | int | None = None,
|
|
123
|
+
) -> JSONResponse:
|
|
124
|
+
"""Build a JSON-RPC error response.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
code: JSON-RPC error code
|
|
128
|
+
data: Optional error data
|
|
129
|
+
request_id: Optional request ID from original request
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
JSONResponse with error
|
|
133
|
+
"""
|
|
134
|
+
error_response = JsonRpcErrorResponse(
|
|
135
|
+
error=JsonRpcError.from_code(code, data=data),
|
|
136
|
+
id=request_id,
|
|
137
|
+
)
|
|
138
|
+
return JSONResponse(status_code=200, content=error_response.model_dump())
|
|
139
|
+
|
|
140
|
+
def record_error_metrics(
|
|
141
|
+
self,
|
|
142
|
+
metrics: MetricsCollector,
|
|
143
|
+
payload_type: str,
|
|
144
|
+
error_type: str,
|
|
145
|
+
duration_seconds: float,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Record error metrics for a failed request.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
metrics: Metrics collector instance
|
|
151
|
+
payload_type: Payload type (or "unknown")
|
|
152
|
+
error_type: Type of error that occurred
|
|
153
|
+
duration_seconds: Request duration in seconds
|
|
154
|
+
"""
|
|
155
|
+
metrics.increment_counter(
|
|
156
|
+
"asap_requests_total",
|
|
157
|
+
{"payload_type": payload_type, "status": "error"},
|
|
158
|
+
)
|
|
159
|
+
metrics.increment_counter(
|
|
160
|
+
"asap_requests_error_total",
|
|
161
|
+
{"payload_type": payload_type, "error_type": error_type},
|
|
162
|
+
)
|
|
163
|
+
metrics.observe_histogram(
|
|
164
|
+
"asap_request_duration_seconds",
|
|
165
|
+
duration_seconds,
|
|
166
|
+
{"payload_type": payload_type, "status": "error"},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def parse_json_body(self, request: Request) -> dict[str, Any]:
|
|
170
|
+
"""Parse JSON body from request.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
request: FastAPI request object
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Parsed JSON body
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If JSON is invalid
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
body: dict[str, Any] = await request.json()
|
|
183
|
+
return body
|
|
184
|
+
except ValueError as e:
|
|
185
|
+
logger.warning("asap.request.invalid_json", error=str(e))
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
def validate_jsonrpc_request(
|
|
189
|
+
self, body: dict[str, Any]
|
|
190
|
+
) -> tuple[JsonRpcRequest | None, JSONResponse | None]:
|
|
191
|
+
"""Validate JSON-RPC request structure and method.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
body: Parsed JSON body
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Tuple of (JsonRpcRequest, None) if valid, or (None, error_response) if invalid
|
|
198
|
+
"""
|
|
199
|
+
# Validate JSON-RPC structure
|
|
200
|
+
try:
|
|
201
|
+
rpc_request = JsonRpcRequest(**body)
|
|
202
|
+
except (ValidationError, TypeError) as e:
|
|
203
|
+
# Check if error is specifically about params type
|
|
204
|
+
error_code = INVALID_REQUEST
|
|
205
|
+
error_message = "Invalid JSON-RPC structure"
|
|
206
|
+
if isinstance(e, ValidationError):
|
|
207
|
+
errors = e.errors()
|
|
208
|
+
# If params validation failed with dict_type error, use INVALID_PARAMS
|
|
209
|
+
for error in errors:
|
|
210
|
+
if error.get("loc") == ("params",) and error.get("type") == "dict_type":
|
|
211
|
+
error_code = INVALID_PARAMS
|
|
212
|
+
error_message = "JSON-RPC 'params' must be an object"
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
logger.warning(
|
|
216
|
+
"asap.request.invalid_structure",
|
|
217
|
+
error=error_message,
|
|
218
|
+
error_type=type(e).__name__,
|
|
219
|
+
validation_errors=str(e.errors()) if isinstance(e, ValidationError) else str(e),
|
|
220
|
+
)
|
|
221
|
+
error_response = self.build_error_response(
|
|
222
|
+
error_code,
|
|
223
|
+
data={
|
|
224
|
+
"error": error_message,
|
|
225
|
+
"validation_errors": (
|
|
226
|
+
e.errors()
|
|
227
|
+
if isinstance(e, ValidationError)
|
|
228
|
+
else [{"type": "type_error", "loc": (), "msg": str(e), "input": None}]
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
request_id=body.get("id") if isinstance(body, dict) else None,
|
|
232
|
+
)
|
|
233
|
+
return None, error_response
|
|
234
|
+
|
|
235
|
+
# Check method
|
|
236
|
+
if rpc_request.method != ASAP_METHOD:
|
|
237
|
+
logger.warning("asap.request.unknown_method", method=rpc_request.method)
|
|
238
|
+
error_response = self.build_error_response(
|
|
239
|
+
METHOD_NOT_FOUND,
|
|
240
|
+
data={"method": rpc_request.method},
|
|
241
|
+
request_id=rpc_request.id,
|
|
242
|
+
)
|
|
243
|
+
return None, error_response
|
|
244
|
+
|
|
245
|
+
return rpc_request, None
|
|
246
|
+
|
|
247
|
+
async def handle_message(self, request: Request) -> JSONResponse:
|
|
248
|
+
"""Handle ASAP messages wrapped in JSON-RPC 2.0.
|
|
249
|
+
|
|
250
|
+
This method:
|
|
251
|
+
1. Receives JSON-RPC wrapped ASAP envelopes
|
|
252
|
+
2. Validates the request structure
|
|
253
|
+
3. Extracts and processes the ASAP envelope
|
|
254
|
+
4. Returns response wrapped in JSON-RPC
|
|
255
|
+
5. Records metrics for observability
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
request: FastAPI request object with JSON body
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
JSON-RPC response or error response
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
>>> response = await handler.handle_message(request)
|
|
265
|
+
"""
|
|
266
|
+
start_time = time.perf_counter()
|
|
267
|
+
metrics = get_metrics()
|
|
268
|
+
payload_type = "unknown"
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
# Parse JSON body
|
|
272
|
+
try:
|
|
273
|
+
body = await self.parse_json_body(request)
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
# Invalid JSON - return parse error
|
|
276
|
+
error_response = self.build_error_response(
|
|
277
|
+
PARSE_ERROR,
|
|
278
|
+
data={"error": str(e)},
|
|
279
|
+
request_id=None,
|
|
280
|
+
)
|
|
281
|
+
self.record_error_metrics(metrics, "unknown", "parse_error", 0.0)
|
|
282
|
+
return error_response
|
|
283
|
+
|
|
284
|
+
# Validate body is a dict (JSON-RPC requires object at root)
|
|
285
|
+
if not isinstance(body, dict):
|
|
286
|
+
error_response = self.build_error_response(
|
|
287
|
+
INVALID_REQUEST,
|
|
288
|
+
data={
|
|
289
|
+
"error": "JSON-RPC request must be an object",
|
|
290
|
+
"received_type": type(body).__name__,
|
|
291
|
+
},
|
|
292
|
+
request_id=None,
|
|
293
|
+
)
|
|
294
|
+
self.record_error_metrics(metrics, "unknown", "invalid_request", 0.0)
|
|
295
|
+
return error_response
|
|
296
|
+
|
|
297
|
+
# Validate JSON-RPC request structure and method
|
|
298
|
+
rpc_request, validation_error = self.validate_jsonrpc_request(body)
|
|
299
|
+
if validation_error is not None:
|
|
300
|
+
self.record_error_metrics(metrics, "unknown", "invalid_request", 0.0)
|
|
301
|
+
return validation_error
|
|
302
|
+
|
|
303
|
+
# Type narrowing: rpc_request is not None here
|
|
304
|
+
if rpc_request is None:
|
|
305
|
+
# This should not happen if validate_jsonrpc_request is correct
|
|
306
|
+
# but guard against it for robustness
|
|
307
|
+
error_response = self.build_error_response(
|
|
308
|
+
INTERNAL_ERROR,
|
|
309
|
+
data={"error": "Internal validation error"},
|
|
310
|
+
request_id=None,
|
|
311
|
+
)
|
|
312
|
+
self.record_error_metrics(
|
|
313
|
+
metrics, "unknown", "internal_error", time.perf_counter() - start_time
|
|
314
|
+
)
|
|
315
|
+
return error_response
|
|
316
|
+
|
|
317
|
+
# Verify authentication if enabled
|
|
318
|
+
authenticated_agent_id: str | None = None
|
|
319
|
+
if self.auth_middleware is not None:
|
|
320
|
+
try:
|
|
321
|
+
authenticated_agent_id = await self.auth_middleware.verify_authentication(
|
|
322
|
+
request
|
|
323
|
+
)
|
|
324
|
+
except HTTPException as e:
|
|
325
|
+
# Authentication failed - return JSON-RPC error
|
|
326
|
+
logger.warning(
|
|
327
|
+
"asap.request.auth_failed",
|
|
328
|
+
status_code=e.status_code,
|
|
329
|
+
detail=e.detail,
|
|
330
|
+
)
|
|
331
|
+
# Map HTTP status to JSON-RPC error code
|
|
332
|
+
error_code = INVALID_REQUEST if e.status_code == 401 else INVALID_PARAMS
|
|
333
|
+
error_response = self.build_error_response(
|
|
334
|
+
error_code,
|
|
335
|
+
data={"error": str(e.detail), "status_code": e.status_code},
|
|
336
|
+
request_id=rpc_request.id,
|
|
337
|
+
)
|
|
338
|
+
self.record_error_metrics(
|
|
339
|
+
metrics, "unknown", "auth_failed", time.perf_counter() - start_time
|
|
340
|
+
)
|
|
341
|
+
return error_response
|
|
342
|
+
|
|
343
|
+
# Validate params is a dict before accessing
|
|
344
|
+
if not isinstance(rpc_request.params, dict):
|
|
345
|
+
logger.warning(
|
|
346
|
+
"asap.request.invalid_params_type",
|
|
347
|
+
params_type=type(rpc_request.params).__name__,
|
|
348
|
+
)
|
|
349
|
+
error_response = self.build_error_response(
|
|
350
|
+
INVALID_PARAMS,
|
|
351
|
+
data={
|
|
352
|
+
"error": "JSON-RPC 'params' must be an object",
|
|
353
|
+
"received_type": type(rpc_request.params).__name__,
|
|
354
|
+
},
|
|
355
|
+
request_id=rpc_request.id,
|
|
356
|
+
)
|
|
357
|
+
self.record_error_metrics(
|
|
358
|
+
metrics, "unknown", "invalid_params", time.perf_counter() - start_time
|
|
359
|
+
)
|
|
360
|
+
return error_response
|
|
361
|
+
|
|
362
|
+
# Extract envelope from params
|
|
363
|
+
envelope_data = rpc_request.params.get("envelope")
|
|
364
|
+
if envelope_data is None:
|
|
365
|
+
logger.warning("asap.request.missing_envelope")
|
|
366
|
+
error_response = self.build_error_response(
|
|
367
|
+
INVALID_PARAMS,
|
|
368
|
+
data={"error": "Missing 'envelope' in params"},
|
|
369
|
+
request_id=rpc_request.id,
|
|
370
|
+
)
|
|
371
|
+
self.record_error_metrics(
|
|
372
|
+
metrics, "unknown", "missing_envelope", time.perf_counter() - start_time
|
|
373
|
+
)
|
|
374
|
+
return error_response
|
|
375
|
+
|
|
376
|
+
# Validate envelope structure
|
|
377
|
+
try:
|
|
378
|
+
envelope = Envelope(**envelope_data)
|
|
379
|
+
payload_type = envelope.payload_type
|
|
380
|
+
except ValidationError as e:
|
|
381
|
+
logger.warning(
|
|
382
|
+
"asap.request.invalid_envelope",
|
|
383
|
+
error="Invalid envelope structure",
|
|
384
|
+
validation_errors=str(e.errors()),
|
|
385
|
+
)
|
|
386
|
+
duration_seconds = time.perf_counter() - start_time
|
|
387
|
+
self.record_error_metrics(
|
|
388
|
+
metrics, payload_type, "invalid_envelope", duration_seconds
|
|
389
|
+
)
|
|
390
|
+
return self.build_error_response(
|
|
391
|
+
INVALID_PARAMS,
|
|
392
|
+
data={
|
|
393
|
+
"error": "Invalid envelope structure",
|
|
394
|
+
"validation_errors": e.errors(),
|
|
395
|
+
},
|
|
396
|
+
request_id=rpc_request.id,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Verify sender matches authenticated identity
|
|
400
|
+
if self.auth_middleware is not None:
|
|
401
|
+
try:
|
|
402
|
+
self.auth_middleware.verify_sender_matches_auth(
|
|
403
|
+
authenticated_agent_id, envelope.sender
|
|
404
|
+
)
|
|
405
|
+
except HTTPException as e:
|
|
406
|
+
# Sender mismatch - return JSON-RPC error
|
|
407
|
+
logger.warning(
|
|
408
|
+
"asap.request.sender_mismatch",
|
|
409
|
+
authenticated_agent=authenticated_agent_id,
|
|
410
|
+
envelope_sender=envelope.sender,
|
|
411
|
+
)
|
|
412
|
+
error_response = self.build_error_response(
|
|
413
|
+
INVALID_PARAMS,
|
|
414
|
+
data={"error": str(e.detail), "status_code": e.status_code},
|
|
415
|
+
request_id=rpc_request.id,
|
|
416
|
+
)
|
|
417
|
+
duration_seconds = time.perf_counter() - start_time
|
|
418
|
+
self.record_error_metrics(
|
|
419
|
+
metrics, payload_type, "sender_mismatch", duration_seconds
|
|
420
|
+
)
|
|
421
|
+
return error_response
|
|
422
|
+
|
|
423
|
+
# Log request received
|
|
424
|
+
logger.info(
|
|
425
|
+
"asap.request.received",
|
|
426
|
+
envelope_id=envelope.id,
|
|
427
|
+
trace_id=envelope.trace_id,
|
|
428
|
+
payload_type=envelope.payload_type,
|
|
429
|
+
sender=envelope.sender,
|
|
430
|
+
recipient=envelope.recipient,
|
|
431
|
+
authenticated=authenticated_agent_id is not None,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Process the envelope using the handler registry
|
|
435
|
+
try:
|
|
436
|
+
response_envelope = await self.registry.dispatch_async(envelope, self.manifest)
|
|
437
|
+
except HandlerNotFoundError as e:
|
|
438
|
+
# No handler registered for this payload type
|
|
439
|
+
logger.warning(
|
|
440
|
+
"asap.request.handler_not_found",
|
|
441
|
+
payload_type=e.payload_type,
|
|
442
|
+
envelope_id=envelope.id,
|
|
443
|
+
)
|
|
444
|
+
# Record error metric
|
|
445
|
+
duration_seconds = time.perf_counter() - start_time
|
|
446
|
+
metrics.increment_counter(
|
|
447
|
+
"asap_requests_total",
|
|
448
|
+
{"payload_type": payload_type, "status": "error"},
|
|
449
|
+
)
|
|
450
|
+
metrics.increment_counter(
|
|
451
|
+
"asap_requests_error_total",
|
|
452
|
+
{"payload_type": payload_type, "error_type": "handler_not_found"},
|
|
453
|
+
)
|
|
454
|
+
metrics.observe_histogram(
|
|
455
|
+
"asap_request_duration_seconds",
|
|
456
|
+
duration_seconds,
|
|
457
|
+
{"payload_type": payload_type, "status": "error"},
|
|
458
|
+
)
|
|
459
|
+
handler_error = JsonRpcErrorResponse(
|
|
460
|
+
error=JsonRpcError.from_code(
|
|
461
|
+
METHOD_NOT_FOUND,
|
|
462
|
+
data={
|
|
463
|
+
"payload_type": e.payload_type,
|
|
464
|
+
"error": str(e),
|
|
465
|
+
},
|
|
466
|
+
),
|
|
467
|
+
id=rpc_request.id,
|
|
468
|
+
)
|
|
469
|
+
return JSONResponse(
|
|
470
|
+
status_code=200,
|
|
471
|
+
content=handler_error.model_dump(),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Calculate duration
|
|
475
|
+
duration_seconds = time.perf_counter() - start_time
|
|
476
|
+
duration_ms = duration_seconds * 1000
|
|
477
|
+
|
|
478
|
+
# Record success metrics
|
|
479
|
+
metrics.increment_counter(
|
|
480
|
+
"asap_requests_total",
|
|
481
|
+
{"payload_type": payload_type, "status": "success"},
|
|
482
|
+
)
|
|
483
|
+
metrics.increment_counter(
|
|
484
|
+
"asap_requests_success_total",
|
|
485
|
+
{"payload_type": payload_type},
|
|
486
|
+
)
|
|
487
|
+
metrics.observe_histogram(
|
|
488
|
+
"asap_request_duration_seconds",
|
|
489
|
+
duration_seconds,
|
|
490
|
+
{"payload_type": payload_type, "status": "success"},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Log successful processing
|
|
494
|
+
logger.info(
|
|
495
|
+
"asap.request.processed",
|
|
496
|
+
envelope_id=envelope.id,
|
|
497
|
+
response_id=response_envelope.id,
|
|
498
|
+
trace_id=envelope.trace_id,
|
|
499
|
+
payload_type=envelope.payload_type,
|
|
500
|
+
duration_ms=round(duration_ms, 2),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Wrap response in JSON-RPC
|
|
504
|
+
rpc_response = JsonRpcResponse(
|
|
505
|
+
result={"envelope": response_envelope.model_dump(mode="json")},
|
|
506
|
+
id=rpc_request.id,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return JSONResponse(
|
|
510
|
+
status_code=200,
|
|
511
|
+
content=rpc_response.model_dump(),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
# Calculate duration for error case
|
|
516
|
+
duration_seconds = time.perf_counter() - start_time
|
|
517
|
+
duration_ms = duration_seconds * 1000
|
|
518
|
+
|
|
519
|
+
# Record error metrics
|
|
520
|
+
metrics.increment_counter(
|
|
521
|
+
"asap_requests_total",
|
|
522
|
+
{"payload_type": payload_type, "status": "error"},
|
|
523
|
+
)
|
|
524
|
+
metrics.increment_counter(
|
|
525
|
+
"asap_requests_error_total",
|
|
526
|
+
{"payload_type": payload_type, "error_type": "internal_error"},
|
|
527
|
+
)
|
|
528
|
+
metrics.observe_histogram(
|
|
529
|
+
"asap_request_duration_seconds",
|
|
530
|
+
duration_seconds,
|
|
531
|
+
{"payload_type": payload_type, "status": "error"},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Log error
|
|
535
|
+
logger.exception(
|
|
536
|
+
"asap.request.error",
|
|
537
|
+
error=str(e),
|
|
538
|
+
error_type=type(e).__name__,
|
|
539
|
+
duration_ms=round(duration_ms, 2),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Internal server error
|
|
543
|
+
internal_error = JsonRpcErrorResponse(
|
|
544
|
+
error=JsonRpcError.from_code(
|
|
545
|
+
INTERNAL_ERROR,
|
|
546
|
+
data={"error": str(e), "type": type(e).__name__},
|
|
547
|
+
),
|
|
548
|
+
id=None,
|
|
549
|
+
)
|
|
550
|
+
return JSONResponse(
|
|
551
|
+
status_code=200,
|
|
552
|
+
content=internal_error.model_dump(),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def create_app(
|
|
557
|
+
manifest: Manifest,
|
|
558
|
+
registry: HandlerRegistry | None = None,
|
|
559
|
+
token_validator: Callable[[str], str | None] | None = None,
|
|
560
|
+
) -> FastAPI:
|
|
561
|
+
"""Create and configure a FastAPI application for ASAP protocol.
|
|
562
|
+
|
|
563
|
+
This factory function creates a FastAPI app with:
|
|
564
|
+
- POST /asap endpoint for handling ASAP messages via JSON-RPC
|
|
565
|
+
- GET /.well-known/asap/manifest.json for agent discovery
|
|
566
|
+
- GET /asap/metrics for Prometheus-compatible metrics
|
|
567
|
+
- Authentication middleware (if manifest.auth is configured)
|
|
568
|
+
- Error handling middleware
|
|
569
|
+
- Request validation
|
|
570
|
+
- Extensible handler registry for payload processing
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
manifest: The agent's manifest describing capabilities and endpoints
|
|
574
|
+
registry: Optional handler registry for processing payloads.
|
|
575
|
+
If None, a default registry with echo handler is created.
|
|
576
|
+
token_validator: Optional function to validate Bearer tokens.
|
|
577
|
+
Required if manifest.auth is configured. Should return agent ID
|
|
578
|
+
if token is valid, None otherwise.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Configured FastAPI application ready to run
|
|
582
|
+
|
|
583
|
+
Raises:
|
|
584
|
+
ValueError: If manifest requires authentication but no token_validator provided
|
|
585
|
+
|
|
586
|
+
Example:
|
|
587
|
+
>>> from asap.models.entities import Manifest, Capability, Endpoint, Skill, AuthScheme
|
|
588
|
+
>>> from asap.transport.handlers import HandlerRegistry
|
|
589
|
+
>>> manifest = Manifest(
|
|
590
|
+
... id="urn:asap:agent:test",
|
|
591
|
+
... name="Test Agent",
|
|
592
|
+
... version="1.0.0",
|
|
593
|
+
... description="Test agent",
|
|
594
|
+
... capabilities=Capability(
|
|
595
|
+
... asap_version="0.1",
|
|
596
|
+
... skills=[Skill(id="test", description="Test skill")],
|
|
597
|
+
... state_persistence=False
|
|
598
|
+
... ),
|
|
599
|
+
... endpoints=Endpoint(asap="http://localhost:8000/asap")
|
|
600
|
+
... )
|
|
601
|
+
>>> app = create_app(manifest)
|
|
602
|
+
>>>
|
|
603
|
+
>>> # With authentication:
|
|
604
|
+
>>> manifest_with_auth = Manifest(
|
|
605
|
+
... ..., # same as above
|
|
606
|
+
... auth=AuthScheme(schemes=["bearer"])
|
|
607
|
+
... )
|
|
608
|
+
>>> def my_token_validator(token: str) -> str | None:
|
|
609
|
+
... if token == "valid-token":
|
|
610
|
+
... return "urn:asap:agent:client"
|
|
611
|
+
... return None
|
|
612
|
+
>>> app = create_app(manifest_with_auth, token_validator=my_token_validator)
|
|
613
|
+
>>>
|
|
614
|
+
>>> # With custom registry:
|
|
615
|
+
>>> registry = HandlerRegistry()
|
|
616
|
+
>>> registry.register("task.request", my_handler)
|
|
617
|
+
>>> app = create_app(manifest, registry)
|
|
618
|
+
>>> # Run with uvicorn: uvicorn module:app
|
|
619
|
+
"""
|
|
620
|
+
# Use default registry if none provided
|
|
621
|
+
if registry is None:
|
|
622
|
+
registry = create_default_registry()
|
|
623
|
+
|
|
624
|
+
# Create authentication middleware if auth is configured
|
|
625
|
+
auth_middleware: AuthenticationMiddleware | None = None
|
|
626
|
+
if manifest.auth is not None:
|
|
627
|
+
if token_validator is None:
|
|
628
|
+
raise ValueError(
|
|
629
|
+
"token_validator is required when manifest.auth is configured. "
|
|
630
|
+
"Provide a function that validates tokens and returns agent IDs."
|
|
631
|
+
)
|
|
632
|
+
validator = BearerTokenValidator(token_validator)
|
|
633
|
+
auth_middleware = AuthenticationMiddleware(manifest, validator)
|
|
634
|
+
logger.info(
|
|
635
|
+
"asap.server.auth_enabled",
|
|
636
|
+
manifest_id=manifest.id,
|
|
637
|
+
schemes=manifest.auth.schemes,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Create request handler
|
|
641
|
+
handler = ASAPRequestHandler(registry, manifest, auth_middleware)
|
|
642
|
+
|
|
643
|
+
app = FastAPI(
|
|
644
|
+
title="ASAP Protocol Server",
|
|
645
|
+
description=f"ASAP server for {manifest.name}",
|
|
646
|
+
version=manifest.version,
|
|
647
|
+
)
|
|
648
|
+
# Note: Request size limits should be configured at the ASGI server level (e.g., uvicorn).
|
|
649
|
+
# For production, consider setting --limit-max-requests or using a reverse proxy
|
|
650
|
+
# (nginx, traefik) to enforce request size limits (e.g., 10MB max).
|
|
651
|
+
|
|
652
|
+
@app.get("/.well-known/asap/manifest.json")
|
|
653
|
+
async def get_manifest() -> dict[str, Any]:
|
|
654
|
+
"""Return the agent's manifest for discovery.
|
|
655
|
+
|
|
656
|
+
This endpoint allows other agents to discover this agent's
|
|
657
|
+
capabilities, skills, and communication endpoints.
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Agent manifest as JSON dictionary
|
|
661
|
+
|
|
662
|
+
Example:
|
|
663
|
+
>>> manifest = get_manifest()
|
|
664
|
+
>>> "id" in manifest
|
|
665
|
+
True
|
|
666
|
+
"""
|
|
667
|
+
return manifest.model_dump()
|
|
668
|
+
|
|
669
|
+
@app.get("/asap/metrics")
|
|
670
|
+
async def get_metrics_endpoint() -> PlainTextResponse:
|
|
671
|
+
"""Return Prometheus-compatible metrics.
|
|
672
|
+
|
|
673
|
+
This endpoint exposes server metrics in Prometheus text format,
|
|
674
|
+
including request counts, error rates, and latency histograms.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
PlainTextResponse with metrics in Prometheus format
|
|
678
|
+
|
|
679
|
+
Example:
|
|
680
|
+
curl http://localhost:8000/asap/metrics
|
|
681
|
+
"""
|
|
682
|
+
metrics = get_metrics()
|
|
683
|
+
return PlainTextResponse(
|
|
684
|
+
content=metrics.export_prometheus(),
|
|
685
|
+
media_type="text/plain; version=0.0.4; charset=utf-8",
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
@app.post("/asap")
|
|
689
|
+
async def handle_asap_message(request: Request) -> JSONResponse:
|
|
690
|
+
"""Handle ASAP messages wrapped in JSON-RPC 2.0.
|
|
691
|
+
|
|
692
|
+
This endpoint:
|
|
693
|
+
1. Receives JSON-RPC wrapped ASAP envelopes
|
|
694
|
+
2. Validates the request structure
|
|
695
|
+
3. Extracts and processes the ASAP envelope
|
|
696
|
+
4. Returns response wrapped in JSON-RPC
|
|
697
|
+
5. Records metrics for observability
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
request: FastAPI request object with JSON body
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
JSON-RPC response or error response
|
|
704
|
+
|
|
705
|
+
Example:
|
|
706
|
+
>>> # Send JSON-RPC to POST /asap and receive JSON-RPC response.
|
|
707
|
+
>>> # See tests/transport/test_server.py for full request examples.
|
|
708
|
+
"""
|
|
709
|
+
return await handler.handle_message(request)
|
|
710
|
+
|
|
711
|
+
return app
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _create_default_manifest() -> Manifest:
|
|
715
|
+
"""Create a default manifest for standalone server execution.
|
|
716
|
+
|
|
717
|
+
This manifest is used when running the server directly via uvicorn
|
|
718
|
+
without providing a custom manifest.
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
Default manifest with basic echo capabilities
|
|
722
|
+
"""
|
|
723
|
+
return Manifest(
|
|
724
|
+
id="urn:asap:agent:default-server",
|
|
725
|
+
name="ASAP Default Server",
|
|
726
|
+
version="0.1.0",
|
|
727
|
+
description="Default ASAP protocol server with echo capabilities",
|
|
728
|
+
capabilities=Capability(
|
|
729
|
+
asap_version="0.1",
|
|
730
|
+
skills=[Skill(id="echo", description="Echo back the input")],
|
|
731
|
+
state_persistence=False,
|
|
732
|
+
),
|
|
733
|
+
endpoints=Endpoint(asap="http://localhost:8000/asap"),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# Default app instance for direct uvicorn execution:
|
|
738
|
+
# uvicorn asap.transport.server:app --host 0.0.0.0 --port 8000
|
|
739
|
+
app = create_app(_create_default_manifest(), create_default_registry())
|