hebbrix 2.4.1__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.1 → hebbrix-2.5.0}/CHANGELOG.md +7 -0
- {hebbrix-2.4.1/hebbrix.egg-info → hebbrix-2.5.0}/PKG-INFO +24 -3
- {hebbrix-2.4.1 → hebbrix-2.5.0}/README.md +1 -1
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/__init__.py +1 -1
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/client.py +1 -1
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/resources.py +155 -3
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/sync_client.py +127 -1
- {hebbrix-2.4.1 → hebbrix-2.5.0/hebbrix.egg-info}/PKG-INFO +24 -3
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/SOURCES.txt +2 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/pyproject.toml +3 -3
- 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.1 → hebbrix-2.5.0}/LICENSE +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/MANIFEST.in +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/chat.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix/models.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/setup.cfg +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_advanced_resources.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_memories_resource.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_openapi_parity.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_procedural_resource.py +0 -0
- {hebbrix-2.4.1 → hebbrix-2.5.0}/tests/test_sync_client.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 2.4.1 — 2026-08-27
|
|
4
11
|
|
|
5
12
|
- Make every async and sync readiness deadline preserve the original durable
|
|
@@ -1,10 +1,31 @@
|
|
|
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
31
|
Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
|
|
@@ -47,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
|
47
68
|
## Install
|
|
48
69
|
|
|
49
70
|
```bash
|
|
50
|
-
pip install hebbrix==2.
|
|
71
|
+
pip install hebbrix==2.5.0
|
|
51
72
|
```
|
|
52
73
|
|
|
53
74
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -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"] = {
|
|
@@ -1156,6 +1181,133 @@ class ProofLoopResource(BaseResource):
|
|
|
1156
1181
|
body["proof_context_token"] = token
|
|
1157
1182
|
return await self.client.post("/v1/learning/decisions", json=body)
|
|
1158
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
|
+
|
|
1159
1311
|
async def get_decision(self, decision_id: str) -> Dict[str, Any]:
|
|
1160
1312
|
return await self.client.get(f"/v1/learning/decisions/{decision_id}")
|
|
1161
1313
|
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import time
|
|
6
6
|
from typing import Any, Dict, List, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
from hebbrix.exceptions import (
|
|
@@ -660,6 +661,131 @@ class SyncProofLoopResource:
|
|
|
660
661
|
body["proof_context_token"] = token
|
|
661
662
|
return self.client.post("/v1/learning/decisions", json=body)
|
|
662
663
|
|
|
664
|
+
def register_verifier(
|
|
665
|
+
self,
|
|
666
|
+
*,
|
|
667
|
+
policy_key: str,
|
|
668
|
+
api_key_id: str,
|
|
669
|
+
source_system: str,
|
|
670
|
+
metric_keys: List[str],
|
|
671
|
+
collection_id: Optional[str] = None,
|
|
672
|
+
user_id: Optional[str] = None,
|
|
673
|
+
) -> Dict[str, Any]:
|
|
674
|
+
"""Owner-session administration; the agent cannot register its own verifier."""
|
|
675
|
+
return self.client.post(
|
|
676
|
+
"/v1/learning/verifiers",
|
|
677
|
+
json={
|
|
678
|
+
"policy_key": policy_key,
|
|
679
|
+
"api_key_id": api_key_id,
|
|
680
|
+
"source_system": source_system,
|
|
681
|
+
"metric_keys": metric_keys,
|
|
682
|
+
"collection_id": collection_id,
|
|
683
|
+
"user_id": user_id,
|
|
684
|
+
},
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
def revoke_verifier(self, verifier_id: str) -> Dict[str, Any]:
|
|
688
|
+
"""Revoke one source without deleting historical evidence."""
|
|
689
|
+
return self.client.post(
|
|
690
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/revoke", json={}
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def create_episode(
|
|
694
|
+
self,
|
|
695
|
+
*,
|
|
696
|
+
policy_key: str,
|
|
697
|
+
verifier_id: str,
|
|
698
|
+
idempotency_key: str,
|
|
699
|
+
collection_id: Optional[str] = None,
|
|
700
|
+
user_id: Optional[str] = None,
|
|
701
|
+
) -> Dict[str, Any]:
|
|
702
|
+
"""Create a scoped durable episode; never grants execution permission."""
|
|
703
|
+
return self.client.post(
|
|
704
|
+
"/v1/learning/episodes",
|
|
705
|
+
json={
|
|
706
|
+
"policy_key": policy_key,
|
|
707
|
+
"verifier_id": verifier_id,
|
|
708
|
+
"idempotency_key": idempotency_key,
|
|
709
|
+
"collection_id": collection_id,
|
|
710
|
+
"user_id": user_id,
|
|
711
|
+
},
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def get_episode(self, episode_id: str, *, offset: int = 0) -> Dict[str, Any]:
|
|
715
|
+
return self.client.get(
|
|
716
|
+
f"/v1/learning/episodes/{quote(episode_id, safe='')}",
|
|
717
|
+
params={"offset": offset},
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
def close_episode(self, episode_id: str, *, status: str) -> Dict[str, Any]:
|
|
721
|
+
return self.client.post(
|
|
722
|
+
f"/v1/learning/episodes/{quote(episode_id, safe='')}/close",
|
|
723
|
+
json={"status": status},
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
def record_execution(
|
|
727
|
+
self,
|
|
728
|
+
decision_id: str,
|
|
729
|
+
*,
|
|
730
|
+
attempt_id: str,
|
|
731
|
+
status: str,
|
|
732
|
+
actual_action_key: str,
|
|
733
|
+
arguments_digest: str,
|
|
734
|
+
evidence_digest: Optional[str] = None,
|
|
735
|
+
occurred_at: Optional[str] = None,
|
|
736
|
+
) -> Dict[str, Any]:
|
|
737
|
+
"""Record an execution claim, not an instruction to execute."""
|
|
738
|
+
body = {
|
|
739
|
+
"attempt_id": attempt_id,
|
|
740
|
+
"status": status,
|
|
741
|
+
"actual_action_key": actual_action_key,
|
|
742
|
+
"arguments_digest": arguments_digest,
|
|
743
|
+
}
|
|
744
|
+
if evidence_digest is not None:
|
|
745
|
+
body["evidence_digest"] = evidence_digest
|
|
746
|
+
if occurred_at is not None:
|
|
747
|
+
body["occurred_at"] = occurred_at
|
|
748
|
+
return self.client.post(
|
|
749
|
+
f"/v1/learning/decisions/{quote(decision_id, safe='')}/executions",
|
|
750
|
+
json=body,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def assessment(
|
|
754
|
+
self, decision_id: str, *, evidence_offset: int = 0
|
|
755
|
+
) -> Dict[str, Any]:
|
|
756
|
+
return self.client.get(
|
|
757
|
+
f"/v1/learning/decisions/{quote(decision_id, safe='')}/assessment",
|
|
758
|
+
params={"evidence_offset": evidence_offset},
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def verifier_evidence(self, verifier_id: str, decision_id: str) -> Dict[str, Any]:
|
|
762
|
+
"""Call with the dedicated verifier client, never the actor's credential."""
|
|
763
|
+
return self.client.get(
|
|
764
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/decisions/{quote(decision_id, safe='')}"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def deliver_verified_outcomes(
|
|
768
|
+
self,
|
|
769
|
+
verifier_id: str,
|
|
770
|
+
*,
|
|
771
|
+
decision_id: str,
|
|
772
|
+
source_event_id: str,
|
|
773
|
+
evidence_digest: str,
|
|
774
|
+
execution_digest: str,
|
|
775
|
+
observations: List[Dict[str, Any]],
|
|
776
|
+
) -> Dict[str, Any]:
|
|
777
|
+
"""Deliver independently checked observations using the registered source key."""
|
|
778
|
+
return self.client.post(
|
|
779
|
+
f"/v1/learning/verifiers/{quote(verifier_id, safe='')}/events",
|
|
780
|
+
json={
|
|
781
|
+
"decision_id": decision_id,
|
|
782
|
+
"source_event_id": source_event_id,
|
|
783
|
+
"evidence_digest": evidence_digest,
|
|
784
|
+
"execution_digest": execution_digest,
|
|
785
|
+
"observations": observations,
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
|
|
663
789
|
def get_decision(self, decision_id: str) -> Dict[str, Any]:
|
|
664
790
|
return self.client.get(f"/v1/learning/decisions/{decision_id}")
|
|
665
791
|
|
|
@@ -789,7 +915,7 @@ class SyncMemoryClient:
|
|
|
789
915
|
self.source = source or os.getenv("HEBBRIX_SOURCE")
|
|
790
916
|
headers = {
|
|
791
917
|
"Content-Type": "application/json",
|
|
792
|
-
"User-Agent": "hebbrix-python/2.
|
|
918
|
+
"User-Agent": "hebbrix-python/2.5.0",
|
|
793
919
|
}
|
|
794
920
|
if api_key:
|
|
795
921
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
@@ -1,10 +1,31 @@
|
|
|
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
31
|
Project-URL: Source, https://github.com/Hebbrix/hebbrix-python
|
|
@@ -47,7 +68,7 @@ Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs.
|
|
|
47
68
|
## Install
|
|
48
69
|
|
|
49
70
|
```bash
|
|
50
|
-
pip install hebbrix==2.
|
|
71
|
+
pip install hebbrix==2.5.0
|
|
51
72
|
```
|
|
52
73
|
|
|
53
74
|
Python 3.8+ is supported. `MemoryClient` is asynchronous. `SyncMemoryClient`
|
|
@@ -16,6 +16,8 @@ hebbrix.egg-info/dependency_links.txt
|
|
|
16
16
|
hebbrix.egg-info/requires.txt
|
|
17
17
|
hebbrix.egg-info/top_level.txt
|
|
18
18
|
tests/test_advanced_resources.py
|
|
19
|
+
tests/test_evidence_loop_resource.py
|
|
20
|
+
tests/test_evidence_safety_boundary.py
|
|
19
21
|
tests/test_memories_resource.py
|
|
20
22
|
tests/test_openapi_parity.py
|
|
21
23
|
tests/test_procedural_resource.py
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hebbrix"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.5.0"
|
|
8
8
|
description = "Typed Python client for Hebbrix memory, retrieval, and outcome-learning APIs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
11
|
-
|
|
12
|
-
license
|
|
11
|
+
# Keep source installation compatible with Python 3.8's setuptools release.
|
|
12
|
+
license = {file = "LICENSE"}
|
|
13
13
|
authors = [
|
|
14
14
|
{name = "Hebbrix Team", email = "support@hebbrix.com"}
|
|
15
15
|
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Sync/async wire parity for the protected evidence lifecycle."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from unittest.mock import AsyncMock, Mock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from hebbrix.resources import ProofLoopResource
|
|
9
|
+
from hebbrix.sync_client import SyncProofLoopResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SCOPE = {
|
|
13
|
+
"policy_key": "workflow.choice",
|
|
14
|
+
"collection_id": "collection",
|
|
15
|
+
"user_id": "end-user",
|
|
16
|
+
}
|
|
17
|
+
DELIVERY = {
|
|
18
|
+
"decision_id": "decision",
|
|
19
|
+
"source_event_id": "source-event",
|
|
20
|
+
"evidence_digest": "a" * 64,
|
|
21
|
+
"execution_digest": "b" * 64,
|
|
22
|
+
"observations": [
|
|
23
|
+
{
|
|
24
|
+
"metric_key": "success",
|
|
25
|
+
"value": 1,
|
|
26
|
+
"is_final": False,
|
|
27
|
+
"observed_at": "2026-09-07T12:00:00Z",
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
CLAIM = {
|
|
32
|
+
"attempt_id": "attempt",
|
|
33
|
+
"status": "started",
|
|
34
|
+
"actual_action_key": "baseline",
|
|
35
|
+
"arguments_digest": "c" * 64,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
CASES = [
|
|
39
|
+
(
|
|
40
|
+
"register_verifier",
|
|
41
|
+
(),
|
|
42
|
+
{
|
|
43
|
+
**SCOPE,
|
|
44
|
+
"api_key_id": "key",
|
|
45
|
+
"source_system": "verifier",
|
|
46
|
+
"metric_keys": ["success"],
|
|
47
|
+
},
|
|
48
|
+
"post",
|
|
49
|
+
"/v1/learning/verifiers",
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
"create_episode",
|
|
53
|
+
(),
|
|
54
|
+
{**SCOPE, "verifier_id": "verifier", "idempotency_key": "episode-key"},
|
|
55
|
+
"post",
|
|
56
|
+
"/v1/learning/episodes",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"revoke_verifier",
|
|
60
|
+
("verifier",),
|
|
61
|
+
{},
|
|
62
|
+
"post",
|
|
63
|
+
"/v1/learning/verifiers/verifier/revoke",
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"get_episode",
|
|
67
|
+
("episode",),
|
|
68
|
+
{"offset": 100},
|
|
69
|
+
"get",
|
|
70
|
+
"/v1/learning/episodes/episode",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"close_episode",
|
|
74
|
+
("episode",),
|
|
75
|
+
{"status": "interrupted"},
|
|
76
|
+
"post",
|
|
77
|
+
"/v1/learning/episodes/episode/close",
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"record_execution",
|
|
81
|
+
("decision",),
|
|
82
|
+
CLAIM,
|
|
83
|
+
"post",
|
|
84
|
+
"/v1/learning/decisions/decision/executions",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"assessment",
|
|
88
|
+
("decision",),
|
|
89
|
+
{"evidence_offset": 100},
|
|
90
|
+
"get",
|
|
91
|
+
"/v1/learning/decisions/decision/assessment",
|
|
92
|
+
),
|
|
93
|
+
(
|
|
94
|
+
"verifier_evidence",
|
|
95
|
+
("verifier", "decision"),
|
|
96
|
+
{},
|
|
97
|
+
"get",
|
|
98
|
+
"/v1/learning/verifiers/verifier/decisions/decision",
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"deliver_verified_outcomes",
|
|
102
|
+
("verifier",),
|
|
103
|
+
DELIVERY,
|
|
104
|
+
"post",
|
|
105
|
+
"/v1/learning/verifiers/verifier/events",
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
@pytest.mark.parametrize("resource_class", [ProofLoopResource, SyncProofLoopResource])
|
|
112
|
+
@pytest.mark.parametrize("name,args,kwargs,method,path", CASES)
|
|
113
|
+
async def test_protected_wire_contract_preserves_server_receipt(
|
|
114
|
+
resource_class, name, args, kwargs, method, path
|
|
115
|
+
):
|
|
116
|
+
receipt = {
|
|
117
|
+
"authorization_granted": False,
|
|
118
|
+
"status": "review_required",
|
|
119
|
+
"replayed": True,
|
|
120
|
+
}
|
|
121
|
+
transport = Mock()
|
|
122
|
+
call = (
|
|
123
|
+
AsyncMock(return_value=receipt)
|
|
124
|
+
if resource_class is ProofLoopResource
|
|
125
|
+
else Mock(return_value=receipt)
|
|
126
|
+
)
|
|
127
|
+
setattr(transport, method, call)
|
|
128
|
+
result = getattr(resource_class(transport), name)(*args, **kwargs)
|
|
129
|
+
if inspect.isawaitable(result):
|
|
130
|
+
result = await result
|
|
131
|
+
assert result is receipt
|
|
132
|
+
expected = (
|
|
133
|
+
{"json" if method == "post" else "params": kwargs}
|
|
134
|
+
if kwargs or method == "post"
|
|
135
|
+
else {}
|
|
136
|
+
)
|
|
137
|
+
call.assert_called_once_with(path, **expected)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.parametrize("resource_class", [ProofLoopResource, SyncProofLoopResource])
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_source_delivery_does_not_fall_back_to_unprotected_endpoint(
|
|
143
|
+
resource_class,
|
|
144
|
+
):
|
|
145
|
+
transport = Mock()
|
|
146
|
+
error = RuntimeError("verifier credential rejected")
|
|
147
|
+
transport.post = (
|
|
148
|
+
AsyncMock(side_effect=error)
|
|
149
|
+
if resource_class is ProofLoopResource
|
|
150
|
+
else Mock(side_effect=error)
|
|
151
|
+
)
|
|
152
|
+
with pytest.raises(RuntimeError, match="verifier credential rejected"):
|
|
153
|
+
result = resource_class(transport).deliver_verified_outcomes(
|
|
154
|
+
"verifier", **DELIVERY
|
|
155
|
+
)
|
|
156
|
+
if inspect.isawaitable(result):
|
|
157
|
+
await result
|
|
158
|
+
assert transport.post.call_count == 1
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from hebbrix.resources import _canonical_search_envelope
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@pytest.mark.parametrize(
|
|
6
|
+
"patch",
|
|
7
|
+
[
|
|
8
|
+
{"sources": [{"content": "unidentified"}]},
|
|
9
|
+
{"sources": [None]},
|
|
10
|
+
{"sources": ["not a row"]},
|
|
11
|
+
{"sources": [{"memory_id": 7}]},
|
|
12
|
+
{"sources": [{"memory_id": " "}]},
|
|
13
|
+
{"sources": [{"memory_id": None, "id": "m-1"}]},
|
|
14
|
+
{"sources": [{"memory_id": "m-1", "id": "m-2"}]},
|
|
15
|
+
{"sources": {}},
|
|
16
|
+
{"sources": []},
|
|
17
|
+
{"evidence_ids": [7]},
|
|
18
|
+
{"evidence_ids": [None]},
|
|
19
|
+
{"evidence_ids": ["m-1", "m-1"]},
|
|
20
|
+
{"safety_contract_version": None},
|
|
21
|
+
{"safety_contract_version": "future-version"},
|
|
22
|
+
{"no_match": True},
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
def test_malformed_evidence_never_exposes_unsupported_synthesis(patch):
|
|
26
|
+
response = {
|
|
27
|
+
"sources": [{"memory_id": "m-1", "content": "fact"}],
|
|
28
|
+
"answer": "unsupported answer",
|
|
29
|
+
"citations": ["m-1"],
|
|
30
|
+
"no_match": False,
|
|
31
|
+
"abstain_recommended": False,
|
|
32
|
+
"query_confidence": 0.9,
|
|
33
|
+
"grounding": {"status": "supported"},
|
|
34
|
+
"evidence_ids": ["m-1"],
|
|
35
|
+
"safety_contract_version": "search-safety-v1",
|
|
36
|
+
}
|
|
37
|
+
result = _canonical_search_envelope(dict(response, **patch), rows_key="sources")
|
|
38
|
+
assert result["sources"] == [] and result["evidence_ids"] == []
|
|
39
|
+
assert result["answer"] is None and result["citations"] == []
|
|
40
|
+
assert result["no_match"] is True and result["abstain_recommended"] is True
|
|
41
|
+
assert result["query_confidence"] == 0
|
|
42
|
+
assert response["sources"] # Caller-owned envelope was not mutated.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|