hebbrix 2.4.0__tar.gz → 2.5.0__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.5.0}/CHANGELOG.md +15 -0
- {hebbrix-2.4.0/hebbrix.egg-info → hebbrix-2.5.0}/PKG-INFO +57 -6
- {hebbrix-2.4.0 → hebbrix-2.5.0}/README.md +32 -3
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/__init__.py +1 -1
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/client.py +19 -2
- hebbrix-2.5.0/hebbrix/exceptions.py +135 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/resources.py +209 -14
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/sync_client.py +203 -17
- {hebbrix-2.4.0 → hebbrix-2.5.0/hebbrix.egg-info}/PKG-INFO +57 -6
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/SOURCES.txt +2 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/pyproject.toml +5 -4
- hebbrix-2.5.0/tests/test_evidence_loop_resource.py +158 -0
- hebbrix-2.5.0/tests/test_evidence_safety_boundary.py +42 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_memories_resource.py +200 -1
- {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_openapi_parity.py +0 -50
- hebbrix-2.5.0/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.5.0}/LICENSE +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/MANIFEST.in +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/chat.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix/models.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/setup.cfg +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.4.0 → hebbrix-2.5.0}/tests/test_procedural_resource.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.5.0 — 2026-09-07
|
|
4
|
+
|
|
5
|
+
- Add matching synchronous and asynchronous Evidence Loop methods: owner-managed verifiers, durable episodes, actual-execution claims, protected outcome delivery and evidence assessments.
|
|
6
|
+
- Preserve scope, replay, incomplete-outcome and permission receipts without converting recommendations into execution authority or falling back to caller-reported outcomes.
|
|
7
|
+
- Reject malformed, ambiguous or unknown evidence contracts before exposing unsupported synthesis; retain valid degraded evidence with its abstention signal.
|
|
8
|
+
- Protected ledger methods require the connected Evidence Loop backend. Separate verifier credentials and an independent execution/outcome check remain required; these methods do not execute actions.
|
|
9
|
+
|
|
10
|
+
## 2.4.1 — 2026-08-27
|
|
11
|
+
|
|
12
|
+
- Make every async and sync readiness deadline preserve the original durable
|
|
13
|
+
receipt and raise top-level `IndexingTimeoutError`, including single writes,
|
|
14
|
+
batches, inference jobs, and direct updates.
|
|
15
|
+
- Normalize memory/job/status/request/outbox/idempotency recovery metadata and
|
|
16
|
+
preserve transport-only `Location`, request, retry, event, and replay headers.
|
|
17
|
+
|
|
3
18
|
## 2.4.0 — 2026-08-27
|
|
4
19
|
|
|
5
20
|
- Reconcile every exported advanced method with the canonical public OpenAPI,
|
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
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
|
-
License
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Hebbrix
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
8
29
|
Project-URL: Homepage, https://hebbrix.com
|
|
9
30
|
Project-URL: Documentation, https://docs.hebbrix.com
|
|
10
|
-
Project-URL: Source, https://
|
|
31
|
+
Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
|
|
32
|
+
Project-URL: Issues, https://github.com/Hebbrix/hebbrix-python/issues
|
|
11
33
|
Project-URL: Support, https://www.hebbrix.com/contact
|
|
12
34
|
Project-URL: API Reference, https://api.hebbrix.com/docs
|
|
13
35
|
Keywords: ai,memory,agents,llm,chatbot,assistant,ml,reinforcement-learning,knowledge-graph,vector-search,rag,temporal,procedural-memory,working-memory
|
|
@@ -46,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
|
46
68
|
## Install
|
|
47
69
|
|
|
48
70
|
```bash
|
|
49
|
-
pip install hebbrix==2.
|
|
71
|
+
pip install hebbrix==2.5.0
|
|
50
72
|
```
|
|
51
73
|
|
|
52
74
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -87,8 +109,37 @@ converging; it is not a failure and does not justify a duplicate write.
|
|
|
87
109
|
When `wait_for_index=True`, the SDK accepts that receipt and polls the documented
|
|
88
110
|
status URL. It returns only after `searchable=true`. If the caller's deadline
|
|
89
111
|
expires, it raises `IndexingTimeoutError`; the exception retains the original
|
|
90
|
-
receipt
|
|
91
|
-
|
|
112
|
+
receipt plus normalized `memory_ids`, `job_id`, `status_url`, `request_id`,
|
|
113
|
+
`outbox_event_id`, retry timing, and idempotency replay metadata when available.
|
|
114
|
+
The synchronous and asynchronous single, batch, inference-job, and update
|
|
115
|
+
readiness paths share this behavior. The SDK never repeats the write while it
|
|
116
|
+
polls.
|
|
117
|
+
|
|
118
|
+
Catch the typed deadline without discarding the durable acceptance:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from hebbrix import IndexingTimeoutError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
created = await client.memories.create(
|
|
125
|
+
content="Customer prefers concise replies",
|
|
126
|
+
wait_for_index=True,
|
|
127
|
+
idempotency_key="customer-42-preference-v1",
|
|
128
|
+
index_timeout=5,
|
|
129
|
+
)
|
|
130
|
+
except IndexingTimeoutError as exc:
|
|
131
|
+
# Resume observation; do not submit an unrelated second write.
|
|
132
|
+
if exc.memory_ids:
|
|
133
|
+
created = await client.memories.wait_until_searchable(exc.memory_ids[0])
|
|
134
|
+
elif exc.job_id:
|
|
135
|
+
created = await client.memory_jobs.wait(exc.job_id)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For a timed-out batch, pass `exc.receipt` to
|
|
139
|
+
`memories.wait_batch_until_searchable(...)`. Alternatively, replay the exact
|
|
140
|
+
same body with `exc.idempotency_key`; a changed body with the same key is
|
|
141
|
+
rejected by the API rather than creating a second logical write. For an update,
|
|
142
|
+
resume polling `exc.memory_ids[0]` because the relational edit already committed.
|
|
92
143
|
|
|
93
144
|
For an asynchronous batch receipt:
|
|
94
145
|
|
|
@@ -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.
|
|
8
|
+
pip install hebbrix==2.5.0
|
|
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.
|
|
93
|
+
"User-Agent": "hebbrix-python/2.5.0",
|
|
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)
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
11
|
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
12
13
|
|
|
13
14
|
from hebbrix.exceptions import IndexingTimeoutError
|
|
14
15
|
from hebbrix.models import SearchSafetyEnvelope
|
|
@@ -97,9 +98,9 @@ def _canonical_search_envelope(
|
|
|
97
98
|
) -> Dict[str, Any]:
|
|
98
99
|
"""Validate API-owned evidence metadata and fail closed on contract drift."""
|
|
99
100
|
|
|
100
|
-
data = dict(response
|
|
101
|
-
|
|
102
|
-
rows =
|
|
101
|
+
data = dict(response) if isinstance(response, dict) else {}
|
|
102
|
+
raw_rows = data.get(rows_key)
|
|
103
|
+
rows = raw_rows if isinstance(raw_rows, list) else []
|
|
103
104
|
missing = sorted(_SEARCH_SAFETY_FIELDS.difference(data))
|
|
104
105
|
reason: Optional[str] = None
|
|
105
106
|
confidence = data.get("query_confidence")
|
|
@@ -117,6 +118,27 @@ def _canonical_search_envelope(
|
|
|
117
118
|
reason = "invalid_grounding_receipt"
|
|
118
119
|
elif not isinstance(data.get("evidence_ids"), list):
|
|
119
120
|
reason = "invalid_evidence_ids"
|
|
121
|
+
elif data.get("safety_contract_version") != "search-safety-v1":
|
|
122
|
+
reason = "unsupported_safety_contract_version"
|
|
123
|
+
elif not isinstance(raw_rows, list):
|
|
124
|
+
reason = "invalid_evidence_rows"
|
|
125
|
+
elif any(
|
|
126
|
+
not isinstance(value, str) or not value.strip()
|
|
127
|
+
for value in data["evidence_ids"]
|
|
128
|
+
):
|
|
129
|
+
reason = "invalid_evidence_ids"
|
|
130
|
+
elif len(set(data["evidence_ids"])) != len(data["evidence_ids"]):
|
|
131
|
+
reason = "duplicate_evidence_ids"
|
|
132
|
+
elif any(
|
|
133
|
+
not isinstance(row, dict)
|
|
134
|
+
or not isinstance(row.get("memory_id", row.get("id")), str)
|
|
135
|
+
or not row.get("memory_id", row.get("id", "")).strip()
|
|
136
|
+
or ("memory_id" in row and "id" in row and row["memory_id"] != row["id"])
|
|
137
|
+
for row in rows
|
|
138
|
+
):
|
|
139
|
+
reason = "invalid_evidence_row_identity"
|
|
140
|
+
elif not rows and data.get("no_match") is False:
|
|
141
|
+
reason = "no_evidence_rows"
|
|
120
142
|
else:
|
|
121
143
|
row_ids = {
|
|
122
144
|
str(row.get("memory_id") or row.get("id"))
|
|
@@ -143,6 +165,9 @@ def _canonical_search_envelope(
|
|
|
143
165
|
data["query_confidence"] = 0.0
|
|
144
166
|
data["evidence_ids"] = []
|
|
145
167
|
data["evidence_claims"] = []
|
|
168
|
+
if rows_key == "sources":
|
|
169
|
+
data["answer"] = None
|
|
170
|
+
data["citations"] = []
|
|
146
171
|
if reason:
|
|
147
172
|
data["sdk_safety_reason"] = reason
|
|
148
173
|
data["grounding"] = {
|
|
@@ -457,6 +482,7 @@ class MemoriesResource(BaseResource):
|
|
|
457
482
|
receipt,
|
|
458
483
|
timeout=index_timeout,
|
|
459
484
|
poll_interval=index_poll_interval,
|
|
485
|
+
idempotency_key=idempotency_key,
|
|
460
486
|
)
|
|
461
487
|
return receipt
|
|
462
488
|
|
|
@@ -505,11 +531,15 @@ class MemoriesResource(BaseResource):
|
|
|
505
531
|
if idempotency_key:
|
|
506
532
|
kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
507
533
|
receipt = await self.client.post("/v1/memories/batch", **kwargs)
|
|
508
|
-
if wait_for_index and not
|
|
534
|
+
if wait_for_index and not (
|
|
535
|
+
receipt.get("searchable") is True
|
|
536
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
537
|
+
):
|
|
509
538
|
receipt = await self.wait_batch_until_searchable(
|
|
510
539
|
receipt,
|
|
511
540
|
timeout=index_timeout,
|
|
512
541
|
poll_interval=index_poll_interval,
|
|
542
|
+
idempotency_key=idempotency_key,
|
|
513
543
|
)
|
|
514
544
|
return receipt
|
|
515
545
|
|
|
@@ -519,6 +549,7 @@ class MemoriesResource(BaseResource):
|
|
|
519
549
|
*,
|
|
520
550
|
timeout: float = 60.0,
|
|
521
551
|
poll_interval: float = 0.5,
|
|
552
|
+
idempotency_key: Optional[str] = None,
|
|
522
553
|
) -> Dict[str, Any]:
|
|
523
554
|
"""Poll every item in an asynchronous batch receipt to one terminal state.
|
|
524
555
|
|
|
@@ -537,11 +568,20 @@ class MemoriesResource(BaseResource):
|
|
|
537
568
|
)
|
|
538
569
|
for row in rows:
|
|
539
570
|
state = str(row.get("processing_status") or "").casefold()
|
|
571
|
+
if state == "completed" and row.get("searchable") is not True:
|
|
572
|
+
raise RuntimeError(
|
|
573
|
+
f"memory {row.get('id')} reported completed without "
|
|
574
|
+
"searchable=true"
|
|
575
|
+
)
|
|
540
576
|
if state in {"failed", "cancelled", "canceled"}:
|
|
541
577
|
raise RuntimeError(
|
|
542
578
|
f"memory {row.get('id')} indexing reached terminal state {state}"
|
|
543
579
|
)
|
|
544
|
-
if all(
|
|
580
|
+
if all(
|
|
581
|
+
row.get("searchable") is True
|
|
582
|
+
and str(row.get("processing_status") or "").casefold() == "completed"
|
|
583
|
+
for row in rows
|
|
584
|
+
):
|
|
545
585
|
return {
|
|
546
586
|
**receipt,
|
|
547
587
|
"processing_status": "completed",
|
|
@@ -556,10 +596,14 @@ class MemoriesResource(BaseResource):
|
|
|
556
596
|
],
|
|
557
597
|
}
|
|
558
598
|
if time.monotonic() >= deadline:
|
|
559
|
-
|
|
599
|
+
timeout_error = TimeoutError(
|
|
560
600
|
f"batch was not searchable within {timeout}s; the write is durable",
|
|
561
|
-
receipt,
|
|
562
601
|
)
|
|
602
|
+
raise IndexingTimeoutError(
|
|
603
|
+
str(timeout_error),
|
|
604
|
+
receipt,
|
|
605
|
+
idempotency_key=idempotency_key,
|
|
606
|
+
) from timeout_error
|
|
563
607
|
await asyncio.sleep(max(0.05, poll_interval))
|
|
564
608
|
|
|
565
609
|
async def wait_until_searchable(
|
|
@@ -597,6 +641,7 @@ class MemoriesResource(BaseResource):
|
|
|
597
641
|
*,
|
|
598
642
|
timeout: float,
|
|
599
643
|
poll_interval: float,
|
|
644
|
+
idempotency_key: Optional[str] = None,
|
|
600
645
|
) -> Dict[str, Any]:
|
|
601
646
|
if (
|
|
602
647
|
receipt.get("searchable") is True
|
|
@@ -619,9 +664,14 @@ class MemoriesResource(BaseResource):
|
|
|
619
664
|
f"memory job {job_id} reached terminal state {state}"
|
|
620
665
|
)
|
|
621
666
|
if time.monotonic() >= deadline:
|
|
622
|
-
|
|
667
|
+
timeout_error = TimeoutError(
|
|
623
668
|
f"memory job {job_id} did not become searchable within {timeout}s"
|
|
624
669
|
)
|
|
670
|
+
raise IndexingTimeoutError(
|
|
671
|
+
f"{timeout_error}; the write is durable",
|
|
672
|
+
receipt,
|
|
673
|
+
idempotency_key=idempotency_key,
|
|
674
|
+
) from timeout_error
|
|
625
675
|
await asyncio.sleep(max(0.05, poll_interval))
|
|
626
676
|
|
|
627
677
|
candidates = [
|
|
@@ -637,11 +687,19 @@ class MemoriesResource(BaseResource):
|
|
|
637
687
|
raise RuntimeError(
|
|
638
688
|
"wait_for_index response contained neither a memory id nor a job id"
|
|
639
689
|
)
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
690
|
+
try:
|
|
691
|
+
ready = await self.wait_until_searchable(
|
|
692
|
+
memory_id,
|
|
693
|
+
timeout=timeout,
|
|
694
|
+
poll_interval=poll_interval,
|
|
695
|
+
)
|
|
696
|
+
except TimeoutError as exc:
|
|
697
|
+
raise IndexingTimeoutError(
|
|
698
|
+
f"memory {memory_id} was not searchable within {timeout}s; "
|
|
699
|
+
"the write is durable",
|
|
700
|
+
receipt,
|
|
701
|
+
idempotency_key=idempotency_key,
|
|
702
|
+
) from exc
|
|
645
703
|
receipt["searchable"] = True
|
|
646
704
|
receipt["processing_status"] = "completed"
|
|
647
705
|
receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
|
|
@@ -793,6 +851,8 @@ class MemoriesResource(BaseResource):
|
|
|
793
851
|
importance: Optional[float] = None,
|
|
794
852
|
metadata: Optional[Dict[str, Any]] = None,
|
|
795
853
|
wait_for_index: Optional[bool] = None,
|
|
854
|
+
index_timeout: float = 60.0,
|
|
855
|
+
index_poll_interval: float = 0.5,
|
|
796
856
|
) -> Dict[str, Any]:
|
|
797
857
|
"""
|
|
798
858
|
Update a memory.
|
|
@@ -816,7 +876,15 @@ class MemoriesResource(BaseResource):
|
|
|
816
876
|
if wait_for_index is not None:
|
|
817
877
|
data["wait_for_index"] = wait_for_index
|
|
818
878
|
|
|
819
|
-
|
|
879
|
+
receipt = await self.client.patch(f"/v1/memories/{memory_id}", json=data)
|
|
880
|
+
if wait_for_index is True:
|
|
881
|
+
receipt = {**receipt, "id": receipt.get("id") or memory_id}
|
|
882
|
+
receipt = await self._ensure_searchable_receipt(
|
|
883
|
+
receipt,
|
|
884
|
+
timeout=index_timeout,
|
|
885
|
+
poll_interval=index_poll_interval,
|
|
886
|
+
)
|
|
887
|
+
return receipt
|
|
820
888
|
|
|
821
889
|
async def delete(self, memory_id: str) -> None:
|
|
822
890
|
"""
|
|
@@ -1113,6 +1181,133 @@ class ProofLoopResource(BaseResource):
|
|
|
1113
1181
|
body["proof_context_token"] = token
|
|
1114
1182
|
return await self.client.post("/v1/learning/decisions", json=body)
|
|
1115
1183
|
|
|
1184
|
+
async def register_verifier(
|
|
1185
|
+
self,
|
|
1186
|
+
*,
|
|
1187
|
+
policy_key: str,
|
|
1188
|
+
api_key_id: str,
|
|
1189
|
+
source_system: str,
|
|
1190
|
+
metric_keys: List[str],
|
|
1191
|
+
collection_id: Optional[str] = None,
|
|
1192
|
+
user_id: Optional[str] = None,
|
|
1193
|
+
) -> Dict[str, Any]:
|
|
1194
|
+
"""Owner-session administration; the agent cannot register its own verifier."""
|
|
1195
|
+
return await self.client.post(
|
|
1196
|
+
"/v1/learning/verifiers",
|
|
1197
|
+
json={
|
|
1198
|
+
"policy_key": policy_key,
|
|
1199
|
+
"api_key_id": api_key_id,
|
|
1200
|
+
"source_system": source_system,
|
|
1201
|
+
"metric_keys": metric_keys,
|
|
1202
|
+
"collection_id": collection_id,
|
|
1203
|
+
"user_id": user_id,
|
|
1204
|
+
},
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
async def revoke_verifier(self, verifier_id: str) -> Dict[str, Any]:
|
|
1208
|
+
"""Revoke one source without deleting historical evidence."""
|
|
1209
|
+
return await self.client.post(
|
|
1210
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/revoke", json={}
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
async def create_episode(
|
|
1214
|
+
self,
|
|
1215
|
+
*,
|
|
1216
|
+
policy_key: str,
|
|
1217
|
+
verifier_id: str,
|
|
1218
|
+
idempotency_key: str,
|
|
1219
|
+
collection_id: Optional[str] = None,
|
|
1220
|
+
user_id: Optional[str] = None,
|
|
1221
|
+
) -> Dict[str, Any]:
|
|
1222
|
+
"""Create a scoped durable episode; never grants execution permission."""
|
|
1223
|
+
return await self.client.post(
|
|
1224
|
+
"/v1/learning/episodes",
|
|
1225
|
+
json={
|
|
1226
|
+
"policy_key": policy_key,
|
|
1227
|
+
"verifier_id": verifier_id,
|
|
1228
|
+
"idempotency_key": idempotency_key,
|
|
1229
|
+
"collection_id": collection_id,
|
|
1230
|
+
"user_id": user_id,
|
|
1231
|
+
},
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
async def get_episode(self, episode_id: str, *, offset: int = 0) -> Dict[str, Any]:
|
|
1235
|
+
return await self.client.get(
|
|
1236
|
+
f"/v1/learning/episodes/{quote(episode_id, safe='')}",
|
|
1237
|
+
params={"offset": offset},
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
async def close_episode(self, episode_id: str, *, status: str) -> Dict[str, Any]:
|
|
1241
|
+
return await self.client.post(
|
|
1242
|
+
f"/v1/learning/episodes/{quote(episode_id, safe='')}/close",
|
|
1243
|
+
json={"status": status},
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
async def record_execution(
|
|
1247
|
+
self,
|
|
1248
|
+
decision_id: str,
|
|
1249
|
+
*,
|
|
1250
|
+
attempt_id: str,
|
|
1251
|
+
status: str,
|
|
1252
|
+
actual_action_key: str,
|
|
1253
|
+
arguments_digest: str,
|
|
1254
|
+
evidence_digest: Optional[str] = None,
|
|
1255
|
+
occurred_at: Optional[str] = None,
|
|
1256
|
+
) -> Dict[str, Any]:
|
|
1257
|
+
"""Record an execution claim, not an instruction to execute."""
|
|
1258
|
+
body = {
|
|
1259
|
+
"attempt_id": attempt_id,
|
|
1260
|
+
"status": status,
|
|
1261
|
+
"actual_action_key": actual_action_key,
|
|
1262
|
+
"arguments_digest": arguments_digest,
|
|
1263
|
+
}
|
|
1264
|
+
if evidence_digest is not None:
|
|
1265
|
+
body["evidence_digest"] = evidence_digest
|
|
1266
|
+
if occurred_at is not None:
|
|
1267
|
+
body["occurred_at"] = occurred_at
|
|
1268
|
+
return await self.client.post(
|
|
1269
|
+
f"/v1/learning/decisions/{quote(decision_id, safe='')}/executions",
|
|
1270
|
+
json=body,
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
async def assessment(
|
|
1274
|
+
self, decision_id: str, *, evidence_offset: int = 0
|
|
1275
|
+
) -> Dict[str, Any]:
|
|
1276
|
+
return await self.client.get(
|
|
1277
|
+
f"/v1/learning/decisions/{quote(decision_id, safe='')}/assessment",
|
|
1278
|
+
params={"evidence_offset": evidence_offset},
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
async def verifier_evidence(
|
|
1282
|
+
self, verifier_id: str, decision_id: str
|
|
1283
|
+
) -> Dict[str, Any]:
|
|
1284
|
+
"""Call with the dedicated verifier client, never the actor's credential."""
|
|
1285
|
+
return await self.client.get(
|
|
1286
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/decisions/{quote(decision_id, safe='')}"
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
async def deliver_verified_outcomes(
|
|
1290
|
+
self,
|
|
1291
|
+
verifier_id: str,
|
|
1292
|
+
*,
|
|
1293
|
+
decision_id: str,
|
|
1294
|
+
source_event_id: str,
|
|
1295
|
+
evidence_digest: str,
|
|
1296
|
+
execution_digest: str,
|
|
1297
|
+
observations: List[Dict[str, Any]],
|
|
1298
|
+
) -> Dict[str, Any]:
|
|
1299
|
+
"""Deliver independently checked observations using the registered source key."""
|
|
1300
|
+
return await self.client.post(
|
|
1301
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/events",
|
|
1302
|
+
json={
|
|
1303
|
+
"decision_id": decision_id,
|
|
1304
|
+
"source_event_id": source_event_id,
|
|
1305
|
+
"evidence_digest": evidence_digest,
|
|
1306
|
+
"execution_digest": execution_digest,
|
|
1307
|
+
"observations": observations,
|
|
1308
|
+
},
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1116
1311
|
async def get_decision(self, decision_id: str) -> Dict[str, Any]:
|
|
1117
1312
|
return await self.client.get(f"/v1/learning/decisions/{decision_id}")
|
|
1118
1313
|
|