axon-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.
axon/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ from axon.client import AxonClient, AxonSyncClient
2
+ from axon.messages import MessagesClient, SyncMessagesClient
3
+ from axon.types import (
4
+ MemoryResult,
5
+ MemorySearchResponse,
6
+ StoredMemory,
7
+ LockInfo,
8
+ LockStatus,
9
+ ReasoningStep,
10
+ StepsLogger,
11
+ ReceiptInfo,
12
+ ReceiptVerifyResult,
13
+ )
14
+ from axon.exceptions import (
15
+ AxonError,
16
+ AuthError,
17
+ LockConflictError,
18
+ NotFoundError,
19
+ AxonPermissionError,
20
+ RateLimitError,
21
+ ServerError,
22
+ AxonConnectionError,
23
+ )
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "AxonClient",
29
+ "AxonSyncClient",
30
+ "MessagesClient",
31
+ "SyncMessagesClient",
32
+ # Types
33
+ "MemoryResult",
34
+ "MemorySearchResponse",
35
+ "StoredMemory",
36
+ "LockInfo",
37
+ "LockStatus",
38
+ "ReasoningStep",
39
+ "StepsLogger",
40
+ "ReceiptInfo",
41
+ "ReceiptVerifyResult",
42
+ # Exceptions
43
+ "AxonError",
44
+ "AuthError",
45
+ "LockConflictError",
46
+ "NotFoundError",
47
+ "AxonPermissionError",
48
+ "RateLimitError",
49
+ "ServerError",
50
+ "AxonConnectionError",
51
+ ]
axon/_base.py ADDED
@@ -0,0 +1,142 @@
1
+ import httpx
2
+ import time
3
+ import asyncio
4
+ from axon.exceptions import (
5
+ AuthError,
6
+ NotFoundError,
7
+ AxonPermissionError,
8
+ LockConflictError,
9
+ RateLimitError,
10
+ ServerError,
11
+ AxonConnectionError,
12
+ )
13
+
14
+
15
+ class _BaseClient:
16
+ def __init__(self, http: httpx.AsyncClient, base_url: str):
17
+ self._http = http
18
+ self._base_url = base_url.rstrip("/")
19
+
20
+ async def _request(self, method: str, path: str, **kwargs) -> dict:
21
+ url = f"{self._base_url}{path}"
22
+ retries = 3
23
+ backoff = 0.5
24
+
25
+ for attempt in range(retries):
26
+ try:
27
+ response = await self._http.request(method, url, **kwargs)
28
+ if response.status_code >= 500 and attempt < retries - 1:
29
+ await asyncio.sleep(backoff * (2 ** attempt))
30
+ continue
31
+ break
32
+ except httpx.ConnectError:
33
+ if attempt == retries - 1:
34
+ raise AxonConnectionError(
35
+ f"Cannot connect to Axon server at {self._base_url}. "
36
+ f"Make sure the server is running."
37
+ )
38
+ await asyncio.sleep(backoff * (2 ** attempt))
39
+ except httpx.TimeoutException:
40
+ if attempt == retries - 1:
41
+ raise AxonConnectionError(
42
+ f"Request to {url} timed out. Server may be overloaded."
43
+ )
44
+ await asyncio.sleep(backoff * (2 ** attempt))
45
+
46
+ # Success
47
+ if response.status_code == 200:
48
+ return response.json()
49
+
50
+ # Parse error detail from response body
51
+ try:
52
+ detail = response.json().get("detail", response.text)
53
+ except Exception:
54
+ detail = response.text
55
+
56
+ # Map status codes to specific exceptions
57
+ if response.status_code == 401:
58
+ raise AuthError(f"Authentication failed: {detail}", 401)
59
+ elif response.status_code == 403:
60
+ raise AxonPermissionError(f"Permission denied: {detail}", 403)
61
+ elif response.status_code == 404:
62
+ raise NotFoundError(f"Not found: {detail}", 404)
63
+ elif response.status_code == 409:
64
+ raise LockConflictError("", detail=detail)
65
+ elif response.status_code == 429:
66
+ retry_after = int(response.headers.get("Retry-After", 60))
67
+ raise RateLimitError(retry_after)
68
+ elif response.status_code >= 500:
69
+ raise ServerError(
70
+ f"Server error ({response.status_code}): {detail}",
71
+ response.status_code,
72
+ )
73
+ else:
74
+ raise ServerError(
75
+ f"Unexpected status {response.status_code}: {detail}",
76
+ response.status_code,
77
+ )
78
+
79
+
80
+ class _BaseSyncClient:
81
+ def __init__(self, http: httpx.Client, base_url: str):
82
+ self._http = http
83
+ self._base_url = base_url.rstrip("/")
84
+
85
+ def _request(self, method: str, path: str, **kwargs) -> dict:
86
+ url = f"{self._base_url}{path}"
87
+ retries = 3
88
+ backoff = 0.5
89
+
90
+ for attempt in range(retries):
91
+ try:
92
+ response = self._http.request(method, url, **kwargs)
93
+ if response.status_code >= 500 and attempt < retries - 1:
94
+ time.sleep(backoff * (2 ** attempt))
95
+ continue
96
+ break
97
+ except httpx.ConnectError:
98
+ if attempt == retries - 1:
99
+ raise AxonConnectionError(
100
+ f"Cannot connect to Axon server at {self._base_url}. "
101
+ f"Make sure the server is running."
102
+ )
103
+ time.sleep(backoff * (2 ** attempt))
104
+ except httpx.TimeoutException:
105
+ if attempt == retries - 1:
106
+ raise AxonConnectionError(
107
+ f"Request to {url} timed out. Server may be overloaded."
108
+ )
109
+ time.sleep(backoff * (2 ** attempt))
110
+
111
+ # Success
112
+ if response.status_code == 200:
113
+ return response.json()
114
+
115
+ # Parse error detail from response body
116
+ try:
117
+ detail = response.json().get("detail", response.text)
118
+ except Exception:
119
+ detail = response.text
120
+
121
+ # Map status codes to specific exceptions
122
+ if response.status_code == 401:
123
+ raise AuthError(f"Authentication failed: {detail}", 401)
124
+ elif response.status_code == 403:
125
+ raise AxonPermissionError(f"Permission denied: {detail}", 403)
126
+ elif response.status_code == 404:
127
+ raise NotFoundError(f"Not found: {detail}", 404)
128
+ elif response.status_code == 409:
129
+ raise LockConflictError("", detail=detail)
130
+ elif response.status_code == 429:
131
+ retry_after = int(response.headers.get("Retry-After", 60))
132
+ raise RateLimitError(retry_after)
133
+ elif response.status_code >= 500:
134
+ raise ServerError(
135
+ f"Server error ({response.status_code}): {detail}",
136
+ response.status_code,
137
+ )
138
+ else:
139
+ raise ServerError(
140
+ f"Unexpected status {response.status_code}: {detail}",
141
+ response.status_code,
142
+ )
axon/client.py ADDED
@@ -0,0 +1,132 @@
1
+ import httpx
2
+ from axon.memory import MemoryClient, SyncMemoryClient
3
+ from axon.coordination import CoordinationClient, SyncCoordinationClient
4
+ from axon.receipts import ReceiptsClient, SyncReceiptsClient
5
+ from axon.events import EventsClient, SyncEventsClient
6
+ from axon.messages import MessagesClient, SyncMessagesClient
7
+
8
+
9
+ class AxonClient:
10
+ """
11
+ Async entry point for the Axon Protocol Python SDK.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ project_id: str,
18
+ agent_token: str = None,
19
+ agent_id: str = None,
20
+ base_url: str = "http://localhost:8000",
21
+ timeout: float = 30.0,
22
+ ):
23
+ self.api_key = api_key
24
+ self.project_id = project_id
25
+ self.agent_token = agent_token
26
+ self.agent_id = agent_id
27
+ self.base_url = base_url.rstrip("/")
28
+
29
+ headers = {}
30
+ if api_key:
31
+ headers["X-API-Key"] = api_key
32
+ if agent_token:
33
+ headers["Authorization"] = f"Bearer {agent_token}"
34
+
35
+ self._http = httpx.AsyncClient(
36
+ headers=headers,
37
+ timeout=httpx.Timeout(timeout),
38
+ limits=httpx.Limits(
39
+ max_connections=20,
40
+ max_keepalive_connections=10,
41
+ keepalive_expiry=30,
42
+ ),
43
+ )
44
+
45
+ self.memory = MemoryClient(self._http, self.base_url)
46
+ self.lock = CoordinationClient(self._http, self.base_url)
47
+ self.receipts = ReceiptsClient(self._http, self.base_url)
48
+ self.events = EventsClient(self.base_url, api_key, project_id)
49
+ self.messages = MessagesClient(self._http, self.base_url)
50
+
51
+ async def ping(self) -> bool:
52
+ """
53
+ Check if the Axon server is reachable (async).
54
+ """
55
+ try:
56
+ response = await self._http.get(f"{self.base_url}/v1/health")
57
+ return response.status_code == 200
58
+ except Exception:
59
+ return False
60
+
61
+ async def close(self):
62
+ """Close the underlying HTTP connection pool."""
63
+ await self._http.aclose()
64
+
65
+ async def __aenter__(self):
66
+ return self
67
+
68
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
69
+ await self.close()
70
+
71
+
72
+ class AxonSyncClient:
73
+ """
74
+ Sync entry point for the Axon Protocol Python SDK.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ api_key: str,
80
+ project_id: str,
81
+ agent_token: str = None,
82
+ agent_id: str = None,
83
+ base_url: str = "http://localhost:8000",
84
+ timeout: float = 30.0,
85
+ ):
86
+ self.api_key = api_key
87
+ self.project_id = project_id
88
+ self.agent_token = agent_token
89
+ self.agent_id = agent_id
90
+ self.base_url = base_url.rstrip("/")
91
+
92
+ headers = {}
93
+ if api_key:
94
+ headers["X-API-Key"] = api_key
95
+ if agent_token:
96
+ headers["Authorization"] = f"Bearer {agent_token}"
97
+
98
+ self._http = httpx.Client(
99
+ headers=headers,
100
+ timeout=httpx.Timeout(timeout),
101
+ limits=httpx.Limits(
102
+ max_connections=20,
103
+ max_keepalive_connections=10,
104
+ keepalive_expiry=30,
105
+ ),
106
+ )
107
+
108
+ self.memory = SyncMemoryClient(self._http, self.base_url)
109
+ self.lock = SyncCoordinationClient(self._http, self.base_url)
110
+ self.receipts = SyncReceiptsClient(self._http, self.base_url)
111
+ self.events = SyncEventsClient(self.base_url, api_key, project_id)
112
+ self.messages = SyncMessagesClient(self._http, self.base_url)
113
+
114
+ def ping(self) -> bool:
115
+ """
116
+ Check if the Axon server is reachable (sync).
117
+ """
118
+ try:
119
+ response = self._http.get(f"{self.base_url}/v1/health")
120
+ return response.status_code == 200
121
+ except Exception:
122
+ return False
123
+
124
+ def close(self):
125
+ """Close the underlying HTTP connection pool."""
126
+ self._http.close()
127
+
128
+ def __enter__(self):
129
+ return self
130
+
131
+ def __exit__(self, exc_type, exc_val, exc_tb):
132
+ self.close()
axon/coordination.py ADDED
@@ -0,0 +1,161 @@
1
+ from contextlib import asynccontextmanager, contextmanager
2
+ from axon._base import _BaseClient, _BaseSyncClient
3
+ from axon.types import LockInfo, LockStatus
4
+
5
+
6
+ class CoordinationClient(_BaseClient):
7
+
8
+ async def acquire(
9
+ self,
10
+ resource_id: str,
11
+ timeout: int = 300,
12
+ metadata: dict = None,
13
+ ) -> LockInfo:
14
+ """
15
+ Acquire an exclusive lock on a resource (async).
16
+ """
17
+ body = {"resource_id": resource_id, "timeout": timeout}
18
+ if metadata is not None:
19
+ body["metadata"] = metadata
20
+
21
+ data = await self._request("POST", "/v1/lock/acquire", json=body)
22
+ return LockInfo(
23
+ lock_id=data["lock_id"],
24
+ resource_id=data["resource_id"],
25
+ expires_at=data["expires_at"],
26
+ )
27
+
28
+ async def release(self, resource_id: str) -> bool:
29
+ """
30
+ Release a lock that you hold (async).
31
+ """
32
+ data = await self._request(
33
+ "POST",
34
+ "/v1/lock/release",
35
+ params={"resource_id": resource_id},
36
+ )
37
+ return data.get("released", False)
38
+
39
+ async def status(self, resource_id: str) -> LockStatus:
40
+ """
41
+ Check lock status of a resource (async).
42
+ """
43
+ data = await self._request("GET", f"/v1/lock/status/{resource_id}")
44
+ return LockStatus(
45
+ locked=data["locked"],
46
+ resource_id=data["resource_id"],
47
+ holder_agent_id=data.get("holder_agent_id"),
48
+ locked_at=data.get("locked_at"),
49
+ expires_at=data.get("expires_at"),
50
+ )
51
+
52
+ async def list_active(self) -> list[dict]:
53
+ """
54
+ List all active locks in the project (async).
55
+ """
56
+ data = await self._request("GET", "/v1/lock/list")
57
+ return data.get("locks", [])
58
+
59
+ @asynccontextmanager
60
+ async def hold(
61
+ self,
62
+ resource_id: str,
63
+ timeout: int = 300,
64
+ metadata: dict = None,
65
+ ):
66
+ """
67
+ Context manager that acquires a lock and automatically releases it on exit (async).
68
+ """
69
+ lock_info = await self.acquire(resource_id, timeout=timeout, metadata=metadata)
70
+ try:
71
+ yield lock_info
72
+ finally:
73
+ try:
74
+ await self.release(resource_id)
75
+ except Exception:
76
+ pass
77
+
78
+ def __call__(self, resource_id: str, timeout: int = 300, metadata: dict = None):
79
+ """
80
+ Redirect axon.lock("resource_id") to hold(resource_id)
81
+ """
82
+ return self.hold(resource_id, timeout=timeout, metadata=metadata)
83
+
84
+
85
+ class SyncCoordinationClient(_BaseSyncClient):
86
+
87
+ def acquire(
88
+ self,
89
+ resource_id: str,
90
+ timeout: int = 300,
91
+ metadata: dict = None,
92
+ ) -> LockInfo:
93
+ """
94
+ Acquire an exclusive lock on a resource (sync).
95
+ """
96
+ body = {"resource_id": resource_id, "timeout": timeout}
97
+ if metadata is not None:
98
+ body["metadata"] = metadata
99
+
100
+ data = self._request("POST", "/v1/lock/acquire", json=body)
101
+ return LockInfo(
102
+ lock_id=data["lock_id"],
103
+ resource_id=data["resource_id"],
104
+ expires_at=data["expires_at"],
105
+ )
106
+
107
+ def release(self, resource_id: str) -> bool:
108
+ """
109
+ Release a lock that you hold (sync).
110
+ """
111
+ data = self._request(
112
+ "POST",
113
+ "/v1/lock/release",
114
+ params={"resource_id": resource_id},
115
+ )
116
+ return data.get("released", False)
117
+
118
+ def status(self, resource_id: str) -> LockStatus:
119
+ """
120
+ Check lock status of a resource (sync).
121
+ """
122
+ data = self._request("GET", f"/v1/lock/status/{resource_id}")
123
+ return LockStatus(
124
+ locked=data["locked"],
125
+ resource_id=data["resource_id"],
126
+ holder_agent_id=data.get("holder_agent_id"),
127
+ locked_at=data.get("locked_at"),
128
+ expires_at=data.get("expires_at"),
129
+ )
130
+
131
+ def list_active(self) -> list[dict]:
132
+ """
133
+ List all active locks in the project (sync).
134
+ """
135
+ data = self._request("GET", "/v1/lock/list")
136
+ return data.get("locks", [])
137
+
138
+ @contextmanager
139
+ def hold(
140
+ self,
141
+ resource_id: str,
142
+ timeout: int = 300,
143
+ metadata: dict = None,
144
+ ):
145
+ """
146
+ Context manager that acquires a lock and automatically releases it on exit (sync).
147
+ """
148
+ lock_info = self.acquire(resource_id, timeout=timeout, metadata=metadata)
149
+ try:
150
+ yield lock_info
151
+ finally:
152
+ try:
153
+ self.release(resource_id)
154
+ except Exception:
155
+ pass
156
+
157
+ def __call__(self, resource_id: str, timeout: int = 300, metadata: dict = None):
158
+ """
159
+ Redirect axon.lock("resource_id") to hold(resource_id)
160
+ """
161
+ return self.hold(resource_id, timeout=timeout, metadata=metadata)
axon/events.py ADDED
@@ -0,0 +1,116 @@
1
+ import json
2
+ import time
3
+ import asyncio
4
+ from typing import AsyncIterator, Iterator
5
+ import websockets
6
+ from websockets.sync.client import connect as ws_sync_connect
7
+ from axon.exceptions import AxonConnectionError
8
+
9
+
10
+ class EventsClient:
11
+ """
12
+ Connects to the Axon server via WebSocket and streams real-time events (async).
13
+ """
14
+
15
+ def __init__(self, base_url: str, api_key: str, project_id: str):
16
+ ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
17
+ self._ws_url = ws_base.rstrip("/")
18
+ self._api_key = api_key
19
+ self._project_id = project_id
20
+
21
+ async def listen(self, reconnect: bool = True) -> AsyncIterator[dict]:
22
+ url = f"{self._ws_url}/v1/events/{self._project_id}"
23
+
24
+ while True:
25
+ try:
26
+ async with websockets.connect(
27
+ url,
28
+ additional_headers={"X-API-Key": self._api_key},
29
+ ping_interval=30,
30
+ ping_timeout=10,
31
+ ) as ws:
32
+ async for raw_message in ws:
33
+ try:
34
+ event = json.loads(raw_message)
35
+
36
+ # Skip heartbeat pings
37
+ if event.get("type") == "ping":
38
+ continue
39
+
40
+ yield event
41
+
42
+ except json.JSONDecodeError:
43
+ continue
44
+
45
+ except websockets.exceptions.ConnectionClosed:
46
+ if not reconnect:
47
+ return
48
+ await asyncio.sleep(2)
49
+
50
+ except Exception as e:
51
+ if not reconnect:
52
+ raise AxonConnectionError(
53
+ f"WebSocket connection failed: {str(e)}"
54
+ )
55
+ await asyncio.sleep(2)
56
+
57
+ async def listen_once(self, timeout: float = 10.0) -> dict | None:
58
+ try:
59
+ async with asyncio.timeout(timeout):
60
+ async for event in self.listen(reconnect=False):
61
+ return event
62
+ except (asyncio.TimeoutError, StopAsyncIteration):
63
+ return None
64
+
65
+
66
+ class SyncEventsClient:
67
+ """
68
+ Connects to the Axon server via WebSocket and streams real-time events (sync).
69
+ """
70
+
71
+ def __init__(self, base_url: str, api_key: str, project_id: str):
72
+ ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
73
+ self._ws_url = ws_base.rstrip("/")
74
+ self._api_key = api_key
75
+ self._project_id = project_id
76
+
77
+ def listen(self, reconnect: bool = True) -> Iterator[dict]:
78
+ url = f"{self._ws_url}/v1/events/{self._project_id}"
79
+
80
+ while True:
81
+ try:
82
+ with ws_sync_connect(
83
+ url,
84
+ additional_headers={"X-API-Key": self._api_key},
85
+ ) as ws:
86
+ for raw_message in ws:
87
+ try:
88
+ event = json.loads(raw_message)
89
+
90
+ # Skip heartbeat pings
91
+ if event.get("type") == "ping":
92
+ continue
93
+
94
+ yield event
95
+
96
+ except json.JSONDecodeError:
97
+ continue
98
+
99
+ except Exception as e:
100
+ if not reconnect:
101
+ raise AxonConnectionError(
102
+ f"WebSocket connection failed: {str(e)}"
103
+ )
104
+ time.sleep(2)
105
+
106
+ def listen_once(self, timeout: float = 10.0) -> dict | None:
107
+ iterator = self.listen(reconnect=False)
108
+ start_time = time.time()
109
+ while time.time() - start_time < timeout:
110
+ try:
111
+ return next(iterator)
112
+ except StopIteration:
113
+ return None
114
+ except Exception:
115
+ raise
116
+ return None
axon/exceptions.py ADDED
@@ -0,0 +1,51 @@
1
+ class AxonError(Exception):
2
+ """Base exception for all Axon errors"""
3
+ def __init__(self, message: str, status_code: int = None):
4
+ super().__init__(message)
5
+ self.message = message
6
+ self.status_code = status_code
7
+
8
+ def __repr__(self):
9
+ return f"{self.__class__.__name__}(message={self.message!r}, status_code={self.status_code})"
10
+
11
+
12
+ class AuthError(AxonError):
13
+ """Raised when authentication fails. Wrong or missing API key or token."""
14
+ pass
15
+
16
+
17
+ class LockConflictError(AxonError):
18
+ """Raised when you try to acquire a lock that another agent already holds."""
19
+ def __init__(self, resource_id: str = "", detail: str = None):
20
+ msg = detail or f"Resource '{resource_id}' is already locked by another agent"
21
+ super().__init__(msg, 409)
22
+ self.resource_id = resource_id
23
+
24
+
25
+ class NotFoundError(AxonError):
26
+ """Raised when the requested resource does not exist."""
27
+ pass
28
+
29
+
30
+ class AxonPermissionError(AxonError):
31
+ """Raised when the agent does not have permission to perform the action."""
32
+ pass
33
+
34
+
35
+ class RateLimitError(AxonError):
36
+ """Raised when the rate limit is exceeded."""
37
+ def __init__(self, retry_after: int = 60):
38
+ super().__init__(
39
+ f"Rate limit exceeded. Retry after {retry_after} seconds.", 429
40
+ )
41
+ self.retry_after = retry_after
42
+
43
+
44
+ class ServerError(AxonError):
45
+ """Raised when the Axon server returns a 5xx error."""
46
+ pass
47
+
48
+
49
+ class AxonConnectionError(AxonError):
50
+ """Raised when the SDK cannot connect to the Axon server at all."""
51
+ pass
@@ -0,0 +1,15 @@
1
+ from axon.integrations.langchain import AxonMemoryTool, AxonLockTool, AxonReceiptCallbackHandler
2
+ from axon.integrations.crewai import (
3
+ AxonMemoryTool as CrewAxonMemoryTool,
4
+ AxonLockTool as CrewAxonLockTool,
5
+ AxonReceiptCallbackHandler as CrewAxonReceiptCallbackHandler,
6
+ )
7
+
8
+ __all__ = [
9
+ "AxonMemoryTool",
10
+ "AxonLockTool",
11
+ "AxonReceiptCallbackHandler",
12
+ "CrewAxonMemoryTool",
13
+ "CrewAxonLockTool",
14
+ "CrewAxonReceiptCallbackHandler",
15
+ ]