asap-protocol 0.3.0__py3-none-any.whl → 1.0.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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
asap/testing/mocks.py ADDED
@@ -0,0 +1,152 @@
1
+ """Mock agents and request/response recording for ASAP tests.
2
+
3
+ This module provides MockAgent: a configurable mock agent that can
4
+ pre-set responses and record incoming requests for assertions.
5
+
6
+ Features:
7
+ - Pre-set responses per skill or per envelope pattern.
8
+ - Request recording (incoming envelopes) for later assertion.
9
+ - Configurable delay or failure for error-path tests.
10
+ """
11
+
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+ from asap.models.envelope import Envelope
17
+ from asap.models.ids import generate_id
18
+
19
+
20
+ class MockAgent:
21
+ """Configurable mock agent for testing ASAP integrations.
22
+
23
+ Simulates an agent without a real server. Supports pre-set responses
24
+ per skill, request recording, optional delay, and simulated failures
25
+ for error-path tests.
26
+
27
+ Attributes:
28
+ agent_id: URN identifying this mock agent (e.g. urn:asap:agent:test-echo).
29
+ requests: List of all envelopes received by handle() (read-only).
30
+ """
31
+
32
+ def __init__(self, agent_id: str = "urn:asap:agent:mock") -> None:
33
+ """Initialize the mock agent.
34
+
35
+ Args:
36
+ agent_id: URN for this mock agent.
37
+ """
38
+ self.agent_id = agent_id
39
+ self._responses: dict[str, dict[str, Any]] = {}
40
+ self._default_response: dict[str, Any] | None = None
41
+ self._delay_seconds: float = 0.0
42
+ self._failure: BaseException | None = None
43
+ self.requests: list[Envelope] = []
44
+
45
+ def set_response(self, skill_id: str, payload: dict[str, Any]) -> None:
46
+ """Pre-set the response payload for a skill.
47
+
48
+ Args:
49
+ skill_id: Skill id (e.g. 'echo').
50
+ payload: Response payload (e.g. TaskResponse.model_dump()).
51
+ """
52
+ self._responses[skill_id] = payload
53
+
54
+ def set_default_response(self, payload: dict[str, Any]) -> None:
55
+ """Pre-set a response used when no skill-specific response is set.
56
+
57
+ Args:
58
+ payload: Response payload (e.g. TaskResponse.model_dump()).
59
+ """
60
+ self._default_response = payload
61
+
62
+ def set_delay(self, seconds: float) -> None:
63
+ """Set a delay (sleep) before returning the response. Useful for timeout tests.
64
+
65
+ Args:
66
+ seconds: Delay in seconds (0 to disable).
67
+ """
68
+ self._delay_seconds = max(0.0, seconds)
69
+
70
+ def set_failure(self, exception: BaseException | None) -> None:
71
+ """Set a failure to raise when handle() is called. Clears after raise.
72
+
73
+ Args:
74
+ exception: Exception to raise (None to disable).
75
+ """
76
+ self._failure = exception
77
+
78
+ def requests_for_skill(self, skill_id: str) -> list[Envelope]:
79
+ """Return recorded requests whose payload has the given skill_id.
80
+
81
+ Args:
82
+ skill_id: Skill id to filter by.
83
+
84
+ Returns:
85
+ List of envelopes that requested this skill.
86
+ """
87
+ result: list[Envelope] = []
88
+ for env in self.requests:
89
+ payload = env.payload if isinstance(env.payload, dict) else {}
90
+ if payload.get("skill_id") == skill_id:
91
+ result.append(env)
92
+ return result
93
+
94
+ def handle(self, envelope: Envelope) -> Envelope | None:
95
+ """Handle an incoming envelope and return a response envelope.
96
+
97
+ Records the request. Optionally sleeps (set_delay), raises (set_failure),
98
+ or returns an envelope built from the pre-set response for the
99
+ envelope's skill_id or default response.
100
+
101
+ Args:
102
+ envelope: Incoming ASAP envelope.
103
+
104
+ Returns:
105
+ Response envelope or None if no response configured.
106
+
107
+ Raises:
108
+ BaseException: If set_failure() was called with an exception.
109
+ """
110
+ self.requests.append(envelope)
111
+
112
+ if self._failure is not None:
113
+ exc = self._failure
114
+ self._failure = None
115
+ raise exc
116
+
117
+ skill_id = (
118
+ (envelope.payload or {}).get("skill_id") if isinstance(envelope.payload, dict) else None
119
+ )
120
+ payload = (self._responses.get(skill_id) if skill_id else None) or self._default_response
121
+ if payload is None:
122
+ return None
123
+
124
+ if self._delay_seconds > 0:
125
+ time.sleep(self._delay_seconds)
126
+
127
+ return Envelope(
128
+ id=generate_id(),
129
+ asap_version=envelope.asap_version,
130
+ timestamp=datetime.now(timezone.utc),
131
+ sender=self.agent_id,
132
+ recipient=envelope.sender,
133
+ payload_type="TaskResponse",
134
+ payload=payload,
135
+ correlation_id=envelope.id,
136
+ trace_id=envelope.trace_id,
137
+ )
138
+
139
+ def clear(self) -> None:
140
+ """Clear recorded requests, pre-set responses, delay, and failure."""
141
+ self.requests.clear()
142
+ self._responses.clear()
143
+ self._default_response = None
144
+ self._delay_seconds = 0.0
145
+ self._failure = None
146
+
147
+ def reset(self) -> None:
148
+ """Reset internal state. Equivalent to clear()."""
149
+ self.clear()
150
+
151
+
152
+ __all__ = ["MockAgent"]
@@ -18,6 +18,7 @@ Public exports:
18
18
  create_echo_handler: Factory for echo handler
