This commit is contained in:
Abhay Singh 2026-08-26 09:36:34 -07:00 committed by GitHub
commit 2a1d42a6ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 288 additions and 28 deletions

View file

@ -101,6 +101,12 @@ def deduplicate_memories(
if isinstance(item, str):
trimmed = item.strip()
return trimmed if trimmed else None
# SDK search results are pydantic models, not dicts — read the
# memory field off the object so they aren't silently dropped.
memory = getattr(item, "memory", None)
if isinstance(memory, str):
trimmed = memory.strip()
return trimmed if trimmed else None
return None
static_memories: list[str] = []

View file

@ -1,5 +1,7 @@
"""Tests for utility functions."""
from types import SimpleNamespace
import pytest
from supermemory_agent_framework.utils import (
@ -56,6 +58,30 @@ class TestDeduplicateMemories:
)
assert result.static == ["valid"]
def test_model_object_search_results(self) -> None:
# SDK search results are pydantic models (attribute access, no
# .get()); they used to fall through extract_memory_text and be
# silently dropped.
results = [
SimpleNamespace(memory="User prefers async"),
SimpleNamespace(memory="User prefers async"),
SimpleNamespace(memory=" "),
SimpleNamespace(memory=None),
]
result = deduplicate_memories(search_results=results)
assert result.search_results == ["User prefers async"]
def test_model_objects_deduplicate_against_profile(self) -> None:
result = deduplicate_memories(
static=["User likes Python"],
search_results=[
SimpleNamespace(memory="User likes Python"),
SimpleNamespace(memory="User prefers async"),
],
)
assert result.static == ["User likes Python"]
assert result.search_results == ["User prefers async"]
class TestConvertProfileToMarkdown:
def test_empty_profile(self) -> None:

View file

@ -49,17 +49,45 @@ def format_relative_time(iso_timestamp: str) -> str:
return ""
def extract_search_result_fields(item: Any) -> tuple[str, str]:
"""Extract (memory, updatedAt) from a search result.
The Supermemory SDK returns search results as pydantic models
(attribute access, snake_case fields), while raw JSON payloads use
dicts with camelCase keys support both so results survive
regardless of how they were fetched.
"""
if isinstance(item, dict):
memory = item.get("memory", "")
updated_at = item.get("updatedAt", "")
else:
memory = getattr(item, "memory", None) or ""
updated_at = getattr(item, "updated_at", None)
if updated_at is None:
updated_at = getattr(item, "updatedAt", "")
if not isinstance(memory, str):
memory = ""
if isinstance(updated_at, datetime):
updated_at = updated_at.isoformat()
elif not isinstance(updated_at, str):
updated_at = ""
return memory, updated_at
def deduplicate_memories(
static: List[str],
dynamic: List[str],
search_results: List[Dict[str, Any]],
) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]:
search_results: List[Any],
) -> Dict[str, Union[List[str], List[Any]]]:
"""Deduplicate memories. Priority: static > dynamic > search.
Args:
static: List of static memory strings.
dynamic: List of dynamic memory strings.
search_results: List of search result dicts with 'memory' and 'updatedAt'.
search_results: List of search results ('memory' and 'updatedAt'
as dicts or SDK model objects).
"""
seen = set()
@ -71,10 +99,10 @@ def deduplicate_memories(
out.append(m)
return out
def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def unique_search(results: List[Any]) -> List[Any]:
out = []
for r in results:
memory = r.get("memory", "")
memory, _ = extract_search_result_fields(r)
if memory and memory not in seen:
seen.add(memory)
out.append(r)
@ -116,16 +144,15 @@ def format_memories_to_text(
sections.append("## Relevant Memories")
lines = []
for item in search_results:
if isinstance(item, dict):
memory = item.get("memory", "")
updated_at = item.get("updatedAt", "")
time_str = format_relative_time(updated_at) if updated_at else ""
if time_str:
lines.append(f"- [{time_str}] {memory}")
else:
lines.append(f"- {memory}")
else:
if isinstance(item, str):
lines.append(f"- {item}")
continue
memory, updated_at = extract_search_result_fields(item)
time_str = format_relative_time(updated_at) if updated_at else ""
if time_str:
lines.append(f"- [{time_str}] {memory}")
else:
lines.append(f"- {memory}")
sections.append("\n".join(lines))
if not sections:

View file

@ -0,0 +1,87 @@
"""Regression tests for SDK model-object search results.
The Supermemory SDK returns search results as pydantic models (attribute
access), not dicts. deduplicate_memories used to call r.get() on them,
raising AttributeError and killing memory injection for any user whose
profile lookup returned search results.
"""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from supermemory_cartesia.utils import (
deduplicate_memories,
extract_search_result_fields,
format_memories_to_text,
)
def _model(memory, updated_at=None):
"""Stand-in for an SDK pydantic Result: attribute access, no .get()."""
return SimpleNamespace(memory=memory, updated_at=updated_at)
class TestModelObjectSearchResults(unittest.TestCase):
def test_deduplicates_model_objects_without_crashing(self):
results = [
_model("User likes Python"),
_model("User likes Python"),
_model("User works remotely"),
]
deduped = deduplicate_memories(static=[], dynamic=[], search_results=results)
memories = [
extract_search_result_fields(r)[0] for r in deduped["search_results"]
]
self.assertEqual(memories, ["User likes Python", "User works remotely"])
def test_profile_entries_still_win_over_model_search_results(self):
deduped = deduplicate_memories(
static=["User likes Python"],
dynamic=[],
search_results=[
_model("User likes Python"),
_model("User prefers async"),
],
)
self.assertEqual(deduped["static"], ["User likes Python"])
self.assertEqual(
[extract_search_result_fields(r)[0] for r in deduped["search_results"]],
["User prefers async"],
)
def test_format_renders_memory_text_not_object_repr(self):
deduped = deduplicate_memories(
static=[], dynamic=[], search_results=[_model("User prefers async")]
)
text = format_memories_to_text(deduped)
self.assertIn("- User prefers async", text)
self.assertNotIn("namespace", text)
def test_dict_results_keep_working(self):
deduped = deduplicate_memories(
static=[],
dynamic=[],
search_results=[
{"memory": "From a dict", "updatedAt": "2026-01-01T00:00:00Z"}
],
)
text = format_memories_to_text(deduped)
self.assertIn("From a dict", text)
def test_extract_handles_datetime_updated_at(self):
item = _model("x", updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
memory, updated_at = extract_search_result_fields(item)
self.assertEqual(memory, "x")
self.assertTrue(updated_at.startswith("2026-01-01"))
def test_extract_tolerates_missing_fields(self):
memory, updated_at = extract_search_result_fields(SimpleNamespace())
self.assertEqual(memory, "")
self.assertEqual(updated_at, "")
if __name__ == "__main__":
unittest.main()

View file

@ -49,17 +49,45 @@ def format_relative_time(iso_timestamp: str) -> str:
return ""
def extract_search_result_fields(item: Any) -> tuple[str, str]:
"""Extract (memory, updatedAt) from a search result.
The Supermemory SDK returns search results as pydantic models
(attribute access, snake_case fields), while raw JSON payloads use
dicts with camelCase keys support both so results survive
regardless of how they were fetched.
"""
if isinstance(item, dict):
memory = item.get("memory", "")
updated_at = item.get("updatedAt", "")
else:
memory = getattr(item, "memory", None) or ""
updated_at = getattr(item, "updated_at", None)
if updated_at is None:
updated_at = getattr(item, "updatedAt", "")
if not isinstance(memory, str):
memory = ""
if isinstance(updated_at, datetime):
updated_at = updated_at.isoformat()
elif not isinstance(updated_at, str):
updated_at = ""
return memory, updated_at
def deduplicate_memories(
static: List[str],
dynamic: List[str],
search_results: List[Dict[str, Any]],
) -> Dict[str, Union[List[str], List[Dict[str, Any]]]]:
search_results: List[Any],
) -> Dict[str, Union[List[str], List[Any]]]:
"""Deduplicate memories. Priority: static > dynamic > search.
Args:
static: List of static memory strings.
dynamic: List of dynamic memory strings.
search_results: List of search result dicts with 'memory' and 'updatedAt'.
search_results: List of search results ('memory' and 'updatedAt'
as dicts or SDK model objects).
"""
seen = set()
@ -71,10 +99,10 @@ def deduplicate_memories(
out.append(m)
return out
def unique_search(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def unique_search(results: List[Any]) -> List[Any]:
out = []
for r in results:
memory = r.get("memory", "")
memory, _ = extract_search_result_fields(r)
if memory and memory not in seen:
seen.add(memory)
out.append(r)
@ -116,16 +144,15 @@ def format_memories_to_text(
sections.append("## Relevant Memories")
lines = []
for item in search_results:
if isinstance(item, dict):
memory = item.get("memory", "")
updated_at = item.get("updatedAt", "")
time_str = format_relative_time(updated_at) if updated_at else ""
if time_str:
lines.append(f"- [{time_str}] {memory}")
else:
lines.append(f"- {memory}")
else:
if isinstance(item, str):
lines.append(f"- {item}")
continue
memory, updated_at = extract_search_result_fields(item)
time_str = format_relative_time(updated_at) if updated_at else ""
if time_str:
lines.append(f"- [{time_str}] {memory}")
else:
lines.append(f"- {memory}")
sections.append("\n".join(lines))
if not sections:

View file

@ -0,0 +1,87 @@
"""Regression tests for SDK model-object search results.
The Supermemory SDK returns search results as pydantic models (attribute
access), not dicts. deduplicate_memories used to call r.get() on them,
raising AttributeError and killing memory injection for any user whose
profile lookup returned search results.
"""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from supermemory_pipecat.utils import (
deduplicate_memories,
extract_search_result_fields,
format_memories_to_text,
)
def _model(memory, updated_at=None):
"""Stand-in for an SDK pydantic Result: attribute access, no .get()."""
return SimpleNamespace(memory=memory, updated_at=updated_at)
class TestModelObjectSearchResults(unittest.TestCase):
def test_deduplicates_model_objects_without_crashing(self):
results = [
_model("User likes Python"),
_model("User likes Python"),
_model("User works remotely"),
]
deduped = deduplicate_memories(static=[], dynamic=[], search_results=results)
memories = [
extract_search_result_fields(r)[0] for r in deduped["search_results"]
]
self.assertEqual(memories, ["User likes Python", "User works remotely"])
def test_profile_entries_still_win_over_model_search_results(self):
deduped = deduplicate_memories(
static=["User likes Python"],
dynamic=[],
search_results=[
_model("User likes Python"),
_model("User prefers async"),
],
)
self.assertEqual(deduped["static"], ["User likes Python"])
self.assertEqual(
[extract_search_result_fields(r)[0] for r in deduped["search_results"]],
["User prefers async"],
)
def test_format_renders_memory_text_not_object_repr(self):
deduped = deduplicate_memories(
static=[], dynamic=[], search_results=[_model("User prefers async")]
)
text = format_memories_to_text(deduped)
self.assertIn("- User prefers async", text)
self.assertNotIn("namespace", text)
def test_dict_results_keep_working(self):
deduped = deduplicate_memories(
static=[],
dynamic=[],
search_results=[
{"memory": "From a dict", "updatedAt": "2026-01-01T00:00:00Z"}
],
)
text = format_memories_to_text(deduped)
self.assertIn("From a dict", text)
def test_extract_handles_datetime_updated_at(self):
item = _model("x", updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
memory, updated_at = extract_search_result_fields(item)
self.assertEqual(memory, "x")
self.assertTrue(updated_at.startswith("2026-01-01"))
def test_extract_tolerates_missing_fields(self):
memory, updated_at = extract_search_result_fields(SimpleNamespace())
self.assertEqual(memory, "")
self.assertEqual(updated_at, "")
if __name__ == "__main__":
unittest.main()