memwal 0.2.0.dev0__tar.gz → 0.2.0.dev1__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.
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/CHANGELOG.md +5 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/PKG-INFO +1 -1
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/__init__.py +1 -1
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/client.py +102 -43
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/middleware.py +157 -23
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/mock.py +5 -3
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/pyproject.toml +1 -1
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_middleware.py +267 -1
- memwal-0.2.0.dev1/tests/test_nonfinite_validation.py +238 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/.gitignore +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/README.md +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/.env.example +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/.gitignore +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/async_remember_demo.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/interactive_demo.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/examples/verify_credentials.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/compatibility.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/types.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/memwal/utils.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/run_tests.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/__init__.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_blank_text.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_client.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_empty_namespace.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_env_presets.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_integration.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_list_namespaces.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_mock.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_polling_delay.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_recall_weights.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_signing.py +0 -0
- {memwal-0.2.0.dev0 → memwal-0.2.0.dev1}/tests/test_transport_retry.py +0 -0
|
@@ -12,6 +12,11 @@ Unreleased.
|
|
|
12
12
|
|
|
13
13
|
- Require certifi 2026.10.19 or newer to remove trust in the compromised emSign certificate chain, and AnyIO 4.14.2 or newer for its TLS hostname fix. Update pip separately to 26.2.2 or newer; the SDK cannot replace pip's vendored trust store.
|
|
14
14
|
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `with_memwal_*` gains `save_mode` and confirmable saves: `memwal_wait_for_saves` (async and sync) polls every job the middleware enqueued, and restores the job ids if that poll raises so a retry can still confirm them. (WALM-307, #410, #411, #412)
|
|
18
|
+
- `wait_for_remember_jobs` chunks its status reads to the relayer's 20-id `MAX_BULK_ITEMS` cap. Above it the relayer answers a non-transient 400, so a wait over 21+ jobs raised on every attempt and could never confirm — reachable from `save_mode="remember"` after 21 turns, or from two fat `analyze` extracts. (WALM-307)
|
|
19
|
+
|
|
15
20
|
## 0.1.12
|
|
16
21
|
|
|
17
22
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
4
|
Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
|
|
5
5
|
Project-URL: Homepage, https://memory.walrus.xyz
|
|
6
6
|
Project-URL: Documentation, https://memory.walrus.xyz
|
|
@@ -98,6 +98,10 @@ PUBLIC_SUI_GRAPHQL_NETWORKS = ("mainnet", "testnet", "devnet")
|
|
|
98
98
|
# Sui read endpoint is unresponsive.
|
|
99
99
|
PACKAGE_VERSION_CHECK_TIMEOUT_S = 10.0
|
|
100
100
|
SEAL_SESSION_SAFETY_MARGIN_MS = 30_000
|
|
101
|
+
|
|
102
|
+
# The relayer's `MAX_BULK_ITEMS` (services/server/src/types.rs). A bulk read
|
|
103
|
+
# above it is rejected with a non-transient 400, so callers must chunk.
|
|
104
|
+
MAX_BULK_STATUS_IDS = 20
|
|
101
105
|
AUTH_REJECTED_MESSAGE = (
|
|
102
106
|
"401 from relayer: typically wrong private key, key not registered on this "
|
|
103
107
|
"account, account ID mismatch (a wallet address is not a MemWal account ID), "
|
|
@@ -410,6 +414,29 @@ def _occurred_at_to_wire(
|
|
|
410
414
|
)
|
|
411
415
|
|
|
412
416
|
|
|
417
|
+
|
|
418
|
+
def _check_limit(limit: Any) -> None:
|
|
419
|
+
"""Reject a limit that is not a positive integer before it is signed (#1218)."""
|
|
420
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
|
421
|
+
raise ValueError(f"limit must be a positive integer, got {limit!r}")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _check_vector(vector: Any) -> None:
|
|
425
|
+
"""Reject a vector that is not a non-empty list of finite numbers (#1218).
|
|
426
|
+
|
|
427
|
+
json.dumps would otherwise emit bare NaN/Infinity tokens, which are not
|
|
428
|
+
JSON, and the relayer would answer with an opaque parse error.
|
|
429
|
+
"""
|
|
430
|
+
if (
|
|
431
|
+
not isinstance(vector, (list, tuple))
|
|
432
|
+
or len(vector) == 0
|
|
433
|
+
or not all(
|
|
434
|
+
isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
435
|
+
for v in vector
|
|
436
|
+
)
|
|
437
|
+
):
|
|
438
|
+
raise ValueError("vector must be a non-empty list of finite numbers")
|
|
439
|
+
|
|
413
440
|
class MemWal:
|
|
414
441
|
"""Async-native Walrus Memory client.
|
|
415
442
|
|
|
@@ -907,60 +934,81 @@ class MemWal:
|
|
|
907
934
|
attempt += 1
|
|
908
935
|
retry_after_ms = 0
|
|
909
936
|
|
|
910
|
-
|
|
911
|
-
|
|
937
|
+
# The relayer rejects a bulk-status read of more than
|
|
938
|
+
# MAX_BULK_STATUS_IDS ids with a 400, which is NOT transient: an
|
|
939
|
+
# unchunked poll of 21+ jobs raises out of the whole wait and
|
|
940
|
+
# every retry hits the same 400. Chunk here so `memwal_wait_for_saves`
|
|
941
|
+
# and `remember_bulk_and_wait` are both fixed.
|
|
942
|
+
terminal_ids: Set[str] = set()
|
|
943
|
+
refused_ids: List[str] = []
|
|
944
|
+
for start in range(0, len(pending), MAX_BULK_STATUS_IDS):
|
|
945
|
+
chunk = pending[start : start + MAX_BULK_STATUS_IDS]
|
|
912
946
|
try:
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
if err.status
|
|
923
|
-
|
|
924
|
-
|
|
947
|
+
poll_timeout = self._poll_deadline_s(deadline_ms)
|
|
948
|
+
try:
|
|
949
|
+
batch = await self.get_remember_bulk_status(chunk, timeout=poll_timeout)
|
|
950
|
+
except TypeError as err:
|
|
951
|
+
if "unexpected keyword argument" in str(err) and "timeout" in str(err):
|
|
952
|
+
batch = await self.get_remember_bulk_status(chunk)
|
|
953
|
+
else:
|
|
954
|
+
raise
|
|
955
|
+
except _HttpStatusError as err:
|
|
956
|
+
if _is_transient_polling_status(err.status):
|
|
957
|
+
last_refusal = err.status
|
|
958
|
+
if err.status == 429:
|
|
959
|
+
saw_rate_limit = True
|
|
960
|
+
retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
|
|
961
|
+
# Refused, not answered: keep this chunk pending.
|
|
962
|
+
refused_ids.extend(chunk)
|
|
963
|
+
continue
|
|
964
|
+
raise
|
|
965
|
+
except httpx.TransportError:
|
|
966
|
+
# Network drops retry as status 0; continuing next loop iteration
|
|
967
|
+
# increments `attempt` so exponential backoff delay continues to grow.
|
|
968
|
+
last_refusal = 0
|
|
969
|
+
refused_ids.extend(chunk)
|
|
925
970
|
continue
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
971
|
+
answered = True
|
|
972
|
+
chunk_pending = set(chunk)
|
|
973
|
+
terminal_ids |= {
|
|
974
|
+
item.job_id
|
|
975
|
+
for item in batch.results
|
|
976
|
+
if item.job_id in chunk_pending and settle(item)
|
|
977
|
+
}
|
|
933
978
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if item.job_id in pending and settle(item)
|
|
938
|
-
}
|
|
979
|
+
if not answered and refused_ids:
|
|
980
|
+
# Nothing got through this tick; back off and retry.
|
|
981
|
+
continue
|
|
939
982
|
pending = [jid for jid in pending if jid not in terminal_ids]
|
|
940
983
|
|
|
941
984
|
if opts.timeout_ms > 0 and len(pending) == len(job_ids) and not last_seen:
|
|
942
985
|
# Nothing settled and nothing was seen: the polls may all have been
|
|
943
986
|
# refused. Confirm with one direct read before reporting anything.
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
987
|
+
settled: Set[str] = set()
|
|
988
|
+
all_ids = list(job_ids)
|
|
989
|
+
for start in range(0, len(all_ids), MAX_BULK_STATUS_IDS):
|
|
990
|
+
chunk = all_ids[start : start + MAX_BULK_STATUS_IDS]
|
|
991
|
+
try:
|
|
992
|
+
probed = await self.get_remember_bulk_status(chunk)
|
|
993
|
+
except _HttpStatusError as err:
|
|
994
|
+
if err.status == 429:
|
|
995
|
+
# Header first, else the body's retry_after_seconds, so a
|
|
996
|
+
# proxy that strips Retry-After does not lose the hint.
|
|
997
|
+
wait_ms = _retry_after_ms(err)
|
|
998
|
+
retry_after = f"{wait_ms / 1000:g}" if wait_ms else None
|
|
999
|
+
raise MemWalRateLimited(job_ids, retry_after) from err
|
|
1000
|
+
# Any other probe failure is not evidence about the jobs.
|
|
1001
|
+
continue
|
|
1002
|
+
except httpx.TransportError:
|
|
1003
|
+
continue
|
|
957
1004
|
answered = True
|
|
958
|
-
|
|
1005
|
+
pending_now = set(pending)
|
|
1006
|
+
settled |= {
|
|
959
1007
|
item.job_id
|
|
960
1008
|
for item in probed.results
|
|
961
|
-
if item.job_id in
|
|
1009
|
+
if item.job_id in pending_now and settle(item)
|
|
962
1010
|
}
|
|
963
|
-
|
|
1011
|
+
pending = [jid for jid in pending if jid not in settled]
|
|
964
1012
|
|
|
965
1013
|
for job_id in pending:
|
|
966
1014
|
seen = last_seen.get(job_id)
|
|
@@ -1057,6 +1105,7 @@ class MemWal:
|
|
|
1057
1105
|
sort = params.sort
|
|
1058
1106
|
else:
|
|
1059
1107
|
query_text = query
|
|
1108
|
+
_check_limit(limit)
|
|
1060
1109
|
body = {
|
|
1061
1110
|
"query": query_text,
|
|
1062
1111
|
"limit": limit,
|
|
@@ -1299,6 +1348,7 @@ class MemWal:
|
|
|
1299
1348
|
downloads in parallel, then 3 SEAL decrypts in parallel, then
|
|
1300
1349
|
embeddings. Expect seconds-per-blob on cold caches.
|
|
1301
1350
|
"""
|
|
1351
|
+
_check_limit(limit)
|
|
1302
1352
|
data = await self._signed_request("POST", "/api/restore", {
|
|
1303
1353
|
"namespace": namespace,
|
|
1304
1354
|
"limit": limit,
|
|
@@ -1354,6 +1404,10 @@ class MemWal:
|
|
|
1354
1404
|
Returns:
|
|
1355
1405
|
:class:`NamespacesResult`.
|
|
1356
1406
|
"""
|
|
1407
|
+
# Validate before resolving the owner: on a fresh client that lookup is
|
|
1408
|
+
# a signed POST /api/stats, so a bad limit must not reach it (#1218).
|
|
1409
|
+
if limit is not None:
|
|
1410
|
+
_check_limit(limit)
|
|
1357
1411
|
owner = await self._resolve_owner()
|
|
1358
1412
|
|
|
1359
1413
|
params: Dict[str, str] = {}
|
|
@@ -1428,6 +1482,7 @@ class MemWal:
|
|
|
1428
1482
|
Returns:
|
|
1429
1483
|
:class:`RememberManualResult` with id, blob_id, owner, namespace.
|
|
1430
1484
|
"""
|
|
1485
|
+
_check_vector(opts.vector)
|
|
1431
1486
|
data = await self._signed_request(
|
|
1432
1487
|
"POST",
|
|
1433
1488
|
"/api/remember/manual",
|
|
@@ -1457,6 +1512,8 @@ class MemWal:
|
|
|
1457
1512
|
Returns:
|
|
1458
1513
|
:class:`RecallManualResult` with blob_id + distance pairs.
|
|
1459
1514
|
"""
|
|
1515
|
+
_check_vector(opts.vector)
|
|
1516
|
+
_check_limit(opts.limit)
|
|
1460
1517
|
body: Dict[str, Any] = {
|
|
1461
1518
|
"vector": opts.vector,
|
|
1462
1519
|
"limit": opts.limit,
|
|
@@ -1751,7 +1808,9 @@ class MemWal:
|
|
|
1751
1808
|
|
|
1752
1809
|
method_upper = method.upper()
|
|
1753
1810
|
timestamp = str(int(time.time()))
|
|
1754
|
-
body_str = "" if method_upper == "GET" else json.dumps(
|
|
1811
|
+
body_str = "" if method_upper == "GET" else json.dumps(
|
|
1812
|
+
body, separators=(",", ":"), allow_nan=False
|
|
1813
|
+
)
|
|
1755
1814
|
body_hash = sha256_hex(body_str)
|
|
1756
1815
|
# MED-1 / LOW-23: nonce + account_id are part of the canonical signed
|
|
1757
1816
|
# message. Server rejects the request as "unsupported legacy SDK"
|
|
@@ -55,7 +55,7 @@ from typing import (
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
from .client import MemWal, _with_fresh_http_client
|
|
58
|
-
from .types import RecallMemory
|
|
58
|
+
from .types import RecallMemory, RememberBulkOptions, RememberBulkResult
|
|
59
59
|
|
|
60
60
|
if TYPE_CHECKING:
|
|
61
61
|
from langchain_core.language_models.chat_models import BaseChatModel
|
|
@@ -178,6 +178,28 @@ def _format_memories(
|
|
|
178
178
|
])
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
SAVE_MODES = ("analyze", "remember")
|
|
182
|
+
"""Valid ``save_mode`` values for the auto-save path.
|
|
183
|
+
|
|
184
|
+
``"analyze"`` extracts spoken-fact-style statements via the server LLM and
|
|
185
|
+
stores one memory per fact — it is lossy by design, and non-sentence content
|
|
186
|
+
(code snippets, structured data) yields zero facts. ``"remember"`` stores the
|
|
187
|
+
user message verbatim as a single memory, which is what callers saving code or
|
|
188
|
+
other non-conversational content want.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_save_mode(save_mode: str) -> str:
|
|
193
|
+
if save_mode not in SAVE_MODES:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"save_mode must be one of {SAVE_MODES}, got {save_mode!r}"
|
|
196
|
+
)
|
|
197
|
+
return save_mode
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_MAX_TRACKED_JOB_IDS = 1000
|
|
201
|
+
|
|
202
|
+
|
|
181
203
|
class _PendingSaves:
|
|
182
204
|
"""Tracks in-flight fire-and-forget auto-save work (asyncio Tasks and
|
|
183
205
|
background Threads) so a caller can drain it deterministically via
|
|
@@ -197,6 +219,7 @@ class _PendingSaves:
|
|
|
197
219
|
self._failures: List[tuple[str, str]] = []
|
|
198
220
|
self._dropped = 0
|
|
199
221
|
self._on_save_error = on_save_error
|
|
222
|
+
self._job_ids: List[str] = []
|
|
200
223
|
self._lock = threading.Lock()
|
|
201
224
|
self._callback_threads: set[int] = set()
|
|
202
225
|
|
|
@@ -250,6 +273,23 @@ class _PendingSaves:
|
|
|
250
273
|
f"Walrus Memory auto-save failed ({total} call(s)): {detail}"
|
|
251
274
|
)
|
|
252
275
|
|
|
276
|
+
def track_job_ids(self, job_ids: List[str]) -> None:
|
|
277
|
+
"""Record the background remember jobs an auto-save enqueued, so a
|
|
278
|
+
caller can later confirm they finished and read their blob IDs."""
|
|
279
|
+
if not job_ids:
|
|
280
|
+
return
|
|
281
|
+
with self._lock:
|
|
282
|
+
self._job_ids.extend(job_ids)
|
|
283
|
+
if len(self._job_ids) > _MAX_TRACKED_JOB_IDS:
|
|
284
|
+
del self._job_ids[:-_MAX_TRACKED_JOB_IDS]
|
|
285
|
+
|
|
286
|
+
def drain_job_ids(self) -> List[str]:
|
|
287
|
+
"""Return every job ID recorded so far and forget them, so a second
|
|
288
|
+
wait does not re-poll jobs that already settled."""
|
|
289
|
+
with self._lock:
|
|
290
|
+
job_ids, self._job_ids = self._job_ids, []
|
|
291
|
+
return job_ids
|
|
292
|
+
|
|
253
293
|
def track_task(self, task: "asyncio.Task[Any]") -> None:
|
|
254
294
|
self._tasks.add(task)
|
|
255
295
|
task.add_done_callback(self._tasks.discard)
|
|
@@ -333,6 +373,46 @@ class _PendingSaves:
|
|
|
333
373
|
thread.join()
|
|
334
374
|
|
|
335
375
|
|
|
376
|
+
async def _run_auto_save(
|
|
377
|
+
memwal: MemWal,
|
|
378
|
+
text: str,
|
|
379
|
+
namespace: str,
|
|
380
|
+
save_mode: str,
|
|
381
|
+
pending: _PendingSaves,
|
|
382
|
+
log: Callable[..., Any],
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Persist `text` via the configured save mode and record its job IDs.
|
|
385
|
+
|
|
386
|
+
``analyze()`` returns cleanly with zero facts for content it cannot read
|
|
387
|
+
as spoken facts (code, structured data), so a plain call there looks
|
|
388
|
+
identical to a successful save. Log in that case and point at
|
|
389
|
+
``save_mode="remember"`` rather than dropping the write silently.
|
|
390
|
+
"""
|
|
391
|
+
try:
|
|
392
|
+
if save_mode == "remember":
|
|
393
|
+
accepted = await memwal.remember(text, namespace)
|
|
394
|
+
if accepted is not None and getattr(accepted, "job_id", None):
|
|
395
|
+
pending.track_job_ids([accepted.job_id])
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
result = await memwal.analyze(text, namespace)
|
|
399
|
+
if result is None:
|
|
400
|
+
return
|
|
401
|
+
job_ids = list(getattr(result, "job_ids", None) or [])
|
|
402
|
+
facts = getattr(result, "facts", None)
|
|
403
|
+
pending.track_job_ids(job_ids)
|
|
404
|
+
if not facts and not job_ids:
|
|
405
|
+
log(
|
|
406
|
+
"[Walrus Memory] Auto-save extracted 0 facts from a "
|
|
407
|
+
f"{len(text)}-character message and stored nothing. analyze() "
|
|
408
|
+
"only extracts spoken-fact-style statements — pass "
|
|
409
|
+
'save_mode="remember" to store the message verbatim instead.'
|
|
410
|
+
)
|
|
411
|
+
except Exception as e: # noqa: BLE001 -- auto-save must never break the LLM call
|
|
412
|
+
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
413
|
+
pending.note_error(e)
|
|
414
|
+
|
|
415
|
+
|
|
336
416
|
def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) -> None:
|
|
337
417
|
"""Attach the underlying client and a way to drain pending auto-saves —
|
|
338
418
|
without this, a short-lived process has no way to avoid silently
|
|
@@ -349,9 +429,44 @@ def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) ->
|
|
|
349
429
|
(obj.memwal_flush) still finds it — OpenAI clients aren't Pydantic
|
|
350
430
|
models and work the same way either way.
|
|
351
431
|
"""
|
|
432
|
+
async def memwal_wait_for_saves(
|
|
433
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
434
|
+
) -> RememberBulkResult:
|
|
435
|
+
"""Drain pending auto-saves, then poll their remember jobs to a
|
|
436
|
+
terminal state and return per-job status + blob IDs."""
|
|
437
|
+
await pending.flush()
|
|
438
|
+
job_ids = pending.drain_job_ids()
|
|
439
|
+
try:
|
|
440
|
+
return await memwal.wait_for_remember_jobs(job_ids, opts)
|
|
441
|
+
except Exception:
|
|
442
|
+
pending.track_job_ids(job_ids)
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
def memwal_wait_for_saves_sync(
|
|
446
|
+
opts: Optional[RememberBulkOptions] = None,
|
|
447
|
+
) -> RememberBulkResult:
|
|
448
|
+
"""Sync counterpart of ``memwal_wait_for_saves`` for callers on the
|
|
449
|
+
blocking entry points (``OpenAI``, ``llm.invoke``)."""
|
|
450
|
+
pending.flush_sync()
|
|
451
|
+
job_ids = pending.drain_job_ids()
|
|
452
|
+
try:
|
|
453
|
+
return _run_blocking(
|
|
454
|
+
lambda: _with_fresh_http_client(
|
|
455
|
+
memwal, memwal.wait_for_remember_jobs(job_ids, opts)
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
except Exception:
|
|
459
|
+
pending.track_job_ids(job_ids)
|
|
460
|
+
raise
|
|
461
|
+
|
|
352
462
|
object.__setattr__(obj, "_memwal", memwal)
|
|
463
|
+
# Public alias: reaching wait_for_remember_jobs() and the rest of the
|
|
464
|
+
# low-level client shouldn't require touching a private attribute.
|
|
465
|
+
object.__setattr__(obj, "memwal", memwal)
|
|
353
466
|
object.__setattr__(obj, "memwal_flush", pending.flush)
|
|
354
467
|
object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
|
|
468
|
+
object.__setattr__(obj, "memwal_wait_for_saves", memwal_wait_for_saves)
|
|
469
|
+
object.__setattr__(obj, "memwal_wait_for_saves_sync", memwal_wait_for_saves_sync)
|
|
355
470
|
|
|
356
471
|
|
|
357
472
|
async def _warn_if_cancelled(coro: Any, label: str) -> None:
|
|
@@ -416,6 +531,7 @@ def with_memwal_langchain(
|
|
|
416
531
|
namespace: str = "default",
|
|
417
532
|
max_memories: int = 5,
|
|
418
533
|
auto_save: bool = True,
|
|
534
|
+
save_mode: str = "analyze",
|
|
419
535
|
min_relevance: float = 0.3,
|
|
420
536
|
debug: bool = False,
|
|
421
537
|
env: Optional[str] = None,
|
|
@@ -438,6 +554,11 @@ def with_memwal_langchain(
|
|
|
438
554
|
namespace: Default namespace.
|
|
439
555
|
max_memories: Max memories to inject per request.
|
|
440
556
|
auto_save: Auto-save new facts from conversation.
|
|
557
|
+
save_mode: How auto_save persists the user message. ``"analyze"``
|
|
558
|
+
(default) extracts spoken-fact-style statements and stores one
|
|
559
|
+
memory per fact — non-sentence content such as code yields zero
|
|
560
|
+
facts and stores nothing. ``"remember"`` stores the message
|
|
561
|
+
verbatim as a single memory.
|
|
441
562
|
min_relevance: Minimum similarity score (0-1) to include a memory.
|
|
442
563
|
debug: Enable debug logging.
|
|
443
564
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
@@ -458,6 +579,8 @@ def with_memwal_langchain(
|
|
|
458
579
|
"Install with: pip install memwal[langchain]"
|
|
459
580
|
) from e
|
|
460
581
|
|
|
582
|
+
_validate_save_mode(save_mode)
|
|
583
|
+
|
|
461
584
|
memwal = MemWal.create(
|
|
462
585
|
key=key,
|
|
463
586
|
account_id=account_id,
|
|
@@ -552,16 +675,12 @@ def with_memwal_langchain(
|
|
|
552
675
|
return messages
|
|
553
676
|
|
|
554
677
|
async def _post_analyze(messages: List[BaseMessage]) -> None:
|
|
555
|
-
"""
|
|
678
|
+
"""Persist the user message via the configured save mode."""
|
|
556
679
|
if not auto_save or not _is_last_message_user(messages):
|
|
557
680
|
return
|
|
558
681
|
user_text = _find_last_user_message(messages)
|
|
559
682
|
if user_text:
|
|
560
|
-
|
|
561
|
-
await memwal.analyze(user_text, namespace)
|
|
562
|
-
except Exception as e:
|
|
563
|
-
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
564
|
-
pending.note_error(e)
|
|
683
|
+
await _run_auto_save(memwal, user_text, namespace, save_mode, pending, log)
|
|
565
684
|
|
|
566
685
|
async def patched_agenerate(
|
|
567
686
|
messages: List[BaseMessage], *args: Any, **kwargs: Any
|
|
@@ -604,6 +723,7 @@ def with_memwal_openai(
|
|
|
604
723
|
namespace: str = "default",
|
|
605
724
|
max_memories: int = 5,
|
|
606
725
|
auto_save: bool = True,
|
|
726
|
+
save_mode: str = "analyze",
|
|
607
727
|
min_relevance: float = 0.3,
|
|
608
728
|
debug: bool = False,
|
|
609
729
|
env: Optional[str] = None,
|
|
@@ -628,6 +748,11 @@ def with_memwal_openai(
|
|
|
628
748
|
namespace: Default namespace.
|
|
629
749
|
max_memories: Max memories to inject per request.
|
|
630
750
|
auto_save: Auto-save new facts from conversation.
|
|
751
|
+
save_mode: How auto_save persists the user message. ``"analyze"``
|
|
752
|
+
(default) extracts spoken-fact-style statements and stores one
|
|
753
|
+
memory per fact — non-sentence content such as code yields zero
|
|
754
|
+
facts and stores nothing. ``"remember"`` stores the message
|
|
755
|
+
verbatim as a single memory.
|
|
631
756
|
min_relevance: Minimum similarity score (0-1) to include a memory.
|
|
632
757
|
debug: Enable debug logging.
|
|
633
758
|
env: Optional relayer preset (``"prod"`` / ``"dev"`` / ``"staging"`` /
|
|
@@ -639,6 +764,8 @@ def with_memwal_openai(
|
|
|
639
764
|
Returns:
|
|
640
765
|
The same client, with ``chat.completions.create`` wrapped to use Walrus Memory.
|
|
641
766
|
"""
|
|
767
|
+
_validate_save_mode(save_mode)
|
|
768
|
+
|
|
642
769
|
memwal = MemWal.create(
|
|
643
770
|
key=key,
|
|
644
771
|
account_id=account_id,
|
|
@@ -654,11 +781,13 @@ def with_memwal_openai(
|
|
|
654
781
|
|
|
655
782
|
if is_async:
|
|
656
783
|
_wrap_async_openai(
|
|
657
|
-
client, memwal, namespace, max_memories, auto_save,
|
|
784
|
+
client, memwal, namespace, max_memories, auto_save, save_mode,
|
|
785
|
+
min_relevance, log, pending,
|
|
658
786
|
)
|
|
659
787
|
else:
|
|
660
788
|
_wrap_sync_openai(
|
|
661
|
-
client, memwal, namespace, max_memories, auto_save,
|
|
789
|
+
client, memwal, namespace, max_memories, auto_save, save_mode,
|
|
790
|
+
min_relevance, log, pending,
|
|
662
791
|
)
|
|
663
792
|
|
|
664
793
|
_expose_memwal_controls(client, memwal, pending)
|
|
@@ -672,6 +801,7 @@ def _wrap_async_openai(
|
|
|
672
801
|
namespace: str,
|
|
673
802
|
max_memories: int,
|
|
674
803
|
auto_save: bool,
|
|
804
|
+
save_mode: str,
|
|
675
805
|
min_relevance: float,
|
|
676
806
|
log: Callable[..., Any],
|
|
677
807
|
pending: _PendingSaves,
|
|
@@ -725,18 +855,14 @@ def _wrap_async_openai(
|
|
|
725
855
|
|
|
726
856
|
result = await original_create(*args, **kwargs)
|
|
727
857
|
|
|
728
|
-
# Fire-and-forget
|
|
858
|
+
# Fire-and-forget auto-save
|
|
729
859
|
if auto_save and _is_last_message_user(messages):
|
|
730
860
|
user_text = _find_last_user_message(messages)
|
|
731
861
|
if user_text:
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
737
|
-
pending.note_error(e)
|
|
738
|
-
|
|
739
|
-
_fire_and_forget(_analyze(), pending)
|
|
862
|
+
_fire_and_forget(
|
|
863
|
+
_run_auto_save(memwal, user_text, namespace, save_mode, pending, log),
|
|
864
|
+
pending,
|
|
865
|
+
)
|
|
740
866
|
|
|
741
867
|
return result
|
|
742
868
|
|
|
@@ -749,6 +875,7 @@ def _wrap_sync_openai(
|
|
|
749
875
|
namespace: str,
|
|
750
876
|
max_memories: int,
|
|
751
877
|
auto_save: bool,
|
|
878
|
+
save_mode: str,
|
|
752
879
|
min_relevance: float,
|
|
753
880
|
log: Callable[..., Any],
|
|
754
881
|
pending: _PendingSaves,
|
|
@@ -807,18 +934,25 @@ def _wrap_sync_openai(
|
|
|
807
934
|
|
|
808
935
|
result = original_create(*args, **kwargs)
|
|
809
936
|
|
|
810
|
-
# Fire-and-forget
|
|
937
|
+
# Fire-and-forget auto-save
|
|
811
938
|
if auto_save and _is_last_message_user(messages):
|
|
812
939
|
user_text = _find_last_user_message(messages)
|
|
813
940
|
if user_text:
|
|
814
|
-
def
|
|
941
|
+
def _save() -> None:
|
|
942
|
+
# _run_auto_save swallows request failures itself; this guards
|
|
943
|
+
# the loop/http-client plumbing around it so a failure there
|
|
944
|
+
# can't take down the background thread.
|
|
815
945
|
try:
|
|
816
|
-
_run_memwal(
|
|
817
|
-
|
|
946
|
+
_run_memwal(
|
|
947
|
+
lambda: _run_auto_save(
|
|
948
|
+
memwal, user_text, namespace, save_mode, pending, log
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
except Exception as e: # noqa: BLE001
|
|
818
952
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
819
953
|
pending.note_error(e)
|
|
820
954
|
|
|
821
|
-
pending.spawn_thread(
|
|
955
|
+
pending.spawn_thread(_save)
|
|
822
956
|
|
|
823
957
|
return result
|
|
824
958
|
|
|
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
|
|
12
12
|
from datetime import datetime, timedelta, timezone
|
|
13
13
|
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
14
14
|
|
|
15
|
+
from .client import _check_limit
|
|
15
16
|
from .types import (
|
|
16
17
|
AnalyzedFact,
|
|
17
18
|
AnalyzeResult,
|
|
@@ -279,8 +280,7 @@ class MemWalMock:
|
|
|
279
280
|
sort = None
|
|
280
281
|
query_text = query
|
|
281
282
|
_validate_text(query_text, "query")
|
|
282
|
-
|
|
283
|
-
raise ValueError("limit must be a non-negative integer")
|
|
283
|
+
_check_limit(limit)
|
|
284
284
|
if sort not in (None, "relevance", "recent"):
|
|
285
285
|
raise ValueError("sort must be 'relevance' or 'recent'")
|
|
286
286
|
resolved_namespace = self._namespace if namespace is None else namespace
|
|
@@ -383,7 +383,7 @@ class MemWalMock:
|
|
|
383
383
|
)
|
|
384
384
|
|
|
385
385
|
async def restore(self, namespace: str, limit: int = 10) -> RestoreResult:
|
|
386
|
-
|
|
386
|
+
_check_limit(limit)
|
|
387
387
|
return RestoreResult(
|
|
388
388
|
restored=0,
|
|
389
389
|
skipped=0,
|
|
@@ -399,6 +399,8 @@ class MemWalMock:
|
|
|
399
399
|
cursor: Optional[str] = None,
|
|
400
400
|
limit: Optional[int] = None,
|
|
401
401
|
) -> NamespacesResult:
|
|
402
|
+
if limit is not None:
|
|
403
|
+
_check_limit(limit)
|
|
402
404
|
grouped: Dict[str, List[_Memory]] = {}
|
|
403
405
|
for memory in self._memories:
|
|
404
406
|
grouped.setdefault(memory.namespace, []).append(memory)
|
|
@@ -26,14 +26,16 @@ import respx
|
|
|
26
26
|
|
|
27
27
|
from memwal.client import MemWal
|
|
28
28
|
from memwal.middleware import (
|
|
29
|
+
_MAX_TRACKED_JOB_IDS,
|
|
29
30
|
_find_last_user_message,
|
|
30
31
|
_format_memories,
|
|
31
32
|
_inject_openai_memory,
|
|
32
33
|
_PendingSaves,
|
|
34
|
+
_run_auto_save,
|
|
33
35
|
with_memwal_langchain,
|
|
34
36
|
with_memwal_openai,
|
|
35
37
|
)
|
|
36
|
-
from memwal.types import RecallMemory
|
|
38
|
+
from memwal.types import RecallMemory, RememberBulkOptions
|
|
37
39
|
from memwal.utils import bytes_to_hex
|
|
38
40
|
|
|
39
41
|
# ============================================================
|
|
@@ -131,6 +133,27 @@ def _mock_analyze() -> httpx.Response:
|
|
|
131
133
|
)
|
|
132
134
|
|
|
133
135
|
|
|
136
|
+
_REMEMBER_URL = f"{_SERVER}/api/remember"
|
|
137
|
+
_BULK_STATUS_URL = f"{_SERVER}/api/remember/bulk/status"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _mock_analyze_with_facts(*job_ids: str) -> httpx.Response:
|
|
141
|
+
return httpx.Response(
|
|
142
|
+
202,
|
|
143
|
+
json={
|
|
144
|
+
"facts": [{"text": f"fact {i}", "id": job_id} for i, job_id in enumerate(job_ids)],
|
|
145
|
+
"job_ids": list(job_ids),
|
|
146
|
+
"fact_count": len(job_ids),
|
|
147
|
+
"status": "pending",
|
|
148
|
+
"owner": "0xowner",
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _mock_remember_accepted(job_id: str = "job-remember") -> httpx.Response:
|
|
154
|
+
return httpx.Response(202, json={"job_id": job_id, "status": "pending"})
|
|
155
|
+
|
|
156
|
+
|
|
134
157
|
# ============================================================
|
|
135
158
|
# Pure function tests (no mocking needed)
|
|
136
159
|
# ============================================================
|
|
@@ -1363,3 +1386,246 @@ class TestPendingSaves:
|
|
|
1363
1386
|
else:
|
|
1364
1387
|
raise AssertionError("flush_sync did not raise")
|
|
1365
1388
|
|
|
1389
|
+
|
|
1390
|
+
# ============================================================
|
|
1391
|
+
# WALM-307: auto_save save_mode + confirmable saves
|
|
1392
|
+
# ============================================================
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
class TestAutoSaveMode:
|
|
1396
|
+
"""analyze() silently stores nothing for non-fact content (GH #411);
|
|
1397
|
+
save_mode="remember" must store it verbatim instead."""
|
|
1398
|
+
|
|
1399
|
+
def _memwal(self) -> MemWal:
|
|
1400
|
+
return MemWal.create(
|
|
1401
|
+
key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, namespace="default"
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
@respx.mock
|
|
1405
|
+
async def test_analyze_mode_reports_zero_facts_through_wrapper_log(self) -> None:
|
|
1406
|
+
_mock_seal_session_prereqs()
|
|
1407
|
+
respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
1408
|
+
|
|
1409
|
+
pending = _PendingSaves()
|
|
1410
|
+
messages: list = []
|
|
1411
|
+
with patch("memwal.middleware.logger") as mock_logger:
|
|
1412
|
+
await _run_auto_save(
|
|
1413
|
+
self._memwal(), "def f():\n return 1", "default", "analyze",
|
|
1414
|
+
pending, messages.append,
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
assert not mock_logger.warning.called
|
|
1418
|
+
assert len(messages) == 1
|
|
1419
|
+
assert "save_mode" in messages[0]
|
|
1420
|
+
assert pending.drain_job_ids() == []
|
|
1421
|
+
|
|
1422
|
+
@respx.mock
|
|
1423
|
+
async def test_analyze_mode_tracks_job_ids_without_warning(self) -> None:
|
|
1424
|
+
_mock_seal_session_prereqs()
|
|
1425
|
+
respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze_with_facts("j1", "j2"))
|
|
1426
|
+
|
|
1427
|
+
pending = _PendingSaves()
|
|
1428
|
+
with patch("memwal.middleware.logger") as mock_logger:
|
|
1429
|
+
await _run_auto_save(
|
|
1430
|
+
self._memwal(), "I am allergic to peanuts", "default", "analyze",
|
|
1431
|
+
pending, lambda *_: None,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
assert not mock_logger.warning.called
|
|
1435
|
+
assert pending.drain_job_ids() == ["j1", "j2"]
|
|
1436
|
+
|
|
1437
|
+
@respx.mock
|
|
1438
|
+
async def test_remember_mode_stores_verbatim_and_skips_analyze(self) -> None:
|
|
1439
|
+
_mock_seal_session_prereqs()
|
|
1440
|
+
analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
1441
|
+
remember_route = respx.post(_REMEMBER_URL).mock(
|
|
1442
|
+
return_value=_mock_remember_accepted("job-1")
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
pending = _PendingSaves()
|
|
1446
|
+
await _run_auto_save(
|
|
1447
|
+
self._memwal(), "def f():\n return 1", "default", "remember",
|
|
1448
|
+
pending, lambda *_: None,
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
assert remember_route.called
|
|
1452
|
+
assert not analyze_route.called
|
|
1453
|
+
assert pending.drain_job_ids() == ["job-1"]
|
|
1454
|
+
|
|
1455
|
+
def test_invalid_save_mode_rejected_at_wrap_time(self) -> None:
|
|
1456
|
+
client = MagicMock()
|
|
1457
|
+
try:
|
|
1458
|
+
with_memwal_openai(
|
|
1459
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER,
|
|
1460
|
+
save_mode="verbatim",
|
|
1461
|
+
)
|
|
1462
|
+
except ValueError as e:
|
|
1463
|
+
assert "save_mode" in str(e)
|
|
1464
|
+
else:
|
|
1465
|
+
raise AssertionError("expected ValueError for an unknown save_mode")
|
|
1466
|
+
|
|
1467
|
+
def test_drain_job_ids_is_one_shot(self) -> None:
|
|
1468
|
+
pending = _PendingSaves()
|
|
1469
|
+
pending.track_job_ids(["a", "b"])
|
|
1470
|
+
assert pending.drain_job_ids() == ["a", "b"]
|
|
1471
|
+
assert pending.drain_job_ids() == []
|
|
1472
|
+
|
|
1473
|
+
def test_tracked_job_ids_are_capped_keeping_the_newest(self) -> None:
|
|
1474
|
+
pending = _PendingSaves()
|
|
1475
|
+
for i in range(_MAX_TRACKED_JOB_IDS + 5):
|
|
1476
|
+
pending.track_job_ids([f"job-{i}"])
|
|
1477
|
+
|
|
1478
|
+
job_ids = pending.drain_job_ids()
|
|
1479
|
+
assert len(job_ids) == _MAX_TRACKED_JOB_IDS
|
|
1480
|
+
assert job_ids[0] == "job-5"
|
|
1481
|
+
assert job_ids[-1] == f"job-{_MAX_TRACKED_JOB_IDS + 4}"
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
class TestConfirmableSaves:
|
|
1485
|
+
"""GH #410/#412: the easy-setup path had no way to confirm a save
|
|
1486
|
+
landed or read its blob ID without dropping to the low-level client."""
|
|
1487
|
+
|
|
1488
|
+
def _make_async_client(self) -> MagicMock:
|
|
1489
|
+
client = MagicMock()
|
|
1490
|
+
client._async_client = MagicMock()
|
|
1491
|
+
mock_response = MagicMock()
|
|
1492
|
+
mock_response.choices = [MagicMock()]
|
|
1493
|
+
mock_response.choices[0].message.content = "answer"
|
|
1494
|
+
client.chat = MagicMock()
|
|
1495
|
+
client.chat.completions = MagicMock()
|
|
1496
|
+
client.chat.completions.create = AsyncMock(return_value=mock_response)
|
|
1497
|
+
return client
|
|
1498
|
+
|
|
1499
|
+
@respx.mock
|
|
1500
|
+
async def test_wait_for_saves_returns_blob_ids(self) -> None:
|
|
1501
|
+
_mock_seal_session_prereqs()
|
|
1502
|
+
respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
|
|
1503
|
+
respx.post(_REMEMBER_URL).mock(return_value=_mock_remember_accepted("job-1"))
|
|
1504
|
+
respx.post(_BULK_STATUS_URL).mock(
|
|
1505
|
+
return_value=httpx.Response(
|
|
1506
|
+
200,
|
|
1507
|
+
json={
|
|
1508
|
+
"results": [
|
|
1509
|
+
{"job_id": "job-1", "status": "done", "blob_id": "blob-abc"}
|
|
1510
|
+
]
|
|
1511
|
+
},
|
|
1512
|
+
)
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
smart = with_memwal_openai(
|
|
1516
|
+
self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
|
|
1517
|
+
server_url=_SERVER, auto_save=True, save_mode="remember",
|
|
1518
|
+
)
|
|
1519
|
+
await smart.chat.completions.create(
|
|
1520
|
+
model="gpt-4o",
|
|
1521
|
+
messages=[{"role": "user", "content": "def f(): return 1"}],
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
result = await smart.memwal_wait_for_saves(
|
|
1525
|
+
RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
assert result.total == 1
|
|
1529
|
+
assert result.succeeded == 1
|
|
1530
|
+
assert result.results[0].blob_id == "blob-abc"
|
|
1531
|
+
|
|
1532
|
+
@respx.mock
|
|
1533
|
+
async def test_wrapper_exposes_low_level_client_publicly(self) -> None:
|
|
1534
|
+
_mock_seal_session_prereqs()
|
|
1535
|
+
smart = with_memwal_openai(
|
|
1536
|
+
self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
|
|
1537
|
+
server_url=_SERVER, auto_save=False,
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
assert isinstance(smart.memwal, MemWal)
|
|
1541
|
+
assert callable(smart.memwal.wait_for_remember_jobs)
|
|
1542
|
+
assert callable(smart.memwal_wait_for_saves)
|
|
1543
|
+
assert callable(smart.memwal_wait_for_saves_sync)
|
|
1544
|
+
|
|
1545
|
+
@respx.mock
|
|
1546
|
+
async def test_failed_wait_leaves_job_ids_for_the_next_wait(self) -> None:
|
|
1547
|
+
_mock_seal_session_prereqs()
|
|
1548
|
+
respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
|
|
1549
|
+
respx.post(_REMEMBER_URL).mock(return_value=_mock_remember_accepted("job-1"))
|
|
1550
|
+
status_route = respx.post(_BULK_STATUS_URL).mock(
|
|
1551
|
+
return_value=httpx.Response(403, json={"error": "forbidden"})
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
smart = with_memwal_openai(
|
|
1555
|
+
self._make_async_client(), key=_KEY_HEX, account_id=_ACCOUNT_ID,
|
|
1556
|
+
server_url=_SERVER, auto_save=True, save_mode="remember",
|
|
1557
|
+
)
|
|
1558
|
+
await smart.chat.completions.create(
|
|
1559
|
+
model="gpt-4o",
|
|
1560
|
+
messages=[{"role": "user", "content": "def f(): return 1"}],
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
opts = RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
|
|
1564
|
+
try:
|
|
1565
|
+
await smart.memwal_wait_for_saves(opts)
|
|
1566
|
+
except Exception:
|
|
1567
|
+
pass
|
|
1568
|
+
else:
|
|
1569
|
+
raise AssertionError("expected the failing poll to raise")
|
|
1570
|
+
|
|
1571
|
+
status_route.mock(
|
|
1572
|
+
return_value=httpx.Response(
|
|
1573
|
+
200,
|
|
1574
|
+
json={
|
|
1575
|
+
"results": [
|
|
1576
|
+
{"job_id": "job-1", "status": "done", "blob_id": "blob-abc"}
|
|
1577
|
+
]
|
|
1578
|
+
},
|
|
1579
|
+
)
|
|
1580
|
+
)
|
|
1581
|
+
result = await smart.memwal_wait_for_saves(opts)
|
|
1582
|
+
|
|
1583
|
+
assert result.total == 1
|
|
1584
|
+
assert result.succeeded == 1
|
|
1585
|
+
assert result.results[0].blob_id == "blob-abc"
|
|
1586
|
+
|
|
1587
|
+
@respx.mock
|
|
1588
|
+
async def test_wait_confirms_more_jobs_than_the_relayer_bulk_cap(self) -> None:
|
|
1589
|
+
"""21 tracked jobs must confirm, not 400-loop.
|
|
1590
|
+
|
|
1591
|
+
`/api/remember/bulk/status` rejects more than `MAX_BULK_ITEMS` (20)
|
|
1592
|
+
ids with a 400. 400 is not transient, so an unchunked poll raised,
|
|
1593
|
+
the ids were restored for the retry, and every retry hit the same
|
|
1594
|
+
400 — a wait that could never confirm. `save_mode="remember"` is one
|
|
1595
|
+
job per turn, so 21 turns reach this.
|
|
1596
|
+
"""
|
|
1597
|
+
_mock_seal_session_prereqs()
|
|
1598
|
+
job_ids = [f"job-{i}" for i in range(21)]
|
|
1599
|
+
seen_chunk_sizes: list[int] = []
|
|
1600
|
+
|
|
1601
|
+
def _status(request: httpx.Request) -> httpx.Response:
|
|
1602
|
+
body = json.loads(request.content)
|
|
1603
|
+
ids = body["job_ids"]
|
|
1604
|
+
seen_chunk_sizes.append(len(ids))
|
|
1605
|
+
if len(ids) > 20:
|
|
1606
|
+
return httpx.Response(
|
|
1607
|
+
400, json={"error": "job_ids exceeds MAX_BULK_ITEMS"}
|
|
1608
|
+
)
|
|
1609
|
+
return httpx.Response(
|
|
1610
|
+
200,
|
|
1611
|
+
json={
|
|
1612
|
+
"results": [
|
|
1613
|
+
{"job_id": jid, "status": "done", "blob_id": f"blob-{jid}"}
|
|
1614
|
+
for jid in ids
|
|
1615
|
+
]
|
|
1616
|
+
},
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
respx.post(_BULK_STATUS_URL).mock(side_effect=_status)
|
|
1620
|
+
|
|
1621
|
+
client = MemWal.create(key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER)
|
|
1622
|
+
result = await client.wait_for_remember_jobs(
|
|
1623
|
+
job_ids, RememberBulkOptions(poll_interval_ms=1, timeout_ms=5000)
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
assert max(seen_chunk_sizes) <= 20, seen_chunk_sizes
|
|
1627
|
+
assert result.total == 21
|
|
1628
|
+
assert result.succeeded == 21
|
|
1629
|
+
assert result.timed_out == 0
|
|
1630
|
+
# Order still follows the caller's input, chunking notwithstanding.
|
|
1631
|
+
assert [r.id for r in result.results] == job_ids
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""#1218: non-finite vectors and bad limits must fail client-side.
|
|
2
|
+
|
|
3
|
+
json.dumps emits bare NaN/Infinity tokens, which are not JSON, so the
|
|
4
|
+
relayer rejected the body with an opaque parse error. Limits went out as
|
|
5
|
+
NaN, -5 or ?limit=nan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from memwal import (
|
|
16
|
+
MemWal,
|
|
17
|
+
MemWalError,
|
|
18
|
+
MemWalMock,
|
|
19
|
+
RecallManualOptions,
|
|
20
|
+
RecallParams,
|
|
21
|
+
RememberManualOptions,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CaptureMemWal(MemWal):
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self.captured = []
|
|
29
|
+
|
|
30
|
+
async def _ensure_compatible_relayer(self):
|
|
31
|
+
return {
|
|
32
|
+
"apiVersion": "1.0.0",
|
|
33
|
+
"relayerVersion": "capture",
|
|
34
|
+
"minSupportedSdk": {"python": "0.0.0"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async def _resolve_owner(self):
|
|
38
|
+
return "0xowner"
|
|
39
|
+
|
|
40
|
+
async def _signed_request(
|
|
41
|
+
self,
|
|
42
|
+
method,
|
|
43
|
+
path,
|
|
44
|
+
body,
|
|
45
|
+
accepted_statuses=(200,),
|
|
46
|
+
include_seal_session=True,
|
|
47
|
+
timeout=None,
|
|
48
|
+
):
|
|
49
|
+
self.captured.append((method, path, body))
|
|
50
|
+
if path.startswith("/v1/owners"):
|
|
51
|
+
return {"namespaces": [], "has_more": False, "snapshot_version": 1}
|
|
52
|
+
if path == "/api/remember/manual":
|
|
53
|
+
return {"id": "1", "blob_id": "b", "owner": "0x1", "namespace": "default"}
|
|
54
|
+
if path == "/api/restore":
|
|
55
|
+
return {"restored": 0, "skipped": 0, "total": 0, "namespace": "ns", "owner": "0x1"}
|
|
56
|
+
return {"results": [], "total": 0}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FreshOwnerMemWal(MemWal):
|
|
60
|
+
"""Keeps the real ``_resolve_owner``, so owner lookup goes through signing.
|
|
61
|
+
|
|
62
|
+
Every signed request is recorded and then fails, like a relayer outage:
|
|
63
|
+
a bad limit must surface as ValueError, not as this error.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args, **kwargs):
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
self.signed = []
|
|
69
|
+
|
|
70
|
+
async def _ensure_compatible_relayer(self):
|
|
71
|
+
return {
|
|
72
|
+
"apiVersion": "1.0.0",
|
|
73
|
+
"relayerVersion": "capture",
|
|
74
|
+
"minSupportedSdk": {"python": "0.0.0"},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def _signed_request(
|
|
78
|
+
self,
|
|
79
|
+
method,
|
|
80
|
+
path,
|
|
81
|
+
body,
|
|
82
|
+
accepted_statuses=(200,),
|
|
83
|
+
include_seal_session=True,
|
|
84
|
+
timeout=None,
|
|
85
|
+
):
|
|
86
|
+
self.signed.append((method, path))
|
|
87
|
+
raise MemWalError("relayer unavailable")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _client():
|
|
91
|
+
return CaptureMemWal.create(
|
|
92
|
+
key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
BAD_VECTORS = [
|
|
97
|
+
[0.1, math.nan],
|
|
98
|
+
[math.inf],
|
|
99
|
+
[0.1, -math.inf],
|
|
100
|
+
[],
|
|
101
|
+
[0.1, "0.2"],
|
|
102
|
+
[0.1, None],
|
|
103
|
+
[True, 0.2],
|
|
104
|
+
"0.1,0.2",
|
|
105
|
+
None,
|
|
106
|
+
]
|
|
107
|
+
BAD_LIMITS = [math.nan, math.inf, -5, -1, 0, 2.5, "5", True]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("vector", BAD_VECTORS, ids=repr)
|
|
111
|
+
async def test_recall_manual_rejects_bad_vector(vector):
|
|
112
|
+
c = _client()
|
|
113
|
+
with pytest.raises(ValueError, match="vector"):
|
|
114
|
+
await c.recall_manual(RecallManualOptions(vector=vector))
|
|
115
|
+
assert c.captured == []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("vector", BAD_VECTORS, ids=repr)
|
|
119
|
+
async def test_remember_manual_rejects_bad_vector(vector):
|
|
120
|
+
c = _client()
|
|
121
|
+
with pytest.raises(ValueError, match="vector"):
|
|
122
|
+
await c.remember_manual(RememberManualOptions(encrypted_data="AAAA", vector=vector))
|
|
123
|
+
assert c.captured == []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
|
|
127
|
+
@pytest.mark.parametrize(
|
|
128
|
+
"call",
|
|
129
|
+
[
|
|
130
|
+
("recall", lambda c, n: c.recall("q", limit=n)),
|
|
131
|
+
("recall_params", lambda c, n: c.recall(RecallParams(query="q", limit=n))),
|
|
132
|
+
("recall_manual", lambda c, n: c.recall_manual(RecallManualOptions(vector=[0.1], limit=n))),
|
|
133
|
+
("restore", lambda c, n: c.restore("ns", limit=n)),
|
|
134
|
+
("list_namespaces", lambda c, n: c.list_namespaces(limit=n)),
|
|
135
|
+
],
|
|
136
|
+
ids=lambda x: x[0],
|
|
137
|
+
)
|
|
138
|
+
async def test_bad_limit_is_rejected_before_signing(call, limit):
|
|
139
|
+
c = _client()
|
|
140
|
+
with pytest.raises(ValueError, match="limit"):
|
|
141
|
+
await call[1](c, limit)
|
|
142
|
+
assert c.captured == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_valid_inputs_still_go_out_unchanged():
|
|
146
|
+
c = _client()
|
|
147
|
+
await c.recall_manual(RecallManualOptions(vector=[0.1, -2, 0], limit=3))
|
|
148
|
+
await c.remember_manual(RememberManualOptions(encrypted_data="AAAA", vector=[1, 0.5]))
|
|
149
|
+
await c.recall("q", limit=1)
|
|
150
|
+
await c.restore("ns", limit=50)
|
|
151
|
+
await c.list_namespaces(limit=500)
|
|
152
|
+
await c.list_namespaces()
|
|
153
|
+
bodies = [b for _, _, b in c.captured]
|
|
154
|
+
assert bodies[0]["vector"] == [0.1, -2, 0] and bodies[0]["limit"] == 3
|
|
155
|
+
assert bodies[1]["vector"] == [1, 0.5]
|
|
156
|
+
assert bodies[2]["limit"] == 1
|
|
157
|
+
assert bodies[3]["limit"] == 50
|
|
158
|
+
assert c.captured[4][1].endswith("?limit=500")
|
|
159
|
+
assert "limit" not in c.captured[5][1]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def test_signed_request_never_emits_nan_tokens(monkeypatch):
|
|
163
|
+
"""Backstop: even if a NaN reaches the signer, it must not be serialised."""
|
|
164
|
+
client = MemWal.create(
|
|
165
|
+
key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def compatible():
|
|
169
|
+
return {
|
|
170
|
+
"apiVersion": "1.0.0",
|
|
171
|
+
"relayerVersion": "t",
|
|
172
|
+
"minSupportedSdk": {"python": "0.0.0"},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sent = []
|
|
176
|
+
|
|
177
|
+
async def fake_post(*args, **kwargs): # pragma: no cover - must not be reached
|
|
178
|
+
sent.append(kwargs)
|
|
179
|
+
raise AssertionError("request sent with a non-JSON body")
|
|
180
|
+
|
|
181
|
+
monkeypatch.setattr(client, "_ensure_compatible_relayer", compatible)
|
|
182
|
+
monkeypatch.setattr(httpx.AsyncClient, "send", fake_post)
|
|
183
|
+
with pytest.raises(ValueError):
|
|
184
|
+
await client._signed_request(
|
|
185
|
+
"POST", "/api/recall", {"limit": math.nan}, include_seal_session=False
|
|
186
|
+
)
|
|
187
|
+
assert sent == []
|
|
188
|
+
await client.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
|
|
192
|
+
async def test_list_namespaces_bad_limit_raises_before_owner_lookup(limit):
|
|
193
|
+
"""The owner lookup is a signed POST /api/stats; a bad limit must not reach it."""
|
|
194
|
+
c = FreshOwnerMemWal.create(
|
|
195
|
+
key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
|
|
196
|
+
)
|
|
197
|
+
with pytest.raises(ValueError, match="limit"):
|
|
198
|
+
await c.list_namespaces(limit=limit)
|
|
199
|
+
assert c.signed == []
|
|
200
|
+
await c.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def test_list_namespaces_valid_limit_still_resolves_owner():
|
|
204
|
+
c = FreshOwnerMemWal.create(
|
|
205
|
+
key="11" * 32, account_id="0x" + "ab" * 32, server_url="https://relayer.example"
|
|
206
|
+
)
|
|
207
|
+
with pytest.raises(MemWalError, match="relayer unavailable"):
|
|
208
|
+
await c.list_namespaces(limit=5)
|
|
209
|
+
assert c.signed == [("POST", "/api/stats")]
|
|
210
|
+
await c.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize("limit", BAD_LIMITS, ids=repr)
|
|
214
|
+
@pytest.mark.parametrize(
|
|
215
|
+
"call",
|
|
216
|
+
[
|
|
217
|
+
("recall", lambda m, n: m.recall("q", limit=n)),
|
|
218
|
+
("recall_params", lambda m, n: m.recall(RecallParams(query="q", limit=n))),
|
|
219
|
+
("ask", lambda m, n: m.ask("q", limit=n)),
|
|
220
|
+
("restore", lambda m, n: m.restore("ns", limit=n)),
|
|
221
|
+
("list_namespaces", lambda m, n: m.list_namespaces(limit=n)),
|
|
222
|
+
],
|
|
223
|
+
ids=lambda x: x[0],
|
|
224
|
+
)
|
|
225
|
+
async def test_mock_rejects_the_same_bad_limits_as_the_client(call, limit):
|
|
226
|
+
mock = MemWalMock.create()
|
|
227
|
+
await mock.remember("a fact")
|
|
228
|
+
with pytest.raises(ValueError, match="limit"):
|
|
229
|
+
await call[1](mock, limit)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_mock_accepts_positive_integer_limits():
|
|
233
|
+
mock = MemWalMock.create()
|
|
234
|
+
await mock.remember("a fact")
|
|
235
|
+
assert len((await mock.recall("fact", limit=1)).results) == 1
|
|
236
|
+
assert (await mock.restore("ns", limit=1)).restored == 0
|
|
237
|
+
assert len((await mock.list_namespaces(limit=1)).namespaces) == 1
|
|
238
|
+
assert len((await mock.list_namespaces()).namespaces) == 1
|
|
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
|
|
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
|