relata-sdk 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.
- relata/__init__.py +127 -0
- relata/_http.py +405 -0
- relata/a2a.py +157 -0
- relata/audit.py +237 -0
- relata/backup.py +257 -0
- relata/client.py +597 -0
- relata/exceptions.py +203 -0
- relata/governance.py +499 -0
- relata/identity.py +267 -0
- relata/ingest.py +209 -0
- relata/log.py +162 -0
- relata/mcp.py +383 -0
- relata/memory.py +479 -0
- relata/models.py +354 -0
- relata/objects.py +228 -0
- relata/query.py +601 -0
- relata/s3.py +190 -0
- relata/streaming.py +320 -0
- relata/system.py +129 -0
- relata/tenants.py +246 -0
- relata/tokens.py +167 -0
- relata/vectors.py +209 -0
- relata_adapters/__init__.py +20 -0
- relata_adapters/_base.py +87 -0
- relata_adapters/ag2.py +85 -0
- relata_adapters/autogen.py +50 -0
- relata_adapters/crewai.py +56 -0
- relata_adapters/langchain.py +64 -0
- relata_adapters/llamaindex.py +60 -0
- relata_adapters/pydantic_ai.py +88 -0
- relata_adapters/registry.py +76 -0
- relata_adapters/smolagents.py +88 -0
- relata_langgraph/__init__.py +180 -0
- relata_sdk-0.2.0.dist-info/METADATA +407 -0
- relata_sdk-0.2.0.dist-info/RECORD +36 -0
- relata_sdk-0.2.0.dist-info/WHEEL +4 -0
relata/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Relata Python SDK
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Python SDK for the `Relata <https://relata.io>`_ enterprise-grade data
|
|
6
|
+
engine. Relata is a Rust-native ontology-driven engine for bi-temporal,
|
|
7
|
+
governed knowledge workloads: link analysis, identity resolution, full-text
|
|
8
|
+
and vector search, access-scoped restricted data, and provenance-tracked
|
|
9
|
+
analytics.
|
|
10
|
+
|
|
11
|
+
Quick start::
|
|
12
|
+
|
|
13
|
+
from relata import RelataClient
|
|
14
|
+
|
|
15
|
+
with RelataClient("http://localhost:8080", purpose="analytics") as client:
|
|
16
|
+
# Simple query
|
|
17
|
+
result = client.query("SELECT * FROM Person WHERE name LIKE 'Ahmed%' LIMIT 10")
|
|
18
|
+
for row in result:
|
|
19
|
+
print(row)
|
|
20
|
+
|
|
21
|
+
# Fluent query builder
|
|
22
|
+
result = (
|
|
23
|
+
client.select("Person")
|
|
24
|
+
.where("nationality = 'IN'")
|
|
25
|
+
.as_of("2025-01-01")
|
|
26
|
+
.with_provenance()
|
|
27
|
+
.limit(20)
|
|
28
|
+
.execute()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
Public API
|
|
32
|
+
----------
|
|
33
|
+
- :class:`~relata.client.RelataClient` — main client (sync + async)
|
|
34
|
+
- :class:`~relata.query.QueryBuilder` — fluent SQL builder
|
|
35
|
+
- :mod:`~relata.models` — Pydantic response models
|
|
36
|
+
- :mod:`~relata.exceptions` — SDK exception hierarchy
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from relata.a2a import A2AClient, AsyncA2AClient
|
|
40
|
+
from relata.audit import AsyncAuditClient, AuditClient
|
|
41
|
+
from relata.client import RelataClient
|
|
42
|
+
from relata.exceptions import (
|
|
43
|
+
AuthError,
|
|
44
|
+
ConnectionError,
|
|
45
|
+
PurposeError,
|
|
46
|
+
QuotaError,
|
|
47
|
+
RelataError,
|
|
48
|
+
ServerError,
|
|
49
|
+
)
|
|
50
|
+
from relata.governance import AsyncGovernanceClient, GovernanceClient
|
|
51
|
+
from relata.identity import AsyncIdentityClient, IdentityClient
|
|
52
|
+
from relata.ingest import AsyncIngestClient, IngestClient
|
|
53
|
+
from relata.mcp import AsyncMcpClient, McpClient
|
|
54
|
+
from relata.memory import AsyncMemory, Memory
|
|
55
|
+
from relata.models import (
|
|
56
|
+
AuditCountResponse,
|
|
57
|
+
ClusterNode,
|
|
58
|
+
HealthResponse,
|
|
59
|
+
IngestDocumentResponse,
|
|
60
|
+
QueryResult,
|
|
61
|
+
ReadyReport,
|
|
62
|
+
Stats,
|
|
63
|
+
StatusResponse,
|
|
64
|
+
VersionInfo,
|
|
65
|
+
)
|
|
66
|
+
from relata.objects import AsyncObjectClient, ObjectClient
|
|
67
|
+
from relata.query import QueryBuilder, select
|
|
68
|
+
from relata.s3 import AsyncS3Client, S3Client
|
|
69
|
+
from relata.streaming import AsyncStreamingClient, StreamingClient
|
|
70
|
+
from relata.system import AsyncSystemClient, SystemClient
|
|
71
|
+
from relata.tenants import AsyncTenantAdminClient, TenantAdminClient
|
|
72
|
+
from relata.vectors import AsyncVectorClient, VectorClient
|
|
73
|
+
|
|
74
|
+
__version__ = "0.2.0"
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
# Client
|
|
78
|
+
"RelataClient",
|
|
79
|
+
# High-level memory client
|
|
80
|
+
"Memory",
|
|
81
|
+
"AsyncMemory",
|
|
82
|
+
# v1.1 SDK modules
|
|
83
|
+
"GovernanceClient",
|
|
84
|
+
"AsyncGovernanceClient",
|
|
85
|
+
"McpClient",
|
|
86
|
+
"AsyncMcpClient",
|
|
87
|
+
"A2AClient",
|
|
88
|
+
"AsyncA2AClient",
|
|
89
|
+
"AuditClient",
|
|
90
|
+
"AsyncAuditClient",
|
|
91
|
+
"IdentityClient",
|
|
92
|
+
"AsyncIdentityClient",
|
|
93
|
+
"ObjectClient",
|
|
94
|
+
"AsyncObjectClient",
|
|
95
|
+
"IngestClient",
|
|
96
|
+
"AsyncIngestClient",
|
|
97
|
+
"VectorClient",
|
|
98
|
+
"AsyncVectorClient",
|
|
99
|
+
"S3Client",
|
|
100
|
+
"AsyncS3Client",
|
|
101
|
+
"SystemClient",
|
|
102
|
+
"AsyncSystemClient",
|
|
103
|
+
"StreamingClient",
|
|
104
|
+
"AsyncStreamingClient",
|
|
105
|
+
"TenantAdminClient",
|
|
106
|
+
"AsyncTenantAdminClient",
|
|
107
|
+
# Query builder
|
|
108
|
+
"QueryBuilder",
|
|
109
|
+
"select",
|
|
110
|
+
# Models
|
|
111
|
+
"QueryResult",
|
|
112
|
+
"HealthResponse",
|
|
113
|
+
"StatusResponse",
|
|
114
|
+
"AuditCountResponse",
|
|
115
|
+
"ClusterNode",
|
|
116
|
+
"IngestDocumentResponse",
|
|
117
|
+
"VersionInfo",
|
|
118
|
+
"Stats",
|
|
119
|
+
"ReadyReport",
|
|
120
|
+
# Exceptions
|
|
121
|
+
"RelataError",
|
|
122
|
+
"PurposeError",
|
|
123
|
+
"QuotaError",
|
|
124
|
+
"AuthError",
|
|
125
|
+
"ConnectionError",
|
|
126
|
+
"ServerError",
|
|
127
|
+
]
|
relata/_http.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP transport layer.
|
|
3
|
+
|
|
4
|
+
This module is **not** part of the public API. All internals are subject to
|
|
5
|
+
change without notice. Callers should use :class:`relata.client.RelataClient`
|
|
6
|
+
instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from relata.exceptions import (
|
|
18
|
+
AuthError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
ForbiddenError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PurposeError,
|
|
24
|
+
RateLimitedError,
|
|
25
|
+
RelataError,
|
|
26
|
+
ServerError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Request / response content-type we always send and expect.
|
|
31
|
+
_CONTENT_TYPE = "application/json"
|
|
32
|
+
|
|
33
|
+
# Server-side error messages that signal a missing/invalid purpose.
|
|
34
|
+
_PURPOSE_HINTS = frozenset(
|
|
35
|
+
[
|
|
36
|
+
"purpose",
|
|
37
|
+
"purposeregistry",
|
|
38
|
+
"purpose_registry",
|
|
39
|
+
"missing purpose",
|
|
40
|
+
"invalid purpose",
|
|
41
|
+
"unregistered purpose",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Default retry config (override via RelataClient constructor).
|
|
46
|
+
_DEFAULT_RETRY_ON: frozenset[int] = frozenset({502, 503, 504})
|
|
47
|
+
_DEFAULT_MAX_RETRIES = 0 # off by default — caller must opt in
|
|
48
|
+
_DEFAULT_RETRY_BACKOFF = 0.5
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _classify_error(
|
|
52
|
+
status_code: int,
|
|
53
|
+
body: dict[str, Any],
|
|
54
|
+
*,
|
|
55
|
+
request_id: str | None = None,
|
|
56
|
+
retry_after: float | None = None,
|
|
57
|
+
) -> RelataError:
|
|
58
|
+
"""Map an HTTP error response to the most-specific SDK exception.
|
|
59
|
+
|
|
60
|
+
Parses both the RFC 7807 ``application/problem+json`` shape (``code``,
|
|
61
|
+
``type``, ``detail``, ``retryable``, ``request_id``) and the legacy
|
|
62
|
+
``{"error": "..."}`` shape.
|
|
63
|
+
"""
|
|
64
|
+
# RFC 7807 fields — present when the server emits problem+json.
|
|
65
|
+
code: str | None = body.get("code")
|
|
66
|
+
type_url: str | None = body.get("type")
|
|
67
|
+
retryable: bool = bool(body.get("retryable", False))
|
|
68
|
+
rid = request_id or body.get("request_id")
|
|
69
|
+
detail: str = (
|
|
70
|
+
body.get("detail") or body.get("error")
|
|
71
|
+
or body.get("message") or "unknown server error"
|
|
72
|
+
)
|
|
73
|
+
lower_detail = detail.lower()
|
|
74
|
+
|
|
75
|
+
# Shared kwargs for every subclass.
|
|
76
|
+
base_kwargs: dict[str, Any] = {
|
|
77
|
+
"code": code,
|
|
78
|
+
"type_url": type_url,
|
|
79
|
+
"retryable": retryable,
|
|
80
|
+
"request_id": rid,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Purpose-specific detection — works for both legacy and problem+json.
|
|
84
|
+
is_purpose = status_code == 400 and (
|
|
85
|
+
(code and "purpose" in code.lower())
|
|
86
|
+
or any(hint in lower_detail for hint in _PURPOSE_HINTS)
|
|
87
|
+
)
|
|
88
|
+
if is_purpose:
|
|
89
|
+
return PurposeError(
|
|
90
|
+
f"Purpose rejected: {detail}. "
|
|
91
|
+
"Pass purpose= to RelataClient.query() or set a default on the client.",
|
|
92
|
+
status_code=status_code,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if status_code == 401:
|
|
96
|
+
return AuthError(
|
|
97
|
+
f"Authentication failed: {detail}. ",
|
|
98
|
+
status_code=status_code,
|
|
99
|
+
**base_kwargs,
|
|
100
|
+
)
|
|
101
|
+
if status_code == 403:
|
|
102
|
+
return ForbiddenError(
|
|
103
|
+
f"Forbidden: {detail}",
|
|
104
|
+
status_code=status_code,
|
|
105
|
+
**base_kwargs,
|
|
106
|
+
)
|
|
107
|
+
if status_code == 404:
|
|
108
|
+
return NotFoundError(
|
|
109
|
+
f"Not found: {detail}",
|
|
110
|
+
status_code=status_code,
|
|
111
|
+
**base_kwargs,
|
|
112
|
+
)
|
|
113
|
+
if status_code == 409:
|
|
114
|
+
return ConflictError(
|
|
115
|
+
f"Conflict: {detail}",
|
|
116
|
+
status_code=status_code,
|
|
117
|
+
**base_kwargs,
|
|
118
|
+
)
|
|
119
|
+
if status_code == 422:
|
|
120
|
+
return ValidationError(
|
|
121
|
+
f"Validation error: {detail}",
|
|
122
|
+
status_code=status_code,
|
|
123
|
+
**base_kwargs,
|
|
124
|
+
)
|
|
125
|
+
if status_code == 429:
|
|
126
|
+
return RateLimitedError(
|
|
127
|
+
f"Rate limited: {detail}",
|
|
128
|
+
status_code=status_code,
|
|
129
|
+
retry_after=retry_after,
|
|
130
|
+
**base_kwargs,
|
|
131
|
+
)
|
|
132
|
+
if status_code >= 500:
|
|
133
|
+
return ServerError(
|
|
134
|
+
f"Server error: {detail}",
|
|
135
|
+
status_code=status_code,
|
|
136
|
+
**base_kwargs,
|
|
137
|
+
)
|
|
138
|
+
return RelataError(detail, status_code=status_code, **base_kwargs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_headers(
|
|
142
|
+
bearer_token: str | None,
|
|
143
|
+
extra: dict[str, str] | None = None,
|
|
144
|
+
) -> dict[str, str]:
|
|
145
|
+
headers: dict[str, str] = {
|
|
146
|
+
"Content-Type": _CONTENT_TYPE,
|
|
147
|
+
"Accept": _CONTENT_TYPE,
|
|
148
|
+
"User-Agent": "relata-sdk-python/0.1.0",
|
|
149
|
+
}
|
|
150
|
+
if bearer_token:
|
|
151
|
+
headers["Authorization"] = f"Bearer {bearer_token}"
|
|
152
|
+
if extra:
|
|
153
|
+
# Caller-supplied headers win over the SDK defaults so tenants,
|
|
154
|
+
# acting-as, request-id, etc. can be pinned per-request.
|
|
155
|
+
headers.update(extra)
|
|
156
|
+
return headers
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Sync transport
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class HttpTransport:
|
|
165
|
+
"""Synchronous HTTP transport backed by :mod:`httpx`."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
base_url: str,
|
|
170
|
+
bearer_token: str | None,
|
|
171
|
+
timeout: float,
|
|
172
|
+
transport: httpx.BaseTransport | None = None,
|
|
173
|
+
extra_headers: dict[str, str] | None = None,
|
|
174
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
175
|
+
retry_backoff: float = _DEFAULT_RETRY_BACKOFF,
|
|
176
|
+
retry_on: frozenset[int] = _DEFAULT_RETRY_ON,
|
|
177
|
+
) -> None:
|
|
178
|
+
self._client = httpx.Client(
|
|
179
|
+
base_url=base_url.rstrip("/"),
|
|
180
|
+
headers=_build_headers(bearer_token, extra_headers),
|
|
181
|
+
timeout=timeout,
|
|
182
|
+
transport=transport,
|
|
183
|
+
)
|
|
184
|
+
self._max_retries = max_retries
|
|
185
|
+
self._retry_backoff = retry_backoff
|
|
186
|
+
self._retry_on = retry_on
|
|
187
|
+
|
|
188
|
+
def _request_id(self) -> str:
|
|
189
|
+
return str(uuid.uuid4())
|
|
190
|
+
|
|
191
|
+
def _send_with_retry(
|
|
192
|
+
self,
|
|
193
|
+
method: str,
|
|
194
|
+
path: str,
|
|
195
|
+
*,
|
|
196
|
+
json_payload: dict[str, Any] | None = None,
|
|
197
|
+
content: str | bytes | None = None,
|
|
198
|
+
headers: dict[str, str] | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
"""Send an HTTP request with retry + request_id propagation."""
|
|
201
|
+
# If the caller already set X-Request-ID in the default headers bag,
|
|
202
|
+
# respect it — only generate a per-attempt UUID when no default exists.
|
|
203
|
+
has_default_rid = "x-request-id" in {k.lower() for k in self._client.headers}
|
|
204
|
+
last_exc: RelataError | None = None
|
|
205
|
+
for attempt in range(self._max_retries + 1):
|
|
206
|
+
req_headers: dict[str, str] = {}
|
|
207
|
+
if not has_default_rid:
|
|
208
|
+
req_headers["X-Request-ID"] = self._request_id()
|
|
209
|
+
if headers:
|
|
210
|
+
req_headers.update(headers)
|
|
211
|
+
try:
|
|
212
|
+
if method == "GET":
|
|
213
|
+
resp = self._client.get(path, headers=req_headers)
|
|
214
|
+
elif method == "DELETE":
|
|
215
|
+
resp = self._client.delete(path, headers=req_headers)
|
|
216
|
+
elif json_payload is not None:
|
|
217
|
+
resp = self._client.request(
|
|
218
|
+
method, path, json=json_payload, headers=req_headers
|
|
219
|
+
)
|
|
220
|
+
elif content is not None:
|
|
221
|
+
resp = self._client.request(method, path, content=content, headers=req_headers)
|
|
222
|
+
else:
|
|
223
|
+
resp = self._client.request(method, path, headers=req_headers)
|
|
224
|
+
except httpx.ConnectError as exc:
|
|
225
|
+
last_exc = ConnectionError(
|
|
226
|
+
f"Cannot connect to Relata server: {exc}. "
|
|
227
|
+
"Check that the server is running and the base_url is correct."
|
|
228
|
+
)
|
|
229
|
+
if attempt < self._max_retries:
|
|
230
|
+
time.sleep(self._retry_backoff * (2**attempt))
|
|
231
|
+
continue
|
|
232
|
+
raise last_exc from exc
|
|
233
|
+
except httpx.TimeoutException as exc:
|
|
234
|
+
last_exc = ConnectionError(
|
|
235
|
+
f"Request timed out: {exc}. "
|
|
236
|
+
"Increase timeout= on RelataClient or check server health."
|
|
237
|
+
)
|
|
238
|
+
if attempt < self._max_retries:
|
|
239
|
+
time.sleep(self._retry_backoff * (2**attempt))
|
|
240
|
+
continue
|
|
241
|
+
raise last_exc from exc
|
|
242
|
+
|
|
243
|
+
# Success or non-retryable error — return / raise via _handle.
|
|
244
|
+
return self._handle(resp)
|
|
245
|
+
|
|
246
|
+
# Exhausted retries on a connection error.
|
|
247
|
+
assert last_exc is not None
|
|
248
|
+
raise last_exc
|
|
249
|
+
|
|
250
|
+
def get(self, path: str) -> dict[str, Any]:
|
|
251
|
+
"""Perform a GET request and return the decoded JSON body."""
|
|
252
|
+
return self._send_with_retry("GET", path)
|
|
253
|
+
|
|
254
|
+
def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
255
|
+
"""Perform a POST request with a JSON body and return the decoded JSON body."""
|
|
256
|
+
return self._send_with_retry("POST", path, json_payload=payload)
|
|
257
|
+
|
|
258
|
+
def delete(self, path: str) -> dict[str, Any]:
|
|
259
|
+
"""Perform a DELETE request and return the decoded JSON body."""
|
|
260
|
+
return self._send_with_retry("DELETE", path)
|
|
261
|
+
|
|
262
|
+
def patch(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
263
|
+
"""Perform a PATCH request with an optional JSON body."""
|
|
264
|
+
return self._send_with_retry("PATCH", path, json_payload=payload or {})
|
|
265
|
+
|
|
266
|
+
def put(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
267
|
+
"""Perform a PUT request with an optional JSON body."""
|
|
268
|
+
return self._send_with_retry("PUT", path, json_payload=payload or {})
|
|
269
|
+
|
|
270
|
+
def close(self) -> None:
|
|
271
|
+
self._client.close()
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _handle(resp: httpx.Response) -> dict[str, Any]:
|
|
275
|
+
if resp.is_success:
|
|
276
|
+
return resp.json() # type: ignore[no-any-return]
|
|
277
|
+
try:
|
|
278
|
+
body: dict[str, Any] = resp.json()
|
|
279
|
+
except Exception:
|
|
280
|
+
body = {"error": resp.text or "empty response"}
|
|
281
|
+
request_id = resp.headers.get("x-request-id")
|
|
282
|
+
retry_after_hdr = resp.headers.get("retry-after")
|
|
283
|
+
retry_after = float(retry_after_hdr) if retry_after_hdr else None
|
|
284
|
+
raise _classify_error(
|
|
285
|
+
resp.status_code,
|
|
286
|
+
body,
|
|
287
|
+
request_id=request_id,
|
|
288
|
+
retry_after=retry_after,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# Async transport
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class AsyncHttpTransport:
|
|
298
|
+
"""Asynchronous HTTP transport backed by :mod:`httpx`."""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
base_url: str,
|
|
303
|
+
bearer_token: str | None,
|
|
304
|
+
timeout: float,
|
|
305
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
306
|
+
extra_headers: dict[str, str] | None = None,
|
|
307
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
308
|
+
retry_backoff: float = _DEFAULT_RETRY_BACKOFF,
|
|
309
|
+
retry_on: frozenset[int] = _DEFAULT_RETRY_ON,
|
|
310
|
+
) -> None:
|
|
311
|
+
self._client = httpx.AsyncClient(
|
|
312
|
+
base_url=base_url.rstrip("/"),
|
|
313
|
+
headers=_build_headers(bearer_token, extra_headers),
|
|
314
|
+
timeout=timeout,
|
|
315
|
+
transport=transport,
|
|
316
|
+
)
|
|
317
|
+
self._max_retries = max_retries
|
|
318
|
+
self._retry_backoff = retry_backoff
|
|
319
|
+
self._retry_on = retry_on
|
|
320
|
+
|
|
321
|
+
def _request_id(self) -> str:
|
|
322
|
+
return str(uuid.uuid4())
|
|
323
|
+
|
|
324
|
+
async def _send_with_retry(
|
|
325
|
+
self,
|
|
326
|
+
method: str,
|
|
327
|
+
path: str,
|
|
328
|
+
*,
|
|
329
|
+
json_payload: dict[str, Any] | None = None,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""Send an async HTTP request with retry + request_id propagation."""
|
|
332
|
+
import asyncio
|
|
333
|
+
|
|
334
|
+
last_exc: RelataError | None = None
|
|
335
|
+
for attempt in range(self._max_retries + 1):
|
|
336
|
+
req_headers = {"X-Request-ID": self._request_id()}
|
|
337
|
+
try:
|
|
338
|
+
if method == "GET":
|
|
339
|
+
resp = await self._client.get(path, headers=req_headers)
|
|
340
|
+
elif method == "DELETE":
|
|
341
|
+
resp = await self._client.delete(path, headers=req_headers)
|
|
342
|
+
else:
|
|
343
|
+
resp = await self._client.request(
|
|
344
|
+
method, path, json=json_payload or {}, headers=req_headers
|
|
345
|
+
)
|
|
346
|
+
except httpx.ConnectError as exc:
|
|
347
|
+
last_exc = ConnectionError(
|
|
348
|
+
f"Cannot connect to Relata server: {exc}."
|
|
349
|
+
)
|
|
350
|
+
if attempt < self._max_retries:
|
|
351
|
+
await asyncio.sleep(self._retry_backoff * (2**attempt))
|
|
352
|
+
continue
|
|
353
|
+
raise last_exc from exc
|
|
354
|
+
except httpx.TimeoutException as exc:
|
|
355
|
+
last_exc = ConnectionError(
|
|
356
|
+
f"Request timed out: {exc}."
|
|
357
|
+
)
|
|
358
|
+
if attempt < self._max_retries:
|
|
359
|
+
await asyncio.sleep(self._retry_backoff * (2**attempt))
|
|
360
|
+
continue
|
|
361
|
+
raise last_exc from exc
|
|
362
|
+
return self._handle(resp)
|
|
363
|
+
assert last_exc is not None
|
|
364
|
+
raise last_exc
|
|
365
|
+
|
|
366
|
+
async def get(self, path: str) -> dict[str, Any]:
|
|
367
|
+
"""Perform an async GET request and return the decoded JSON body."""
|
|
368
|
+
return await self._send_with_retry("GET", path)
|
|
369
|
+
|
|
370
|
+
async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
371
|
+
"""Perform an async POST request with a JSON body."""
|
|
372
|
+
return await self._send_with_retry("POST", path, json_payload=payload)
|
|
373
|
+
|
|
374
|
+
async def delete(self, path: str) -> dict[str, Any]:
|
|
375
|
+
"""Perform an async DELETE request."""
|
|
376
|
+
return await self._send_with_retry("DELETE", path)
|
|
377
|
+
|
|
378
|
+
async def patch(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
379
|
+
"""Perform an async PATCH request with an optional JSON body."""
|
|
380
|
+
return await self._send_with_retry("PATCH", path, json_payload=payload or {})
|
|
381
|
+
|
|
382
|
+
async def put(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
383
|
+
"""Perform an async PUT request with an optional JSON body."""
|
|
384
|
+
return await self._send_with_retry("PUT", path, json_payload=payload or {})
|
|
385
|
+
|
|
386
|
+
async def aclose(self) -> None:
|
|
387
|
+
await self._client.aclose()
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _handle(resp: httpx.Response) -> dict[str, Any]:
|
|
391
|
+
if resp.is_success:
|
|
392
|
+
return resp.json() # type: ignore[no-any-return]
|
|
393
|
+
try:
|
|
394
|
+
body: dict[str, Any] = resp.json()
|
|
395
|
+
except Exception:
|
|
396
|
+
body = {"error": resp.text or "empty response"}
|
|
397
|
+
request_id = resp.headers.get("x-request-id")
|
|
398
|
+
retry_after_hdr = resp.headers.get("retry-after")
|
|
399
|
+
retry_after = float(retry_after_hdr) if retry_after_hdr else None
|
|
400
|
+
raise _classify_error(
|
|
401
|
+
resp.status_code,
|
|
402
|
+
body,
|
|
403
|
+
request_id=request_id,
|
|
404
|
+
retry_after=retry_after,
|
|
405
|
+
)
|
relata/a2a.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""A2A client — Agent-to-Agent task surface (#77 Phase 3).
|
|
2
|
+
|
|
3
|
+
Wraps ``/a2a/tasks`` (create), ``/a2a/tasks/:id`` (status),
|
|
4
|
+
``/a2a/tasks/:id/stream`` (SSE tail — pairs with the streaming ticket), and
|
|
5
|
+
``/a2a/checkpoints/:thread_id`` (LangGraph checkpointer backing store).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from relata._http import AsyncHttpTransport, HttpTransport
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from relata.client import RelataClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class A2AClient:
|
|
21
|
+
"""Synchronous A2A client — submit / poll / list tasks; manage checkpoints."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str,
|
|
26
|
+
*,
|
|
27
|
+
bearer_token: str | None = None,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
extra_headers: dict[str, str] | None = None,
|
|
30
|
+
transport: httpx.BaseTransport | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._t = HttpTransport(
|
|
33
|
+
base_url, bearer_token, timeout, transport=transport, extra_headers=extra_headers
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_client(cls, client: RelataClient) -> A2AClient:
|
|
38
|
+
return cls(
|
|
39
|
+
client._base_url,
|
|
40
|
+
bearer_token=client._bearer_token,
|
|
41
|
+
timeout=client._timeout,
|
|
42
|
+
extra_headers=client._extra_headers,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def submit_task(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
"""Submit a long-running A2A task. The ``payload`` matches the A2A
|
|
47
|
+
protocol — typically ``{"name": "...", "input": {...}, "skill": ...}``.
|
|
48
|
+
Returns the task record with its ``id`` and initial ``status``."""
|
|
49
|
+
return self._t.post("/a2a/tasks", payload)
|
|
50
|
+
|
|
51
|
+
def get_task(self, task_id: str) -> dict[str, Any]:
|
|
52
|
+
"""Poll a task's status. Returns ``{"id", "status", "result", "error", ...}``."""
|
|
53
|
+
return self._t.get(f"/a2a/tasks/{task_id}")
|
|
54
|
+
|
|
55
|
+
def list_checkpoints(self, thread_id: str) -> list[dict[str, Any]]:
|
|
56
|
+
"""List the LangGraph checkpoints for a thread (used by
|
|
57
|
+
:class:`relata_langgraph.RelataCheckpointer`)."""
|
|
58
|
+
data = self._t.get(f"/a2a/checkpoints/{thread_id}")
|
|
59
|
+
checkpoints = data.get("checkpoints") if isinstance(data, dict) else data
|
|
60
|
+
return checkpoints if isinstance(checkpoints, list) else []
|
|
61
|
+
|
|
62
|
+
def load_checkpoint(self, thread_id: str, checkpoint_id: str) -> dict[str, Any]:
|
|
63
|
+
"""Load a specific checkpoint by id (``GET /a2a/checkpoints/:t/:c``)."""
|
|
64
|
+
return self._t.get(f"/a2a/checkpoints/{thread_id}/{checkpoint_id}")
|
|
65
|
+
|
|
66
|
+
def save_checkpoint(
|
|
67
|
+
self,
|
|
68
|
+
thread_id: str,
|
|
69
|
+
checkpoint_id: str,
|
|
70
|
+
payload: dict[str, Any],
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Save (PUT) a checkpoint. ``payload`` matches the LangGraph
|
|
73
|
+
checkpointer shape (``{"checkpoint": ..., "metadata": ...}``).
|
|
74
|
+
Returns the server's save receipt."""
|
|
75
|
+
return self._t.put(
|
|
76
|
+
f"/a2a/checkpoints/{thread_id}/{checkpoint_id}",
|
|
77
|
+
payload,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def agent_card(self) -> dict[str, Any]:
|
|
81
|
+
"""Fetch the A2A agent card (``GET /.well-known/agent.json``).
|
|
82
|
+
|
|
83
|
+
The card advertises the server's A2A capabilities, skills, and
|
|
84
|
+
authentication modes to discovery clients."""
|
|
85
|
+
return self._t.get("/.well-known/agent.json")
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._t.close()
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> A2AClient:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *_exc: object) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncA2AClient:
|
|
98
|
+
"""Asynchronous A2A client — see :class:`A2AClient`."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
base_url: str,
|
|
103
|
+
*,
|
|
104
|
+
bearer_token: str | None = None,
|
|
105
|
+
timeout: float = 30.0,
|
|
106
|
+
extra_headers: dict[str, str] | None = None,
|
|
107
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._t = AsyncHttpTransport(
|
|
110
|
+
base_url, bearer_token, timeout, transport=transport, extra_headers=extra_headers
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_client(cls, client: RelataClient) -> AsyncA2AClient:
|
|
115
|
+
return cls(
|
|
116
|
+
client._base_url,
|
|
117
|
+
bearer_token=client._bearer_token,
|
|
118
|
+
timeout=client._timeout,
|
|
119
|
+
extra_headers=client._extra_headers,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def submit_task(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
123
|
+
return await self._t.post("/a2a/tasks", payload)
|
|
124
|
+
|
|
125
|
+
async def get_task(self, task_id: str) -> dict[str, Any]:
|
|
126
|
+
return await self._t.get(f"/a2a/tasks/{task_id}")
|
|
127
|
+
|
|
128
|
+
async def list_checkpoints(self, thread_id: str) -> list[dict[str, Any]]:
|
|
129
|
+
data = await self._t.get(f"/a2a/checkpoints/{thread_id}")
|
|
130
|
+
checkpoints = data.get("checkpoints") if isinstance(data, dict) else data
|
|
131
|
+
return checkpoints if isinstance(checkpoints, list) else []
|
|
132
|
+
|
|
133
|
+
async def load_checkpoint(self, thread_id: str, checkpoint_id: str) -> dict[str, Any]:
|
|
134
|
+
return await self._t.get(f"/a2a/checkpoints/{thread_id}/{checkpoint_id}")
|
|
135
|
+
|
|
136
|
+
async def save_checkpoint(
|
|
137
|
+
self,
|
|
138
|
+
thread_id: str,
|
|
139
|
+
checkpoint_id: str,
|
|
140
|
+
payload: dict[str, Any],
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
return await self._t.put(
|
|
143
|
+
f"/a2a/checkpoints/{thread_id}/{checkpoint_id}",
|
|
144
|
+
payload,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def agent_card(self) -> dict[str, Any]:
|
|
148
|
+
return await self._t.get("/.well-known/agent.json")
|
|
149
|
+
|
|
150
|
+
async def close(self) -> None:
|
|
151
|
+
await self._t.aclose()
|
|
152
|
+
|
|
153
|
+
async def __aenter__(self) -> AsyncA2AClient:
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
157
|
+
await self.close()
|