From a2328e8aceb5c4e1f2061d4bead7ddc66902d9d3 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 13 Jul 2026 01:38:03 +0530 Subject: [PATCH] fix(python-sdks): stop mishandling SDK model objects in memory dedup/format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipecat, cartesia, and agent-framework packages all pass search results from the Supermemory SDK (pydantic models, attribute access, snake_case fields) into dedup/format helpers written for plain dicts. The consequences differed by package but all killed the feature's primary path: - pipecat: deduplicate_memories called r.get() on a Result model -> AttributeError -> the outer handler logs "Error processing frame" and forwards the frame unchanged, so memories are never injected for any user whose profile lookup returns search results - cartesia: identical crash, swallowed by _enrich_event_with_memories' generic except -> memory_context silently comes back empty and the previous memory block is stripped from the system prompt - agent-framework: extract_memory_text only handled dict/str, so model objects fell through to `return None` and every search-result memory was silently dropped from the injected context (The OpenAI package shares the same helper shape but works because it fetches profile data over raw aiohttp and receives dicts — that asymmetry is what hid this.) pipecat/cartesia gain an extract_search_result_fields helper that reads memory/updatedAt off dicts (camelCase keys) or SDK models (snake_case attributes, datetime-tolerant), used by both deduplicate_memories and format_memories_to_text; the formatter also no longer prints raw object reprs for non-dict items. agent-framework's extract_memory_text gains a model-object branch. Verified against the real SDK: built a supermemory.types.search_memories_response.Result from an API-shaped payload and ran it through dedup -> extract -> format (crashed with AttributeError before, renders "- [1 Jan] User prefers async" after). Test suites: pipecat 7 passed, cartesia 7 passed, agent-framework 56 passed (agent-framework-core pinned to 1.0.0rc3 — newer releases have dropped BaseContextProvider, which breaks the package import independently of this change). --- .../src/supermemory_agent_framework/utils.py | 6 ++ .../tests/test_utils.py | 26 ++++++ .../src/supermemory_cartesia/utils.py | 55 +++++++++--- .../tests/test_search_result_objects.py | 87 +++++++++++++++++++ .../src/supermemory_pipecat/utils.py | 55 +++++++++--- .../tests/test_search_result_objects.py | 87 +++++++++++++++++++ 6 files changed, 288 insertions(+), 28 deletions(-) create mode 100644 packages/cartesia-sdk-python/tests/test_search_result_objects.py create mode 100644 packages/pipecat-sdk-python/tests/test_search_result_objects.py diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py index 8b8c9be0..7e871690 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/utils.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/utils.py @@ -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] = [] diff --git a/packages/agent-framework-python/tests/test_utils.py b/packages/agent-framework-python/tests/test_utils.py index 6b9362bb..5803797c 100644 --- a/packages/agent-framework-python/tests/test_utils.py +++ b/packages/agent-framework-python/tests/test_utils.py @@ -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: diff --git a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py index eb366426..75fb15fe 100644 --- a/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py +++ b/packages/cartesia-sdk-python/src/supermemory_cartesia/utils.py @@ -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: diff --git a/packages/cartesia-sdk-python/tests/test_search_result_objects.py b/packages/cartesia-sdk-python/tests/test_search_result_objects.py new file mode 100644 index 00000000..8e093589 --- /dev/null +++ b/packages/cartesia-sdk-python/tests/test_search_result_objects.py @@ -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() diff --git a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py index a27da256..72936c43 100644 --- a/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py +++ b/packages/pipecat-sdk-python/src/supermemory_pipecat/utils.py @@ -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: diff --git a/packages/pipecat-sdk-python/tests/test_search_result_objects.py b/packages/pipecat-sdk-python/tests/test_search_result_objects.py new file mode 100644 index 00000000..f5e11fd2 --- /dev/null +++ b/packages/pipecat-sdk-python/tests/test_search_result_objects.py @@ -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()