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.
Files changed (31) hide show
  1. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/CHANGELOG.md +1 -0
  2. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/PKG-INFO +1 -1
  3. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/__init__.py +1 -1
  4. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/client.py +54 -13
  5. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/middleware.py +50 -15
  6. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/pyproject.toml +1 -1
  7. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_client.py +84 -0
  8. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_middleware.py +129 -7
  9. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/.gitignore +0 -0
  10. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/README.md +0 -0
  11. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/.env.example +0 -0
  12. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/.gitignore +0 -0
  13. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/async_remember_demo.py +0 -0
  14. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/interactive_demo.py +0 -0
  15. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/examples/verify_credentials.py +0 -0
  16. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/compatibility.py +0 -0
  17. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/mock.py +0 -0
  18. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/types.py +0 -0
  19. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/memwal/utils.py +0 -0
  20. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  21. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/run_tests.py +0 -0
  22. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/__init__.py +0 -0
  23. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_auth_rejected_message.py +0 -0
  24. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_blank_text.py +0 -0
  25. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_env_presets.py +0 -0
  26. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_integration.py +0 -0
  27. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_list_namespaces.py +0 -0
  28. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_mock.py +0 -0
  29. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_normalize_server_url.py +0 -0
  30. {memwal-0.1.11.dev5 → memwal-0.1.11.dev7}/tests/test_polling_delay.py +0 -0
  31. {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.dev5
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
@@ -128,4 +128,4 @@ __all__ = [
128
128
  "RecallManualResult",
129
129
  ]
130
130
 
131
- __version__ = "0.1.11.dev5"
131
+ __version__ = "0.1.11.dev7"
@@ -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 _remember_job_timeout(job_id, timeout_ms, saw_rate_limit)
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
- def __init__(self, job_id: str, timeout_ms: int) -> None:
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
- def _remember_job_timeout(
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[last_human_idx:last_human_idx] = [guard_msg, memory_msg]
371
+ result.insert(last_human_idx, memory_msg)
373
372
  else:
374
- result[0:0] = [guard_msg, memory_msg]
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 plus memory content in a user message."""
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(messages) - 1, -1, -1):
631
- if isinstance(messages[i], dict) and messages[i].get("role") == "user":
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
- memory_messages: List[Dict[str, Any]] = [
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
- messages[last_user_idx:last_user_idx] = memory_messages
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
- messages[0:0] = memory_messages
678
+ result.insert(0, {"role": "system", "content": _UNTRUSTED_MEMORY_SYSTEM_INSTRUCTION})
644
679
 
645
- return messages
680
+ return result
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.11.dev5"
7
+ version = "0.1.11.dev7"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 test_inserts_before_last_user_message(self) -> None:
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 | fixed-guard | memory-user | user
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[1]["role"] == "system"
252
- assert "untrusted data" in result[1]["content"]
253
- assert result[2] == {"role": "user", "content": "memory context"}
254
- assert result[3]["role"] == "user"
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
- # last_user_idx == 0, so prepend guard + memory-data messages
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