memwal 0.1.11.dev5__tar.gz → 0.1.11.dev7__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.dev5 → memwal-0.1.11.dev7}/CHANGELOG.md +1 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/PKG-INFO +1 -1
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/__init__.py +1 -1
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/client.py +54 -13
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/middleware.py +50 -15
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/pyproject.toml +1 -1
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_client.py +84 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_middleware.py +129 -7
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/.gitignore +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/README.md +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/.env.example +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/.gitignore +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/async_remember_demo.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/interactive_demo.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/verify_credentials.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/compatibility.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/mock.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/types.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/utils.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/run_tests.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/__init__.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_auth_rejected_message.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_blank_text.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_env_presets.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_integration.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_list_namespaces.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_mock.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_normalize_server_url.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_polling_delay.py +0 -0
- {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_signing.py +0 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- `remember`, `remember_bulk`, `analyze`, and `embed` reject whitespace-only text before the request. A blank body used to be accepted and then sit until the caller timed out. (#1129)
|
|
8
8
|
- A 401 `AUTH_REJECTED` now says a wallet address is not a MemWal account ID. (#1132)
|
|
9
9
|
- `wait_for_remember_jobs` / `remember_bulk_and_wait` no longer return a batch they could not read as if it were still uploading. When every status poll is rate-limited, the wait makes one confirming read and raises `MemWalRateLimited` (`status` 429, `job_ids`, `retry_after`) if that is refused too. An item still unsettled at the deadline names its last known status, or says no read got through. (WALM-671, #967)
|
|
10
|
+
- `MemWalRememberJobTimeout` carries `last_status`, `server_error` and `last_refusal`, and its message names the relayer's last reported state. Under upload congestion a job stays `running` for up to ~25 minutes while the relayer retries, and the timeout used to hide that, so a job that was still retrying looked dead. (WALM-670, #966)
|
|
10
11
|
|
|
11
12
|
## 0.1.12
|
|
12
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memwal
|
|
3
|
-
Version: 0.1.11.
|
|
3
|
+
Version: 0.1.11.dev7
|
|
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
|
|
@@ -485,6 +485,9 @@ class MemWal:
|
|
|
485
485
|
attempt = 0
|
|
486
486
|
saw_rate_limit = False
|
|
487
487
|
retry_after_ms = 0
|
|
488
|
+
last_status: Optional[str] = None
|
|
489
|
+
server_error: Optional[str] = None
|
|
490
|
+
last_refusal: Optional[int] = None
|
|
488
491
|
|
|
489
492
|
while _now_ms() < deadline_ms:
|
|
490
493
|
if retry_after_ms > 0:
|
|
@@ -503,6 +506,7 @@ class MemWal:
|
|
|
503
506
|
)
|
|
504
507
|
except _HttpStatusError as err:
|
|
505
508
|
if _is_transient_polling_status(err.status):
|
|
509
|
+
last_refusal = err.status
|
|
506
510
|
if err.status == 429:
|
|
507
511
|
saw_rate_limit = True
|
|
508
512
|
retry_after_ms = _clamped_retry_after_ms(err, deadline_ms)
|
|
@@ -512,6 +516,8 @@ class MemWal:
|
|
|
512
516
|
status_str = data.get("status")
|
|
513
517
|
if status_str is None or status_str == "not_found":
|
|
514
518
|
raise MemWalRememberJobNotFound(job_id)
|
|
519
|
+
last_status = status_str
|
|
520
|
+
server_error = data.get("error")
|
|
515
521
|
|
|
516
522
|
if status_str == "done":
|
|
517
523
|
return RememberResult(
|
|
@@ -528,7 +534,14 @@ class MemWal:
|
|
|
528
534
|
|
|
529
535
|
# pending / running / uploaded — keep polling.
|
|
530
536
|
|
|
531
|
-
raise
|
|
537
|
+
raise MemWalRememberJobTimeout(
|
|
538
|
+
job_id=job_id,
|
|
539
|
+
timeout_ms=timeout_ms,
|
|
540
|
+
last_status=last_status,
|
|
541
|
+
server_error=server_error,
|
|
542
|
+
last_refusal=last_refusal,
|
|
543
|
+
saw_rate_limit=saw_rate_limit,
|
|
544
|
+
)
|
|
532
545
|
|
|
533
546
|
async def remember_and_wait(
|
|
534
547
|
self,
|
|
@@ -1689,24 +1702,52 @@ class MemWalRateLimited(MemWalError):
|
|
|
1689
1702
|
|
|
1690
1703
|
|
|
1691
1704
|
class MemWalRememberJobTimeout(MemWalError):
|
|
1692
|
-
"""Polling loop exceeded the configured timeout.
|
|
1705
|
+
"""Polling loop exceeded the configured timeout.
|
|
1693
1706
|
|
|
1694
|
-
|
|
1707
|
+
Carries the last status the relayer reported (GH #966): under upload
|
|
1708
|
+
congestion a job stays ``running`` for tens of minutes, and without it a
|
|
1709
|
+
job that is still retrying looks dead.
|
|
1710
|
+
"""
|
|
1711
|
+
|
|
1712
|
+
def __init__(
|
|
1713
|
+
self,
|
|
1714
|
+
job_id: str,
|
|
1715
|
+
timeout_ms: int,
|
|
1716
|
+
last_status: Optional[str] = None,
|
|
1717
|
+
server_error: Optional[str] = None,
|
|
1718
|
+
last_refusal: Optional[int] = None,
|
|
1719
|
+
saw_rate_limit: bool = False,
|
|
1720
|
+
) -> None:
|
|
1721
|
+
# "no status read got through (last poll: HTTP 429)" already names the rate limit.
|
|
1722
|
+
named = last_status is None and last_refusal == 429
|
|
1723
|
+
rate_limited = "; wait hit a rate limit (429)" if saw_rate_limit and not named else ""
|
|
1724
|
+
# Redacted once, for both the message and the attribute.
|
|
1725
|
+
server_error = _redact_internal_urls(server_error) if server_error else None
|
|
1726
|
+
if last_status is not None:
|
|
1727
|
+
# The wait only times out on a job still pending/running/uploaded
|
|
1728
|
+
# (a failed job raises), so the advice is to keep waiting on it.
|
|
1729
|
+
suffix = f" ({server_error})" if server_error else ""
|
|
1730
|
+
detail = (
|
|
1731
|
+
f"last status: {last_status}{suffix}. The job is still in progress: "
|
|
1732
|
+
f"call wait_for_remember_job({job_id!r}) to keep waiting on it. Sending "
|
|
1733
|
+
"the text again under a new idempotency key stores it twice; a key you "
|
|
1734
|
+
"passed as idempotency_key is not remembered by the client, so pass it "
|
|
1735
|
+
"again on any retry."
|
|
1736
|
+
)
|
|
1737
|
+
else:
|
|
1738
|
+
detail = (
|
|
1739
|
+
f"no status read got through (last poll: HTTP {last_refusal or 0}), "
|
|
1740
|
+
"so the job's state is unknown."
|
|
1741
|
+
)
|
|
1695
1742
|
super().__init__(
|
|
1696
|
-
f"remember job timed out after {timeout_ms}ms (job_id={job_id})"
|
|
1743
|
+
f"remember job timed out after {timeout_ms}ms (job_id={job_id}){rate_limited}; {detail}"
|
|
1697
1744
|
)
|
|
1698
1745
|
self.status = 504
|
|
1699
1746
|
self.job_id = job_id
|
|
1700
1747
|
self.timeout_ms = timeout_ms
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
job_id: str, timeout_ms: int, saw_rate_limit: bool
|
|
1705
|
-
) -> MemWalRememberJobTimeout:
|
|
1706
|
-
exc = MemWalRememberJobTimeout(job_id, timeout_ms)
|
|
1707
|
-
if saw_rate_limit:
|
|
1708
|
-
exc.args = (f"{exc.args[0]}; wait hit a rate limit (429)",)
|
|
1709
|
-
return exc
|
|
1748
|
+
self.last_status = last_status
|
|
1749
|
+
self.server_error = server_error
|
|
1750
|
+
self.last_refusal = last_refusal
|
|
1710
1751
|
|
|
1711
1752
|
|
|
1712
1753
|
async def _discard_http_client(memwal: MemWal) -> None:
|
|
@@ -188,7 +188,7 @@ class _PendingSaves:
|
|
|
188
188
|
|
|
189
189
|
try:
|
|
190
190
|
asyncio.run(_drain(list(self._tasks)))
|
|
191
|
-
except RuntimeError:
|
|
191
|
+
except (RuntimeError, ValueError):
|
|
192
192
|
logger.warning(
|
|
193
193
|
"Walrus Memory flush_sync() could not await %d pending "
|
|
194
194
|
"task(s) bound to a different event loop (mixing sync "
|
|
@@ -366,12 +366,32 @@ def with_memwal_langchain(
|
|
|
366
366
|
last_human_idx = i
|
|
367
367
|
break
|
|
368
368
|
|
|
369
|
-
guard_msg = SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION)
|
|
370
369
|
memory_msg = HumanMessage(content=memory_context)
|
|
371
370
|
if last_human_idx > 0:
|
|
372
|
-
result
|
|
371
|
+
result.insert(last_human_idx, memory_msg)
|
|
373
372
|
else:
|
|
374
|
-
result
|
|
373
|
+
result.insert(0, memory_msg)
|
|
374
|
+
|
|
375
|
+
if result and isinstance(result[0], SystemMessage):
|
|
376
|
+
existing_content = result[0].content
|
|
377
|
+
if isinstance(existing_content, list):
|
|
378
|
+
new_content = list(existing_content) + [
|
|
379
|
+
{"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
|
|
380
|
+
]
|
|
381
|
+
else:
|
|
382
|
+
text_str = str(existing_content or "")
|
|
383
|
+
new_content = f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip()
|
|
384
|
+
if hasattr(result[0], "model_copy"):
|
|
385
|
+
result[0] = result[0].model_copy(update={"content": new_content})
|
|
386
|
+
else:
|
|
387
|
+
result[0] = SystemMessage(
|
|
388
|
+
content=new_content,
|
|
389
|
+
additional_kwargs=getattr(result[0], "additional_kwargs", {}),
|
|
390
|
+
id=getattr(result[0], "id", None),
|
|
391
|
+
name=getattr(result[0], "name", None),
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
result.insert(0, SystemMessage(content=_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION))
|
|
375
395
|
|
|
376
396
|
return result
|
|
377
397
|
except Exception as e:
|
|
@@ -625,21 +645,36 @@ def _inject_openai_memory(
|
|
|
625
645
|
messages: List[Dict[str, Any]],
|
|
626
646
|
memory_context: str,
|
|
627
647
|
) -> List[Dict[str, Any]]:
|
|
628
|
-
"""Insert a fixed system guard
|
|
648
|
+
"""Insert a fixed system guard at index 0 and memory content in a user message."""
|
|
649
|
+
result = [dict(m) if isinstance(m, dict) else m for m in messages]
|
|
629
650
|
last_user_idx = -1
|
|
630
|
-
for i in range(len(
|
|
631
|
-
if isinstance(
|
|
651
|
+
for i in range(len(result) - 1, -1, -1):
|
|
652
|
+
if isinstance(result[i], dict) and result[i].get("role") == "user":
|
|
632
653
|
last_user_idx = i
|
|
633
654
|
break
|
|
634
655
|
|
|
635
|
-
|
|
636
|
-
{"role": "system", "content": _UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION},
|
|
637
|
-
{"role": "user", "content": memory_context},
|
|
638
|
-
]
|
|
639
|
-
|
|
656
|
+
memory_message = {"role": "user", "content": memory_context}
|
|
640
657
|
if last_user_idx > 0:
|
|
641
|
-
|
|
658
|
+
result.insert(last_user_idx, memory_message)
|
|
659
|
+
else:
|
|
660
|
+
result.insert(0, memory_message)
|
|
661
|
+
|
|
662
|
+
if result and isinstance(result[0], dict) and result[0].get("role") == "system":
|
|
663
|
+
existing_content = result[0].get("content")
|
|
664
|
+
if isinstance(existing_content, list):
|
|
665
|
+
result[0] = {
|
|
666
|
+
**result[0],
|
|
667
|
+
"content": existing_content + [
|
|
668
|
+
{"type": "text", "text": f"\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}"}
|
|
669
|
+
],
|
|
670
|
+
}
|
|
671
|
+
else:
|
|
672
|
+
text_str = str(existing_content or "")
|
|
673
|
+
result[0] = {
|
|
674
|
+
**result[0],
|
|
675
|
+
"content": f"{text_str}\n\n{_UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION}".strip(),
|
|
676
|
+
}
|
|
642
677
|
else:
|
|
643
|
-
|
|
678
|
+
result.insert(0, {"role": "system", "content": _UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION})
|
|
644
679
|
|
|
645
|
-
return
|
|
680
|
+
return result
|
|
@@ -24,6 +24,7 @@ from memwal.client import (
|
|
|
24
24
|
MemWalCompatibilityError,
|
|
25
25
|
MemWalError,
|
|
26
26
|
MemWalRateLimited,
|
|
27
|
+
MemWalRememberJobTimeout,
|
|
27
28
|
MemWalSync,
|
|
28
29
|
_HttpStatusError,
|
|
29
30
|
)
|
|
@@ -558,6 +559,89 @@ class TestBulkWaitUnderRateLimit:
|
|
|
558
559
|
)
|
|
559
560
|
|
|
560
561
|
|
|
562
|
+
class TestRememberJobTimeoutDetail:
|
|
563
|
+
async def test_reports_last_status_and_server_message(
|
|
564
|
+
self, memwal_client: MemWal
|
|
565
|
+
) -> None:
|
|
566
|
+
async def running(*_args: Any, **_kwargs: Any) -> dict:
|
|
567
|
+
return {
|
|
568
|
+
"job_id": "slow-job",
|
|
569
|
+
"status": "running",
|
|
570
|
+
"error": "the hosted relayer is still retrying this upload",
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
memwal_client._signed_request = running # type: ignore[method-assign]
|
|
574
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
575
|
+
await memwal_client.wait_for_remember_job(
|
|
576
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
577
|
+
)
|
|
578
|
+
assert exc.value.status == 504
|
|
579
|
+
assert exc.value.last_status == "running"
|
|
580
|
+
assert "still retrying" in (exc.value.server_error or "")
|
|
581
|
+
assert str(exc.value).startswith("remember job timed out after 300ms")
|
|
582
|
+
assert "last status: running" in str(exc.value)
|
|
583
|
+
# The job is still live, so the advice is to wait on its id, not to
|
|
584
|
+
# POST again: `remember` drops its key once accepted, so a re-send
|
|
585
|
+
# would mint a new key and a second paid write.
|
|
586
|
+
assert "call wait_for_remember_job('slow-job')" in str(exc.value)
|
|
587
|
+
assert "idempotency_key is not remembered" in str(exc.value)
|
|
588
|
+
|
|
589
|
+
async def test_timeout_attribute_carries_the_redacted_server_error(
|
|
590
|
+
self, memwal_client: MemWal
|
|
591
|
+
) -> None:
|
|
592
|
+
async def running(*_args: Any, **_kwargs: Any) -> dict:
|
|
593
|
+
return {
|
|
594
|
+
"job_id": "slow-job",
|
|
595
|
+
"status": "running",
|
|
596
|
+
"error": "sidecar http://localhost:9000/upload unreachable",
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
memwal_client._signed_request = running # type: ignore[method-assign]
|
|
600
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
601
|
+
await memwal_client.wait_for_remember_job(
|
|
602
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
603
|
+
)
|
|
604
|
+
assert exc.value.server_error == "sidecar [internal] unreachable"
|
|
605
|
+
assert "localhost" not in str(exc.value)
|
|
606
|
+
|
|
607
|
+
async def test_every_poll_refused_reports_unknown_state(
|
|
608
|
+
self, memwal_client: MemWal
|
|
609
|
+
) -> None:
|
|
610
|
+
async def refused(*_args: Any, **_kwargs: Any) -> dict:
|
|
611
|
+
raise _HttpStatusError(429, '{"error":"rate limited"}')
|
|
612
|
+
|
|
613
|
+
memwal_client._signed_request = refused # type: ignore[method-assign]
|
|
614
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
615
|
+
await memwal_client.wait_for_remember_job(
|
|
616
|
+
"hidden-job", poll_interval_ms=0, timeout_ms=300
|
|
617
|
+
)
|
|
618
|
+
assert exc.value.last_status is None
|
|
619
|
+
assert exc.value.last_refusal == 429
|
|
620
|
+
assert "no status read got through (last poll: HTTP 429)" in str(exc.value)
|
|
621
|
+
|
|
622
|
+
async def test_seen_then_rate_limited_keeps_both(
|
|
623
|
+
self, memwal_client: MemWal
|
|
624
|
+
) -> None:
|
|
625
|
+
calls = 0
|
|
626
|
+
|
|
627
|
+
async def running_then_refused(*_args: Any, **_kwargs: Any) -> dict:
|
|
628
|
+
nonlocal calls
|
|
629
|
+
calls += 1
|
|
630
|
+
if calls == 1:
|
|
631
|
+
return {"job_id": "slow-job", "status": "running"}
|
|
632
|
+
raise _HttpStatusError(429, '{"error":"rate limited"}')
|
|
633
|
+
|
|
634
|
+
memwal_client._signed_request = running_then_refused # type: ignore[method-assign]
|
|
635
|
+
with pytest.raises(MemWalRememberJobTimeout) as exc:
|
|
636
|
+
await memwal_client.wait_for_remember_job(
|
|
637
|
+
"slow-job", poll_interval_ms=0, timeout_ms=300
|
|
638
|
+
)
|
|
639
|
+
assert exc.value.last_status == "running"
|
|
640
|
+
assert "(job_id=slow-job); wait hit a rate limit (429); last status: running." in str(
|
|
641
|
+
exc.value
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
561
645
|
# ============================================================
|
|
562
646
|
# recall() tests
|
|
563
647
|
# ============================================================
|
|
@@ -240,27 +240,53 @@ class TestFormatMemories:
|
|
|
240
240
|
class TestInjectOpenAIMemory:
|
|
241
241
|
"""Tests for _inject_openai_memory."""
|
|
242
242
|
|
|
243
|
-
def
|
|
243
|
+
def test_folds_guard_into_system_message_and_inserts_memory_before_user_message(self) -> None:
|
|
244
244
|
messages = [
|
|
245
245
|
{"role": "system", "content": "You are helpful."},
|
|
246
246
|
{"role": "user", "content": "question"},
|
|
247
247
|
]
|
|
248
248
|
result = _inject_openai_memory(messages, "memory context")
|
|
249
|
-
# Should be: existing-system
|
|
249
|
+
# Should be: existing-system-with-guard | memory-user | user
|
|
250
|
+
assert len(result) == 3
|
|
250
251
|
assert result[0]["role"] == "system"
|
|
251
|
-
assert result[
|
|
252
|
-
assert "untrusted data" in result[
|
|
253
|
-
assert result[
|
|
254
|
-
assert result[
|
|
252
|
+
assert "You are helpful." in result[0]["content"]
|
|
253
|
+
assert "untrusted data" in result[0]["content"]
|
|
254
|
+
assert result[1] == {"role": "user", "content": "memory context"}
|
|
255
|
+
assert result[2]["role"] == "user"
|
|
256
|
+
assert result[2]["content"] == "question"
|
|
257
|
+
|
|
258
|
+
def test_multi_turn_never_injects_system_message_after_index_0(self) -> None:
|
|
259
|
+
messages = [
|
|
260
|
+
{"role": "system", "content": "You are helpful."},
|
|
261
|
+
{"role": "user", "content": "first"},
|
|
262
|
+
{"role": "assistant", "content": "reply"},
|
|
263
|
+
{"role": "user", "content": "second"},
|
|
264
|
+
]
|
|
265
|
+
result = _inject_openai_memory(messages, "recalled fact")
|
|
266
|
+
# System message only at index 0
|
|
267
|
+
assert result[0]["role"] == "system"
|
|
268
|
+
assert "You are helpful." in result[0]["content"]
|
|
269
|
+
assert "untrusted data" in result[0]["content"]
|
|
270
|
+
|
|
271
|
+
# No system message after index 0
|
|
272
|
+
assert not any(m.get("role") == "system" for m in result[1:])
|
|
273
|
+
|
|
274
|
+
# Memory is injected as user message right before "second"
|
|
275
|
+
assert result[1] == {"role": "user", "content": "first"}
|
|
276
|
+
assert result[2] == {"role": "assistant", "content": "reply"}
|
|
277
|
+
assert result[3] == {"role": "user", "content": "recalled fact"}
|
|
278
|
+
assert result[4] == {"role": "user", "content": "second"}
|
|
255
279
|
|
|
256
280
|
def test_inserts_at_start_when_only_user_message(self) -> None:
|
|
257
281
|
messages = [{"role": "user", "content": "question"}]
|
|
258
282
|
result = _inject_openai_memory(messages, "context")
|
|
259
|
-
#
|
|
283
|
+
# When no system message exists, prepends guard system message at index 0
|
|
260
284
|
assert result[0]["role"] == "system"
|
|
285
|
+
assert "untrusted data" in result[0]["content"]
|
|
261
286
|
assert result[1]["role"] == "user"
|
|
262
287
|
assert result[1]["content"] == "context"
|
|
263
288
|
assert result[2]["role"] == "user"
|
|
289
|
+
assert result[2]["content"] == "question"
|
|
264
290
|
|
|
265
291
|
def test_does_not_mutate_original(self) -> None:
|
|
266
292
|
original = [{"role": "user", "content": "q"}]
|
|
@@ -335,6 +361,102 @@ class TestWithMemWalLangChain:
|
|
|
335
361
|
assert "User loves coffee" in human_msgs[0].content
|
|
336
362
|
assert "BEGIN_UNTRUSTED_WALRUS_MEMORY_" in human_msgs[0].content
|
|
337
363
|
|
|
364
|
+
@respx.mock
|
|
365
|
+
async def test_multi_turn_never_injects_system_message_after_index_0(self) -> None:
|
|
366
|
+
"""In a multi-turn conversation, SystemMessage stays strictly at index 0."""
|
|
367
|
+
_mock_seal_session_prereqs()
|
|
368
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
369
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
370
|
+
|
|
371
|
+
llm = self._make_llm()
|
|
372
|
+
captured: list = []
|
|
373
|
+
|
|
374
|
+
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
375
|
+
captured.extend(messages_batch)
|
|
376
|
+
return ChatResult(
|
|
377
|
+
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
llm._agenerate = _capture_agenerate
|
|
381
|
+
|
|
382
|
+
respx.post(_RECALL_URL).mock(
|
|
383
|
+
return_value=_mock_recall([
|
|
384
|
+
{"blob_id": "b1", "text": "User loves coffee", "distance": 0.05}
|
|
385
|
+
])
|
|
386
|
+
)
|
|
387
|
+
respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
388
|
+
|
|
389
|
+
smart_llm = with_memwal_langchain(
|
|
390
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
391
|
+
)
|
|
392
|
+
messages = [
|
|
393
|
+
SystemMessage(content="You are helpful."),
|
|
394
|
+
HumanMessage(content="I like coffee"),
|
|
395
|
+
AIMessage(content="Noted!"),
|
|
396
|
+
HumanMessage(content="What do I like?"),
|
|
397
|
+
]
|
|
398
|
+
await smart_llm._agenerate([messages])
|
|
399
|
+
|
|
400
|
+
assert len(captured) == 1
|
|
401
|
+
injected = captured[0]
|
|
402
|
+
# SystemMessage strictly at index 0, with guard folded in
|
|
403
|
+
assert isinstance(injected[0], SystemMessage)
|
|
404
|
+
assert "You are helpful." in injected[0].content
|
|
405
|
+
assert "untrusted data" in injected[0].content
|
|
406
|
+
|
|
407
|
+
# No SystemMessage after index 0
|
|
408
|
+
assert not any(isinstance(m, SystemMessage) for m in injected[1:])
|
|
409
|
+
|
|
410
|
+
# The recalled memory is in a HumanMessage before the final query
|
|
411
|
+
assert isinstance(injected[3], HumanMessage)
|
|
412
|
+
assert "User loves coffee" in injected[3].content
|
|
413
|
+
assert isinstance(injected[4], HumanMessage)
|
|
414
|
+
assert injected[4].content == "What do I like?"
|
|
415
|
+
|
|
416
|
+
@respx.mock
|
|
417
|
+
async def test_multimodal_system_message_appends_block_and_preserves_metadata(self) -> None:
|
|
418
|
+
"""A list-content SystemMessage appends a text block and retains its metadata."""
|
|
419
|
+
_mock_seal_session_prereqs()
|
|
420
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
421
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
422
|
+
|
|
423
|
+
llm = self._make_llm()
|
|
424
|
+
captured: list = []
|
|
425
|
+
|
|
426
|
+
async def _capture_agenerate(messages_batch, *a, **kw):
|
|
427
|
+
captured.extend(messages_batch)
|
|
428
|
+
return ChatResult(
|
|
429
|
+
generations=[ChatGeneration(message=AIMessage(content="ok"))]
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
llm._agenerate = _capture_agenerate
|
|
433
|
+
|
|
434
|
+
respx.post(_RECALL_URL).mock(
|
|
435
|
+
return_value=_mock_recall([
|
|
436
|
+
{"blob_id": "b1", "text": "User loves coffee", "distance": 0.05}
|
|
437
|
+
])
|
|
438
|
+
)
|
|
439
|
+
respx.post(_ANALYZE_URL).mock(return_value=_mock_analyze())
|
|
440
|
+
|
|
441
|
+
smart_llm = with_memwal_langchain(
|
|
442
|
+
llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
|
|
443
|
+
)
|
|
444
|
+
blocks = [
|
|
445
|
+
{"type": "text", "text": "Base instructions", "cache_control": {"type": "ephemeral"}}
|
|
446
|
+
]
|
|
447
|
+
sys_msg = SystemMessage(content=blocks, id="sys-123", additional_kwargs={"source": "test"})
|
|
448
|
+
await smart_llm._agenerate([[sys_msg, HumanMessage("What do I like?")]])
|
|
449
|
+
|
|
450
|
+
assert len(captured) == 1
|
|
451
|
+
injected = captured[0]
|
|
452
|
+
assert isinstance(injected[0], SystemMessage)
|
|
453
|
+
assert injected[0].id == "sys-123"
|
|
454
|
+
assert injected[0].additional_kwargs == {"source": "test"}
|
|
455
|
+
assert isinstance(injected[0].content, list)
|
|
456
|
+
assert len(injected[0].content) == 2
|
|
457
|
+
assert injected[0].content[0] == blocks[0]
|
|
458
|
+
assert "untrusted data" in injected[0].content[1]["text"]
|
|
459
|
+
|
|
338
460
|
@respx.mock
|
|
339
461
|
async def test_no_memories_found_no_injection(self) -> None:
|
|
340
462
|
"""When no memories match, the message list is passed unchanged."""
|
|
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
|