hebbrix 2.4.0__tar.gz → 2.4.1__tar.gz
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.
- {hebbrix-2.4.0 → hebbrix-2.4.1}/CHANGELOG.md +8 -0
- {hebbrix-2.4.0/hebbrix.egg-info → hebbrix-2.4.1}/PKG-INFO +35 -5
- {hebbrix-2.4.0 → hebbrix-2.4.1}/README.md +32 -3
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/__init__.py +1 -1
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/client.py +19 -2
- hebbrix-2.4.1/hebbrix/exceptions.py +135 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/resources.py +54 -11
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/sync_client.py +77 -17
- {hebbrix-2.4.0 → hebbrix-2.4.1/hebbrix.egg-info}/PKG-INFO +35 -5
- {hebbrix-2.4.0 → hebbrix-2.4.1}/pyproject.toml +3 -2
- {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_memories_resource.py +200 -1
- {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_openapi_parity.py +0 -50
- hebbrix-2.4.1/tests/test_sync_client.py +305 -0
- hebbrix-2.4.0/hebbrix/exceptions.py +0 -76
- hebbrix-2.4.0/tests/test_sync_client.py +0 -130
- {hebbrix-2.4.0 → hebbrix-2.4.1}/LICENSE +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/MANIFEST.in +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/chat.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix/models.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/SOURCES.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/setup.cfg +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.4.1}/tests/test_procedural_resource.py +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.1 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- Make every async and sync readiness deadline preserve the original durable
|
|
6
|
+
receipt and raise top-level `IndexingTimeoutError`, including single writes,
|
|
7
|
+
batches, inference jobs, and direct updates.
|
|
8
|
+
- Normalize memory/job/status/request/outbox/idempotency recovery metadata and
|
|
9
|
+
preserve transport-only `Location`, request, retry, event, and replay headers.
|
|
10
|
+
|
|
3
11
|
## 2.4.0 — 2026-08-27
|
|
4
12
|
|
|
5
13
|
- Reconcile every exported advanced method with the canonical public OpenAPI,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs
|
|
5
5
|
Author-email: Hebbrix Team <support@hebbrix.com>
|
|
6
6
|
Maintainer-email: Hebbrix Team <support@hebbrix.com>
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
Project-URL: Homepage, https://hebbrix.com
|
|
9
9
|
Project-URL: Documentation, https://docs.hebbrix.com
|
|
10
|
-
Project-URL: Source, https://
|
|
10
|
+
Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
|
|
11
|
+
Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
|
|
11
12
|
Project-URL: Support, https://www.hebbrix.com/contact
|
|
12
13
|
Project-URL: API Reference, https://api.hebbrix.com/docs
|
|
13
14
|
Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
|
|
@@ -46,7 +47,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
|
46
47
|
## Install
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
|
-
pip install hebbrix==2.4.
|
|
50
|
+
pip install hebbrix==2.4.1
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -87,8 +88,37 @@ converging; it is not a failure and does not justify a duplicate write.
|
|
|
87
88
|
When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
|
|
88
89
|
status URL. It returns only after `searchable=true`. If the caller's deadline
|
|
89
90
|
expires, it raises `IndexingTimeoutError`; the exception retains the original
|
|
90
|
-
receipt
|
|
91
|
-
|
|
91
|
+
receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
|
|
92
|
+
`outbox_event_id`, retry timing, and idempotency replay metadata when available.
|
|
93
|
+
The synchronous and asynchronous single, batch, inference-job, and update
|
|
94
|
+
readiness paths share this behavior. The SDK never repeats the write while it
|
|
95
|
+
polls.
|
|
96
|
+
|
|
97
|
+
Catch the typed deadline without discarding the durable acceptance:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from hebbrix import IndexingTimeoutError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
created = await client.memories.create(
|
|
104
|
+
content="Customer prefers concise replies",
|
|
105
|
+
wait_for_index=True,
|
|
106
|
+
idempotency_key="customer-42-preference-v1",
|
|
107
|
+
index_timeout=5,
|
|
108
|
+
)
|
|
109
|
+
except IndexingTimeoutError as exc:
|
|
110
|
+
# Resume observation; do not submit an unrelated second write.
|
|
111
|
+
if exc.memory_ids:
|
|
112
|
+
created = await client.memories.wait_until_searchable(exc.memory_ids[0])
|
|
113
|
+
elif exc.job_id:
|
|
114
|
+
created = await client.memory_jobs.wait(exc.job_id)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
For a timed-out batch, pass `exc.receipt` to
|
|
118
|
+
`memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
|
|
119
|
+
same body with `exc.idempotency_key`; a changed body with the same key is
|
|
120
|
+
rejected by the API rather than creating a second logical write. For an update,
|
|
121
|
+
resume polling `exc.memory_ids[0]` because the relational edit already committed.
|
|
92
122
|
|
|
93
123
|
For an asynchronous batch receipt:
|
|
94
124
|
|
|
@@ -5,7 +5,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pip install hebbrix==2.4.
|
|
8
|
+
pip install hebbrix==2.4.1
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -46,8 +46,37 @@ converging; it is not a failure and does not justify a duplicate write.
|
|
|
46
46
|
When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
|
|
47
47
|
status URL. It returns only after `searchable=true`. If the caller's deadline
|
|
48
48
|
expires, it raises `IndexingTimeoutError`; the exception retains the original
|
|
49
|
-
receipt
|
|
50
|
-
|
|
49
|
+
receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
|
|
50
|
+
`outbox_event_id`, retry timing, and idempotency replay metadata when available.
|
|
51
|
+
The synchronous and asynchronous single, batch, inference-job, and update
|
|
52
|
+
readiness paths share this behavior. The SDK never repeats the write while it
|
|
53
|
+
polls.
|
|
54
|
+
|
|
55
|
+
Catch the typed deadline without discarding the durable acceptance:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from hebbrix import IndexingTimeoutError
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
created = await client.memories.create(
|
|
62
|
+
content="Customer prefers concise replies",
|
|
63
|
+
wait_for_index=True,
|
|
64
|
+
idempotency_key="customer-42-preference-v1",
|
|
65
|
+
index_timeout=5,
|
|
66
|
+
)
|
|
67
|
+
except IndexingTimeoutError as exc:
|
|
68
|
+
# Resume observation; do not submit an unrelated second write.
|
|
69
|
+
if exc.memory_ids:
|
|
70
|
+
created = await client.memories.wait_until_searchable(exc.memory_ids[0])
|
|
71
|
+
elif exc.job_id:
|
|
72
|
+
created = await client.memory_jobs.wait(exc.job_id)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For a timed-out batch, pass `exc.receipt` to
|
|
76
|
+
`memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
|
|
77
|
+
same body with `exc.idempotency_key`; a changed body with the same key is
|
|
78
|
+
rejected by the API rather than creating a second logical write. For an update,
|
|
79
|
+
resume polling `exc.memory_ids[0]` because the relational edit already committed.
|
|
51
80
|
|
|
52
81
|
For an asynchronous batch receipt:
|
|
53
82
|
|
|
@@ -90,7 +90,7 @@ class MemoryClient:
|
|
|
90
90
|
"""Get request headers."""
|
|
91
91
|
headers = {
|
|
92
92
|
"Content-Type": "application/json",
|
|
93
|
-
"User-Agent": "hebbrix-python/2.4.
|
|
93
|
+
"User-Agent": "hebbrix-python/2.4.1",
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
if self.api_key:
|
|
@@ -200,7 +200,24 @@ class MemoryClient:
|
|
|
200
200
|
if response.status_code >= 400:
|
|
201
201
|
self._handle_error(response)
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
payload = response.json() if response.text else {}
|
|
204
|
+
if isinstance(payload, dict):
|
|
205
|
+
# Preserve transport-only recovery identifiers on durable 202
|
|
206
|
+
# receipts. The resource layer needs these values if its local
|
|
207
|
+
# readiness deadline expires after the write has committed.
|
|
208
|
+
recovery_headers = {
|
|
209
|
+
"request_id": response.headers.get("X-Request-ID"),
|
|
210
|
+
"status_url": response.headers.get("Location"),
|
|
211
|
+
"outbox_event_id": response.headers.get("X-Hebbrix-Index-Event"),
|
|
212
|
+
"retry_after": response.headers.get("Retry-After"),
|
|
213
|
+
}
|
|
214
|
+
for key, value in recovery_headers.items():
|
|
215
|
+
if value and not payload.get(key):
|
|
216
|
+
payload[key] = value
|
|
217
|
+
replay = response.headers.get("X-Idempotent-Replay")
|
|
218
|
+
if replay is not None and "idempotency_replay" not in payload:
|
|
219
|
+
payload["idempotency_replay"] = replay.casefold() == "true"
|
|
220
|
+
return payload
|
|
204
221
|
|
|
205
222
|
async def get(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
206
223
|
"""Make a GET request."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hebbrix SDK Exceptions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HebbrixError(Exception):
|
|
7
|
+
"""Base exception for Hebbrix SDK."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: int = None,
|
|
13
|
+
*,
|
|
14
|
+
code: str = None,
|
|
15
|
+
request_id: str = None,
|
|
16
|
+
details: dict = None,
|
|
17
|
+
):
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.code = code
|
|
21
|
+
self.request_id = request_id
|
|
22
|
+
self.details = details or {}
|
|
23
|
+
super().__init__(self.message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EntitlementError(HebbrixError):
|
|
27
|
+
"""The authenticated account lacks the plan or role for an operation."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, *, status_code: int, **kwargs):
|
|
30
|
+
super().__init__(message, status_code=status_code, **kwargs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IndexingTimeoutError(TimeoutError):
|
|
34
|
+
"""A durable write did not become searchable before the client deadline.
|
|
35
|
+
|
|
36
|
+
The write has already committed when this exception is raised. Recovery
|
|
37
|
+
metadata therefore lives on the exception so callers can resume polling or
|
|
38
|
+
safely replay the *same* request with the same idempotency key instead of
|
|
39
|
+
issuing an uncorrelated duplicate write.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
message: str,
|
|
45
|
+
receipt: dict,
|
|
46
|
+
*,
|
|
47
|
+
idempotency_key: str = None,
|
|
48
|
+
):
|
|
49
|
+
self.receipt = dict(receipt or {})
|
|
50
|
+
|
|
51
|
+
candidates = [
|
|
52
|
+
*(self.receipt.get("memory_ids") or []),
|
|
53
|
+
self.receipt.get("memory_id"),
|
|
54
|
+
self.receipt.get("id"),
|
|
55
|
+
*(
|
|
56
|
+
item.get("memory_id") or item.get("id")
|
|
57
|
+
for item in (self.receipt.get("results") or [])
|
|
58
|
+
if isinstance(item, dict)
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
self.memory_ids = list(
|
|
62
|
+
dict.fromkeys(str(value) for value in candidates if value)
|
|
63
|
+
)
|
|
64
|
+
self.job_id = self.receipt.get("job_id")
|
|
65
|
+
self.status_url = self.receipt.get("status_url")
|
|
66
|
+
if not self.status_url and self.memory_ids:
|
|
67
|
+
self.status_url = f"/v1/memories/{self.memory_ids[0]}"
|
|
68
|
+
if not self.status_url and self.job_id:
|
|
69
|
+
self.status_url = f"/v1/memory-jobs/{self.job_id}"
|
|
70
|
+
|
|
71
|
+
self.request_id = self.receipt.get("request_id")
|
|
72
|
+
self.outbox_event_id = self.receipt.get("outbox_event_id")
|
|
73
|
+
self.indexing_event_id = (
|
|
74
|
+
self.receipt.get("indexing_event_id") or self.outbox_event_id
|
|
75
|
+
)
|
|
76
|
+
self.event_id = self.receipt.get("event_id") or self.indexing_event_id
|
|
77
|
+
self.idempotency_key = idempotency_key or self.receipt.get("idempotency_key")
|
|
78
|
+
self.idempotency_replay = self.receipt.get(
|
|
79
|
+
"idempotency_replay",
|
|
80
|
+
self.receipt.get("idempotency_replayed"),
|
|
81
|
+
)
|
|
82
|
+
self.retry_after = self.receipt.get("retry_after")
|
|
83
|
+
self.recovery = {
|
|
84
|
+
key: value
|
|
85
|
+
for key, value in {
|
|
86
|
+
"memory_ids": list(self.memory_ids),
|
|
87
|
+
"job_id": self.job_id,
|
|
88
|
+
"status_url": self.status_url,
|
|
89
|
+
"request_id": self.request_id,
|
|
90
|
+
"outbox_event_id": self.outbox_event_id,
|
|
91
|
+
"indexing_event_id": self.indexing_event_id,
|
|
92
|
+
"event_id": self.event_id,
|
|
93
|
+
"idempotency_key": self.idempotency_key,
|
|
94
|
+
"idempotency_replay": self.idempotency_replay,
|
|
95
|
+
"retry_after": self.retry_after,
|
|
96
|
+
}.items()
|
|
97
|
+
if value not in (None, "", [])
|
|
98
|
+
}
|
|
99
|
+
super().__init__(message)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AuthenticationError(HebbrixError):
|
|
103
|
+
"""Raised when authentication fails."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, message: str = "Authentication failed", **kwargs):
|
|
106
|
+
super().__init__(message, status_code=401, **kwargs)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ValidationError(HebbrixError):
|
|
110
|
+
"""Raised when request validation fails."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, errors: list = None, **kwargs):
|
|
113
|
+
self.errors = errors or []
|
|
114
|
+
super().__init__(message, status_code=422, **kwargs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class NotFoundError(HebbrixError):
|
|
118
|
+
"""Raised when a resource is not found."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, message: str = "Resource not found", **kwargs):
|
|
121
|
+
super().__init__(message, status_code=404, **kwargs)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RateLimitError(HebbrixError):
|
|
125
|
+
"""Raised when rate limit is exceeded."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, message: str = "Rate limit exceeded", **kwargs):
|
|
128
|
+
super().__init__(message, status_code=429, **kwargs)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ServerError(HebbrixError):
|
|
132
|
+
"""Raised when server returns 5xx error."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, message: str = "Internal server error", **kwargs):
|
|
135
|
+
super().__init__(message, status_code=500, **kwargs)
|
|
@@ -457,6 +457,7 @@ class MemoriesResource(BaseResource):
|
|
|
457
457
|
receipt,
|
|
458
458
|
timeout=index_timeout,
|
|
459
459
|
poll_interval=index_poll_interval,
|
|
460
|
+
idempotency_key=idempotency_key,
|
|
460
461
|
)
|
|
461
462
|
return receipt
|
|
462
463
|
|
|
@@ -505,11 +506,15 @@ class MemoriesResource(BaseResource):
|
|
|
505
506
|
if idempotency_key:
|
|
506
507
|
kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
507
508
|
receipt = await self.client.post("/v1/memories/batch", **kwargs)
|
|
508
|
-
if wait_for_index and not
|
|
509
|
+
if wait_for_index and not (
|
|
510
|
+
receipt.get("searchable") is True
|
|
511
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
512
|
+
):
|
|
509
513
|
receipt = await self.wait_batch_until_searchable(
|
|
510
514
|
receipt,
|
|
511
515
|
timeout=index_timeout,
|
|
512
516
|
poll_interval=index_poll_interval,
|
|
517
|
+
idempotency_key=idempotency_key,
|
|
513
518
|
)
|
|
514
519
|
return receipt
|
|
515
520
|
|
|
@@ -519,6 +524,7 @@ class MemoriesResource(BaseResource):
|
|
|
519
524
|
*,
|
|
520
525
|
timeout: float = 60.0,
|
|
521
526
|
poll_interval: float = 0.5,
|
|
527
|
+
idempotency_key: Optional[str] = None,
|
|
522
528
|
) -> Dict[str, Any]:
|
|
523
529
|
"""Poll every item in an asynchronous batch receipt to one terminal state.
|
|
524
530
|
|
|
@@ -537,11 +543,20 @@ class MemoriesResource(BaseResource):
|
|
|
537
543
|
)
|
|
538
544
|
for row in rows:
|
|
539
545
|
state = str(row.get("processing_status") or "").casefold()
|
|
546
|
+
if state == "completed" and row.get("searchable") is not True:
|
|
547
|
+
raise RuntimeError(
|
|
548
|
+
f"memory {row.get('id')} reported completed without "
|
|
549
|
+
"searchable=true"
|
|
550
|
+
)
|
|
540
551
|
if state in {"failed", "cancelled", "canceled"}:
|
|
541
552
|
raise RuntimeError(
|
|
542
553
|
f"memory {row.get('id')} indexing reached terminal state {state}"
|
|
543
554
|
)
|
|
544
|
-
if all(
|
|
555
|
+
if all(
|
|
556
|
+
row.get("searchable") is True
|
|
557
|
+
and str(row.get("processing_status") or "").casefold() == "completed"
|
|
558
|
+
for row in rows
|
|
559
|
+
):
|
|
545
560
|
return {
|
|
546
561
|
**receipt,
|
|
547
562
|
"processing_status": "completed",
|
|
@@ -556,10 +571,14 @@ class MemoriesResource(BaseResource):
|
|
|
556
571
|
],
|
|
557
572
|
}
|
|
558
573
|
if time.monotonic() >= deadline:
|
|
559
|
-
|
|
574
|
+
timeout_error = TimeoutError(
|
|
560
575
|
f"batch was not searchable within {timeout}s; the write is durable",
|
|
561
|
-
receipt,
|
|
562
576
|
)
|
|
577
|
+
raise IndexingTimeoutError(
|
|
578
|
+
str(timeout_error),
|
|
579
|
+
receipt,
|
|
580
|
+
idempotency_key=idempotency_key,
|
|
581
|
+
) from timeout_error
|
|
563
582
|
await asyncio.sleep(max(0.05, poll_interval))
|
|
564
583
|
|
|
565
584
|
async def wait_until_searchable(
|
|
@@ -597,6 +616,7 @@ class MemoriesResource(BaseResource):
|
|
|
597
616
|
*,
|
|
598
617
|
timeout: float,
|
|
599
618
|
poll_interval: float,
|
|
619
|
+
idempotency_key: Optional[str] = None,
|
|
600
620
|
) -> Dict[str, Any]:
|
|
601
621
|
if (
|
|
602
622
|
receipt.get("searchable") is True
|
|
@@ -619,9 +639,14 @@ class MemoriesResource(BaseResource):
|
|
|
619
639
|
f"memory job {job_id} reached terminal state {state}"
|
|
620
640
|
)
|
|
621
641
|
if time.monotonic() >= deadline:
|
|
622
|
-
|
|
642
|
+
timeout_error = TimeoutError(
|
|
623
643
|
f"memory job {job_id} did not become searchable within {timeout}s"
|
|
624
644
|
)
|
|
645
|
+
raise IndexingTimeoutError(
|
|
646
|
+
f"{timeout_error}; the write is durable",
|
|
647
|
+
receipt,
|
|
648
|
+
idempotency_key=idempotency_key,
|
|
649
|
+
) from timeout_error
|
|
625
650
|
await asyncio.sleep(max(0.05, poll_interval))
|
|
626
651
|
|
|
627
652
|
candidates = [
|
|
@@ -637,11 +662,19 @@ class MemoriesResource(BaseResource):
|
|
|
637
662
|
raise RuntimeError(
|
|
638
663
|
"wait_for_index response contained neither a memory id nor a job id"
|
|
639
664
|
)
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
665
|
+
try:
|
|
666
|
+
ready = await self.wait_until_searchable(
|
|
667
|
+
memory_id,
|
|
668
|
+
timeout=timeout,
|
|
669
|
+
poll_interval=poll_interval,
|
|
670
|
+
)
|
|
671
|
+
except TimeoutError as exc:
|
|
672
|
+
raise IndexingTimeoutError(
|
|
673
|
+
f"memory {memory_id} was not searchable within {timeout}s; "
|
|
674
|
+
"the write is durable",
|
|
675
|
+
receipt,
|
|
676
|
+
idempotency_key=idempotency_key,
|
|
677
|
+
) from exc
|
|
645
678
|
receipt["searchable"] = True
|
|
646
679
|
receipt["processing_status"] = "completed"
|
|
647
680
|
receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
|
|
@@ -793,6 +826,8 @@ class MemoriesResource(BaseResource):
|
|
|
793
826
|
importance: Optional[float] = None,
|
|
794
827
|
metadata: Optional[Dict[str, Any]] = None,
|
|
795
828
|
wait_for_index: Optional[bool] = None,
|
|
829
|
+
index_timeout: float = 60.0,
|
|
830
|
+
index_poll_interval: float = 0.5,
|
|
796
831
|
) -> Dict[str, Any]:
|
|
797
832
|
"""
|
|
798
833
|
Update a memory.
|
|
@@ -816,7 +851,15 @@ class MemoriesResource(BaseResource):
|
|
|
816
851
|
if wait_for_index is not None:
|
|
817
852
|
data["wait_for_index"] = wait_for_index
|
|
818
853
|
|
|
819
|
-
|
|
854
|
+
receipt = await self.client.patch(f"/v1/memories/{memory_id}", json=data)
|
|
855
|
+
if wait_for_index is True:
|
|
856
|
+
receipt = {**receipt, "id": receipt.get("id") or memory_id}
|
|
857
|
+
receipt = await self._ensure_searchable_receipt(
|
|
858
|
+
receipt,
|
|
859
|
+
timeout=index_timeout,
|
|
860
|
+
poll_interval=index_poll_interval,
|
|
861
|
+
)
|
|
862
|
+
return receipt
|
|
820
863
|
|
|
821
864
|
async def delete(self, memory_id: str) -> None:
|
|
822
865
|
"""
|
|
@@ -100,6 +100,7 @@ class SyncMemoriesResource:
|
|
|
100
100
|
receipt,
|
|
101
101
|
timeout=index_timeout,
|
|
102
102
|
poll_interval=index_poll_interval,
|
|
103
|
+
idempotency_key=idempotency_key,
|
|
103
104
|
)
|
|
104
105
|
return receipt
|
|
105
106
|
|
|
@@ -141,11 +142,15 @@ class SyncMemoriesResource:
|
|
|
141
142
|
if idempotency_key:
|
|
142
143
|
kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
143
144
|
receipt = self.client.post("/v1/memories/batch", **kwargs)
|
|
144
|
-
if wait_for_index and not
|
|
145
|
+
if wait_for_index and not (
|
|
146
|
+
receipt.get("searchable") is True
|
|
147
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
148
|
+
):
|
|
145
149
|
receipt = self.wait_batch_until_searchable(
|
|
146
150
|
receipt,
|
|
147
151
|
timeout=index_timeout,
|
|
148
152
|
poll_interval=index_poll_interval,
|
|
153
|
+
idempotency_key=idempotency_key,
|
|
149
154
|
)
|
|
150
155
|
return receipt
|
|
151
156
|
|
|
@@ -155,6 +160,7 @@ class SyncMemoriesResource:
|
|
|
155
160
|
*,
|
|
156
161
|
timeout: float = 60.0,
|
|
157
162
|
poll_interval: float = 0.5,
|
|
163
|
+
idempotency_key: Optional[str] = None,
|
|
158
164
|
) -> Dict[str, Any]:
|
|
159
165
|
"""Poll every item in an asynchronous batch receipt until searchable."""
|
|
160
166
|
|
|
@@ -166,11 +172,20 @@ class SyncMemoriesResource:
|
|
|
166
172
|
rows = [self.get(memory_id) for memory_id in memory_ids]
|
|
167
173
|
for row in rows:
|
|
168
174
|
state = str(row.get("processing_status") or "").casefold()
|
|
175
|
+
if state == "completed" and row.get("searchable") is not True:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"memory {row.get('id')} reported completed without "
|
|
178
|
+
"searchable=true"
|
|
179
|
+
)
|
|
169
180
|
if state in {"failed", "cancelled", "canceled"}:
|
|
170
181
|
raise RuntimeError(
|
|
171
182
|
f"memory {row.get('id')} indexing reached terminal state {state}"
|
|
172
183
|
)
|
|
173
|
-
if all(
|
|
184
|
+
if all(
|
|
185
|
+
row.get("searchable") is True
|
|
186
|
+
and str(row.get("processing_status") or "").casefold() == "completed"
|
|
187
|
+
for row in rows
|
|
188
|
+
):
|
|
174
189
|
return {
|
|
175
190
|
**receipt,
|
|
176
191
|
"processing_status": "completed",
|
|
@@ -185,10 +200,14 @@ class SyncMemoriesResource:
|
|
|
185
200
|
],
|
|
186
201
|
}
|
|
187
202
|
if time.monotonic() >= deadline:
|
|
188
|
-
|
|
203
|
+
timeout_error = TimeoutError(
|
|
189
204
|
f"batch was not searchable within {timeout}s; the write is durable",
|
|
190
|
-
receipt,
|
|
191
205
|
)
|
|
206
|
+
raise IndexingTimeoutError(
|
|
207
|
+
str(timeout_error),
|
|
208
|
+
receipt,
|
|
209
|
+
idempotency_key=idempotency_key,
|
|
210
|
+
) from timeout_error
|
|
192
211
|
time.sleep(max(0.05, poll_interval))
|
|
193
212
|
|
|
194
213
|
def wait_until_searchable(
|
|
@@ -224,6 +243,7 @@ class SyncMemoriesResource:
|
|
|
224
243
|
*,
|
|
225
244
|
timeout: float,
|
|
226
245
|
poll_interval: float,
|
|
246
|
+
idempotency_key: Optional[str] = None,
|
|
227
247
|
) -> Dict[str, Any]:
|
|
228
248
|
if (
|
|
229
249
|
receipt.get("searchable") is True
|
|
@@ -232,11 +252,19 @@ class SyncMemoriesResource:
|
|
|
232
252
|
return receipt
|
|
233
253
|
job_id = str(receipt.get("job_id") or "")
|
|
234
254
|
if job_id:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
255
|
+
try:
|
|
256
|
+
job = SyncMemoryJobsResource(self.client).wait(
|
|
257
|
+
job_id,
|
|
258
|
+
timeout=timeout,
|
|
259
|
+
poll_interval=poll_interval,
|
|
260
|
+
)
|
|
261
|
+
except TimeoutError as exc:
|
|
262
|
+
raise IndexingTimeoutError(
|
|
263
|
+
f"memory job {job_id} did not become searchable within {timeout}s; "
|
|
264
|
+
"the write is durable",
|
|
265
|
+
receipt,
|
|
266
|
+
idempotency_key=idempotency_key,
|
|
267
|
+
) from exc
|
|
240
268
|
if str(job.get("status") or "").casefold() != "completed":
|
|
241
269
|
raise RuntimeError(f"memory job {job_id} did not complete")
|
|
242
270
|
receipt.update(job)
|
|
@@ -256,11 +284,19 @@ class SyncMemoriesResource:
|
|
|
256
284
|
raise RuntimeError(
|
|
257
285
|
"wait_for_index response contained neither a memory id nor a job id"
|
|
258
286
|
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
287
|
+
try:
|
|
288
|
+
ready = self.wait_until_searchable(
|
|
289
|
+
memory_id,
|
|
290
|
+
timeout=timeout,
|
|
291
|
+
poll_interval=poll_interval,
|
|
292
|
+
)
|
|
293
|
+
except TimeoutError as exc:
|
|
294
|
+
raise IndexingTimeoutError(
|
|
295
|
+
f"memory {memory_id} was not searchable within {timeout}s; "
|
|
296
|
+
"the write is durable",
|
|
297
|
+
receipt,
|
|
298
|
+
idempotency_key=idempotency_key,
|
|
299
|
+
) from exc
|
|
264
300
|
receipt["searchable"] = True
|
|
265
301
|
receipt["processing_status"] = "completed"
|
|
266
302
|
receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
|
|
@@ -307,6 +343,8 @@ class SyncMemoriesResource:
|
|
|
307
343
|
importance: Optional[float] = None,
|
|
308
344
|
metadata: Optional[Dict[str, Any]] = None,
|
|
309
345
|
wait_for_index: Optional[bool] = None,
|
|
346
|
+
index_timeout: float = 60.0,
|
|
347
|
+
index_poll_interval: float = 0.5,
|
|
310
348
|
) -> Dict[str, Any]:
|
|
311
349
|
payload = {
|
|
312
350
|
"content": content,
|
|
@@ -314,10 +352,18 @@ class SyncMemoriesResource:
|
|
|
314
352
|
"metadata": metadata,
|
|
315
353
|
"wait_for_index": wait_for_index,
|
|
316
354
|
}
|
|
317
|
-
|
|
355
|
+
receipt = self.client.patch(
|
|
318
356
|
f"/v1/memories/{memory_id}",
|
|
319
357
|
json={key: value for key, value in payload.items() if value is not None},
|
|
320
358
|
)
|
|
359
|
+
if wait_for_index is True:
|
|
360
|
+
receipt = {**receipt, "id": receipt.get("id") or memory_id}
|
|
361
|
+
receipt = self._ensure_searchable_receipt(
|
|
362
|
+
receipt,
|
|
363
|
+
timeout=index_timeout,
|
|
364
|
+
poll_interval=index_poll_interval,
|
|
365
|
+
)
|
|
366
|
+
return receipt
|
|
321
367
|
|
|
322
368
|
def delete(self, memory_id: str) -> Dict[str, Any]:
|
|
323
369
|
return self.client.delete(f"/v1/memories/{memory_id}")
|
|
@@ -743,7 +789,7 @@ class SyncMemoryClient:
|
|
|
743
789
|
self.source = source or os.getenv("HEBBRIX_SOURCE")
|
|
744
790
|
headers = {
|
|
745
791
|
"Content-Type": "application/json",
|
|
746
|
-
"User-Agent": "hebbrix-python/2.4.
|
|
792
|
+
"User-Agent": "hebbrix-python/2.4.1",
|
|
747
793
|
}
|
|
748
794
|
if api_key:
|
|
749
795
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
@@ -833,7 +879,21 @@ class SyncMemoryClient:
|
|
|
833
879
|
response = self._client.request(method, path, **kwargs)
|
|
834
880
|
if response.status_code >= 400:
|
|
835
881
|
self._handle_error(response)
|
|
836
|
-
|
|
882
|
+
payload = response.json() if response.text else {}
|
|
883
|
+
if isinstance(payload, dict):
|
|
884
|
+
recovery_headers = {
|
|
885
|
+
"request_id": response.headers.get("X-Request-ID"),
|
|
886
|
+
"status_url": response.headers.get("Location"),
|
|
887
|
+
"outbox_event_id": response.headers.get("X-Hebbrix-Index-Event"),
|
|
888
|
+
"retry_after": response.headers.get("Retry-After"),
|
|
889
|
+
}
|
|
890
|
+
for key, value in recovery_headers.items():
|
|
891
|
+
if value and not payload.get(key):
|
|
892
|
+
payload[key] = value
|
|
893
|
+
replay = response.headers.get("X-Idempotent-Replay")
|
|
894
|
+
if replay is not None and "idempotency_replay" not in payload:
|
|
895
|
+
payload["idempotency_replay"] = replay.casefold() == "true"
|
|
896
|
+
return payload
|
|
837
897
|
|
|
838
898
|
def get(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
839
899
|
return self.request("GET", path, **kwargs)
|