memwal 0.1.5.dev0__tar.gz → 0.1.5.dev2__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.1.5.dev0 → memwal-0.1.5.dev2}/PKG-INFO +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/__init__.py +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/client.py +17 -5
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/middleware.py +132 -12
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/types.py +11 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/pyproject.toml +1 -1
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_client.py +52 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_middleware.py +124 -2
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/.gitignore +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/CHANGELOG.md +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/README.md +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/.env.example +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/.gitignore +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/interactive_demo.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/verify_credentials.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/compatibility.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/utils.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/run_tests.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/__init__.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_env_presets.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_integration.py +0 -0
- {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_signing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.5.
|
|
3
|
+
Version: 0.1.5.dev2
|
|
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
|
|
@@ -784,22 +784,30 @@ class MemWal:
|
|
|
784
784
|
do **not** count as either restored or skipped.
|
|
785
785
|
* ``total`` — count of on-chain blobs the relayer saw for
|
|
786
786
|
``(owner, namespace)`` before the limit was applied.
|
|
787
|
+
* ``truncated`` — True when this restore is known-incomplete (limit
|
|
788
|
+
truncation or an upstream on-chain candidate-fetch cap; see
|
|
789
|
+
:class:`~memwal.types.RestoreResult`). Defaults to ``False`` when
|
|
790
|
+
talking to a relayer older than WALM-319 that omits the field.
|
|
787
791
|
|
|
788
792
|
Args:
|
|
789
793
|
namespace: Namespace to restore. Exact match — no prefix or
|
|
790
794
|
hierarchy semantics (see SKILL.md "Namespace Semantics").
|
|
791
795
|
limit: Max blobs the relayer will *inspect* this call (default:
|
|
792
796
|
10, matches the server-side default and the TypeScript SDK).
|
|
793
|
-
The relayer
|
|
794
|
-
|
|
797
|
+
The relayer selects up to this many missing blobs from the
|
|
798
|
+
candidates it sees, in unspecified order; it does **not** cap
|
|
799
|
+
``restored`` separately.
|
|
795
800
|
|
|
796
801
|
Returns:
|
|
797
802
|
:class:`RestoreResult`.
|
|
798
803
|
|
|
799
804
|
Notes:
|
|
800
|
-
* **No pagination cursor** — restore is single-shot.
|
|
801
|
-
|
|
802
|
-
|
|
805
|
+
* **No pagination cursor** — restore is single-shot. A larger
|
|
806
|
+
``limit`` may help when ``truncated`` is caused by the request
|
|
807
|
+
limit, but candidate discovery also has a per-owner source cap
|
|
808
|
+
shared across namespaces. Hitting that cap requires cursor/
|
|
809
|
+
pagination support for guaranteed full recovery; increasing
|
|
810
|
+
``limit`` or retrying cannot guarantee it.
|
|
803
811
|
* **Performance** scales linearly in ``limit``: up to 10 Walrus
|
|
804
812
|
downloads in parallel, then 3 SEAL decrypts in parallel, then
|
|
805
813
|
embeddings. Expect seconds-per-blob on cold caches.
|
|
@@ -814,6 +822,10 @@ class MemWal:
|
|
|
814
822
|
total=data["total"],
|
|
815
823
|
namespace=data["namespace"],
|
|
816
824
|
owner=data["owner"],
|
|
825
|
+
# Relayers older than WALM-319 omit `truncated` entirely --
|
|
826
|
+
# treat "not present" as "not known to be truncated" rather
|
|
827
|
+
# than require every relayer version to send it.
|
|
828
|
+
truncated=data.get("truncated", False),
|
|
817
829
|
)
|
|
818
830
|
|
|
819
831
|
async def health(self) -> HealthResult:
|
|
@@ -130,14 +130,124 @@ def _format_memories(
|
|
|
130
130
|
])
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
"""
|
|
133
|
+
class _PendingSaves:
|
|
134
|
+
"""Tracks in-flight fire-and-forget auto-save work (asyncio Tasks and
|
|
135
|
+
background Threads) so a caller can drain it deterministically via
|
|
136
|
+
:meth:`flush`/:meth:`flush_sync` instead of losing it silently when the
|
|
137
|
+
process exits before it completes. Tasks scheduled via
|
|
138
|
+
``loop.create_task()`` with no other reference are only weakly held by
|
|
139
|
+
the event loop and can be cancelled or garbage-collected before they
|
|
140
|
+
run — this keeps a strong reference until each one is done.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self._tasks: "set[asyncio.Task[Any]]" = set()
|
|
145
|
+
self._threads: List[threading.Thread] = []
|
|
146
|
+
self._lock = threading.Lock()
|
|
147
|
+
|
|
148
|
+
def track_task(self, task: "asyncio.Task[Any]") -> None:
|
|
149
|
+
self._tasks.add(task)
|
|
150
|
+
task.add_done_callback(self._tasks.discard)
|
|
151
|
+
|
|
152
|
+
def spawn_thread(self, target: Callable[[], None]) -> None:
|
|
153
|
+
"""Run `target` in a new daemon thread, tracked until it completes.
|
|
154
|
+
The thread untracks itself on completion -- a long-lived client
|
|
155
|
+
that keeps using fire-and-forget saves but never calls flush()
|
|
156
|
+
would otherwise accumulate one Thread object per save, forever.
|
|
157
|
+
"""
|
|
158
|
+
def _run_and_untrack() -> None:
|
|
159
|
+
try:
|
|
160
|
+
target()
|
|
161
|
+
finally:
|
|
162
|
+
with self._lock:
|
|
163
|
+
if thread in self._threads:
|
|
164
|
+
self._threads.remove(thread)
|
|
165
|
+
|
|
166
|
+
thread = threading.Thread(target=_run_and_untrack, daemon=True)
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._threads.append(thread)
|
|
169
|
+
thread.start()
|
|
170
|
+
|
|
171
|
+
async def flush(self) -> None:
|
|
172
|
+
"""Await every pending task, then join every pending thread."""
|
|
173
|
+
if self._tasks:
|
|
174
|
+
await asyncio.gather(*list(self._tasks), return_exceptions=True)
|
|
175
|
+
self._join_threads()
|
|
176
|
+
|
|
177
|
+
def flush_sync(self) -> None:
|
|
178
|
+
"""Join every pending thread. A pending Task belongs to the loop
|
|
179
|
+
that created it (e.g. an earlier `await llm.ainvoke(...)` call) and
|
|
180
|
+
cannot be awaited from a different one -- if any are still pending
|
|
181
|
+
here, the caller mixed sync and async entry points on the same
|
|
182
|
+
wrapped client, and this cleanup path can't safely drain them. Log
|
|
183
|
+
and continue draining threads rather than raise out of what's
|
|
184
|
+
usually shutdown code."""
|
|
185
|
+
if self._tasks:
|
|
186
|
+
async def _drain(tasks: "list[asyncio.Task[Any]]") -> None:
|
|
187
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
asyncio.run(_drain(list(self._tasks)))
|
|
191
|
+
except RuntimeError:
|
|
192
|
+
logger.warning(
|
|
193
|
+
"Walrus Memory flush_sync() could not await %d pending "
|
|
194
|
+
"task(s) bound to a different event loop (mixing sync "
|
|
195
|
+
"and async calls on the same wrapped client?) — those "
|
|
196
|
+
"saves may be lost.",
|
|
197
|
+
len(self._tasks),
|
|
198
|
+
)
|
|
199
|
+
self._join_threads()
|
|
200
|
+
|
|
201
|
+
def _join_threads(self) -> None:
|
|
202
|
+
with self._lock:
|
|
203
|
+
threads, self._threads = self._threads, []
|
|
204
|
+
for thread in threads:
|
|
205
|
+
thread.join()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) -> None:
|
|
209
|
+
"""Attach the underlying client and a way to drain pending auto-saves —
|
|
210
|
+
without this, a short-lived process has no way to avoid silently
|
|
211
|
+
losing writes still in flight when it exits.
|
|
212
|
+
|
|
213
|
+
Uses object.__setattr__ rather than plain attribute assignment: a
|
|
214
|
+
LangChain BaseChatModel is a Pydantic model, and Pydantic's __setattr__
|
|
215
|
+
rejects assignment to any name that isn't a declared field (or a
|
|
216
|
+
leading-underscore private attribute) — memwal_flush/memwal_flush_sync
|
|
217
|
+
are neither, so plain `obj.memwal_flush = ...` raises ValueError on a
|
|
218
|
+
real LangChain model (masked by tests using MagicMock, which doesn't
|
|
219
|
+
enforce this). object.__setattr__ bypasses that check and writes
|
|
220
|
+
straight into the instance's __dict__, where normal attribute lookup
|
|
221
|
+
(obj.memwal_flush) still finds it — OpenAI clients aren't Pydantic
|
|
222
|
+
models and work the same way either way.
|
|
223
|
+
"""
|
|
224
|
+
object.__setattr__(obj, "_memwal", memwal)
|
|
225
|
+
object.__setattr__(obj, "memwal_flush", pending.flush)
|
|
226
|
+
object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _warn_if_cancelled(coro: Any, label: str) -> None:
|
|
230
|
+
try:
|
|
231
|
+
await coro
|
|
232
|
+
except asyncio.CancelledError:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Walrus Memory %s was cancelled before it completed — the process "
|
|
235
|
+
"likely exited without draining pending saves. Call flush()/"
|
|
236
|
+
"flush_sync() before exiting to avoid silent data loss.",
|
|
237
|
+
label,
|
|
238
|
+
)
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _fire_and_forget(coro: Any, pending: _PendingSaves, label: str = "auto-save") -> None:
|
|
243
|
+
"""Schedule an async coroutine as fire-and-forget, tracked in `pending`.
|
|
135
244
|
|
|
136
245
|
Works whether or not an event loop is already running.
|
|
137
246
|
"""
|
|
138
247
|
try:
|
|
139
248
|
loop = asyncio.get_running_loop()
|
|
140
|
-
loop.create_task(coro)
|
|
249
|
+
task = loop.create_task(_warn_if_cancelled(coro, label))
|
|
250
|
+
pending.track_task(task)
|
|
141
251
|
except RuntimeError:
|
|
142
252
|
# No running loop -- run in a background thread
|
|
143
253
|
def _run() -> None:
|
|
@@ -146,8 +256,7 @@ def _fire_and_forget(coro: Any) -> None:
|
|
|
146
256
|
except Exception:
|
|
147
257
|
logger.debug("Fire-and-forget analyze() failed", exc_info=True)
|
|
148
258
|
|
|
149
|
-
|
|
150
|
-
thread.start()
|
|
259
|
+
pending.spawn_thread(_run)
|
|
151
260
|
|
|
152
261
|
|
|
153
262
|
def _run_blocking(coro_factory: Callable[[], Any]) -> Any:
|
|
@@ -226,6 +335,7 @@ def with_memwal_langchain(
|
|
|
226
335
|
)
|
|
227
336
|
|
|
228
337
|
log = logger.debug if not debug else logger.warning
|
|
338
|
+
pending = _PendingSaves()
|
|
229
339
|
|
|
230
340
|
original_agenerate = llm._agenerate
|
|
231
341
|
original_generate = llm._generate
|
|
@@ -289,7 +399,7 @@ def with_memwal_langchain(
|
|
|
289
399
|
result = await original_agenerate(enriched, *args, **kwargs)
|
|
290
400
|
|
|
291
401
|
for msg_list in messages:
|
|
292
|
-
_fire_and_forget(_post_analyze(msg_list))
|
|
402
|
+
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
293
403
|
|
|
294
404
|
return result
|
|
295
405
|
|
|
@@ -315,7 +425,7 @@ def with_memwal_langchain(
|
|
|
315
425
|
result = original_generate(enriched, *args, **kwargs)
|
|
316
426
|
|
|
317
427
|
for msg_list in messages:
|
|
318
|
-
_fire_and_forget(_post_analyze(msg_list))
|
|
428
|
+
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
319
429
|
|
|
320
430
|
return result
|
|
321
431
|
|
|
@@ -323,6 +433,8 @@ def with_memwal_langchain(
|
|
|
323
433
|
llm._agenerate = patched_agenerate # type: ignore[assignment]
|
|
324
434
|
llm._generate = patched_generate # type: ignore[assignment]
|
|
325
435
|
|
|
436
|
+
_expose_memwal_controls(llm, memwal, pending)
|
|
437
|
+
|
|
326
438
|
return llm
|
|
327
439
|
|
|
328
440
|
|
|
@@ -379,13 +491,20 @@ def with_memwal_openai(
|
|
|
379
491
|
)
|
|
380
492
|
|
|
381
493
|
log = logger.debug if not debug else logger.warning
|
|
494
|
+
pending = _PendingSaves()
|
|
382
495
|
|
|
383
496
|
is_async = hasattr(client, "_async_client") or type(client).__name__ == "AsyncOpenAI"
|
|
384
497
|
|
|
385
498
|
if is_async:
|
|
386
|
-
_wrap_async_openai(
|
|
499
|
+
_wrap_async_openai(
|
|
500
|
+
client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
|
|
501
|
+
)
|
|
387
502
|
else:
|
|
388
|
-
_wrap_sync_openai(
|
|
503
|
+
_wrap_sync_openai(
|
|
504
|
+
client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
_expose_memwal_controls(client, memwal, pending)
|
|
389
508
|
|
|
390
509
|
return client
|
|
391
510
|
|
|
@@ -398,6 +517,7 @@ def _wrap_async_openai(
|
|
|
398
517
|
auto_save: bool,
|
|
399
518
|
min_relevance: float,
|
|
400
519
|
log: Callable[..., Any],
|
|
520
|
+
pending: _PendingSaves,
|
|
401
521
|
) -> None:
|
|
402
522
|
"""Wrap an async OpenAI client's chat.completions.create."""
|
|
403
523
|
original_create = client.chat.completions.create
|
|
@@ -437,7 +557,7 @@ def _wrap_async_openai(
|
|
|
437
557
|
except Exception as e:
|
|
438
558
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
439
559
|
|
|
440
|
-
_fire_and_forget(_analyze())
|
|
560
|
+
_fire_and_forget(_analyze(), pending)
|
|
441
561
|
|
|
442
562
|
return result
|
|
443
563
|
|
|
@@ -452,6 +572,7 @@ def _wrap_sync_openai(
|
|
|
452
572
|
auto_save: bool,
|
|
453
573
|
min_relevance: float,
|
|
454
574
|
log: Callable[..., Any],
|
|
575
|
+
pending: _PendingSaves,
|
|
455
576
|
) -> None:
|
|
456
577
|
"""Wrap a sync OpenAI client's chat.completions.create."""
|
|
457
578
|
original_create = client.chat.completions.create
|
|
@@ -498,8 +619,7 @@ def _wrap_sync_openai(
|
|
|
498
619
|
except Exception as e:
|
|
499
620
|
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
500
621
|
|
|
501
|
-
|
|
502
|
-
thread.start()
|
|
622
|
+
pending.spawn_thread(_analyze)
|
|
503
623
|
|
|
504
624
|
return result
|
|
505
625
|
|
|
@@ -201,6 +201,17 @@ class RestoreResult:
|
|
|
201
201
|
total: int
|
|
202
202
|
namespace: str
|
|
203
203
|
owner: str
|
|
204
|
+
#: True when this restore is known-incomplete: either more on-chain
|
|
205
|
+
#: blobs were missing locally than ``limit`` allowed this call to
|
|
206
|
+
#: restore, or the sidecar's raw on-chain candidate fetch hit its own
|
|
207
|
+
#: cap before this namespace's blobs were even filtered out of that
|
|
208
|
+
#: set (WALM-319) -- this can be ``True`` even when ``total == 0``,
|
|
209
|
+
#: since a cap hit elsewhere can starve this namespace's fetch
|
|
210
|
+
#: entirely. Raising ``limit`` only helps with the first case.
|
|
211
|
+
#:
|
|
212
|
+
#: Relayers older than WALM-319 don't send this field at all; the SDK
|
|
213
|
+
#: defaults it to ``False`` in that case rather than requiring it.
|
|
214
|
+
truncated: bool = False
|
|
204
215
|
|
|
205
216
|
|
|
206
217
|
@dataclass
|
|
@@ -630,6 +630,7 @@ class TestRestore:
|
|
|
630
630
|
"total": 7,
|
|
631
631
|
"namespace": "my-app",
|
|
632
632
|
"owner": "0xowner",
|
|
633
|
+
"truncated": False,
|
|
633
634
|
},
|
|
634
635
|
)
|
|
635
636
|
)
|
|
@@ -641,6 +642,57 @@ class TestRestore:
|
|
|
641
642
|
assert body["limit"] == 100
|
|
642
643
|
assert result.restored == 5
|
|
643
644
|
assert result.skipped == 2
|
|
645
|
+
assert result.truncated is False
|
|
646
|
+
|
|
647
|
+
@respx.mock
|
|
648
|
+
async def test_restore_preserves_truncated_true(
|
|
649
|
+
self, memwal_client: MemWal
|
|
650
|
+
) -> None:
|
|
651
|
+
"""truncated=True from the relayer must survive into RestoreResult
|
|
652
|
+
instead of being dropped when the dataclass is reconstructed."""
|
|
653
|
+
mock_seal_session_prereqs()
|
|
654
|
+
respx.post(f"{_TEST_SERVER}/api/restore").mock(
|
|
655
|
+
return_value=httpx.Response(
|
|
656
|
+
200,
|
|
657
|
+
json={
|
|
658
|
+
"restored": 5,
|
|
659
|
+
"skipped": 2,
|
|
660
|
+
"total": 7,
|
|
661
|
+
"namespace": "my-app",
|
|
662
|
+
"owner": "0xowner",
|
|
663
|
+
"truncated": True,
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
result = await memwal_client.restore("my-app", limit=100)
|
|
669
|
+
|
|
670
|
+
assert result.truncated is True
|
|
671
|
+
|
|
672
|
+
@respx.mock
|
|
673
|
+
async def test_restore_truncated_defaults_false_when_omitted(
|
|
674
|
+
self, memwal_client: MemWal
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Relayers older than WALM-319 omit `truncated` entirely — the
|
|
677
|
+
SDK must default it to False rather than requiring the field."""
|
|
678
|
+
mock_seal_session_prereqs()
|
|
679
|
+
respx.post(f"{_TEST_SERVER}/api/restore").mock(
|
|
680
|
+
return_value=httpx.Response(
|
|
681
|
+
200,
|
|
682
|
+
json={
|
|
683
|
+
"restored": 5,
|
|
684
|
+
"skipped": 2,
|
|
685
|
+
"total": 7,
|
|
686
|
+
"namespace": "my-app",
|
|
687
|
+
"owner": "0xowner",
|
|
688
|
+
# no "truncated" key — simulates an older relayer
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
result = await memwal_client.restore("my-app", limit=100)
|
|
694
|
+
|
|
695
|
+
assert result.truncated is False
|
|
644
696
|
|
|
645
697
|
|
|
646
698
|
class TestHealth:
|
|
@@ -17,16 +17,19 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
19
|
import json
|
|
20
|
-
|
|
20
|
+
import threading
|
|
21
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
21
22
|
|
|
22
23
|
import httpx
|
|
23
24
|
import nacl.signing
|
|
24
25
|
import respx
|
|
25
26
|
|
|
27
|
+
from memwal.client import MemWal
|
|
26
28
|
from memwal.middleware import (
|
|
27
29
|
_find_last_user_message,
|
|
28
30
|
_format_memories,
|
|
29
31
|
_inject_openai_memory,
|
|
32
|
+
_PendingSaves,
|
|
30
33
|
with_memwal_langchain,
|
|
31
34
|
with_memwal_openai,
|
|
32
35
|
)
|
|
@@ -444,6 +447,24 @@ class TestWithMemWalLangChain:
|
|
|
444
447
|
await smart_llm._agenerate([[SystemMessage("only system")]])
|
|
445
448
|
assert not recall_route.called
|
|
446
449
|
|
|
450
|
+
def test_wraps_a_real_pydantic_backed_chat_model(self) -> None:
|
|
451
|
+
"""with_memwal_langchain must work on a real BaseChatModel, not just
|
|
452
|
+
a MagicMock. LangChain chat models are Pydantic models that reject
|
|
453
|
+
assignment of undeclared fields under normal attribute assignment
|
|
454
|
+
-- MagicMock doesn't enforce that, so it silently hides this."""
|
|
455
|
+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
|
|
456
|
+
|
|
457
|
+
llm = FakeListChatModel(responses=["canned response"])
|
|
458
|
+
|
|
459
|
+
smart_llm = with_memwal_langchain(
|
|
460
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
assert smart_llm is llm
|
|
464
|
+
assert smart_llm._memwal is not None
|
|
465
|
+
assert callable(smart_llm.memwal_flush)
|
|
466
|
+
assert callable(smart_llm.memwal_flush_sync)
|
|
467
|
+
|
|
447
468
|
|
|
448
469
|
# ============================================================
|
|
449
470
|
# OpenAI middleware tests
|
|
@@ -587,6 +608,41 @@ class TestWithMemWalOpenAI:
|
|
|
587
608
|
await asyncio.sleep(0.05)
|
|
588
609
|
assert analyze_route.called
|
|
589
610
|
|
|
611
|
+
@respx.mock
|
|
612
|
+
async def test_memwal_flush_awaits_pending_autosave(self) -> None:
|
|
613
|
+
"""memwal_flush() deterministically waits for the fire-and-forget
|
|
614
|
+
analyze() call, instead of the caller having to guess a sleep
|
|
615
|
+
duration and hope the background task finished in time."""
|
|
616
|
+
_mock_seal_session_prereqs()
|
|
617
|
+
client = self._make_async_client()
|
|
618
|
+
|
|
619
|
+
respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
|
|
620
|
+
|
|
621
|
+
gate = asyncio.Event()
|
|
622
|
+
completed = {"value": False}
|
|
623
|
+
|
|
624
|
+
async def gated_analyze(self, *args, **kwargs):
|
|
625
|
+
await gate.wait()
|
|
626
|
+
completed["value"] = True
|
|
627
|
+
|
|
628
|
+
with patch.object(MemWal, "analyze", gated_analyze):
|
|
629
|
+
smart = with_memwal_openai(
|
|
630
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
|
|
631
|
+
)
|
|
632
|
+
await smart.chat.completions.create(
|
|
633
|
+
model="gpt-4o",
|
|
634
|
+
messages=[{"role": "user", "content": "remember this"}],
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Fire-and-forget: the response came back, but the gated
|
|
638
|
+
# analyze() call has not completed yet.
|
|
639
|
+
assert completed["value"] is False
|
|
640
|
+
|
|
641
|
+
gate.set()
|
|
642
|
+
await smart.memwal_flush()
|
|
643
|
+
|
|
644
|
+
assert completed["value"] is True
|
|
645
|
+
|
|
590
646
|
@respx.mock
|
|
591
647
|
async def test_min_relevance_filter(self) -> None:
|
|
592
648
|
"""Memories with relevance below min_relevance are not injected."""
|
|
@@ -671,4 +727,70 @@ class TestWithMemWalOpenAI:
|
|
|
671
727
|
assert "TV support appointment is 9 AM to noon" not in system_msgs[0]["content"]
|
|
672
728
|
user_msgs = [m for m in captured if isinstance(m, dict) and m.get("role") == "user"]
|
|
673
729
|
assert len(user_msgs) == 2
|
|
674
|
-
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ============================================================
|
|
733
|
+
# _PendingSaves
|
|
734
|
+
# ============================================================
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class TestPendingSaves:
|
|
738
|
+
"""Tests for _PendingSaves, isolated from the middleware wrappers."""
|
|
739
|
+
|
|
740
|
+
def test_flush_sync_does_not_crash_on_a_task_from_a_different_loop(self) -> None:
|
|
741
|
+
"""Reproduces the real bug: a caller that mixes an earlier async
|
|
742
|
+
call (which populates self._tasks on that call's event loop) with
|
|
743
|
+
flush_sync() from sync code afterward. asyncio.Tasks are bound to
|
|
744
|
+
the loop that created them and can't be awaited from a new one --
|
|
745
|
+
flush_sync() must not raise out of this, since it's typically
|
|
746
|
+
called during cleanup."""
|
|
747
|
+
pending = _PendingSaves()
|
|
748
|
+
task_holder: dict = {}
|
|
749
|
+
release = threading.Event()
|
|
750
|
+
|
|
751
|
+
def _run_other_loop() -> None:
|
|
752
|
+
async def _pending_forever() -> None:
|
|
753
|
+
await asyncio.get_event_loop().run_in_executor(None, release.wait)
|
|
754
|
+
|
|
755
|
+
loop = asyncio.new_event_loop()
|
|
756
|
+
asyncio.set_event_loop(loop)
|
|
757
|
+
task = loop.create_task(_pending_forever())
|
|
758
|
+
task_holder["task"] = task
|
|
759
|
+
pending.track_task(task)
|
|
760
|
+
# Keep this loop alive long enough for the main thread's
|
|
761
|
+
# flush_sync() to observe the still-pending, cross-loop task.
|
|
762
|
+
loop.run_until_complete(asyncio.sleep(0.3))
|
|
763
|
+
|
|
764
|
+
other_loop_thread = threading.Thread(target=_run_other_loop)
|
|
765
|
+
other_loop_thread.start()
|
|
766
|
+
|
|
767
|
+
# Give the other thread time to create its loop and schedule the task.
|
|
768
|
+
import time
|
|
769
|
+
|
|
770
|
+
time.sleep(0.05)
|
|
771
|
+
assert task_holder.get("task") is not None
|
|
772
|
+
assert not task_holder["task"].done()
|
|
773
|
+
|
|
774
|
+
try:
|
|
775
|
+
pending.flush_sync() # must not raise
|
|
776
|
+
finally:
|
|
777
|
+
release.set()
|
|
778
|
+
other_loop_thread.join(timeout=2)
|
|
779
|
+
|
|
780
|
+
def test_completed_threads_are_untracked_without_flushing(self) -> None:
|
|
781
|
+
"""A long-lived client that keeps using fire-and-forget saves but
|
|
782
|
+
never calls flush()/flush_sync() must not accumulate one Thread
|
|
783
|
+
object per save forever."""
|
|
784
|
+
pending = _PendingSaves()
|
|
785
|
+
done = threading.Event()
|
|
786
|
+
|
|
787
|
+
pending.spawn_thread(done.set)
|
|
788
|
+
|
|
789
|
+
assert done.wait(timeout=2), "background thread never ran"
|
|
790
|
+
# Give the thread's own cleanup a moment to run after done.set()
|
|
791
|
+
# returns (the Event is set from inside the tracked callable,
|
|
792
|
+
# microseconds before the thread function itself returns).
|
|
793
|
+
import time
|
|
794
|
+
|
|
795
|
+
time.sleep(0.05)
|
|
796
|
+
assert len(pending._threads) == 0
|
|
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
|