memwal 0.1.11.dev13__tar.gz → 0.1.11.dev14__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.11.dev13 → memwal-0.1.11.dev14}/CHANGELOG.md +1 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/PKG-INFO +2 -2
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/README.md +1 -1
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/client.py +11 -2
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/middleware.py +214 -121
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/mock.py +17 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/types.py +2 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/pyproject.toml +1 -1
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_middleware.py +225 -16
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_mock.py +31 -0
- memwal-0.1.11.dev14/tests/test_recall_weights.py +42 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/.gitignore +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/.env.example +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/run_tests.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_blank_text.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_client.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_polling_delay.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_remember_derived_key.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_retry_after_http_date.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_signing.py +0 -0
- {memwal-0.1.11.dev13 → memwal-0.1.11.dev14}/tests/test_transport_retry.py +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
### Added
|
|
6
6
|
|
|
7
7
|
- `get_remember_status(job_id)` on `MemWal` and `MemWalSync` returns `RememberJobStatus` from `GET /api/remember/{job_id}`, accepting 200 and 404 like the TypeScript SDK. (WALM-832, #1096)
|
|
8
|
+
- `RecallParams` accepts `scoring_weights` and `sort`. `recall()` forwards them on `POST /api/recall`. (#1062)
|
|
8
9
|
|
|
9
10
|
### Fixed
|
|
10
11
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.11.
|
|
3
|
+
Version: 0.1.11.dev14
|
|
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
|
|
@@ -246,7 +246,7 @@ Create a new async client.
|
|
|
246
246
|
| `await remember_bulk(items)` | Accept several background remember jobs |
|
|
247
247
|
| `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
|
|
248
248
|
| `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
|
|
249
|
-
| `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
|
|
249
|
+
| `await recall(RecallParams(query, limit?, namespace?, max_distance?, scoring_weights?, sort?))` | Search memories, optionally filtering by distance |
|
|
250
250
|
| `await analyze(text, namespace?)` | Extract and store facts |
|
|
251
251
|
| `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
|
|
252
252
|
| `await restore(namespace, limit?)` | Restore a namespace |
|
|
@@ -207,7 +207,7 @@ Create a new async client.
|
|
|
207
207
|
| `await remember_bulk(items)` | Accept several background remember jobs |
|
|
208
208
|
| `await wait_for_remember_jobs(job_ids, opts?)` | Poll several remember jobs together |
|
|
209
209
|
| `await remember_bulk_and_wait(items, opts?)` | Store several memories and wait for completion |
|
|
210
|
-
| `await recall(RecallParams(query, limit?, namespace?, max_distance?))` | Search memories, optionally filtering by distance |
|
|
210
|
+
| `await recall(RecallParams(query, limit?, namespace?, max_distance?, scoring_weights?, sort?))` | Search memories, optionally filtering by distance |
|
|
211
211
|
| `await analyze(text, namespace?)` | Extract and store facts |
|
|
212
212
|
| `await ask(question, limit?, namespace?)` | Ask a question answered using memories |
|
|
213
213
|
| `await restore(namespace, limit?)` | Restore a namespace |
|
|
@@ -1037,19 +1037,28 @@ class MemWal:
|
|
|
1037
1037
|
Returns:
|
|
1038
1038
|
:class:`RecallResult` with decrypted text results.
|
|
1039
1039
|
"""
|
|
1040
|
+
scoring_weights = None
|
|
1041
|
+
sort = None
|
|
1040
1042
|
if isinstance(query, RecallParams):
|
|
1041
1043
|
params = query
|
|
1042
1044
|
query_text = params.query
|
|
1043
1045
|
limit = params.limit
|
|
1044
1046
|
namespace = params.namespace
|
|
1045
1047
|
max_distance = params.max_distance
|
|
1048
|
+
scoring_weights = params.scoring_weights
|
|
1049
|
+
sort = params.sort
|
|
1046
1050
|
else:
|
|
1047
1051
|
query_text = query
|
|
1048
|
-
|
|
1052
|
+
body = {
|
|
1049
1053
|
"query": query_text,
|
|
1050
1054
|
"limit": limit,
|
|
1051
1055
|
"namespace": namespace or self._namespace,
|
|
1052
|
-
}
|
|
1056
|
+
}
|
|
1057
|
+
if scoring_weights is not None:
|
|
1058
|
+
body["scoring_weights"] = scoring_weights.to_wire()
|
|
1059
|
+
if sort is not None:
|
|
1060
|
+
body["sort"] = sort
|
|
1061
|
+
data = await self._signed_request("POST", "/api/recall", body)
|
|
1053
1062
|
memories = [
|
|
1054
1063
|
RecallMemory(
|
|
1055
1064
|
blob_id=m["blob_id"],
|
|
@@ -105,6 +105,54 @@ def _find_last_user_message(messages: Any) -> Optional[str]:
|
|
|
105
105
|
return None
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _is_last_message_user(messages: Any) -> bool:
|
|
109
|
+
"""Return True if the last message in `messages` is a user/human turn."""
|
|
110
|
+
if not isinstance(messages, (list, tuple)) or not messages:
|
|
111
|
+
return False
|
|
112
|
+
last = messages[-1]
|
|
113
|
+
if isinstance(last, dict):
|
|
114
|
+
return last.get("role") == "user"
|
|
115
|
+
if hasattr(last, "type"):
|
|
116
|
+
return getattr(last, "type") in ("human", "user")
|
|
117
|
+
if hasattr(last, "role"):
|
|
118
|
+
return getattr(last, "role") == "user"
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _user_turn_key(messages: Any) -> Optional[str]:
|
|
123
|
+
"""Identity of the latest user turn: index plus text.
|
|
124
|
+
|
|
125
|
+
A tool step keeps the same key, so a cached recall can be re-injected
|
|
126
|
+
without calling the relayer again.
|
|
127
|
+
"""
|
|
128
|
+
if not isinstance(messages, (list, tuple)) or not messages:
|
|
129
|
+
return None
|
|
130
|
+
text = _find_last_user_message(messages)
|
|
131
|
+
if not text:
|
|
132
|
+
return None
|
|
133
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
134
|
+
msg = messages[i]
|
|
135
|
+
is_user = (
|
|
136
|
+
(isinstance(msg, dict) and msg.get("role") == "user")
|
|
137
|
+
or getattr(msg, "type", None) in ("human", "user")
|
|
138
|
+
or (not isinstance(msg, dict) and getattr(msg, "role", None) == "user")
|
|
139
|
+
)
|
|
140
|
+
if is_user:
|
|
141
|
+
return f"{i}\0{text}"
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_RECALL_CACHE_LIMIT = 8
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _remember_recall(cache: dict[str, str], key: str, context: str) -> None:
|
|
149
|
+
"""Keep one entry per turn, newest last, and drop the oldest past the cap."""
|
|
150
|
+
cache.pop(key, None)
|
|
151
|
+
cache[key] = context
|
|
152
|
+
while len(cache) > _RECALL_CACHE_LIMIT:
|
|
153
|
+
cache.pop(next(iter(cache)))
|
|
154
|
+
|
|
155
|
+
|
|
108
156
|
def _format_memories(
|
|
109
157
|
memories: List[RecallMemory],
|
|
110
158
|
nonce: Optional[str] = None,
|
|
@@ -420,12 +468,65 @@ def with_memwal_langchain(
|
|
|
420
468
|
|
|
421
469
|
log = logger.debug if not debug else logger.warning
|
|
422
470
|
pending = _PendingSaves(on_save_error)
|
|
471
|
+
recall_cache: dict[str, str] = {}
|
|
423
472
|
|
|
424
473
|
original_agenerate = llm._agenerate
|
|
425
474
|
original_generate = llm._generate
|
|
426
475
|
|
|
476
|
+
def _with_memory(messages: List[BaseMessage], memory_context: str) -> List[BaseMessage]:
|
|
477
|
+
result = list(messages)
|
|
478
|
+
last_human_idx = -1
|
|
479
|
+
for i in range(len(result) - 1, -1, -1):
|
|
480
|
+
if isinstance(result[i], HumanMessage):
|
|
481
|
+
last_human_idx = i
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
memory_msg = HumanMessage(content=memory_context)
|
|
485
|
+
if last_human_idx > 0:
|
|
486
|
+
result.insert(last_human_idx, memory_msg)
|
|
487
|
+
else:
|
|
488
|
+
result.insert(0, memory_msg)
|
|
489
|
+
|
|
490
|
+
if result and isinstance(result[0], SystemMessage):
|
|
491
|
+
existing_content = result[0].content
|
|
492
|
+
if isinstance(existing_content, list):
|
|
493
|
+
new_content = list(existing_content) + [
|
|
494
|
+
{"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
|
|
495
|
+
]
|
|
496
|
+
else:
|
|
497
|
+
text_str = str(existing_content or "")
|
|
498
|
+
new_content = f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip()
|
|
499
|
+
if hasattr(result[0], "model_copy"):
|
|
500
|
+
result[0] = result[0].model_copy(update={"content": new_content})
|
|
501
|
+
else:
|
|
502
|
+
result[0] = SystemMessage(
|
|
503
|
+
content=new_content,
|
|
504
|
+
additional_kwargs=getattr(result[0], "additional_kwargs", {}),
|
|
505
|
+
id=getattr(result[0], "id", None),
|
|
506
|
+
name=getattr(result[0], "name", None),
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
result.insert(0, SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION))
|
|
510
|
+
return result
|
|
511
|
+
|
|
427
512
|
async def _inject_memories(messages: List[BaseMessage]) -> List[BaseMessage]:
|
|
428
513
|
"""Recall memories and inject them without granting system priority."""
|
|
514
|
+
turn_key = _user_turn_key(messages)
|
|
515
|
+
# Tool steps reuse this turn, including a recall that matched nothing.
|
|
516
|
+
# A user message always recalls again, so a later fact is not stuck
|
|
517
|
+
# behind the first result for the same text.
|
|
518
|
+
if (
|
|
519
|
+
turn_key
|
|
520
|
+
and not _is_last_message_user(messages)
|
|
521
|
+
and turn_key in recall_cache
|
|
522
|
+
):
|
|
523
|
+
cached = recall_cache[turn_key]
|
|
524
|
+
if cached:
|
|
525
|
+
return _with_memory(messages, cached)
|
|
526
|
+
return messages
|
|
527
|
+
if not turn_key and not _is_last_message_user(messages):
|
|
528
|
+
return messages
|
|
529
|
+
|
|
429
530
|
user_text = _find_last_user_message(messages)
|
|
430
531
|
if not user_text:
|
|
431
532
|
return messages
|
|
@@ -437,54 +538,22 @@ def with_memwal_langchain(
|
|
|
437
538
|
if (1 - m.distance) >= min_relevance
|
|
438
539
|
]
|
|
439
540
|
if not relevant:
|
|
541
|
+
if turn_key:
|
|
542
|
+
_remember_recall(recall_cache, turn_key, "")
|
|
440
543
|
return messages
|
|
441
544
|
|
|
442
545
|
memory_context = _format_memories(relevant)
|
|
546
|
+
if turn_key:
|
|
547
|
+
_remember_recall(recall_cache, turn_key, memory_context)
|
|
443
548
|
log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
|
|
444
|
-
|
|
445
|
-
# The fixed trust rule is privileged; recalled bytes are not.
|
|
446
|
-
result = list(messages)
|
|
447
|
-
last_human_idx = -1
|
|
448
|
-
for i in range(len(result) - 1, -1, -1):
|
|
449
|
-
if isinstance(result[i], HumanMessage):
|
|
450
|
-
last_human_idx = i
|
|
451
|
-
break
|
|
452
|
-
|
|
453
|
-
memory_msg = HumanMessage(content=memory_context)
|
|
454
|
-
if last_human_idx > 0:
|
|
455
|
-
result.insert(last_human_idx, memory_msg)
|
|
456
|
-
else:
|
|
457
|
-
result.insert(0, memory_msg)
|
|
458
|
-
|
|
459
|
-
if result and isinstance(result[0], SystemMessage):
|
|
460
|
-
existing_content = result[0].content
|
|
461
|
-
if isinstance(existing_content, list):
|
|
462
|
-
new_content = list(existing_content) + [
|
|
463
|
-
{"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
|
|
464
|
-
]
|
|
465
|
-
else:
|
|
466
|
-
text_str = str(existing_content or "")
|
|
467
|
-
new_content = f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip()
|
|
468
|
-
if hasattr(result[0], "model_copy"):
|
|
469
|
-
result[0] = result[0].model_copy(update={"content": new_content})
|
|
470
|
-
else:
|
|
471
|
-
result[0] = SystemMessage(
|
|
472
|
-
content=new_content,
|
|
473
|
-
additional_kwargs=getattr(result[0], "additional_kwargs", {}),
|
|
474
|
-
id=getattr(result[0], "id", None),
|
|
475
|
-
name=getattr(result[0], "name", None),
|
|
476
|
-
)
|
|
477
|
-
else:
|
|
478
|
-
result.insert(0, SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION))
|
|
479
|
-
|
|
480
|
-
return result
|
|
549
|
+
return _with_memory(messages, memory_context)
|
|
481
550
|
except Exception as e:
|
|
482
551
|
log(f"[Walrus Memory] Memory search failed: {e}")
|
|
483
552
|
return messages
|
|
484
553
|
|
|
485
554
|
async def _post_analyze(messages: List[BaseMessage]) -> None:
|
|
486
555
|
"""Analyze user message for new facts."""
|
|
487
|
-
if not auto_save:
|
|
556
|
+
if not auto_save or not _is_last_message_user(messages):
|
|
488
557
|
return
|
|
489
558
|
user_text = _find_last_user_message(messages)
|
|
490
559
|
if user_text:
|
|
@@ -495,39 +564,22 @@ def with_memwal_langchain(
|
|
|
495
564
|
pending.note_error(e)
|
|
496
565
|
|
|
497
566
|
async def patched_agenerate(
|
|
498
|
-
messages: List[
|
|
567
|
+
messages: List[BaseMessage], *args: Any, **kwargs: Any
|
|
499
568
|
) -> ChatResult:
|
|
500
|
-
enriched =
|
|
501
|
-
for msg_list in messages:
|
|
502
|
-
enriched.append(await _inject_memories(msg_list))
|
|
503
|
-
|
|
569
|
+
enriched = await _inject_memories(messages)
|
|
504
570
|
result = await original_agenerate(enriched, *args, **kwargs)
|
|
505
|
-
|
|
506
|
-
for msg_list in messages:
|
|
507
|
-
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
508
|
-
|
|
571
|
+
_fire_and_forget(_post_analyze(messages), pending)
|
|
509
572
|
return result
|
|
510
573
|
|
|
511
574
|
def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
|
|
512
|
-
# Keep httpx clients bound to the short-lived loop that uses them.
|
|
513
575
|
return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
|
|
514
576
|
|
|
515
577
|
def patched_generate(
|
|
516
|
-
messages: List[
|
|
578
|
+
messages: List[BaseMessage], *args: Any, **kwargs: Any
|
|
517
579
|
) -> ChatResult:
|
|
518
|
-
|
|
519
|
-
# running (notebooks, async hosts) — the same helper the sync OpenAI
|
|
520
|
-
# wrapper uses. Passing the messages through untouched there made
|
|
521
|
-
# Walrus Memory look connected while recall silently never ran.
|
|
522
|
-
enriched = []
|
|
523
|
-
for msg_list in messages:
|
|
524
|
-
enriched.append(_run_memwal(lambda: _inject_memories(msg_list)))
|
|
525
|
-
|
|
580
|
+
enriched = _run_memwal(lambda: _inject_memories(messages))
|
|
526
581
|
result = original_generate(enriched, *args, **kwargs)
|
|
527
|
-
|
|
528
|
-
for msg_list in messages:
|
|
529
|
-
_fire_and_forget(_post_analyze(msg_list), pending)
|
|
530
|
-
|
|
582
|
+
_fire_and_forget(_post_analyze(messages), pending)
|
|
531
583
|
return result
|
|
532
584
|
|
|
533
585
|
# Monkey-patch the LLM instance
|
|
@@ -626,44 +678,65 @@ def _wrap_async_openai(
|
|
|
626
678
|
) -> None:
|
|
627
679
|
"""Wrap an async OpenAI client's chat.completions.create."""
|
|
628
680
|
original_create = client.chat.completions.create
|
|
629
|
-
|
|
681
|
+
recall_cache: dict[str, str] = {}
|
|
630
682
|
async def patched_create(*args: Any, **kwargs: Any) -> Any:
|
|
631
683
|
messages = kwargs.get("messages") or (args[0] if args else None)
|
|
632
684
|
if messages is None:
|
|
633
685
|
return await original_create(*args, **kwargs)
|
|
634
686
|
|
|
635
|
-
#
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
messages =
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
687
|
+
# A tool step reuses this turn's recall, including an empty one.
|
|
688
|
+
# A user message always recalls again.
|
|
689
|
+
turn_key = _user_turn_key(messages)
|
|
690
|
+
if (
|
|
691
|
+
turn_key
|
|
692
|
+
and not _is_last_message_user(messages)
|
|
693
|
+
and turn_key in recall_cache
|
|
694
|
+
):
|
|
695
|
+
cached = recall_cache[turn_key]
|
|
696
|
+
if cached:
|
|
697
|
+
messages = _inject_openai_memory(list(messages), cached)
|
|
698
|
+
if "messages" in kwargs:
|
|
699
|
+
kwargs["messages"] = messages
|
|
700
|
+
elif args:
|
|
701
|
+
args = (messages,) + args[1:]
|
|
702
|
+
elif turn_key:
|
|
703
|
+
user_text = _find_last_user_message(messages)
|
|
704
|
+
if user_text:
|
|
705
|
+
try:
|
|
706
|
+
recall_result = await memwal.recall(user_text, max_memories, namespace)
|
|
707
|
+
relevant = [
|
|
708
|
+
m for m in recall_result.results
|
|
709
|
+
if (1 - m.distance) >= min_relevance
|
|
710
|
+
]
|
|
711
|
+
if not relevant and turn_key:
|
|
712
|
+
_remember_recall(recall_cache, turn_key, "")
|
|
713
|
+
if relevant:
|
|
714
|
+
memory_context = _format_memories(relevant)
|
|
715
|
+
if turn_key:
|
|
716
|
+
_remember_recall(recall_cache, turn_key, memory_context)
|
|
717
|
+
log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
|
|
718
|
+
messages = _inject_openai_memory(list(messages), memory_context)
|
|
719
|
+
if "messages" in kwargs:
|
|
720
|
+
kwargs["messages"] = messages
|
|
721
|
+
elif args:
|
|
722
|
+
args = (messages,) + args[1:]
|
|
723
|
+
except Exception as e:
|
|
724
|
+
log(f"[Walrus Memory] Memory search failed: {e}")
|
|
654
725
|
|
|
655
726
|
result = await original_create(*args, **kwargs)
|
|
656
727
|
|
|
657
728
|
# Fire-and-forget analyze
|
|
658
|
-
if auto_save and
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
729
|
+
if auto_save and _is_last_message_user(messages):
|
|
730
|
+
user_text = _find_last_user_message(messages)
|
|
731
|
+
if user_text:
|
|
732
|
+
async def _analyze() -> None:
|
|
733
|
+
try:
|
|
734
|
+
await memwal.analyze(user_text, namespace)
|
|
735
|
+
except Exception as e:
|
|
736
|
+
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
737
|
+
pending.note_error(e)
|
|
738
|
+
|
|
739
|
+
_fire_and_forget(_analyze(), pending)
|
|
667
740
|
|
|
668
741
|
return result
|
|
669
742
|
|
|
@@ -682,7 +755,7 @@ def _wrap_sync_openai(
|
|
|
682
755
|
) -> None:
|
|
683
756
|
"""Wrap a sync OpenAI client's chat.completions.create."""
|
|
684
757
|
original_create = client.chat.completions.create
|
|
685
|
-
|
|
758
|
+
recall_cache: dict[str, str] = {}
|
|
686
759
|
def _run_memwal(coro_factory: Callable[[], Any]) -> Any:
|
|
687
760
|
# Keep httpx clients bound to the short-lived loop that uses them.
|
|
688
761
|
return _run_blocking(lambda: _with_fresh_http_client(memwal, coro_factory()))
|
|
@@ -692,40 +765,60 @@ def _wrap_sync_openai(
|
|
|
692
765
|
if messages is None:
|
|
693
766
|
return original_create(*args, **kwargs)
|
|
694
767
|
|
|
695
|
-
#
|
|
696
|
-
|
|
697
|
-
if
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
768
|
+
# A user message always recalls again.
|
|
769
|
+
turn_key = _user_turn_key(messages)
|
|
770
|
+
if (
|
|
771
|
+
turn_key
|
|
772
|
+
and not _is_last_message_user(messages)
|
|
773
|
+
and turn_key in recall_cache
|
|
774
|
+
):
|
|
775
|
+
cached = recall_cache[turn_key]
|
|
776
|
+
if cached:
|
|
777
|
+
messages = _inject_openai_memory(list(messages), cached)
|
|
778
|
+
if "messages" in kwargs:
|
|
779
|
+
kwargs["messages"] = messages
|
|
780
|
+
elif args:
|
|
781
|
+
args = (messages,) + args[1:]
|
|
782
|
+
elif turn_key:
|
|
783
|
+
user_text = _find_last_user_message(messages)
|
|
784
|
+
if user_text:
|
|
785
|
+
try:
|
|
786
|
+
recall_result = _run_memwal(
|
|
787
|
+
lambda: memwal.recall(user_text, max_memories, namespace)
|
|
788
|
+
)
|
|
789
|
+
relevant = [
|
|
790
|
+
m for m in recall_result.results
|
|
791
|
+
if (1 - m.distance) >= min_relevance
|
|
792
|
+
]
|
|
793
|
+
if not relevant and turn_key:
|
|
794
|
+
_remember_recall(recall_cache, turn_key, "")
|
|
795
|
+
if relevant:
|
|
796
|
+
memory_context = _format_memories(relevant)
|
|
797
|
+
if turn_key:
|
|
798
|
+
_remember_recall(recall_cache, turn_key, memory_context)
|
|
799
|
+
log(f"[Walrus Memory] Found {len(relevant)} relevant memories")
|
|
800
|
+
messages = _inject_openai_memory(list(messages), memory_context)
|
|
801
|
+
if "messages" in kwargs:
|
|
802
|
+
kwargs["messages"] = messages
|
|
803
|
+
elif args:
|
|
804
|
+
args = (messages,) + args[1:]
|
|
805
|
+
except Exception as e:
|
|
806
|
+
log(f"[Walrus Memory] Memory search failed: {e}")
|
|
716
807
|
|
|
717
808
|
result = original_create(*args, **kwargs)
|
|
718
809
|
|
|
719
810
|
# Fire-and-forget analyze
|
|
720
|
-
if auto_save and
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
811
|
+
if auto_save and _is_last_message_user(messages):
|
|
812
|
+
user_text = _find_last_user_message(messages)
|
|
813
|
+
if user_text:
|
|
814
|
+
def _analyze() -> None:
|
|
815
|
+
try:
|
|
816
|
+
_run_memwal(lambda: memwal.analyze(user_text, namespace))
|
|
817
|
+
except Exception as e:
|
|
818
|
+
log(f"[Walrus Memory] Auto-save failed: {e}")
|
|
819
|
+
pending.note_error(e)
|
|
820
|
+
|
|
821
|
+
pending.spawn_thread(_analyze)
|
|
729
822
|
|
|
730
823
|
return result
|
|
731
824
|
|
|
@@ -261,16 +261,28 @@ class MemWalMock:
|
|
|
261
261
|
namespace: Optional[str] = None,
|
|
262
262
|
max_distance: Optional[float] = None,
|
|
263
263
|
) -> RecallResult:
|
|
264
|
+
"""Token-overlap recall over the in-memory store.
|
|
265
|
+
|
|
266
|
+
``RecallParams.sort`` is honored: ``"recent"`` keeps the semantic top
|
|
267
|
+
``max(min(limit * 5, 50), limit)`` and orders that window newest first,
|
|
268
|
+
like the relayer. Otherwise results are ordered by token-overlap
|
|
269
|
+
distance. ``RecallParams.scoring_weights`` is accepted but ignored and
|
|
270
|
+
does not re-rank; ``distance`` is always token overlap.
|
|
271
|
+
"""
|
|
264
272
|
if isinstance(query, RecallParams):
|
|
265
273
|
limit = query.limit
|
|
266
274
|
namespace = query.namespace
|
|
267
275
|
max_distance = query.max_distance
|
|
276
|
+
sort = query.sort
|
|
268
277
|
query_text = query.query
|
|
269
278
|
else:
|
|
279
|
+
sort = None
|
|
270
280
|
query_text = query
|
|
271
281
|
_validate_text(query_text, "query")
|
|
272
282
|
if not isinstance(limit, int) or limit < 0:
|
|
273
283
|
raise ValueError("limit must be a non-negative integer")
|
|
284
|
+
if sort not in (None, "relevance", "recent"):
|
|
285
|
+
raise ValueError("sort must be 'relevance' or 'recent'")
|
|
274
286
|
resolved_namespace = namespace or self._namespace
|
|
275
287
|
query_tokens = _tokens(query_text)
|
|
276
288
|
ranked = [
|
|
@@ -279,6 +291,11 @@ class MemWalMock:
|
|
|
279
291
|
if memory.namespace == resolved_namespace
|
|
280
292
|
]
|
|
281
293
|
ranked.sort(key=lambda item: (item[1], item[0].sequence))
|
|
294
|
+
if sort == "recent":
|
|
295
|
+
# Same window as the TypeScript mock and RecallSort::candidate_limit.
|
|
296
|
+
# A newer memory outside that semantic window must not win.
|
|
297
|
+
window = max(min(limit * 5, 50), limit)
|
|
298
|
+
ranked = sorted(ranked[:window], key=lambda item: (-item[0].sequence, item[1]))
|
|
282
299
|
page = ranked[:limit]
|
|
283
300
|
if max_distance is not None:
|
|
284
301
|
page = [item for item in page if item[1] < max_distance]
|
|
@@ -331,7 +331,7 @@ class TestWithMemWalLangChain:
|
|
|
331
331
|
captured: list = []
|
|
332
332
|
|
|
333
333
|
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
334
|
-
captured.
|
|
334
|
+
captured.append(messages_batch)
|
|
335
335
|
return ChatResult(
|
|
336
336
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
337
337
|
)
|
|
@@ -348,7 +348,7 @@ class TestWithMemWalLangChain:
|
|
|
348
348
|
smart_llm = with_memwal_langchain(
|
|
349
349
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
350
350
|
)
|
|
351
|
-
await smart_llm._agenerate([
|
|
351
|
+
await smart_llm._agenerate([HumanMessage("What do I drink?")])
|
|
352
352
|
|
|
353
353
|
assert len(captured) == 1
|
|
354
354
|
injected = captured[0]
|
|
@@ -372,7 +372,7 @@ class TestWithMemWalLangChain:
|
|
|
372
372
|
captured: list = []
|
|
373
373
|
|
|
374
374
|
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
375
|
-
captured.
|
|
375
|
+
captured.append(messages_batch)
|
|
376
376
|
return ChatResult(
|
|
377
377
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
378
378
|
)
|
|
@@ -395,7 +395,7 @@ class TestWithMemWalLangChain:
|
|
|
395
395
|
AIMessage(content="Noted!"),
|
|
396
396
|
HumanMessage(content="What do I like?"),
|
|
397
397
|
]
|
|
398
|
-
await smart_llm._agenerate(
|
|
398
|
+
await smart_llm._agenerate(messages)
|
|
399
399
|
|
|
400
400
|
assert len(captured) == 1
|
|
401
401
|
injected = captured[0]
|
|
@@ -424,7 +424,7 @@ class TestWithMemWalLangChain:
|
|
|
424
424
|
captured: list = []
|
|
425
425
|
|
|
426
426
|
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
427
|
-
captured.
|
|
427
|
+
captured.append(messages_batch)
|
|
428
428
|
return ChatResult(
|
|
429
429
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
430
430
|
)
|
|
@@ -445,7 +445,7 @@ class TestWithMemWalLangChain:
|
|
|
445
445
|
{"type": "text", "text": "Base instructions", "cache_control": {"type": "ephemeral"}}
|
|
446
446
|
]
|
|
447
447
|
sys_msg = SystemMessage(content=blocks, id="sys-123", additional_kwargs={"source": "test"})
|
|
448
|
-
await smart_llm._agenerate([
|
|
448
|
+
await smart_llm._agenerate([sys_msg, HumanMessage("What do I like?")])
|
|
449
449
|
|
|
450
450
|
assert len(captured) == 1
|
|
451
451
|
injected = captured[0]
|
|
@@ -468,7 +468,7 @@ class TestWithMemWalLangChain:
|
|
|
468
468
|
captured: list = []
|
|
469
469
|
|
|
470
470
|
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
471
|
-
captured.
|
|
471
|
+
captured.append(messages_batch)
|
|
472
472
|
return ChatResult(
|
|
473
473
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
474
474
|
)
|
|
@@ -481,7 +481,7 @@ class TestWithMemWalLangChain:
|
|
|
481
481
|
smart_llm = with_memwal_langchain(
|
|
482
482
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
483
483
|
)
|
|
484
|
-
await smart_llm._agenerate([
|
|
484
|
+
await smart_llm._agenerate([HumanMessage("hello")])
|
|
485
485
|
|
|
486
486
|
injected = captured[0]
|
|
487
487
|
system_msgs = [m for m in injected if isinstance(m, SystemMessage)]
|
|
@@ -498,7 +498,7 @@ class TestWithMemWalLangChain:
|
|
|
498
498
|
captured: list = []
|
|
499
499
|
|
|
500
500
|
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
501
|
-
captured.
|
|
501
|
+
captured.append(messages_batch)
|
|
502
502
|
return ChatResult(
|
|
503
503
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
504
504
|
)
|
|
@@ -517,7 +517,7 @@ class TestWithMemWalLangChain:
|
|
|
517
517
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER,
|
|
518
518
|
min_relevance=0.3, auto_save=False
|
|
519
519
|
)
|
|
520
|
-
await smart_llm._agenerate([
|
|
520
|
+
await smart_llm._agenerate([HumanMessage("hello")])
|
|
521
521
|
|
|
522
522
|
injected = captured[0]
|
|
523
523
|
system_msgs = [m for m in injected if isinstance(m, SystemMessage)]
|
|
@@ -538,7 +538,7 @@ class TestWithMemWalLangChain:
|
|
|
538
538
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
539
539
|
)
|
|
540
540
|
# Should NOT raise — recall failure is swallowed, LLM still called
|
|
541
|
-
await smart_llm._agenerate([
|
|
541
|
+
await smart_llm._agenerate([HumanMessage("will this work?")])
|
|
542
542
|
|
|
543
543
|
@respx.mock
|
|
544
544
|
async def test_auto_save_triggers_analyze(self) -> None:
|
|
@@ -554,7 +554,7 @@ class TestWithMemWalLangChain:
|
|
|
554
554
|
smart_llm = with_memwal_langchain(
|
|
555
555
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
|
|
556
556
|
)
|
|
557
|
-
await smart_llm._agenerate([
|
|
557
|
+
await smart_llm._agenerate([HumanMessage("store this")])
|
|
558
558
|
|
|
559
559
|
# Give fire-and-forget task time to run
|
|
560
560
|
await asyncio.sleep(0.05)
|
|
@@ -572,7 +572,7 @@ class TestWithMemWalLangChain:
|
|
|
572
572
|
smart_llm = with_memwal_langchain(
|
|
573
573
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
574
574
|
)
|
|
575
|
-
await smart_llm._agenerate([
|
|
575
|
+
await smart_llm._agenerate([SystemMessage("only system")])
|
|
576
576
|
assert not recall_route.called
|
|
577
577
|
|
|
578
578
|
def _capturing_generate(self, captured: list):
|
|
@@ -581,7 +581,7 @@ class TestWithMemWalLangChain:
|
|
|
581
581
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
582
582
|
|
|
583
583
|
def _generate(messages_batch, *a, **kw):
|
|
584
|
-
captured.
|
|
584
|
+
captured.append(messages_batch)
|
|
585
585
|
return ChatResult(
|
|
586
586
|
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
587
587
|
)
|
|
@@ -607,7 +607,7 @@ class TestWithMemWalLangChain:
|
|
|
607
607
|
smart_llm = with_memwal_langchain(
|
|
608
608
|
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
609
609
|
)
|
|
610
|
-
smart_llm._generate([
|
|
610
|
+
smart_llm._generate([HumanMessage("What do I drink?")])
|
|
611
611
|
|
|
612
612
|
assert recall_route.called
|
|
613
613
|
assert any("User loves coffee" in m.content for m in captured[0])
|
|
@@ -641,7 +641,7 @@ class TestWithMemWalLangChain:
|
|
|
641
641
|
# An async test body is itself a running loop -- the exact condition
|
|
642
642
|
# that used to disable injection.
|
|
643
643
|
assert asyncio.get_running_loop().is_running()
|
|
644
|
-
smart_llm._generate([
|
|
644
|
+
smart_llm._generate([HumanMessage("What do I drink?")])
|
|
645
645
|
|
|
646
646
|
assert recall_route.called, "recall was skipped inside a running loop"
|
|
647
647
|
assert len(captured) == 1
|
|
@@ -671,6 +671,109 @@ class TestWithMemWalLangChain:
|
|
|
671
671
|
assert callable(smart_llm.memwal_flush)
|
|
672
672
|
assert callable(smart_llm.memwal_flush_sync)
|
|
673
673
|
|
|
674
|
+
@respx.mock
|
|
675
|
+
async def test_tool_steps_do_not_retrigger_recall_or_analyze(self) -> None:
|
|
676
|
+
"""LangChain tool messages do not retrigger recall or auto-save."""
|
|
677
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
678
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
679
|
+
_mock_seal_session_prereqs()
|
|
680
|
+
llm = self._make_llm()
|
|
681
|
+
captured: list = []
|
|
682
|
+
|
|
683
|
+
async def _capture(batch, *args, **kwargs):
|
|
684
|
+
captured.append(batch)
|
|
685
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
|
686
|
+
|
|
687
|
+
llm._agenerate = _capture
|
|
688
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
689
|
+
{"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
|
|
690
|
+
]))
|
|
691
|
+
analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
692
|
+
|
|
693
|
+
smart = with_memwal_langchain(
|
|
694
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
messages = [HumanMessage(content="What is the weather?")]
|
|
698
|
+
await smart._agenerate(messages)
|
|
699
|
+
await smart.memwal_flush()
|
|
700
|
+
|
|
701
|
+
assert recall_route.call_count == 1
|
|
702
|
+
assert analyze_route.call_count == 1
|
|
703
|
+
|
|
704
|
+
messages.extend([
|
|
705
|
+
AIMessage(content="", tool_calls=[{"name": "weather", "args": {}, "id": "1"}]),
|
|
706
|
+
ToolMessage(content="Sunny 20C", tool_call_id="1"),
|
|
707
|
+
])
|
|
708
|
+
await smart._agenerate(messages)
|
|
709
|
+
await smart.memwal_flush()
|
|
710
|
+
|
|
711
|
+
assert recall_route.call_count == 1, "recall must NOT be called again on tool step"
|
|
712
|
+
assert analyze_route.call_count == 1, "analyze must NOT be called again on tool step"
|
|
713
|
+
assert any(
|
|
714
|
+
"User lives in Lisbon" in getattr(message, "content", "")
|
|
715
|
+
for message in captured[-1]
|
|
716
|
+
)
|
|
717
|
+
# Step 3: Next user turn starts
|
|
718
|
+
messages.extend([
|
|
719
|
+
AIMessage(content="It is sunny!"),
|
|
720
|
+
HumanMessage(content="Thanks!"),
|
|
721
|
+
])
|
|
722
|
+
await smart._agenerate(messages)
|
|
723
|
+
await smart.memwal_flush()
|
|
724
|
+
|
|
725
|
+
assert recall_route.call_count == 2
|
|
726
|
+
assert analyze_route.call_count == 2
|
|
727
|
+
|
|
728
|
+
@respx.mock
|
|
729
|
+
async def test_same_user_text_recalls_again(self) -> None:
|
|
730
|
+
"""A later user turn with the same text must not reuse the first recall."""
|
|
731
|
+
from langchain_core.messages import AIMessage, HumanMessage
|
|
732
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
733
|
+
_mock_seal_session_prereqs()
|
|
734
|
+
llm = self._make_llm()
|
|
735
|
+
|
|
736
|
+
async def _ok(batch, *args, **kwargs):
|
|
737
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
|
738
|
+
|
|
739
|
+
llm._agenerate = _ok
|
|
740
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
741
|
+
{"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
|
|
742
|
+
]))
|
|
743
|
+
smart = with_memwal_langchain(
|
|
744
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
745
|
+
)
|
|
746
|
+
await smart._agenerate([HumanMessage(content="what's my name?")])
|
|
747
|
+
await smart._agenerate([HumanMessage(content="what's my name?")])
|
|
748
|
+
assert recall_route.call_count == 2
|
|
749
|
+
|
|
750
|
+
@respx.mock
|
|
751
|
+
async def test_empty_recall_is_reused_on_the_tool_step(self) -> None:
|
|
752
|
+
"""A recall with no relevant hit is cached, so the tool step does not call again."""
|
|
753
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
754
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
755
|
+
_mock_seal_session_prereqs()
|
|
756
|
+
llm = self._make_llm()
|
|
757
|
+
|
|
758
|
+
async def _ok(batch, *args, **kwargs):
|
|
759
|
+
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
|
|
760
|
+
|
|
761
|
+
llm._agenerate = _ok
|
|
762
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
763
|
+
{"blob_id": "b1", "text": "unrelated", "distance": 0.99}
|
|
764
|
+
]))
|
|
765
|
+
smart = with_memwal_langchain(
|
|
766
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
767
|
+
)
|
|
768
|
+
messages = [HumanMessage(content="What is the weather?")]
|
|
769
|
+
await smart._agenerate(messages)
|
|
770
|
+
messages.extend([
|
|
771
|
+
AIMessage(content="", tool_calls=[{"name": "weather", "args": {}, "id": "1"}]),
|
|
772
|
+
ToolMessage(content="Sunny", tool_call_id="1"),
|
|
773
|
+
])
|
|
774
|
+
await smart._agenerate(messages)
|
|
775
|
+
assert recall_route.call_count == 1
|
|
776
|
+
|
|
674
777
|
|
|
675
778
|
# ============================================================
|
|
676
779
|
# OpenAI middleware tests
|
|
@@ -882,6 +985,112 @@ class TestWithMemWalOpenAI:
|
|
|
882
985
|
system_msgs = [m for m in captured if isinstance(m, dict) and m.get("role") == "system"]
|
|
883
986
|
assert len(system_msgs) == 0
|
|
884
987
|
|
|
988
|
+
@respx.mock
|
|
989
|
+
async def test_tool_execution_steps_do_not_retrigger_recall_or_analyze(self) -> None:
|
|
990
|
+
"""Tool response messages (role: tool) do not retrigger recall or auto-save."""
|
|
991
|
+
_mock_seal_session_prereqs()
|
|
992
|
+
client = self._make_async_client()
|
|
993
|
+
captured: list = []
|
|
994
|
+
|
|
995
|
+
async def _capture(*args, **kwargs):
|
|
996
|
+
captured.append(kwargs.get("messages"))
|
|
997
|
+
return MagicMock()
|
|
998
|
+
|
|
999
|
+
client.chat.completions.create = _capture
|
|
1000
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
1001
|
+
{"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
|
|
1002
|
+
]))
|
|
1003
|
+
analyze_route = respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
1004
|
+
|
|
1005
|
+
smart = with_memwal_openai(
|
|
1006
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
messages = [{"role": "user", "content": "What is the weather in Lisbon?"}]
|
|
1010
|
+
await smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1011
|
+
await smart.memwal_flush()
|
|
1012
|
+
|
|
1013
|
+
assert recall_route.call_count == 1
|
|
1014
|
+
assert analyze_route.call_count == 1
|
|
1015
|
+
|
|
1016
|
+
messages.extend([
|
|
1017
|
+
{
|
|
1018
|
+
"role": "assistant",
|
|
1019
|
+
"tool_calls": [{"id": "call_1", "function": {"name": "get_weather"}}],
|
|
1020
|
+
},
|
|
1021
|
+
{"role": "tool", "tool_call_id": "call_1", "content": "22C sunny"},
|
|
1022
|
+
])
|
|
1023
|
+
await smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1024
|
+
await smart.memwal_flush()
|
|
1025
|
+
|
|
1026
|
+
assert recall_route.call_count == 1, "recall must NOT be called again on tool step"
|
|
1027
|
+
assert analyze_route.call_count == 1, "analyze must NOT be called again on tool step"
|
|
1028
|
+
assert any(
|
|
1029
|
+
isinstance(message, dict) and "User lives in Lisbon" in str(message.get("content", ""))
|
|
1030
|
+
for message in captured[-1]
|
|
1031
|
+
)
|
|
1032
|
+
# Step 3: Next user turn starts
|
|
1033
|
+
messages.extend([
|
|
1034
|
+
{"role": "assistant", "content": "It is 22C and sunny in Lisbon."},
|
|
1035
|
+
{"role": "user", "content": "And in Porto?"},
|
|
1036
|
+
])
|
|
1037
|
+
await smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1038
|
+
await smart.memwal_flush()
|
|
1039
|
+
|
|
1040
|
+
assert recall_route.call_count == 2
|
|
1041
|
+
assert analyze_route.call_count == 2
|
|
1042
|
+
|
|
1043
|
+
@respx.mock
|
|
1044
|
+
async def test_same_user_text_recalls_again(self) -> None:
|
|
1045
|
+
_mock_seal_session_prereqs()
|
|
1046
|
+
client = self._make_async_client()
|
|
1047
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
1048
|
+
{"blob_id": "b1", "text": "User lives in Lisbon", "distance": 0.05}
|
|
1049
|
+
]))
|
|
1050
|
+
smart = with_memwal_openai(
|
|
1051
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
1052
|
+
)
|
|
1053
|
+
await smart.chat.completions.create(
|
|
1054
|
+
model="gpt-4o", messages=[{"role": "user", "content": "what's my name?"}]
|
|
1055
|
+
)
|
|
1056
|
+
await smart.chat.completions.create(
|
|
1057
|
+
model="gpt-4o", messages=[{"role": "user", "content": "what's my name?"}]
|
|
1058
|
+
)
|
|
1059
|
+
assert recall_route.call_count == 2
|
|
1060
|
+
|
|
1061
|
+
@respx.mock
|
|
1062
|
+
async def test_empty_recall_is_reused_on_the_tool_step(self) -> None:
|
|
1063
|
+
_mock_seal_session_prereqs()
|
|
1064
|
+
client = self._make_async_client()
|
|
1065
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
1066
|
+
{"blob_id": "b1", "text": "unrelated", "distance": 0.99}
|
|
1067
|
+
]))
|
|
1068
|
+
smart = with_memwal_openai(
|
|
1069
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
1070
|
+
)
|
|
1071
|
+
messages = [{"role": "user", "content": "hello"}]
|
|
1072
|
+
await smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1073
|
+
messages = messages + [{"role": "tool", "tool_call_id": "1", "content": "ok"}]
|
|
1074
|
+
await smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1075
|
+
assert recall_route.call_count == 1
|
|
1076
|
+
|
|
1077
|
+
@respx.mock
|
|
1078
|
+
def test_sync_empty_recall_is_reused_on_the_tool_step(self) -> None:
|
|
1079
|
+
_mock_seal_session_prereqs()
|
|
1080
|
+
client = self._make_sync_client()
|
|
1081
|
+
recall_route = respx.post(_RECALL_URL).mock(return_value=_mock_recall([
|
|
1082
|
+
{"blob_id": "b1", "text": "unrelated", "distance": 0.99}
|
|
1083
|
+
]))
|
|
1084
|
+
smart = with_memwal_openai(
|
|
1085
|
+
client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
1086
|
+
)
|
|
1087
|
+
messages = [{"role": "user", "content": "hello"}]
|
|
1088
|
+
smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1089
|
+
messages = messages + [{"role": "tool", "tool_call_id": "1", "content": "ok"}]
|
|
1090
|
+
smart.chat.completions.create(model="gpt-4o", messages=messages)
|
|
1091
|
+
assert recall_route.call_count == 1
|
|
1092
|
+
|
|
1093
|
+
|
|
885
1094
|
@respx.mock
|
|
886
1095
|
def test_sync_client_wraps_create(self) -> None:
|
|
887
1096
|
"""Sync OpenAI client wrapper is applied correctly."""
|
|
@@ -46,6 +46,37 @@ async def test_mock_remember_and_recall_are_deterministic_and_offline(monkeypatc
|
|
|
46
46
|
assert recalled.results[1].created_at == "2026-01-01T00:00:02.000Z"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_mock_recall_sort_recent_is_not_relevance_order():
|
|
52
|
+
mock = MemWalMock.create(namespace="user-a")
|
|
53
|
+
await mock.remember_and_wait("I prefer coffee in the morning")
|
|
54
|
+
await mock.remember_and_wait("I drink tea at night")
|
|
55
|
+
|
|
56
|
+
recent = await mock.recall(RecallParams(query="I", sort="recent"))
|
|
57
|
+
assert [memory.text for memory in recent.results] == [
|
|
58
|
+
"I drink tea at night",
|
|
59
|
+
"I prefer coffee in the morning",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
with pytest.raises(ValueError, match="sort"):
|
|
63
|
+
await mock.recall(RecallParams(query="I", sort="newest"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_mock_recall_sort_recent_stays_inside_the_semantic_window():
|
|
68
|
+
mock = MemWalMock.create(namespace="user-a")
|
|
69
|
+
for i in range(6):
|
|
70
|
+
await mock.remember_and_wait(f"same fact {i}")
|
|
71
|
+
recent = await mock.recall(RecallParams(query="same fact", limit=1, sort="recent"))
|
|
72
|
+
assert recent.results[0].text == "same fact 4"
|
|
73
|
+
|
|
74
|
+
wide = MemWalMock.create(namespace="user-a")
|
|
75
|
+
for i in range(51):
|
|
76
|
+
await wide.remember_and_wait(f"same fact {i}")
|
|
77
|
+
first = await wide.recall(RecallParams(query="same fact", limit=11, sort="recent"))
|
|
78
|
+
assert first.results[0].text == "same fact 49"
|
|
79
|
+
|
|
49
80
|
@pytest.mark.asyncio
|
|
50
81
|
async def test_mock_isolates_namespaces_and_honors_max_distance():
|
|
51
82
|
mock = MemWalMock.create()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from memwal import MemWal, RecallParams, ScoringWeights
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CaptureMemWal(MemWal):
|
|
7
|
+
def __init__(self, *args, **kwargs):
|
|
8
|
+
super().__init__(*args, **kwargs)
|
|
9
|
+
self.captured = []
|
|
10
|
+
|
|
11
|
+
async def _ensure_compatible_relayer(self):
|
|
12
|
+
return {
|
|
13
|
+
"apiVersion": "1.0.0",
|
|
14
|
+
"relayerVersion": "capture",
|
|
15
|
+
"minSupportedSdk": {"python": "0.0.0"},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async def _signed_request(
|
|
19
|
+
self, method, path, body, accepted_statuses=(200,), include_seal_session=True,
|
|
20
|
+
):
|
|
21
|
+
self.captured.append((method, path, body))
|
|
22
|
+
return {"results": [], "total": 0}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_recall_forwards_scoring_weights_and_sort():
|
|
27
|
+
client = CaptureMemWal.create(
|
|
28
|
+
key="11" * 32,
|
|
29
|
+
account_id="0x" + "ab" * 32,
|
|
30
|
+
server_url="https://relayer.example",
|
|
31
|
+
)
|
|
32
|
+
await client.recall(RecallParams(
|
|
33
|
+
query="food allergies",
|
|
34
|
+
scoring_weights=ScoringWeights(recency=1.0),
|
|
35
|
+
sort="recent",
|
|
36
|
+
))
|
|
37
|
+
method, path, body = client.captured[0]
|
|
38
|
+
assert method == "POST"
|
|
39
|
+
assert path == "/api/recall"
|
|
40
|
+
assert body["scoring_weights"] == {"recency": 1.0}
|
|
41
|
+
assert body["sort"] == "recent"
|
|
42
|
+
await client.close()
|
|
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
|