hebbrix 2.3.0__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hebbrix-2.3.1/CHANGELOG.md +23 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/MANIFEST.in +1 -0
- {hebbrix-2.3.0/hebbrix.egg-info → hebbrix-2.3.1}/PKG-INFO +5 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/README.md +4 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/__init__.py +1 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/client.py +1 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/resources.py +108 -7
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/sync_client.py +87 -3
- {hebbrix-2.3.0 → hebbrix-2.3.1/hebbrix.egg-info}/PKG-INFO +5 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix.egg-info/SOURCES.txt +1 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/pyproject.toml +1 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/tests/test_memories_resource.py +102 -2
- {hebbrix-2.3.0 → hebbrix-2.3.1}/tests/test_sync_client.py +5 -1
- {hebbrix-2.3.0 → hebbrix-2.3.1}/LICENSE +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/chat.py +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/exceptions.py +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix/models.py +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix.egg-info/dependency_links.txt +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix.egg-info/requires.txt +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/hebbrix.egg-info/top_level.txt +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/setup.cfg +0 -0
- {hebbrix-2.3.0 → hebbrix-2.3.1}/tests/test_openapi_parity.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.3.1 — 2026-08-26
|
|
4
|
+
|
|
5
|
+
- Make `wait_for_index=True` poll the authoritative memory or job readiness
|
|
6
|
+
endpoint instead of returning a non-searchable receipt.
|
|
7
|
+
- Add `memories.wait_until_searchable(...)` to the async and synchronous
|
|
8
|
+
clients, with explicit timeout and terminal-failure handling.
|
|
9
|
+
- Preserve valid evidence-bound search rows during a degraded or abstaining
|
|
10
|
+
server response while retaining the safety metadata; malformed and explicit
|
|
11
|
+
no-match envelopes still fail closed.
|
|
12
|
+
- Keep the synchronous and asynchronous memory create, scope, idempotency, and
|
|
13
|
+
search-safety behavior aligned.
|
|
14
|
+
- Add `temporal.delete_fact(fact_id)` for tenant-scoped, idempotent cleanup of
|
|
15
|
+
facts created through the temporal API.
|
|
16
|
+
|
|
17
|
+
The supported server/SDK release pair is published by the server OpenAPI
|
|
18
|
+
document in `info.x-hebbrix-sdk-compatibility`. Patch releases preserve the
|
|
19
|
+
public API within the same major version.
|
|
20
|
+
|
|
21
|
+
## 2.3.0
|
|
22
|
+
|
|
23
|
+
- Added the GA scoped memory, corrections, search proof, and ProofLoop surface.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
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>
|
|
@@ -92,6 +92,10 @@ async def main():
|
|
|
92
92
|
wait_for_index=True,
|
|
93
93
|
)
|
|
94
94
|
|
|
95
|
+
# wait_for_index=True polls the returned memory status through the SDK.
|
|
96
|
+
# It returns only when searchable=true, raises on terminal failure, and
|
|
97
|
+
# raises TimeoutError if the caller's readiness deadline expires.
|
|
98
|
+
|
|
95
99
|
# Search memories
|
|
96
100
|
results = await client.search(
|
|
97
101
|
query="What programming language does user like?",
|
|
@@ -51,6 +51,10 @@ async def main():
|
|
|
51
51
|
wait_for_index=True,
|
|
52
52
|
)
|
|
53
53
|
|
|
54
|
+
# wait_for_index=True polls the returned memory status through the SDK.
|
|
55
|
+
# It returns only when searchable=true, raises on terminal failure, and
|
|
56
|
+
# raises TimeoutError if the caller's readiness deadline expires.
|
|
57
|
+
|
|
54
58
|
# Search memories
|
|
55
59
|
results = await client.search(
|
|
56
60
|
query="What programming language does user like?",
|
|
@@ -127,12 +127,12 @@ def _canonical_search_envelope(
|
|
|
127
127
|
elif data.get("no_match") and (row_ids or evidence_ids):
|
|
128
128
|
reason = "no_match_contains_evidence"
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
):
|
|
130
|
+
# Degradation and abstention are confidence signals, not proof that the
|
|
131
|
+
# API returned no evidence. Erasing valid, evidence-bound rows here made
|
|
132
|
+
# the SDK disagree with the REST response and converted a safe fallback
|
|
133
|
+
# into a false negative. Fail closed only when the envelope is malformed
|
|
134
|
+
# or the server explicitly reports no match.
|
|
135
|
+
if reason or data.get("no_match") is True:
|
|
136
136
|
data[rows_key] = []
|
|
137
137
|
if rows_key == "results":
|
|
138
138
|
data["total"] = 0
|
|
@@ -147,6 +147,8 @@ def _canonical_search_envelope(
|
|
|
147
147
|
"status": "no_grounded_match",
|
|
148
148
|
"reason": reason,
|
|
149
149
|
}
|
|
150
|
+
elif data.get("degraded") is True or data.get("abstain_recommended") is True:
|
|
151
|
+
data["sdk_safety_reason"] = "degraded_evidence_preserved"
|
|
150
152
|
return data
|
|
151
153
|
|
|
152
154
|
|
|
@@ -347,6 +349,8 @@ class MemoriesResource(BaseResource):
|
|
|
347
349
|
tags: Optional[List[str]] = None,
|
|
348
350
|
source: Optional[str] = None,
|
|
349
351
|
idempotency_key: Optional[str] = None,
|
|
352
|
+
index_timeout: float = 60.0,
|
|
353
|
+
index_poll_interval: float = 0.5,
|
|
350
354
|
) -> Dict[str, Any]:
|
|
351
355
|
"""
|
|
352
356
|
Create a memory.
|
|
@@ -422,7 +426,99 @@ class MemoriesResource(BaseResource):
|
|
|
422
426
|
request_kwargs: Dict[str, Any] = {"json": payload}
|
|
423
427
|
if idempotency_key:
|
|
424
428
|
request_kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
425
|
-
|
|
429
|
+
receipt = await self.client.post("/v1/memories", **request_kwargs)
|
|
430
|
+
if wait_for_index:
|
|
431
|
+
receipt = await self._ensure_searchable_receipt(
|
|
432
|
+
receipt,
|
|
433
|
+
timeout=index_timeout,
|
|
434
|
+
poll_interval=index_poll_interval,
|
|
435
|
+
)
|
|
436
|
+
return receipt
|
|
437
|
+
|
|
438
|
+
async def wait_until_searchable(
|
|
439
|
+
self,
|
|
440
|
+
memory_id: str,
|
|
441
|
+
*,
|
|
442
|
+
timeout: float = 60.0,
|
|
443
|
+
poll_interval: float = 0.5,
|
|
444
|
+
) -> Dict[str, Any]:
|
|
445
|
+
"""Poll the authoritative memory status until indexing is terminal."""
|
|
446
|
+
|
|
447
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
448
|
+
while True:
|
|
449
|
+
memory = await self.get(memory_id)
|
|
450
|
+
state = str(memory.get("processing_status") or "").casefold()
|
|
451
|
+
if memory.get("searchable") is True:
|
|
452
|
+
return memory
|
|
453
|
+
if state == "completed":
|
|
454
|
+
raise RuntimeError(
|
|
455
|
+
f"memory {memory_id} reported completed without searchable=true"
|
|
456
|
+
)
|
|
457
|
+
if state in {"failed", "cancelled", "canceled"}:
|
|
458
|
+
raise RuntimeError(
|
|
459
|
+
f"memory {memory_id} indexing reached terminal state {state}"
|
|
460
|
+
)
|
|
461
|
+
if time.monotonic() >= deadline:
|
|
462
|
+
raise TimeoutError(
|
|
463
|
+
f"memory {memory_id} was not searchable within {timeout}s"
|
|
464
|
+
)
|
|
465
|
+
await asyncio.sleep(max(0.05, poll_interval))
|
|
466
|
+
|
|
467
|
+
async def _ensure_searchable_receipt(
|
|
468
|
+
self,
|
|
469
|
+
receipt: Dict[str, Any],
|
|
470
|
+
*,
|
|
471
|
+
timeout: float,
|
|
472
|
+
poll_interval: float,
|
|
473
|
+
) -> Dict[str, Any]:
|
|
474
|
+
if (
|
|
475
|
+
receipt.get("searchable") is True
|
|
476
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
477
|
+
):
|
|
478
|
+
return receipt
|
|
479
|
+
job_id = str(receipt.get("job_id") or "")
|
|
480
|
+
if job_id:
|
|
481
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
482
|
+
while True:
|
|
483
|
+
job = await self.client.get(f"/v1/memory-jobs/{job_id}")
|
|
484
|
+
state = str(job.get("status") or "").casefold()
|
|
485
|
+
if state == "completed":
|
|
486
|
+
receipt.update(job)
|
|
487
|
+
receipt["searchable"] = True
|
|
488
|
+
receipt["processing_status"] = "completed"
|
|
489
|
+
return receipt
|
|
490
|
+
if state in {"failed", "cancelled", "canceled"}:
|
|
491
|
+
raise RuntimeError(
|
|
492
|
+
f"memory job {job_id} reached terminal state {state}"
|
|
493
|
+
)
|
|
494
|
+
if time.monotonic() >= deadline:
|
|
495
|
+
raise TimeoutError(
|
|
496
|
+
f"memory job {job_id} did not become searchable within {timeout}s"
|
|
497
|
+
)
|
|
498
|
+
await asyncio.sleep(max(0.05, poll_interval))
|
|
499
|
+
|
|
500
|
+
candidates = [
|
|
501
|
+
receipt.get("id"),
|
|
502
|
+
*(
|
|
503
|
+
item.get("id") or item.get("memory_id")
|
|
504
|
+
for item in (receipt.get("results") or [])
|
|
505
|
+
if isinstance(item, dict)
|
|
506
|
+
),
|
|
507
|
+
]
|
|
508
|
+
memory_id = next((str(value) for value in candidates if value), "")
|
|
509
|
+
if not memory_id:
|
|
510
|
+
raise RuntimeError(
|
|
511
|
+
"wait_for_index response contained neither a memory id nor a job id"
|
|
512
|
+
)
|
|
513
|
+
ready = await self.wait_until_searchable(
|
|
514
|
+
memory_id,
|
|
515
|
+
timeout=timeout,
|
|
516
|
+
poll_interval=poll_interval,
|
|
517
|
+
)
|
|
518
|
+
receipt["searchable"] = True
|
|
519
|
+
receipt["processing_status"] = "completed"
|
|
520
|
+
receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
|
|
521
|
+
return receipt
|
|
426
522
|
|
|
427
523
|
async def list_page(
|
|
428
524
|
self,
|
|
@@ -1333,6 +1429,11 @@ class TemporalResource(BaseResource):
|
|
|
1333
1429
|
},
|
|
1334
1430
|
)
|
|
1335
1431
|
|
|
1432
|
+
async def delete_fact(self, fact_id: str) -> Dict[str, Any]:
|
|
1433
|
+
"""Permanently delete a tenant-scoped temporal fact by stable ID."""
|
|
1434
|
+
|
|
1435
|
+
return await self.client.delete(f"/temporal/facts/{fact_id}")
|
|
1436
|
+
|
|
1336
1437
|
|
|
1337
1438
|
class WorkingMemoryResource(BaseResource):
|
|
1338
1439
|
"""Working memory buffer endpoints."""
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Synchronous Hebbrix client for the core memory and ProofLoop lifecycle."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
import json
|
|
4
|
+
import os
|
|
5
5
|
import time
|
|
6
6
|
from typing import Any, Dict, List, Optional
|
|
7
7
|
|
|
@@ -65,6 +65,8 @@ class SyncMemoriesResource:
|
|
|
65
65
|
tags: Optional[List[str]] = None,
|
|
66
66
|
source: Optional[str] = None,
|
|
67
67
|
idempotency_key: Optional[str] = None,
|
|
68
|
+
index_timeout: float = 60.0,
|
|
69
|
+
index_poll_interval: float = 0.5,
|
|
68
70
|
) -> Dict[str, Any]:
|
|
69
71
|
request_kwargs: Dict[str, Any] = {
|
|
70
72
|
"json": _memory_create_payload(
|
|
@@ -90,7 +92,89 @@ class SyncMemoriesResource:
|
|
|
90
92
|
}
|
|
91
93
|
if idempotency_key:
|
|
92
94
|
request_kwargs["headers"] = {"Idempotency-Key": idempotency_key}
|
|
93
|
-
|
|
95
|
+
receipt = self.client.post("/v1/memories", **request_kwargs)
|
|
96
|
+
if wait_for_index:
|
|
97
|
+
receipt = self._ensure_searchable_receipt(
|
|
98
|
+
receipt,
|
|
99
|
+
timeout=index_timeout,
|
|
100
|
+
poll_interval=index_poll_interval,
|
|
101
|
+
)
|
|
102
|
+
return receipt
|
|
103
|
+
|
|
104
|
+
def wait_until_searchable(
|
|
105
|
+
self,
|
|
106
|
+
memory_id: str,
|
|
107
|
+
*,
|
|
108
|
+
timeout: float = 60.0,
|
|
109
|
+
poll_interval: float = 0.5,
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
112
|
+
while True:
|
|
113
|
+
memory = self.get(memory_id)
|
|
114
|
+
state = str(memory.get("processing_status") or "").casefold()
|
|
115
|
+
if memory.get("searchable") is True:
|
|
116
|
+
return memory
|
|
117
|
+
if state == "completed":
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
f"memory {memory_id} reported completed without searchable=true"
|
|
120
|
+
)
|
|
121
|
+
if state in {"failed", "cancelled", "canceled"}:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"memory {memory_id} indexing reached terminal state {state}"
|
|
124
|
+
)
|
|
125
|
+
if time.monotonic() >= deadline:
|
|
126
|
+
raise TimeoutError(
|
|
127
|
+
f"memory {memory_id} was not searchable within {timeout}s"
|
|
128
|
+
)
|
|
129
|
+
time.sleep(max(0.05, poll_interval))
|
|
130
|
+
|
|
131
|
+
def _ensure_searchable_receipt(
|
|
132
|
+
self,
|
|
133
|
+
receipt: Dict[str, Any],
|
|
134
|
+
*,
|
|
135
|
+
timeout: float,
|
|
136
|
+
poll_interval: float,
|
|
137
|
+
) -> Dict[str, Any]:
|
|
138
|
+
if (
|
|
139
|
+
receipt.get("searchable") is True
|
|
140
|
+
and str(receipt.get("processing_status") or "").casefold() == "completed"
|
|
141
|
+
):
|
|
142
|
+
return receipt
|
|
143
|
+
job_id = str(receipt.get("job_id") or "")
|
|
144
|
+
if job_id:
|
|
145
|
+
job = SyncMemoryJobsResource(self.client).wait(
|
|
146
|
+
job_id,
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
poll_interval=poll_interval,
|
|
149
|
+
)
|
|
150
|
+
if str(job.get("status") or "").casefold() != "completed":
|
|
151
|
+
raise RuntimeError(f"memory job {job_id} did not complete")
|
|
152
|
+
receipt.update(job)
|
|
153
|
+
receipt["searchable"] = True
|
|
154
|
+
receipt["processing_status"] = "completed"
|
|
155
|
+
return receipt
|
|
156
|
+
candidates = [
|
|
157
|
+
receipt.get("id"),
|
|
158
|
+
*(
|
|
159
|
+
item.get("id") or item.get("memory_id")
|
|
160
|
+
for item in (receipt.get("results") or [])
|
|
161
|
+
if isinstance(item, dict)
|
|
162
|
+
),
|
|
163
|
+
]
|
|
164
|
+
memory_id = next((str(value) for value in candidates if value), "")
|
|
165
|
+
if not memory_id:
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"wait_for_index response contained neither a memory id nor a job id"
|
|
168
|
+
)
|
|
169
|
+
ready = self.wait_until_searchable(
|
|
170
|
+
memory_id,
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
poll_interval=poll_interval,
|
|
173
|
+
)
|
|
174
|
+
receipt["searchable"] = True
|
|
175
|
+
receipt["processing_status"] = "completed"
|
|
176
|
+
receipt["status_url"] = ready.get("status_url") or f"/v1/memories/{memory_id}"
|
|
177
|
+
return receipt
|
|
94
178
|
|
|
95
179
|
def list_page(
|
|
96
180
|
self,
|
|
@@ -460,7 +544,7 @@ class SyncMemoryClient:
|
|
|
460
544
|
self.source = source or os.getenv("HEBBRIX_SOURCE")
|
|
461
545
|
headers = {
|
|
462
546
|
"Content-Type": "application/json",
|
|
463
|
-
"User-Agent": "hebbrix-python/2.3.
|
|
547
|
+
"User-Agent": "hebbrix-python/2.3.1",
|
|
464
548
|
}
|
|
465
549
|
if api_key:
|
|
466
550
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hebbrix
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
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>
|
|
@@ -92,6 +92,10 @@ async def main():
|
|
|
92
92
|
wait_for_index=True,
|
|
93
93
|
)
|
|
94
94
|
|
|
95
|
+
# wait_for_index=True polls the returned memory status through the SDK.
|
|
96
|
+
# It returns only when searchable=true, raises on terminal failure, and
|
|
97
|
+
# raises TimeoutError if the caller's readiness deadline expires.
|
|
98
|
+
|
|
95
99
|
# Search memories
|
|
96
100
|
results = await client.search(
|
|
97
101
|
query="What programming language does user like?",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import pytest
|
|
2
1
|
from unittest.mock import AsyncMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
3
4
|
from hebbrix.resources import (
|
|
4
5
|
CorrectionsResource,
|
|
5
6
|
MemoriesResource,
|
|
@@ -15,7 +16,11 @@ class RecordingClient:
|
|
|
15
16
|
|
|
16
17
|
async def post(self, path, **kwargs):
|
|
17
18
|
self.calls.append((path, kwargs))
|
|
18
|
-
return {
|
|
19
|
+
return {
|
|
20
|
+
"results": [{"id": "memory-1"}],
|
|
21
|
+
"processing_status": "completed",
|
|
22
|
+
"searchable": True,
|
|
23
|
+
}
|
|
19
24
|
|
|
20
25
|
async def get(self, path, **kwargs):
|
|
21
26
|
self.calls.append((path, kwargs))
|
|
@@ -108,6 +113,75 @@ async def test_create_rejects_empty_content_before_network_io():
|
|
|
108
113
|
assert client.calls == []
|
|
109
114
|
|
|
110
115
|
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_wait_for_index_polls_until_the_memory_is_actually_searchable(
|
|
118
|
+
monkeypatch,
|
|
119
|
+
):
|
|
120
|
+
client = RecordingClient()
|
|
121
|
+
states = iter(
|
|
122
|
+
[
|
|
123
|
+
{"id": "memory-1", "processing_status": "processing", "searchable": False},
|
|
124
|
+
{"id": "memory-1", "processing_status": "completed", "searchable": True},
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def post(_path, **_kwargs):
|
|
129
|
+
return {
|
|
130
|
+
"results": [{"id": "memory-1"}],
|
|
131
|
+
"processing_status": "processing",
|
|
132
|
+
"searchable": False,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async def get(_path, **_kwargs):
|
|
136
|
+
return next(states)
|
|
137
|
+
|
|
138
|
+
client.post = post
|
|
139
|
+
client.get = get
|
|
140
|
+
monkeypatch.setattr("hebbrix.resources.asyncio.sleep", AsyncMock())
|
|
141
|
+
|
|
142
|
+
receipt = await MemoriesResource(client).create(
|
|
143
|
+
content="A fact",
|
|
144
|
+
wait_for_index=True,
|
|
145
|
+
index_timeout=2,
|
|
146
|
+
index_poll_interval=0.05,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert receipt["searchable"] is True
|
|
150
|
+
assert receipt["processing_status"] == "completed"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_wait_for_index_never_returns_a_failed_terminal_state():
|
|
155
|
+
client = RecordingClient()
|
|
156
|
+
|
|
157
|
+
async def post(_path, **_kwargs):
|
|
158
|
+
return {
|
|
159
|
+
"results": [{"id": "memory-1"}],
|
|
160
|
+
"processing_status": "processing",
|
|
161
|
+
"searchable": False,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async def get(_path, **_kwargs):
|
|
165
|
+
return {"processing_status": "failed", "searchable": False}
|
|
166
|
+
|
|
167
|
+
client.post = post
|
|
168
|
+
client.get = get
|
|
169
|
+
with pytest.raises(RuntimeError, match="terminal state failed"):
|
|
170
|
+
await MemoriesResource(client).create(content="A fact", wait_for_index=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_wait_for_index_never_invents_searchability_from_completed_state():
|
|
175
|
+
client = RecordingClient()
|
|
176
|
+
|
|
177
|
+
async def get(_path, **_kwargs):
|
|
178
|
+
return {"processing_status": "completed", "searchable": False}
|
|
179
|
+
|
|
180
|
+
client.get = get
|
|
181
|
+
with pytest.raises(RuntimeError, match="completed without searchable=true"):
|
|
182
|
+
await MemoriesResource(client).wait_until_searchable("memory-1")
|
|
183
|
+
|
|
184
|
+
|
|
111
185
|
@pytest.mark.asyncio
|
|
112
186
|
async def test_create_supports_the_conversation_message_input_mode():
|
|
113
187
|
client = RecordingClient()
|
|
@@ -261,6 +335,32 @@ async def test_search_sdk_fails_closed_when_safety_metadata_is_missing():
|
|
|
261
335
|
assert response["sdk_safety_reason"].startswith("missing_safety_fields:")
|
|
262
336
|
|
|
263
337
|
|
|
338
|
+
@pytest.mark.asyncio
|
|
339
|
+
async def test_search_sdk_preserves_evidence_bound_degraded_results():
|
|
340
|
+
row = {"memory_id": "memory-1", "content": "exact fact", "score": 0.8}
|
|
341
|
+
client = SearchRecordingClient(
|
|
342
|
+
{
|
|
343
|
+
"results": [row],
|
|
344
|
+
"total": 1,
|
|
345
|
+
"no_match": False,
|
|
346
|
+
"abstain_recommended": True,
|
|
347
|
+
"query_confidence": 0.45,
|
|
348
|
+
"grounding": {"status": "degraded", "reason": "reranker_unavailable"},
|
|
349
|
+
"evidence_ids": ["memory-1"],
|
|
350
|
+
"evidence_claims": [],
|
|
351
|
+
"safety_contract_version": "search-safety-v1",
|
|
352
|
+
"degraded": True,
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
response = await SearchResource(client).search_with_proof("exact fact")
|
|
357
|
+
|
|
358
|
+
assert response["results"] == [row]
|
|
359
|
+
assert response["no_match"] is False
|
|
360
|
+
assert response["abstain_recommended"] is True
|
|
361
|
+
assert response["sdk_safety_reason"] == "degraded_evidence_preserved"
|
|
362
|
+
|
|
363
|
+
|
|
264
364
|
@pytest.mark.asyncio
|
|
265
365
|
async def test_reason_forwards_scopes_facets_and_preserves_safety_envelope():
|
|
266
366
|
response = {
|
|
@@ -12,7 +12,11 @@ class RecordingSyncClient:
|
|
|
12
12
|
|
|
13
13
|
def post(self, path, **kwargs):
|
|
14
14
|
self.calls.append(("POST", path, kwargs))
|
|
15
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
"decision_id": "decision-1",
|
|
17
|
+
"processing_status": "completed",
|
|
18
|
+
"searchable": True,
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
def get(self, path, **kwargs):
|
|
18
22
|
self.calls.append(("GET", path, kwargs))
|
|
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
|