19
19
  create_default_registry: Factory for default registry
20
20
  ASAPClient: Async HTTP client for agent communication
21
+ RetryConfig: Configuration dataclass for retry logic and circuit breaker
21
22
  ASAPConnectionError: Connection error exception
22
23
  ASAPTimeoutError: Timeout error exception
23
24
  ASAPRemoteError: Remote error exception
@@ -40,11 +41,27 @@ Example:
40
41
  >>> app = create_app(manifest)
41
42
  """
42
43
 
44
+ from asap.transport.cache import (
45
+ DEFAULT_MAX_SIZE,
46
+ DEFAULT_TTL,
47
+ ManifestCache,
48
+ )
43
49
  from asap.transport.client import (
44
50
  ASAPClient,
45
51
  ASAPConnectionError,
46
52
  ASAPRemoteError,
47
53
  ASAPTimeoutError,
54
+ RetryConfig,
55
+ )
56
+ from asap.transport.compression import (
57
+ COMPRESSION_THRESHOLD,
58
+ CompressionAlgorithm,
59
+ compress_payload,
60
+ decompress_payload,
61
+ get_accept_encoding_header,
62
+ get_supported_encodings,
63
+ is_brotli_available,
64
+ select_best_encoding,
48
65
  )
49
66
  from asap.transport.handlers import (
50
67
  Handler,
@@ -81,4 +98,18 @@ __all__ = [
81
98
  "ASAPConnectionError",
82
99
  "ASAPTimeoutError",
83
100
  "ASAPRemoteError",
101
+ "RetryConfig",
102
+ # Cache
103
+ "ManifestCache",
104
+ "DEFAULT_TTL",
105
+ "DEFAULT_MAX_SIZE",
106
+ # Compression
107
+ "COMPRESSION_THRESHOLD",
108
+ "CompressionAlgorithm",
109
+ "compress_payload",
110
+ "decompress_payload",
111
+ "get_accept_encoding_header",
112
+ "get_supported_encodings",
113
+ "is_brotli_available",
114
+ "select_best_encoding",
84
115
  ]
@@ -0,0 +1,180 @@
1
+ """Manifest caching for ASAP protocol.
2
+
3
+ This module provides caching for agent manifests to reduce HTTP requests
4
+ and improve performance when discovering agent capabilities.
5
+
6
+ The cache uses an OrderedDict with TTL (Time To Live) expiration and LRU
7
+ eviction when max_size is reached. Entries expire after the configured
8
+ TTL (default: 5 minutes).
9
+ """
10
+
11
+ import time
12
+ from collections import OrderedDict
13
+ from threading import Lock
14
+ from typing import Optional
15
+
16
+ from asap.models.entities import Manifest
17
+
18
+
19
+ # Default TTL in seconds (5 minutes)
20
+ DEFAULT_TTL = 300.0
21
+
22
+ # Default max cache size (number of entries)
23
+ DEFAULT_MAX_SIZE = 1000
24
+
25
+
26
+ class CacheEntry:
27
+ """Cache entry with TTL expiration.
28
+
29
+ Attributes:
30
+ manifest: Cached manifest object
31
+ expires_at: Timestamp when entry expires (seconds since epoch)
32
+ """
33
+
34
+ def __init__(self, manifest: Manifest, ttl: float) -> None:
35
+ """Initialize cache entry.
36
+
37
+ Args:
38
+ manifest: Manifest to cache
39
+ ttl: Time to live in seconds
40
+ """
41
+ self.manifest = manifest
42
+ self.expires_at = time.time() + ttl
43
+
44
+ def is_expired(self) -> bool:
45
+ """Check if cache entry has expired.
46
+
47
+ Returns:
48
+ True if entry has expired, False otherwise
49
+ """
50
+ return time.time() >= self.expires_at
51
+
52
+
53
+ class ManifestCache:
54
+ """Thread-safe in-memory LRU cache for agent manifests.
55
+
56
+ Provides TTL-based expiration, LRU eviction when max_size is reached,
57
+ and methods for cache management. Thread-safe for concurrent access
58
+ from multiple async tasks.
59
+
60
+ Attributes:
61
+ _cache: OrderedDict mapping URL to CacheEntry (maintains LRU order)
62
+ _lock: Lock for thread-safe access
63
+ _default_ttl: Default TTL in seconds
64
+ _max_size: Maximum number of entries (0 for unlimited)
65
+
66
+ Example:
67
+ >>> cache = ManifestCache(default_ttl=300.0, max_size=100)
68
+ >>> cache.set("http://agent.example.com/manifest.json", manifest, ttl=300.0)
69
+ >>> cached = cache.get("http://agent.example.com/manifest.json")
70
+ >>> if cached:
71
+ ... print(cached.id)
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ default_ttl: float = DEFAULT_TTL,
77
+ max_size: int = DEFAULT_MAX_SIZE,
78
+ ) -> None:
79
+ """Initialize manifest cache.
80
+
81
+ Args:
82
+ default_ttl: Default TTL in seconds for cache entries (default: 300.0)
83
+ max_size: Maximum number of cache entries. When exceeded, the least
84
+ recently used entry is evicted. Set to 0 for unlimited size.
85
+ Default: 1000. Very large values may increase cleanup_expired() latency.
86
+ """
87
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
88
+ self._lock = Lock()
89
+ self._default_ttl = default_ttl
90
+ self._max_size = max_size
91
+
92
+ def get(self, url: str) -> Optional[Manifest]:
93
+ """Get manifest from cache if present and not expired.
94
+
95
+ Accessing an entry moves it to the end of the LRU queue,
96
+ marking it as most recently used.
97
+
98
+ Args:
99
+ url: Manifest URL
100
+
101
+ Returns:
102
+ Cached manifest if found and not expired, None otherwise
103
+ """
104
+ with self._lock:
105
+ entry = self._cache.get(url)
106
+ if entry is None:
107
+ return None
108
+ if entry.is_expired():
109
+ del self._cache[url]
110
+ return None
111
+ self._cache.move_to_end(url)
112
+ return entry.manifest
113
+
114
+ def set(self, url: str, manifest: Manifest, ttl: Optional[float] = None) -> None:
115
+ """Store manifest in cache with TTL.
116
+
117
+ If max_size is set and the cache is full, the least recently used
118
+ entry is evicted before adding the new entry.
119
+
120
+ Args:
121
+ url: Manifest URL (cache key)
122
+ manifest: Manifest to cache
123
+ ttl: Time to live in seconds (defaults to default_ttl)
124
+ """
125
+ if ttl is None:
126
+ ttl = self._default_ttl
127
+ with self._lock:
128
+ if url in self._cache:
129
+ del self._cache[url]
130
+ elif self._max_size > 0:
131
+ while len(self._cache) >= self._max_size:
132
+ self._cache.popitem(last=False)
133
+ self._cache[url] = CacheEntry(manifest, ttl)
134
+
135
+ def invalidate(self, url: str) -> None:
136
+ """Remove manifest from cache.
137
+
138
+ Args:
139
+ url: Manifest URL to invalidate
140
+ """
141
+ with self._lock:
142
+ self._cache.pop(url, None)
143
+
144
+ def clear_all(self) -> None:
145
+ """Clear all cached manifests."""
146
+ with self._lock:
147
+ self._cache.clear()
148
+
149
+ def size(self) -> int:
150
+ """Get number of cached entries (including expired).
151
+
152
+ Returns:
153
+ Number of entries in cache
154
+ """
155
+ with self._lock:
156
+ return len(self._cache)
157
+
158
+ @property
159
+ def max_size(self) -> int:
160
+ """Get the configured maximum cache size.
161
+
162
+ Returns:
163
+ Maximum number of entries (0 for unlimited)
164
+ """
165
+ return self._max_size
166
+
167
+ def cleanup_expired(self) -> int:
168
+ """Remove all expired entries from cache.
169
+
170
+ Holds the lock for O(N). For very large max_size, prefer lazy eviction
171
+ in get() or call less frequently.
172
+
173
+ Returns:
174
+ Number of expired entries removed
175
+ """
176
+ with self._lock:
177
+ expired_urls = [url for url, entry in self._cache.items() if entry.is_expired()]
178
+ for url in expired_urls:
179
+ del self._cache[url]
180
+ return len(expired_urls)
@@ -0,0 +1,194 @@
1
+ """Circuit breaker implementation for resilient request handling.
2
+
3
+ This module provides the CircuitBreaker pattern implementation and a registry
4
+ for sharing circuit breaker state across multiple client instances.
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from enum import Enum
10
+
11
+ from asap.models.constants import (
12
+ DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
13
+ DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
14
+ )
15
+ from asap.observability import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class CircuitState(str, Enum):
21
+ """Circuit breaker states.
22
+
23
+ CLOSED: Normal operation, requests are allowed
24
+ OPEN: Circuit is open, requests are rejected immediately
25
+ HALF_OPEN: Testing state, allows one request to test if service recovered
26
+ """
27
+
28
+ CLOSED = "closed"
29
+ OPEN = "open"
30
+ HALF_OPEN = "half_open"
31
+
32
+
33
+ class CircuitBreaker:
34
+ """Circuit breaker pattern implementation for resilient request handling.
35
+
36
+ The circuit breaker prevents cascading failures by opening the circuit
37
+ after a threshold of consecutive failures, then attempting to recover
38
+ after a timeout period.
39
+
40
+ States:
41
+ - CLOSED: Normal operation, all requests allowed
42
+ - OPEN: Circuit is open, all requests rejected immediately
43
+ - HALF_OPEN: Testing state, allows one request to test recovery
44
+
45
+ This implementation is thread-safe using RLock for concurrent access.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
51
+ timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
52
+ ) -> None:
53
+ """Initialize circuit breaker.
54
+
55
+ Args:
56
+ threshold: Number of consecutive failures before opening (default: 5)
57
+ timeout: Seconds before transitioning OPEN -> HALF_OPEN (default: 60.0)
58
+ """
59
+ self.threshold = threshold
60
+ self.timeout = timeout
61
+ self._state = CircuitState.CLOSED
62
+ self._consecutive_failures = 0
63
+ self._last_failure_time: float | None = None
64
+ self._lock = threading.RLock()
65
+
66
+ def record_success(self) -> None:
67
+ """Record a successful request.
68
+
69
+ Resets failure count and closes circuit if it was HALF_OPEN.
70
+ """
71
+ with self._lock:
72
+ self._consecutive_failures = 0
73
+ if self._state == CircuitState.HALF_OPEN:
74
+ self._state = CircuitState.CLOSED
75
+ self._last_failure_time = None
76
+
77
+ def record_failure(self) -> None:
78
+ """Record a failed request.
79
+
80
+ Increments failure count and opens circuit if threshold is reached
81
+ or if the circuit was in HALF_OPEN state.
82
+ """
83
+ with self._lock:
84
+ self._consecutive_failures += 1
85
+ self._last_failure_time = time.time()
86
+
87
+ threshold_reached = (
88
+ self._consecutive_failures >= self.threshold and self._state == CircuitState.CLOSED
89
+ )
90
+
91
+ if self._state == CircuitState.HALF_OPEN or threshold_reached:
92
+ self._state = CircuitState.OPEN
93
+
94
+ def can_attempt(self) -> bool:
95
+ """Check if a request can be attempted.
96
+
97
+ Returns:
98
+ True if request can be attempted, False if circuit is open
99
+ """
100
+ with self._lock:
101
+ if self._state == CircuitState.OPEN:
102
+ if self._last_failure_time is not None:
103
+ elapsed = time.time() - self._last_failure_time
104
+ if elapsed >= self.timeout:
105
+ self._state = CircuitState.HALF_OPEN
106
+ return True
107
+ return False
108
+
109
+ return True
110
+
111
+ def get_state(self) -> CircuitState:
112
+ """Get current circuit state.
113
+
114
+ Returns:
115
+ Current circuit state
116
+ """
117
+ with self._lock:
118
+ return self._state
119
+
120
+ def get_consecutive_failures(self) -> int:
121
+ """Get number of consecutive failures.
122
+
123
+ Returns:
124
+ Number of consecutive failures
125
+ """
126
+ with self._lock:
127
+ return self._consecutive_failures
128
+
129
+
130
+ class CircuitBreakerRegistry:
131
+ """Registry for managing shared CircuitBreaker instances.
132
+
133
+ Ensures that multiple clients connecting to the same implementation
134
+ share the same circuit breaker state.
135
+ """
136
+
137
+ def __init__(self) -> None:
138
+ """Initialize registry."""
139
+ self._breakers: dict[str, CircuitBreaker] = {}
140
+ self._lock = threading.RLock()
141
+
142
+ def get_or_create(
143
+ self,
144
+ base_url: str,
145
+ threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
146
+ timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
147
+ ) -> CircuitBreaker:
148
+ """Get existing circuit breaker or create a new one.
149
+
150
+ Args:
151
+ base_url: The target URL (key for the registry)
152
+ threshold: Threshold for new breakers (ignored if exists)
153
+ timeout: Timeout for new breakers (ignored if exists)
154
+
155
+ Returns:
156
+ Shared CircuitBreaker instance
157
+ """
158
+ with self._lock:
159
+ if base_url not in self._breakers:
160
+ logger.info(
161
+ "asap.circuit_breaker.created",
162
+ base_url=base_url,
163
+ threshold=threshold,
164
+ timeout=timeout,
165
+ message=f"Created shared circuit breaker for {base_url}",
166
+ )
167
+ self._breakers[base_url] = CircuitBreaker(threshold=threshold, timeout=timeout)
168
+ return self._breakers[base_url]
169
+
170
+ def clear(self) -> None:
171
+ """Clear all registered circuit breakers (mostly for testing)."""
172
+ with self._lock:
173
+ self._breakers.clear()
174
+
175
+
176
+ # Global registry instance
177
+ # In a more complex app, this might be injected, but a module-level singleton
178
+ # is standard for this pattern in Python clients.
179
+ # WARNING: This state persists across tests. Use get_registry().clear() in tearDown.
180
+ _registry = CircuitBreakerRegistry()
181
+
182
+
183
+ def get_circuit_breaker(
184
+ base_url: str,
185
+ threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
186
+ timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
187
+ ) -> CircuitBreaker:
188
+ """Helper to get a circuit breaker from the global registry."""
189
+ return _registry.get_or_create(base_url, threshold, timeout)
190
+
191
+
192
+ def get_registry() -> CircuitBreakerRegistry:
193
+ """Helper to get the global registry instance."""
194
+ return _registry