axonio 0.2.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.
- axonio/__init__.py +62 -0
- axonio/_http.py +474 -0
- axonio/async_client.py +281 -0
- axonio/branches.py +4 -0
- axonio/client.py +161 -0
- axonio/config.py +63 -0
- axonio/errors.py +123 -0
- axonio/integrations/__init__.py +1 -0
- axonio/integrations/langchain.py +82 -0
- axonio/integrations/mcp.py +54 -0
- axonio/integrations/openai_agents.py +81 -0
- axonio/memory.py +156 -0
- axonio/models.py +82 -0
- axonio/provision.py +172 -0
- axonio/reflect.py +4 -0
- axonio/session.py +59 -0
- axonio/sync_client.py +89 -0
- axonio/transactions.py +31 -0
- axonio-0.2.0.dist-info/METADATA +102 -0
- axonio-0.2.0.dist-info/RECORD +21 -0
- axonio-0.2.0.dist-info/WHEEL +4 -0
axonio/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Axonio Python SDK: persistent memory for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Awaitable
|
|
7
|
+
|
|
8
|
+
from axonio.async_client import AsyncClawDB
|
|
9
|
+
from axonio.client import ClawDB
|
|
10
|
+
from axonio.errors import (
|
|
11
|
+
ClawDBAccessDeniedError,
|
|
12
|
+
ClawDBAuthError,
|
|
13
|
+
ClawDBError,
|
|
14
|
+
ClawDBInternalError,
|
|
15
|
+
ClawDBNotFoundError,
|
|
16
|
+
ClawDBRateLimitError,
|
|
17
|
+
ClawDBProvisionError,
|
|
18
|
+
ClawDBTimeoutError,
|
|
19
|
+
ClawDBUnavailableError,
|
|
20
|
+
ClawDBValidationError,
|
|
21
|
+
)
|
|
22
|
+
from axonio.models import BranchInfo, DiffResult, MemoryRecord, MergeResult, ReflectJob, SearchResult, SyncActionResult, SyncResult, SyncStatusResult, TxInfo
|
|
23
|
+
from axonio.transactions import TxClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def axonio() -> ClawDB | Awaitable[AsyncClawDB]:
|
|
27
|
+
"""Return sync client in sync code, or awaitable async client inside an event loop."""
|
|
28
|
+
try:
|
|
29
|
+
asyncio.get_running_loop()
|
|
30
|
+
return AsyncClawDB.auto_provision()
|
|
31
|
+
except RuntimeError:
|
|
32
|
+
return ClawDB.auto_provision()
|
|
33
|
+
|
|
34
|
+
# `Axonio` / `AsyncAxonio` are the same classes as `ClawDB` / `AsyncClawDB`;
|
|
35
|
+
# `clawdb()` remains as an alias of `axonio()`.
|
|
36
|
+
Axonio = ClawDB
|
|
37
|
+
AsyncAxonio = AsyncClawDB
|
|
38
|
+
clawdb = axonio
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"axonio",
|
|
42
|
+
"Axonio",
|
|
43
|
+
"AsyncAxonio",
|
|
44
|
+
"clawdb",
|
|
45
|
+
"ClawDB",
|
|
46
|
+
"AsyncClawDB",
|
|
47
|
+
"MemoryRecord",
|
|
48
|
+
"SearchResult",
|
|
49
|
+
"BranchInfo",
|
|
50
|
+
"SyncResult",
|
|
51
|
+
"ReflectJob",
|
|
52
|
+
"ClawDBError",
|
|
53
|
+
"ClawDBAuthError",
|
|
54
|
+
"ClawDBAccessDeniedError",
|
|
55
|
+
"ClawDBNotFoundError",
|
|
56
|
+
"ClawDBRateLimitError",
|
|
57
|
+
"ClawDBProvisionError",
|
|
58
|
+
"ClawDBUnavailableError",
|
|
59
|
+
"ClawDBTimeoutError",
|
|
60
|
+
"ClawDBValidationError",
|
|
61
|
+
"ClawDBInternalError",
|
|
62
|
+
]
|
axonio/_http.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""HTTP transport for the ClawDB Python SDK.
|
|
2
|
+
|
|
3
|
+
Every client talks to the server's HTTP API (``/v1/...``): the hosted Axonio
|
|
4
|
+
gateway, or a self-hosted clawdb-server's HTTP port. One route table maps each
|
|
5
|
+
operation to a request and reshapes the reply, and both the sync and async
|
|
6
|
+
cores use it, so they can't drift apart.
|
|
7
|
+
|
|
8
|
+
Sessions are opened on first use with a one-hour TTL, renewed a minute before
|
|
9
|
+
they expire, and renewed once (then the call retried) if the server rejects
|
|
10
|
+
the token. Network errors and 502/503/504 are retried with backoff; 429 waits
|
|
11
|
+
for ``Retry-After``.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from axonio.errors import (
|
|
29
|
+
ClawDBAccessDeniedError,
|
|
30
|
+
ClawDBAuthError,
|
|
31
|
+
ClawDBError,
|
|
32
|
+
ClawDBInternalError,
|
|
33
|
+
ClawDBNotFoundError,
|
|
34
|
+
ClawDBRateLimitError,
|
|
35
|
+
ClawDBTimeoutError,
|
|
36
|
+
ClawDBUnavailableError,
|
|
37
|
+
ClawDBValidationError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: The hosted cloud. Requests carry the API key; the gateway routes them to the
|
|
41
|
+
#: workspace's own ClawDB instance.
|
|
42
|
+
CLOUD_ENDPOINT = "https://api.axonio.xyz/api/v1/gateway"
|
|
43
|
+
#: Retired default cloud hosts; requests to them are sent to CLOUD_ENDPOINT.
|
|
44
|
+
_LEGACY_CLOUD_HOSTS = {"cloud.clawdb.dev"}
|
|
45
|
+
|
|
46
|
+
SESSION_TTL_SECS = 3600
|
|
47
|
+
_RENEW_MARGIN_SECS = 60
|
|
48
|
+
_MAX_ATTEMPTS = 3
|
|
49
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def http_base_url(endpoint: str) -> str:
|
|
53
|
+
"""The HTTP API base for a configured endpoint.
|
|
54
|
+
|
|
55
|
+
- A retired cloud host becomes the current cloud gateway.
|
|
56
|
+
- A URL with a path (such as the cloud gateway) is used as-is.
|
|
57
|
+
- A bare server address on the gRPC port 50050 maps to its HTTP port 8080,
|
|
58
|
+
so the long-standing default ``http://localhost:50050`` keeps working.
|
|
59
|
+
- Any other bare address is assumed to be the HTTP API already.
|
|
60
|
+
"""
|
|
61
|
+
endpoint = (endpoint or "").strip() or "http://localhost:50050"
|
|
62
|
+
if "://" not in endpoint:
|
|
63
|
+
endpoint = f"http://{endpoint}"
|
|
64
|
+
parts = urlsplit(endpoint)
|
|
65
|
+
if parts.hostname in _LEGACY_CLOUD_HOSTS:
|
|
66
|
+
return CLOUD_ENDPOINT
|
|
67
|
+
path = parts.path.rstrip("/")
|
|
68
|
+
if path:
|
|
69
|
+
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
70
|
+
netloc = parts.netloc
|
|
71
|
+
if parts.port == 50050:
|
|
72
|
+
netloc = netloc.rsplit(":", 1)[0] + ":8080"
|
|
73
|
+
return urlunsplit((parts.scheme, netloc, "", "", ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def stable_agent_id(agent_id: str) -> str:
|
|
77
|
+
"""The server needs a UUID agent id. Any other id maps to a stable UUID,
|
|
78
|
+
byte-for-byte the same as the TypeScript SDK, so one agent name means one
|
|
79
|
+
agent across both SDKs."""
|
|
80
|
+
if _UUID_RE.match(agent_id):
|
|
81
|
+
return agent_id
|
|
82
|
+
digest = list(hashlib.sha256(agent_id.encode()).hexdigest()[:32])
|
|
83
|
+
digest[12] = "5"
|
|
84
|
+
digest[16] = format((int(digest[16], 16) & 0x3) | 0x8, "x")
|
|
85
|
+
h = "".join(digest)
|
|
86
|
+
return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Routes ───────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class _Route:
|
|
94
|
+
method: str
|
|
95
|
+
path: str
|
|
96
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
body: Any = None
|
|
98
|
+
reply: Callable[[Any], dict[str, Any]] | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _q(value: Any) -> str:
|
|
102
|
+
return quote(str(value), safe="")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _as_list(value: Any) -> list[Any]:
|
|
106
|
+
return value if isinstance(value, list) else []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
110
|
+
return value if isinstance(value, dict) else {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _branch(value: Any) -> dict[str, Any]:
|
|
114
|
+
b = _as_dict(value)
|
|
115
|
+
return {"branch_id": str(b.get("id") or b.get("branch_id") or ""), "name": str(b.get("name") or ""), "branch_json": json.dumps(b)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _route(op: str, p: dict[str, Any]) -> _Route:
|
|
119
|
+
if op == "Health":
|
|
120
|
+
return _Route("GET", "/v1/health")
|
|
121
|
+
if op == "CreateSession":
|
|
122
|
+
return _Route("POST", "/v1/sessions", body={k: p[k] for k in ("agent_id", "role", "scopes", "ttl_secs")})
|
|
123
|
+
if op == "ValidateSession":
|
|
124
|
+
return _Route("GET", "/v1/sessions/me")
|
|
125
|
+
if op == "RevokeSession":
|
|
126
|
+
return _Route("DELETE", f"/v1/sessions/{_q(p['session_id'])}", reply=lambda _: {"revoked": True})
|
|
127
|
+
if op == "ActiveSessionCount":
|
|
128
|
+
return _Route("GET", "/v1/sessions/active/count")
|
|
129
|
+
|
|
130
|
+
if op == "Remember":
|
|
131
|
+
body = {"content": p["content"], "type": p.get("type") or "context", "tags": p.get("tags") or [], "metadata": p.get("metadata") or {}}
|
|
132
|
+
return _Route("POST", "/v1/memories", body=body)
|
|
133
|
+
if op == "UpdateMemory":
|
|
134
|
+
return _Route("PATCH", f"/v1/memories/{_q(p['memory_id'])}", body={"content": p["content"]})
|
|
135
|
+
if op == "DeleteMemory":
|
|
136
|
+
return _Route("DELETE", f"/v1/memories/{_q(p['memory_id'])}", reply=lambda _: {"deleted": True})
|
|
137
|
+
if op == "GetMemory":
|
|
138
|
+
return _Route("GET", f"/v1/memories/{_q(p['memory_id'])}")
|
|
139
|
+
if op == "Search":
|
|
140
|
+
params = {"q": p["query"], "top_k": p.get("top_k", 5), "semantic": "true" if p.get("semantic", True) else "false"}
|
|
141
|
+
return _Route("GET", "/v1/memories/search", params=params, reply=lambda j: {"hits": _as_list(j)})
|
|
142
|
+
if op == "ListMemories":
|
|
143
|
+
params = {"limit": p.get("limit", 50)}
|
|
144
|
+
if p.get("type"):
|
|
145
|
+
params["type"] = p["type"]
|
|
146
|
+
return _Route("GET", "/v1/memories", params=params, reply=lambda j: {"memories": _as_list(j)})
|
|
147
|
+
|
|
148
|
+
if op == "Branch":
|
|
149
|
+
body = {"name": p["name"]}
|
|
150
|
+
if p.get("from"):
|
|
151
|
+
body["from"] = p["from"]
|
|
152
|
+
return _Route("POST", "/v1/branches", body=body, reply=lambda j: {"branch_id": str(_as_dict(j).get("branch_id") or _as_dict(j).get("id") or ""), "name": str(_as_dict(j).get("name") or "")})
|
|
153
|
+
if op == "ListBranches":
|
|
154
|
+
return _Route("GET", "/v1/branches", reply=lambda j: {"branches": [_branch(b) for b in _as_list(j)]})
|
|
155
|
+
if op == "GetBranch":
|
|
156
|
+
return _Route("GET", f"/v1/branches/{_q(p['branch_id'])}", reply=_branch)
|
|
157
|
+
if op == "GetBranchByName":
|
|
158
|
+
return _Route("GET", f"/v1/branches/by-name/{_q(p['name'])}", reply=_branch)
|
|
159
|
+
if op == "GetTrunkBranch":
|
|
160
|
+
return _Route("GET", "/v1/branches/trunk", reply=_branch)
|
|
161
|
+
if op == "Diff":
|
|
162
|
+
def diff_reply(j: Any) -> dict[str, Any]:
|
|
163
|
+
d = _as_dict(j)
|
|
164
|
+
stats = _as_dict(d.get("stats"))
|
|
165
|
+
return {
|
|
166
|
+
**{k: int(stats.get(k, d.get(k, 0)) or 0) for k in ("added", "removed", "modified", "unchanged")},
|
|
167
|
+
"divergence_score": float(d.get("divergence_score") or 0.0),
|
|
168
|
+
"diff_json": json.dumps(d),
|
|
169
|
+
}
|
|
170
|
+
return _Route("GET", f"/v1/branches/{_q(p['branch_id'])}/diff", params={"target": p.get("target") or ""}, reply=diff_reply)
|
|
171
|
+
if op == "Merge":
|
|
172
|
+
def merge_reply(j: Any) -> dict[str, Any]:
|
|
173
|
+
r = _as_dict(j)
|
|
174
|
+
conflicts = r.get("conflicts")
|
|
175
|
+
return {
|
|
176
|
+
"success": bool(r.get("success", True)),
|
|
177
|
+
"applied": int(r.get("applied") or 0),
|
|
178
|
+
"skipped": int(r.get("skipped") or 0),
|
|
179
|
+
"conflicts": len(conflicts) if isinstance(conflicts, list) else int(conflicts or 0),
|
|
180
|
+
"duration_ms": int(r.get("duration_ms") or 0),
|
|
181
|
+
"conflicts_json": json.dumps(conflicts if isinstance(conflicts, list) else []),
|
|
182
|
+
}
|
|
183
|
+
body = {"target": p.get("target") or ""}
|
|
184
|
+
if p.get("strategy"):
|
|
185
|
+
body["strategy"] = p["strategy"]
|
|
186
|
+
return _Route("POST", f"/v1/branches/{_q(p['source'])}/merge", body=body, reply=merge_reply)
|
|
187
|
+
if op == "DiscardBranch":
|
|
188
|
+
return _Route("DELETE", f"/v1/branches/{_q(p['branch_id'])}", reply=lambda _: {"discarded": True})
|
|
189
|
+
if op == "ArchiveBranch":
|
|
190
|
+
return _Route("POST", f"/v1/branches/{_q(p['branch_id'])}/archive", reply=lambda _: {"archived": True})
|
|
191
|
+
|
|
192
|
+
if op == "Sync":
|
|
193
|
+
return _Route("POST", "/v1/sync")
|
|
194
|
+
if op in ("PushSync", "PullSync", "ReconcileSync"):
|
|
195
|
+
path = {"PushSync": "/v1/sync/push", "PullSync": "/v1/sync/pull", "ReconcileSync": "/v1/sync/reconcile"}[op]
|
|
196
|
+
return _Route("POST", path, reply=lambda j: {"summary_json": json.dumps(j if j is not None else {})})
|
|
197
|
+
if op == "SyncStatus":
|
|
198
|
+
return _Route("GET", "/v1/sync/status", reply=lambda j: {"status_json": json.dumps(j if j is not None else {})})
|
|
199
|
+
|
|
200
|
+
wrap = lambda j: {"json": j} # noqa: E731
|
|
201
|
+
if op == "Reflect":
|
|
202
|
+
return _Route("POST", "/v1/reflect")
|
|
203
|
+
if op == "ReflectGetJob":
|
|
204
|
+
return _Route("GET", f"/v1/reflect/jobs/{_q(p['job_id'])}", reply=wrap)
|
|
205
|
+
if op == "ReflectListJobs":
|
|
206
|
+
params = {k: v for k, v in {"agent_id": p.get("agent_id"), "status": p.get("status"), "limit": p.get("limit"), "offset": p.get("offset")}.items() if v not in (None, "")}
|
|
207
|
+
return _Route("GET", "/v1/reflect/jobs", params=params, reply=wrap)
|
|
208
|
+
if op in ("ReflectGetFacts", "ReflectGetPreferences", "ReflectGetContradictions"):
|
|
209
|
+
kind = {"ReflectGetFacts": "facts", "ReflectGetPreferences": "preferences", "ReflectGetContradictions": "contradictions"}[op]
|
|
210
|
+
return _Route("GET", f"/v1/reflect/{kind}/{_q(p['agent_id'])}", reply=wrap)
|
|
211
|
+
if op == "ReflectResolveContradiction":
|
|
212
|
+
body = {"strategy": p.get("strategy") or "", "merged_value": p.get("merged_value")}
|
|
213
|
+
return _Route("POST", f"/v1/reflect/contradictions/{_q(p['agent_id'])}/{_q(p['contradiction_id'])}/resolve", body=body, reply=wrap)
|
|
214
|
+
|
|
215
|
+
if op == "BeginTx":
|
|
216
|
+
return _Route("POST", "/v1/tx")
|
|
217
|
+
if op == "TxRemember":
|
|
218
|
+
return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/memories", body={"content": p["content"]})
|
|
219
|
+
if op == "TxRememberTyped":
|
|
220
|
+
body = {"content": p["content"], "type": p.get("type") or "context", "tags": p.get("tags") or [], "metadata": p.get("metadata") or {}}
|
|
221
|
+
return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/memories/typed", body=body)
|
|
222
|
+
if op == "CommitTx":
|
|
223
|
+
return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/commit")
|
|
224
|
+
if op == "RollbackTx":
|
|
225
|
+
return _Route("POST", f"/v1/tx/{_q(p['tx_id'])}/rollback")
|
|
226
|
+
raise ClawDBValidationError(f"Unknown operation: {op}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Errors ───────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _SessionRejected(Exception):
|
|
233
|
+
"""The instance rejected the session token; a new session may fix it."""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _error_for(response: httpx.Response, op: str) -> ClawDBError:
|
|
237
|
+
request_id = response.headers.get("x-request-id")
|
|
238
|
+
try:
|
|
239
|
+
body = _as_dict(response.json())
|
|
240
|
+
except ValueError:
|
|
241
|
+
body = {"error": response.text}
|
|
242
|
+
reason = str(body.get("error") or body.get("message") or response.reason_phrase or f"HTTP {response.status_code}")
|
|
243
|
+
detail = body.get("detail") or (f"requires {body['required']}" if body.get("required") else "")
|
|
244
|
+
message = f"{reason}: {detail}" if detail else reason
|
|
245
|
+
status = response.status_code
|
|
246
|
+
if status == 401:
|
|
247
|
+
return ClawDBAuthError(message, request_id=request_id, details=body)
|
|
248
|
+
if status in (402, 403):
|
|
249
|
+
return ClawDBAccessDeniedError(message, resource=op, action="call", request_id=request_id, details=body)
|
|
250
|
+
if status == 404:
|
|
251
|
+
return ClawDBNotFoundError(message, entity_type=op, entity_id="unknown", request_id=request_id, details=body)
|
|
252
|
+
if status in (400, 409, 422):
|
|
253
|
+
return ClawDBValidationError(message, request_id=request_id, details=body)
|
|
254
|
+
if status == 429:
|
|
255
|
+
retry_after = response.headers.get("retry-after")
|
|
256
|
+
retry_ms = int(float(retry_after) * 1000) if retry_after and retry_after.replace(".", "", 1).isdigit() else 1000
|
|
257
|
+
return ClawDBRateLimitError(message, retry_after_ms=retry_ms, request_id=request_id, details=body)
|
|
258
|
+
if status in (502, 503):
|
|
259
|
+
return ClawDBUnavailableError(message, request_id=request_id, details=body)
|
|
260
|
+
if status in (408, 504):
|
|
261
|
+
return ClawDBTimeoutError(message, request_id=request_id, details=body)
|
|
262
|
+
return ClawDBInternalError(message, request_id=request_id, details=body)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _is_session_rejection(response: httpx.Response) -> bool:
|
|
266
|
+
# The instance answers a bad session with {"error":"unauthorized"}; the
|
|
267
|
+
# gateway answers a bad API key with "invalid_api_key".
|
|
268
|
+
if response.status_code != 401:
|
|
269
|
+
return False
|
|
270
|
+
try:
|
|
271
|
+
return _as_dict(response.json()).get("error") == "unauthorized"
|
|
272
|
+
except ValueError:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── Cores ────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class _CoreBase:
|
|
280
|
+
def __init__(self, endpoint: str, *, api_key: str = "", agent_id: str = "default-agent", role: str = "assistant", timeout_ms: int = 30000) -> None:
|
|
281
|
+
self.base_url = http_base_url(endpoint)
|
|
282
|
+
self.api_key = api_key or ""
|
|
283
|
+
self.agent_id = stable_agent_id(agent_id or "default-agent")
|
|
284
|
+
self.role = role or "assistant"
|
|
285
|
+
self.timeout = max(timeout_ms, 1) / 1000.0
|
|
286
|
+
self.session_id: str | None = None
|
|
287
|
+
self.token: str | None = None
|
|
288
|
+
self._expires_at: float | None = None
|
|
289
|
+
|
|
290
|
+
def _session_fresh(self) -> bool:
|
|
291
|
+
if not self.token:
|
|
292
|
+
return False
|
|
293
|
+
return self._expires_at is None or time.time() < self._expires_at - _RENEW_MARGIN_SECS
|
|
294
|
+
|
|
295
|
+
def _remember_session(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
296
|
+
token = str(body.get("token") or "")
|
|
297
|
+
if not token:
|
|
298
|
+
raise ClawDBAuthError("Server returned a session without a token", details=body)
|
|
299
|
+
self.token = token
|
|
300
|
+
self.session_id = str(body.get("session_id") or body.get("id") or "") or None
|
|
301
|
+
expires = str(body.get("expires_at") or "")
|
|
302
|
+
try:
|
|
303
|
+
from datetime import datetime
|
|
304
|
+
|
|
305
|
+
self._expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00")).timestamp()
|
|
306
|
+
except ValueError:
|
|
307
|
+
self._expires_at = None
|
|
308
|
+
return body
|
|
309
|
+
|
|
310
|
+
def forget_session(self) -> None:
|
|
311
|
+
self.token = None
|
|
312
|
+
self.session_id = None
|
|
313
|
+
self._expires_at = None
|
|
314
|
+
|
|
315
|
+
def _session_payload(self) -> dict[str, Any]:
|
|
316
|
+
return {"agent_id": self.agent_id, "role": self.role, "scopes": ["*"], "ttl_secs": SESSION_TTL_SECS}
|
|
317
|
+
|
|
318
|
+
def _request_kwargs(self, route: _Route, with_session: bool) -> dict[str, Any]:
|
|
319
|
+
headers = {"accept": "application/json", "x-agent-id": self.agent_id}
|
|
320
|
+
if self.api_key:
|
|
321
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
322
|
+
# x-claw-session, not Authorization: the gateway replaces Authorization
|
|
323
|
+
# (the API key) before forwarding to the instance.
|
|
324
|
+
if with_session and self.token:
|
|
325
|
+
headers["x-claw-session"] = self.token
|
|
326
|
+
kwargs: dict[str, Any] = {"headers": headers, "params": {k: v for k, v in route.params.items() if v is not None}}
|
|
327
|
+
if route.body is not None:
|
|
328
|
+
kwargs["json"] = route.body
|
|
329
|
+
return kwargs
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _decode(response: httpx.Response, route: _Route) -> dict[str, Any]:
|
|
333
|
+
data: Any = None
|
|
334
|
+
if response.content:
|
|
335
|
+
try:
|
|
336
|
+
data = response.json()
|
|
337
|
+
except ValueError:
|
|
338
|
+
data = {"message": response.text}
|
|
339
|
+
result = route.reply(data) if route.reply else _as_dict(data)
|
|
340
|
+
request_id = response.headers.get("x-request-id")
|
|
341
|
+
if request_id and "request_id" not in result:
|
|
342
|
+
result = {**result, "request_id": request_id}
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _backoff(attempt: int) -> float:
|
|
347
|
+
return min(0.2 * (2**attempt), 2.0)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class HttpCore(_CoreBase):
|
|
351
|
+
"""Synchronous HTTP core."""
|
|
352
|
+
|
|
353
|
+
def __init__(self, endpoint: str, **kwargs: Any) -> None:
|
|
354
|
+
super().__init__(endpoint, **kwargs)
|
|
355
|
+
self._client = httpx.Client(timeout=self.timeout)
|
|
356
|
+
self._lock = threading.Lock()
|
|
357
|
+
|
|
358
|
+
def close(self) -> None:
|
|
359
|
+
self._client.close()
|
|
360
|
+
|
|
361
|
+
def open_session(self) -> dict[str, Any]:
|
|
362
|
+
with self._lock:
|
|
363
|
+
return self._remember_session(self._send("CreateSession", self._session_payload(), with_session=False))
|
|
364
|
+
|
|
365
|
+
def _ensure_session(self) -> None:
|
|
366
|
+
if not self._session_fresh():
|
|
367
|
+
self.open_session()
|
|
368
|
+
|
|
369
|
+
def _send(self, op: str, payload: dict[str, Any], *, with_session: bool) -> dict[str, Any]:
|
|
370
|
+
route = _route(op, payload)
|
|
371
|
+
url = f"{self.base_url}{route.path}"
|
|
372
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
373
|
+
try:
|
|
374
|
+
response = self._client.request(route.method, url, **self._request_kwargs(route, with_session))
|
|
375
|
+
except httpx.TimeoutException as exc:
|
|
376
|
+
if attempt == _MAX_ATTEMPTS - 1:
|
|
377
|
+
raise ClawDBTimeoutError(f"{op} timed out", timeout_ms=int(self.timeout * 1000)) from exc
|
|
378
|
+
except httpx.HTTPError as exc:
|
|
379
|
+
if attempt == _MAX_ATTEMPTS - 1:
|
|
380
|
+
raise ClawDBUnavailableError(f"Could not reach {self.base_url}: {exc}") from exc
|
|
381
|
+
else:
|
|
382
|
+
if response.is_success:
|
|
383
|
+
return self._decode(response, route)
|
|
384
|
+
if with_session and _is_session_rejection(response):
|
|
385
|
+
raise _SessionRejected()
|
|
386
|
+
error = _error_for(response, op)
|
|
387
|
+
if isinstance(error, ClawDBRateLimitError) and attempt < _MAX_ATTEMPTS - 1:
|
|
388
|
+
time.sleep(error.retry_after_ms / 1000.0)
|
|
389
|
+
continue
|
|
390
|
+
if not isinstance(error, (ClawDBUnavailableError, ClawDBTimeoutError)) or attempt == _MAX_ATTEMPTS - 1:
|
|
391
|
+
raise error
|
|
392
|
+
time.sleep(self._backoff(attempt))
|
|
393
|
+
raise ClawDBUnavailableError(f"{op}: retries exhausted")
|
|
394
|
+
|
|
395
|
+
def call(self, op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
396
|
+
payload = payload or {}
|
|
397
|
+
if op in ("Health", "CreateSession"):
|
|
398
|
+
return self._send(op, payload, with_session=False)
|
|
399
|
+
self._ensure_session()
|
|
400
|
+
try:
|
|
401
|
+
return self._send(op, payload, with_session=True)
|
|
402
|
+
except _SessionRejected:
|
|
403
|
+
# Expired or revoked: open a new session and retry once.
|
|
404
|
+
self.forget_session()
|
|
405
|
+
self._ensure_session()
|
|
406
|
+
try:
|
|
407
|
+
return self._send(op, payload, with_session=True)
|
|
408
|
+
except _SessionRejected:
|
|
409
|
+
raise ClawDBAuthError("The server rejected a freshly opened session") from None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class AsyncHttpCore(_CoreBase):
|
|
413
|
+
"""Asynchronous HTTP core."""
|
|
414
|
+
|
|
415
|
+
def __init__(self, endpoint: str, **kwargs: Any) -> None:
|
|
416
|
+
super().__init__(endpoint, **kwargs)
|
|
417
|
+
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
418
|
+
self._lock = asyncio.Lock()
|
|
419
|
+
|
|
420
|
+
async def close(self) -> None:
|
|
421
|
+
await self._client.aclose()
|
|
422
|
+
|
|
423
|
+
async def open_session(self) -> dict[str, Any]:
|
|
424
|
+
async with self._lock:
|
|
425
|
+
return self._remember_session(await self._send("CreateSession", self._session_payload(), with_session=False))
|
|
426
|
+
|
|
427
|
+
async def _ensure_session(self) -> None:
|
|
428
|
+
if not self._session_fresh():
|
|
429
|
+
await self.open_session()
|
|
430
|
+
|
|
431
|
+
async def _send(self, op: str, payload: dict[str, Any], *, with_session: bool) -> dict[str, Any]:
|
|
432
|
+
route = _route(op, payload)
|
|
433
|
+
url = f"{self.base_url}{route.path}"
|
|
434
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
435
|
+
try:
|
|
436
|
+
response = await self._client.request(route.method, url, **self._request_kwargs(route, with_session))
|
|
437
|
+
except httpx.TimeoutException as exc:
|
|
438
|
+
if attempt == _MAX_ATTEMPTS - 1:
|
|
439
|
+
raise ClawDBTimeoutError(f"{op} timed out", timeout_ms=int(self.timeout * 1000)) from exc
|
|
440
|
+
except httpx.HTTPError as exc:
|
|
441
|
+
if attempt == _MAX_ATTEMPTS - 1:
|
|
442
|
+
raise ClawDBUnavailableError(f"Could not reach {self.base_url}: {exc}") from exc
|
|
443
|
+
else:
|
|
444
|
+
if response.is_success:
|
|
445
|
+
return self._decode(response, route)
|
|
446
|
+
if with_session and _is_session_rejection(response):
|
|
447
|
+
raise _SessionRejected()
|
|
448
|
+
error = _error_for(response, op)
|
|
449
|
+
if isinstance(error, ClawDBRateLimitError) and attempt < _MAX_ATTEMPTS - 1:
|
|
450
|
+
await asyncio.sleep(error.retry_after_ms / 1000.0)
|
|
451
|
+
continue
|
|
452
|
+
if not isinstance(error, (ClawDBUnavailableError, ClawDBTimeoutError)) or attempt == _MAX_ATTEMPTS - 1:
|
|
453
|
+
raise error
|
|
454
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
455
|
+
raise ClawDBUnavailableError(f"{op}: retries exhausted")
|
|
456
|
+
|
|
457
|
+
async def call(self, op: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
458
|
+
payload = payload or {}
|
|
459
|
+
if op in ("Health", "CreateSession"):
|
|
460
|
+
return await self._send(op, payload, with_session=False)
|
|
461
|
+
await self._ensure_session()
|
|
462
|
+
try:
|
|
463
|
+
return await self._send(op, payload, with_session=True)
|
|
464
|
+
except _SessionRejected:
|
|
465
|
+
self.forget_session()
|
|
466
|
+
await self._ensure_session()
|
|
467
|
+
try:
|
|
468
|
+
return await self._send(op, payload, with_session=True)
|
|
469
|
+
except _SessionRejected:
|
|
470
|
+
raise ClawDBAuthError("The server rejected a freshly opened session") from None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def new_uuid() -> str:
|
|
474
|
+
return str(uuid.uuid4())
|