hebbrix 2.3.1__tar.gz → 2.3.2__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.
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.2 — 2026-08-27
4
+
5
+ - Align procedure create/list/get/update/execute/delete with the canonical
6
+ `/v1/procedures` REST and OpenAPI contract, including idempotent `204`
7
+ deletion.
8
+ - Add explicit synchronous/asynchronous batch readiness receipts to the async
9
+ and synchronous clients.
10
+ - Add batch wait helpers that poll every accepted memory, propagate terminal
11
+ failures, respect deadlines, and support asynchronous cancellation.
12
+
3
13
  ## 2.3.1 — 2026-08-26
4
14
 
5
15
  - Make `wait_for_index=True` poll the authoritative memory or job readiness
@@ -1,10 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
7
- License-Expression: MIT
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: Repository, https://github.com/hebbrix/hebbrix-python
@@ -96,6 +117,18 @@ async def main():
96
117
  # It returns only when searchable=true, raises on terminal failure, and
97
118
  # raises TimeoutError if the caller's readiness deadline expires.
98
119
 
120
+ # Batch contract: a successful synchronous return means every accepted item
121
+ # is searchable. A bounded server timeout raises explicitly and is safe to
122
+ # retry with the same idempotency key; it is never a successful 202.
123
+ batch = await client.memories.create_batch(
124
+ [{"content": "First fact"}, {"content": "Second fact"}],
125
+ collection_id=collection["id"],
126
+ wait_for_index=True,
127
+ idempotency_key="import-42",
128
+ )
129
+ # For wait_for_index=False receipts:
130
+ # batch = await client.memories.wait_batch_until_searchable(batch)
131
+
99
132
  # Search memories
