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 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()