100
133
  results = await client.search(
101
134
  query="What programming language does user like?",
@@ -55,6 +55,18 @@ async def main():
55
55
  # It returns only when searchable=true, raises on terminal failure, and
56
56
  # raises TimeoutError if the caller's readiness deadline expires.
57
57
 
58
+ # Batch contract: a successful synchronous return means every accepted item
59
+ # is searchable. A bounded server timeout raises explicitly and is safe to
60
+ # retry with the same idempotency key; it is never a successful 202.
61
+ batch = await client.memories.create_batch(
62
+ [{"content": "First fact"}, {"content": "Second fact"}],
63
+ collection_id=collection["id"],
64
+ wait_for_index=True,
65
+ idempotency_key="import-42",
66
+ )
67
+ # For wait_for_index=False receipts:
68
+ # batch = await client.memories.wait_batch_until_searchable(batch)
69
+
58
70
  # Search memories
59
71
  results = await client.search(
60
72
  query="What programming language does user like?",
@@ -41,7 +41,7 @@ Features:
41
41
  - ✅ Type hints throughout
42
42
  """
43
43
 
44
- __version__ = "2.3.1"
44
+ __version__ = "2.3.2"
45
45
  __author__ = "Hebbrix Team"
46
46
  __license__ = "MIT"
47
47
 
@@ -91,7 +91,7 @@ class MemoryClient:
91
91
  """Get request headers."""
92
92
  headers = {
93
93
  "Content-Type": "application/json",
94
- "User-Agent": "hebbrix-python/2.3.1",
94
+ "User-Agent": "hebbrix-python/2.3.2",
95
95
  }
96
96
 
97
97
  if self.api_key:
@@ -435,6 +435,106 @@ class MemoriesResource(BaseResource):
435
435
  )
436
436
  return receipt
437
437
 
438
+ async def create_batch(
439
+ self,
440
+ memories: List[Dict[str, Any]],
441
+ *,
442
+ collection_id: Optional[str] = None,
443
+ user_id: Optional[str] = None,
444
+ agent_id: Optional[str] = None,
445
+ run_id: Optional[str] = None,
446
+ app_id: Optional[str] = None,
447
+ namespace: Optional[str] = None,
448
+ wait_for_index: bool = False,
449
+ idempotency_key: Optional[str] = None,
450
+ ) -> Dict[str, Any]:
451
+ """Create a durable batch with explicit synchronous/async readiness.
452
+
453
+ When ``wait_for_index`` is true, a successful return is guaranteed to
454
+ have ``processing_status=completed`` and ``searchable=true``. A bounded
455
+ server timeout raises an HTTP error and may be retried with the same
456
+ idempotency key; it is never returned as a successful processing state.
457
+ """
458
+
459
+ if not 1 <= len(memories) <= 100:
460
+ raise ValueError("memories must contain between 1 and 100 items")
461
+ if any(not str(item.get("content") or "").strip() for item in memories):
462
+ raise ValueError("every batch memory must contain non-empty content")
463
+ payload = {
464
+ "memories": memories,
465
+ "wait_for_index": wait_for_index,
466
+ }
467
+ optional = {
468
+ "collection_id": collection_id,
469
+ "user_id": user_id,
470
+ "agent_id": agent_id,
471
+ "run_id": run_id,
472
+ "app_id": app_id,
473
+ "namespace": namespace,
474
+ }
475
+ payload.update(
476
+ {key: value for key, value in optional.items() if value is not None}
477
+ )
478
+ kwargs: Dict[str, Any] = {"json": payload}
479
+ if idempotency_key:
480
+ kwargs["headers"] = {"Idempotency-Key": idempotency_key}
481
+ receipt = await self.client.post("/v1/memories/batch", **kwargs)
482
+ if wait_for_index and not (
483
+ receipt.get("searchable") is True
484
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
485
+ ):
486
+ raise RuntimeError(
487
+ "wait_for_index batch response was not fully searchable; retry "
488
+ "with the same Idempotency-Key"
489
+ )
490
+ return receipt
491
+
492
+ async def wait_batch_until_searchable(
493
+ self,
494
+ receipt: Dict[str, Any],
495
+ *,
496
+ timeout: float = 60.0,
497
+ poll_interval: float = 0.5,
498
+ ) -> Dict[str, Any]:
499
+ """Poll every item in an asynchronous batch receipt to one terminal state.
500
+
501
+ Cancellation uses normal asyncio task cancellation. A failed/cancelled
502
+ item or a local deadline raises explicitly; a successful return means
503
+ every batch item reported ``searchable=true``.
504
+ """
505
+
506
+ memory_ids = list(dict.fromkeys(map(str, receipt.get("memory_ids") or [])))
507
+ if not memory_ids:
508
+ raise ValueError("batch receipt does not contain memory_ids")
509
+ deadline = time.monotonic() + max(0.0, timeout)
510
+ while True:
511
+ rows = await asyncio.gather(
512
+ *(self.get(memory_id) for memory_id in memory_ids)
513
+ )
514
+ for row in rows:
515
+ state = str(row.get("processing_status") or "").casefold()
516
+ if state in {"failed", "cancelled", "canceled"}:
517
+ raise RuntimeError(
518
+ f"memory {row.get('id')} indexing reached terminal state {state}"
519
+ )
520
+ if all(row.get("searchable") is True for row in rows):
521
+ return {
522
+ **receipt,
523
+ "processing_status": "completed",
524
+ "searchable": True,
525
+ "results": [
526
+ {
527
+ "id": memory_id,
528
+ "memory_id": memory_id,
529
+ "processing_status": "completed",
530
+ }
531
+ for memory_id in memory_ids
532
+ ],
533
+ }
534
+ if time.monotonic() >= deadline:
535
+ raise TimeoutError(f"batch was not searchable within {timeout}s")
536
+ await asyncio.sleep(max(0.05, poll_interval))
537
+
438
538
  async def wait_until_searchable(
439
539
  self,
440
540
  memory_id: str,
@@ -1187,6 +1287,15 @@ class RLResource(BaseResource):
1187
1287
  class ProceduralResource(BaseResource):
1188
1288
  """Procedural memory endpoints."""
1189
1289
 
1290
+ @staticmethod
1291
+ def _unwrap_procedure(response: Dict[str, Any]) -> Dict[str, Any]:
1292
+ procedure = response.get("procedure")
1293
+ if isinstance(procedure, dict):
1294
+ return procedure
1295
+ if response.get("procedure_id") and not response.get("id"):
1296
+ return {**response, "id": response["procedure_id"]}
1297
+ return response
1298
+
1190
1299
  async def create(
1191
1300
  self,
1192
1301
  name: str,
@@ -1212,18 +1321,19 @@ class ProceduralResource(BaseResource):
1212
1321
  Returns:
1213
1322
  Created procedure
1214
1323
  """
1215
- return await self.client.post(
1216
- "/procedural",
1324
+ response = await self.client.post(
1325
+ "/v1/procedures",
1217
1326
  json={
1218
1327
  "name": name,
1219
1328
  "description": description,
1220
- "trigger_condition": trigger_condition,
1221
- "action_sequence": action_sequence,
1329
+ "condition": {"expression": trigger_condition},
1330
+ "action": {"steps": action_sequence},
1222
1331
  "collection_id": collection_id,
1223
1332
  "category": category,
1224
- "metadata": metadata or {},
1333
+ "parameters": metadata or {},
1225
1334
  },
1226
1335
  )
1336
+ return self._unwrap_procedure(response)
1227
1337
 
1228
1338
  async def list(
1229
1339
  self,
@@ -1250,7 +1360,10 @@ class ProceduralResource(BaseResource):
1250
1360
  if category:
1251
1361
  params["category"] = category
1252
1362
 
1253
- return await self.client.get("/procedural", params=params)
1363
+ response = await self.client.get("/v1/procedures", params=params)
1364
+ return (
1365
+ response if isinstance(response, list) else response.get("procedures", [])
1366
+ )
1254
1367
 
1255
1368
  async def get(self, procedure_id: str) -> Dict[str, Any]:
1256
1369
  """
@@ -1262,7 +1375,8 @@ class ProceduralResource(BaseResource):
1262
1375
  Returns:
1263
1376
  Procedure data
1264
1377
  """
1265
- return await self.client.get(f"/procedural/{procedure_id}")
1378
+ response = await self.client.get(f"/v1/procedures/{procedure_id}")
1379
+ return self._unwrap_procedure(response)
1266
1380
 
1267
1381
  async def execute(
1268
1382
  self,
@@ -1279,10 +1393,12 @@ class ProceduralResource(BaseResource):
1279
1393
  Returns:
1280
1394
  Execution result
1281
1395
  """
1282
- return await self.client.post(
1283
- f"/procedural/{procedure_id}/execute",
1284
- json={"context": context or {}},
1396
+ response = await self.client.post(
1397
+ f"/v1/procedures/{procedure_id}/execute",
1398
+ json={"input_state": context or {}},
1285
1399
  )
1400
+ result = response.get("execution_result")
1401
+ return result if isinstance(result, dict) else response
1286
1402
 
1287
1403
  async def update(
1288
1404
  self,
@@ -1313,13 +1429,14 @@ class ProceduralResource(BaseResource):
1313
1429
  if description is not None:
1314
1430
  data["description"] = description
1315
1431
  if trigger_condition is not None:
1316
- data["trigger_condition"] = trigger_condition
1432
+ data["condition"] = {"expression": trigger_condition}
1317
1433
  if action_sequence is not None:
1318
- data["action_sequence"] = action_sequence
1434
+ data["action"] = {"steps": action_sequence}
1319
1435
  if metadata is not None:
1320
- data["metadata"] = metadata
1436
+ data["parameters"] = metadata
1321
1437
 
1322
- return await self.client.patch(f"/procedural/{procedure_id}", json=data)
1438
+ response = await self.client.patch(f"/v1/procedures/{procedure_id}", json=data)
1439
+ return self._unwrap_procedure(response)
1323
1440
 
1324
1441
  async def delete(self, procedure_id: str) -> None:
1325
1442
  """
@@ -1328,7 +1445,7 @@ class ProceduralResource(BaseResource):
1328
1445
  Args:
1329
1446
  procedure_id: Procedure ID
1330
1447
  """
1331
- await self.client.delete(f"/procedural/{procedure_id}")
1448
+ await self.client.delete(f"/v1/procedures/{procedure_id}")
1332
1449
 
1333
1450
 
1334
1451
  class TemporalResource(BaseResource):
@@ -101,6 +101,91 @@ class SyncMemoriesResource:
101
101
  )
102
102
  return receipt
103
103
 
104
+ def create_batch(
105
+ self,
106
+ memories: List[Dict[str, Any]],
107
+ *,
108
+ collection_id: Optional[str] = None,
109
+ user_id: Optional[str] = None,
110
+ agent_id: Optional[str] = None,
111
+ run_id: Optional[str] = None,
112
+ app_id: Optional[str] = None,
113
+ namespace: Optional[str] = None,
114
+ wait_for_index: bool = False,
115
+ idempotency_key: Optional[str] = None,
116
+ ) -> Dict[str, Any]:
117
+ if not 1 <= len(memories) <= 100:
118
+ raise ValueError("memories must contain between 1 and 100 items")
119
+ if any(not str(item.get("content") or "").strip() for item in memories):
120
+ raise ValueError("every batch memory must contain non-empty content")
121
+ payload: Dict[str, Any] = {
122
+ "memories": memories,
123
+ "wait_for_index": wait_for_index,
124
+ }
125
+ optional = {
126
+ "collection_id": collection_id,
127
+ "user_id": user_id,
128
+ "agent_id": agent_id,
129
+ "run_id": run_id,
130
+ "app_id": app_id,
131
+ "namespace": namespace,
132
+ }
133
+ payload.update(
134
+ {key: value for key, value in optional.items() if value is not None}
135
+ )
136
+ kwargs: Dict[str, Any] = {"json": payload}
137
+ if idempotency_key:
138
+ kwargs["headers"] = {"Idempotency-Key": idempotency_key}
139
+ receipt = self.client.post("/v1/memories/batch", **kwargs)
140
+ if wait_for_index and not (
141
+ receipt.get("searchable") is True
142
+ and str(receipt.get("processing_status") or "").casefold() == "completed"
143
+ ):
144
+ raise RuntimeError(
145
+ "wait_for_index batch response was not fully searchable; retry "
146
+ "with the same Idempotency-Key"
147
+ )
148
+ return receipt
149
+
150
+ def wait_batch_until_searchable(
151
+ self,
152
+ receipt: Dict[str, Any],
153
+ *,
154
+ timeout: float = 60.0,
155
+ poll_interval: float = 0.5,
156
+ ) -> Dict[str, Any]:
157
+ """Poll every item in an asynchronous batch receipt until searchable."""
158
+
159
+ memory_ids = list(dict.fromkeys(map(str, receipt.get("memory_ids") or [])))
160
+ if not memory_ids:
161
+ raise ValueError("batch receipt does not contain memory_ids")
162
+ deadline = time.monotonic() + max(0.0, timeout)
163
+ while True:
164
+ rows = [self.get(memory_id) for memory_id in memory_ids]
165
+ for row in rows:
166
+ state = str(row.get("processing_status") or "").casefold()
167
+ if state in {"failed", "cancelled", "canceled"}:
168
+ raise RuntimeError(
169
+ f"memory {row.get('id')} indexing reached terminal state {state}"
170
+ )
171
+ if all(row.get("searchable") is True for row in rows):
172
+ return {
173
+ **receipt,
174
+ "processing_status": "completed",
175
+ "searchable": True,
176
+ "results": [
177
+ {
178
+ "id": memory_id,
179
+ "memory_id": memory_id,
180
+ "processing_status": "completed",
181
+ }
182
+ for memory_id in memory_ids
183
+ ],
184
+ }
185
+ if time.monotonic() >= deadline:
186
+ raise TimeoutError(f"batch was not searchable within {timeout}s")
187
+ time.sleep(max(0.05, poll_interval))
188
+
104
189
  def wait_until_searchable(
105
190
  self,
106
191
  memory_id: str,
@@ -310,6 +395,115 @@ class SyncCorrectionsResource:
310
395
  return self.client.delete(f"/v1/corrections/{correction_id}")
311
396
 
312
397
 
398
+ class SyncProceduralResource:
399
+ """Blocking procedure lifecycle mapped to the canonical REST contract."""
400
+
401
+ def __init__(self, client: "SyncMemoryClient"):
402
+ self.client = client
403
+
404
+ @staticmethod
405
+ def _unwrap(response: Dict[str, Any]) -> Dict[str, Any]:
406
+ procedure = response.get("procedure")
407
+ if isinstance(procedure, dict):
408
+ return procedure
409
+ if response.get("procedure_id") and not response.get("id"):
410
+ return {**response, "id": response["procedure_id"]}
411
+ return response
412
+
413
+ def create(
414
+ self,
415
+ *,
416
+ name: str,
417
+ description: str,
418
+ trigger_condition: str,
419
+ action_sequence: List[str],
420
+ collection_id: Optional[str] = None,
421
+ category: Optional[str] = None,
422
+ metadata: Optional[Dict[str, Any]] = None,
423
+ ) -> Dict[str, Any]:
424
+ response = self.client.post(
425
+ "/v1/procedures",
426
+ json={
427
+ "name": name,
428
+ "description": description,
429
+ "condition": {"expression": trigger_condition},
430
+ "action": {"steps": action_sequence},
431
+ "collection_id": collection_id,
432
+ "category": category,
433
+ "parameters": metadata or {},
434
+ },
435
+ )
436
+ return self._unwrap(response)
437
+
438
+ def list(
439
+ self,
440
+ *,
441
+ collection_id: Optional[str] = None,
442
+ category: Optional[str] = None,
443
+ skip: int = 0,
444
+ limit: int = 100,
445
+ ) -> List[Dict[str, Any]]:
446
+ response = self.client.get(
447
+ "/v1/procedures",
448
+ params={
449
+ key: value
450
+ for key, value in {
451
+ "collection_id": collection_id,
452
+ "category": category,
453
+ "skip": skip,
454
+ "limit": limit,
455
+ }.items()
456
+ if value is not None
457
+ },
458
+ )
459
+ return (
460
+ response if isinstance(response, list) else response.get("procedures", [])
461
+ )
462
+
463
+ def get(self, procedure_id: str) -> Dict[str, Any]:
464
+ return self._unwrap(self.client.get(f"/v1/procedures/{procedure_id}"))
465
+
466
+ def update(
467
+ self,
468
+ procedure_id: str,
469
+ *,
470
+ name: Optional[str] = None,
471
+ description: Optional[str] = None,
472
+ trigger_condition: Optional[str] = None,
473
+ action_sequence: Optional[List[str]] = None,
474
+ metadata: Optional[Dict[str, Any]] = None,
475
+ ) -> Dict[str, Any]:
476
+ payload: Dict[str, Any] = {}
477
+ if name is not None:
478
+ payload["name"] = name
479
+ if description is not None:
480
+ payload["description"] = description
481
+ if trigger_condition is not None:
482
+ payload["condition"] = {"expression": trigger_condition}
483
+ if action_sequence is not None:
484
+ payload["action"] = {"steps": action_sequence}
485
+ if metadata is not None:
486
+ payload["parameters"] = metadata
487
+ return self._unwrap(
488
+ self.client.patch(f"/v1/procedures/{procedure_id}", json=payload)
489
+ )
490
+
491
+ def execute(
492
+ self,
493
+ procedure_id: str,
494
+ context: Optional[Dict[str, Any]] = None,
495
+ ) -> Dict[str, Any]:
496
+ response = self.client.post(
497
+ f"/v1/procedures/{procedure_id}/execute",
498
+ json={"input_state": context or {}},
499
+ )
500
+ result = response.get("execution_result")
501
+ return result if isinstance(result, dict) else response
502
+
503
+ def delete(self, procedure_id: str) -> None:
504
+ self.client.delete(f"/v1/procedures/{procedure_id}")
505
+
506
+
313
507
  class SyncSearchResource:
314
508
  def __init__(self, client: "SyncMemoryClient"):
315
509
  self.client = client
@@ -544,7 +738,7 @@ class SyncMemoryClient:
544
738
  self.source = source or os.getenv("HEBBRIX_SOURCE")
545
739
  headers = {
546
740
  "Content-Type": "application/json",
547
- "User-Agent": "hebbrix-python/2.3.1",
741
+ "User-Agent": "hebbrix-python/2.3.2",
548
742
  }
549
743
  if api_key:
550
744
  headers["Authorization"] = f"Bearer {api_key}"
@@ -559,6 +753,7 @@ class SyncMemoryClient:
559
753
  self.memories = SyncMemoriesResource(self)
560
754
  self.memory_jobs = SyncMemoryJobsResource(self)
561
755
  self.corrections = SyncCorrectionsResource(self)
756
+ self.procedural = SyncProceduralResource(self)
562
757
  self.search_resource = SyncSearchResource(self)
563
758
  self.proofloop = SyncProofLoopResource(self)
564
759
 
@@ -1,10 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hebbrix
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration
5
5
  Author-email: Hebbrix Team <support@hebbrix.com>
6
6
  Maintainer-email: Hebbrix Team <support@hebbrix.com>
7
- License-Expression: MIT
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: Repository, https://github.com/hebbrix/hebbrix-python
@@ -96,6 +117,18 @@ async def main():
96
117
  # It returns only when searchable=true, raises on terminal failure, and
97
118
  # raises TimeoutError if the caller's readiness deadline expires.
98
119
 
120
+ # Batch contract: a successful synchronous return means every accepted item
121
+ # is searchable. A bounded server timeout raises explicitly and is safe to
122
+ # retry with the same idempotency key; it is never a successful 202.
123
+ batch = await client.memories.create_batch(
124
+ [{"content": "First fact"}, {"content": "Second fact"}],
125
+ collection_id=collection["id"],
126
+ wait_for_index=True,
127
+ idempotency_key="import-42",
128
+ )
129
+ # For wait_for_index=False receipts:
130
+ # batch = await client.memories.wait_batch_until_searchable(batch)
131
+
99
132
  # Search memories
100
133
  results = await client.search(
101
134
  query="What programming language does user like?",
@@ -17,4 +17,5 @@ hebbrix.egg-info/requires.txt
17
17
  hebbrix.egg-info/top_level.txt
18
18
  tests/test_memories_resource.py
19
19
  tests/test_openapi_parity.py
20
+ tests/test_procedural_resource.py
20
21
  tests/test_sync_client.py
@@ -1,15 +1,14 @@
1
1
  [build-system]
2
- requires = ["setuptools>=77.0", "wheel"]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hebbrix"
7
- version = "2.3.1"
7
+ version = "2.3.2"
8
8
  description = "Advanced Memory API for AI Agents with Reinforcement Learning - 3-line integration"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
11
- license = "MIT"
12
- license-files = ["LICENSE"]
11
+ license = {file = "LICENSE"}
13
12
  authors = [
14
13
  {name = "Hebbrix Team", email = "support@hebbrix.com"}
15
14
  ]
@@ -30,6 +30,10 @@ class RecordingClient:
30
30
  self.calls.append((path, kwargs))
31
31
  return {"id": "memory-1"}
32
32
 
33
+ async def delete(self, path, **kwargs):
34
+ self.calls.append((path, kwargs))
35
+ return {}
36
+
33
37
 
34
38
  @pytest.mark.asyncio
35
39
  async def test_create_forwards_every_supported_scope_and_source_field():
@@ -102,6 +106,71 @@ async def test_create_sends_idempotency_as_transport_header_not_payload():
102
106
  assert "idempotency_key" not in request["json"]
103
107
 
104
108
 
109
+ @pytest.mark.asyncio
110
+ async def test_batch_create_has_one_truthful_readiness_contract():
111
+ client = RecordingClient()
112
+
113
+ async def post(path, **kwargs):
114
+ client.calls.append((path, kwargs))
115
+ return {
116
+ "created": 1,
117
+ "failed": 0,
118
+ "memory_ids": ["memory-1"],
119
+ "results": [{"id": "memory-1", "processing_status": "completed"}],
120
+ "processing_status": "completed",
121
+ "searchable": True,
122
+ }
123
+
124
+ client.post = post
125
+ receipt = await MemoriesResource(client).create_batch(
126
+ [{"content": "A batch fact"}],
127
+ collection_id="collection-1",
128
+ wait_for_index=True,
129
+ idempotency_key="batch-retry-1",
130
+ )
131
+
132
+ path, request = client.calls[0]
133
+ assert path == "/v1/memories/batch"
134
+ assert receipt["searchable"] is True
135
+ assert request["json"]["wait_for_index"] is True
136
+ assert request["headers"] == {"Idempotency-Key": "batch-retry-1"}
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_async_batch_polling_requires_every_item_to_be_searchable(monkeypatch):
141
+ client = RecordingClient()
142
+ calls = {"memory-1": 0, "memory-2": 0}
143
+
144
+ async def get(path, **_kwargs):
145
+ memory_id = path.rsplit("/", 1)[-1]
146
+ calls[memory_id] += 1
147
+ ready = calls[memory_id] >= 2
148
+ return {
149
+ "id": memory_id,
150
+ "processing_status": "completed" if ready else "processing",
151
+ "searchable": ready,
152
+ }
153
+
154
+ client.get = get
155
+ monkeypatch.setattr("hebbrix.resources.asyncio.sleep", AsyncMock())
156
+ receipt = await MemoriesResource(client).wait_batch_until_searchable(
157
+ {
158
+ "memory_ids": ["memory-1", "memory-2"],
159
+ "processing_status": "processing",
160
+ "searchable": False,
161
+ },
162
+ timeout=2,
163
+ poll_interval=0.05,
164
+ )
165
+
166
+ assert receipt["processing_status"] == "completed"
167
+ assert receipt["searchable"] is True
168
+ assert {row["memory_id"] for row in receipt["results"]} == {
169
+ "memory-1",
170
+ "memory-2",
171
+ }
172
+
173
+
105
174
  @pytest.mark.asyncio
106
175
  async def test_create_rejects_empty_content_before_network_io():
107
176
  client = RecordingClient()
@@ -1,13 +1,12 @@
1
1
  """Release guard: GA SDK methods must remain mapped to exported OpenAPI operations."""
2
2
 
3
3
  import inspect
4
- import json
5
- from pathlib import Path
6
4
 
7
5
  from hebbrix.resources import (
8
6
  CorrectionsResource,
9
7
  MemoriesResource,
10
8
  MemoryJobsResource,
9
+ ProceduralResource,
11
10
  ProofLoopResource,
12
11
  SearchResource,
13
12
  )
@@ -15,50 +14,22 @@ from hebbrix.sync_client import (
15
14
  SyncCorrectionsResource,
16
15
  SyncMemoriesResource,
17
16
  SyncMemoryJobsResource,
17
+ SyncProceduralResource,
18
18
  SyncProofLoopResource,
19
19
  SyncSearchResource,
20
20
  )
21
21
 
22
-
23
- OPENAPI = json.loads(
24
- (Path(__file__).resolve().parents[3] / "docs/api/openapi.json").read_text()
25
- )
26
-
27
-
28
- GA_OPERATIONS = {
29
- ("/v1/memories", "post"),
30
- ("/v1/memories", "get"),
31
- ("/v1/memory-jobs/{job_id}", "get"),
32
- ("/v1/corrections", "post"),
33
- ("/v1/corrections/relevant", "get"),
34
- ("/v1/corrections/{correction_id}", "get"),
35
- ("/v1/corrections/{correction_id}", "delete"),
36
- ("/v1/search", "post"),
37
- ("/v1/search/reason", "post"),
38
- ("/v1/learning/decisions", "post"),
39
- ("/v1/learning/decisions/{decision_id}", "get"),
40
- ("/v1/learning/decisions/{decision_id}/outcomes", "post"),
41
- ("/v1/learning/decisions/{decision_id}/proof", "get"),
42
- ("/v1/learning/metrics", "post"),
43
- ("/v1/learning/metrics", "get"),
44
- ("/v1/learning/policies/{policy_key}/insights", "get"),
45
- ("/v1/learning/policies/{policy_key}/evaluate", "post"),
46
- ("/v1/learning/proof-key", "get"),
47
- }
48
-
49
-
50
- def test_ga_operation_manifest_exists_in_exported_openapi():
51
- missing = sorted(
52
- (path, method)
53
- for path, method in GA_OPERATIONS
54
- if path not in OPENAPI["paths"] or method not in OPENAPI["paths"][path]
55
- )
56
- assert missing == []
57
-
58
-
59
22
  def test_async_and_sync_resources_cover_the_ga_lifecycle():
60
23
  required = {
61
- MemoriesResource: {"create", "list_page", "get", "update", "delete"},
24
+ MemoriesResource: {
25
+ "create",
26
+ "create_batch",
27
+ "wait_batch_until_searchable",
28
+ "list_page",
29
+ "get",
30
+ "update",
31
+ "delete",
32
+ },
62
33
  MemoryJobsResource: {"get", "wait"},
63
34
  CorrectionsResource: {"create", "relevant", "get", "delete"},
64
35
  SearchResource: {"search_with_proof", "reason"},
@@ -73,7 +44,16 @@ def test_async_and_sync_resources_cover_the_ga_lifecycle():
73
44
  "proof",
74
45
  "public_key",
75
46
  },
76
- SyncMemoriesResource: {"create", "list_page", "get", "update", "delete"},
47
+ ProceduralResource: {"create", "list", "get", "update", "execute", "delete"},
48
+ SyncMemoriesResource: {
49
+ "create",
50
+ "create_batch",
51
+ "wait_batch_until_searchable",
52
+ "list_page",
53
+ "get",
54
+ "update",
55
+ "delete",
56
+ },
77
57
  SyncMemoryJobsResource: {"get", "wait"},
78
58
  SyncCorrectionsResource: {"create", "relevant", "get", "delete"},
79
59
  SyncSearchResource: {"search_with_proof", "reason"},
@@ -88,6 +68,14 @@ def test_async_and_sync_resources_cover_the_ga_lifecycle():
88
68
  "proof",
89
69
  "public_key",
90
70
  },
71
+ SyncProceduralResource: {
72
+ "create",
73
+ "list",
74
+ "get",
75
+ "update",
76
+ "execute",
77
+ "delete",
78
+ },
91
79
  }
92
80
  missing = {
93
81
  resource.__name__: sorted(
@@ -0,0 +1,121 @@
1
+ import pytest
2
+ from hebbrix.resources import ProceduralResource
3
+ from hebbrix.sync_client import SyncProceduralResource
4
+
5
+
6
+ class RecordingClient:
7
+ def __init__(self):
8
+ self.calls = []
9
+
10
+ async def post(self, path, **kwargs):
11
+ self.calls.append(("POST", path, kwargs))
12
+ if path == "/v1/procedures":
13
+ return {"status": "success", "procedure_id": "procedure-1"}
14
+ if path.endswith("/execute"):
15
+ return {"execution_result": {"output": "recovered"}}
16
+ return {"status": "success"}
17
+
18
+ async def get(self, path, **kwargs):
19
+ self.calls.append(("GET", path, kwargs))
20
+ return {"procedures": []}
21
+
22
+ async def patch(self, path, **kwargs):
23
+ self.calls.append(("PATCH", path, kwargs))
24
+ return {
25
+ "status": "success",
26
+ "procedure": {"id": "procedure-1", "name": "updated"},
27
+ }
28
+
29
+ async def delete(self, path, **kwargs):
30
+ self.calls.append(("DELETE", path, kwargs))
31
+ return {}
32
+
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_procedure_lifecycle_matches_canonical_rest_contract():
36
+ client = RecordingClient()
37
+ resource = ProceduralResource(client)
38
+
39
+ created = await resource.create(
40
+ name="restart proxy",
41
+ description="Recover the query proxy",
42
+ trigger_condition="proxy unavailable",
43
+ action_sequence=["recycle proxy once"],
44
+ metadata={"owner": "on-call"},
45
+ )
46
+ assert await resource.list() == []
47
+ executed = await resource.execute("procedure-1", {"incident": "INC-1"})
48
+ updated = await resource.update(
49
+ "procedure-1",
50
+ trigger_condition="proxy unhealthy",
51
+ action_sequence=["page on-call", "recycle proxy once"],
52
+ )
53
+ await resource.delete("procedure-1")
54
+ assert created["id"] == "procedure-1"
55
+ assert executed == {"output": "recovered"}
56
+ assert updated == {"id": "procedure-1", "name": "updated"}
57
+
58
+ assert [(method, path) for method, path, _ in client.calls] == [
59
+ ("POST", "/v1/procedures"),
60
+ ("GET", "/v1/procedures"),
61
+ ("POST", "/v1/procedures/procedure-1/execute"),
62
+ ("PATCH", "/v1/procedures/procedure-1"),
63
+ ("DELETE", "/v1/procedures/procedure-1"),
64
+ ]
65
+ create = client.calls[0][2]["json"]
66
+ assert create["condition"] == {"expression": "proxy unavailable"}
67
+ assert create["action"] == {"steps": ["recycle proxy once"]}
68
+ assert create["parameters"] == {"owner": "on-call"}
69
+ assert client.calls[2][2]["json"] == {"input_state": {"incident": "INC-1"}}
70
+ update = client.calls[3][2]["json"]
71
+ assert update["condition"] == {"expression": "proxy unhealthy"}
72
+ assert update["action"] == {"steps": ["page on-call", "recycle proxy once"]}
73
+
74
+
75
+ class SyncRecordingClient:
76
+ def __init__(self):
77
+ self.calls = []
78
+
79
+ def post(self, path, **kwargs):
80
+ self.calls.append(("POST", path, kwargs))
81
+ if path == "/v1/procedures":
82
+ return {"procedure_id": "procedure-1"}
83
+ if path.endswith("/execute"):
84
+ return {"execution_result": {"output": "recovered"}}
85
+ return {}
86
+
87
+ def get(self, path, **kwargs):
88
+ self.calls.append(("GET", path, kwargs))
89
+ return {"procedures": []}
90
+
91
+ def patch(self, path, **kwargs):
92
+ self.calls.append(("PATCH", path, kwargs))
93
+ return {"procedure": {"id": "procedure-1", "name": "updated"}}
94
+
95
+ def delete(self, path, **kwargs):
96
+ self.calls.append(("DELETE", path, kwargs))
97
+ return None
98
+
99
+
100
+ def test_sync_procedure_lifecycle_matches_canonical_rest_contract():
101
+ client = SyncRecordingClient()
102
+ resource = SyncProceduralResource(client)
103
+
104
+ created = resource.create(
105
+ name="restart proxy",
106
+ description="Recover the query proxy",
107
+ trigger_condition="proxy unavailable",
108
+ action_sequence=["recycle proxy once"],
109
+ )
110
+ assert resource.list() == []
111
+ assert resource.execute("procedure-1") == {"output": "recovered"}
112
+ assert resource.update("procedure-1", name="updated")["name"] == "updated"
113
+ assert resource.delete("procedure-1") is None
114
+ assert created["id"] == "procedure-1"
115
+ assert [(method, path) for method, path, _ in client.calls] == [
116
+ ("POST", "/v1/procedures"),
117
+ ("GET", "/v1/procedures"),
118
+ ("POST", "/v1/procedures/procedure-1/execute"),
119
+ ("PATCH", "/v1/procedures/procedure-1"),
120
+ ("DELETE", "/v1/procedures/procedure-1"),
121
+ ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes