From 9b8308beeeb0248645ece402c163e44e5cde5c28 Mon Sep 17 00:00:00 2001 From: Dhravya <63950637+Dhravya@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:22:25 +0000 Subject: [PATCH] fix(livekit): recall on text turns that skip the voice hook session.run and generate_reply never call on_user_turn_completed, so a text turn reached the model with no memory. SupermemoryAgent now recalls inside llm_node and skips the fetch when the voice hook already injected. --- apps/docs/integrations/livekit.mdx | 2 +- .../src/supermemory_livekit/agent.py | 4 +++ .../src/supermemory_livekit/memory.py | 36 +++++++++++++++++++ .../livekit-sdk-python/tests/test_memory.py | 14 ++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/docs/integrations/livekit.mdx b/apps/docs/integrations/livekit.mdx index 7b397aa3..f8e8e7ee 100644 --- a/apps/docs/integrations/livekit.mdx +++ b/apps/docs/integrations/livekit.mdx @@ -89,7 +89,7 @@ if __name__ == "__main__": agents.cli.run_app(server) ``` -`preload` puts the caller's profile into the first turn, so the greeting can use it. `attach` stores user and assistant turns when the session closes, including a caller who hangs up mid-turn. `SupermemoryAgent` recalls before each reply and adds the memory tools. +`preload` puts the caller's profile into the first turn, so the greeting can use it. `attach` stores user and assistant turns when the session closes, including a caller who hangs up mid-turn. `SupermemoryAgent` recalls before each reply and adds the memory tools. Voice turns use `on_user_turn_completed`. Text turns from `generate_reply` or `session.run` skip that hook, so the agent also recalls inside `llm_node`. A turn that already has memory is not fetched again. ## Your own agent diff --git a/packages/livekit-sdk-python/src/supermemory_livekit/agent.py b/packages/livekit-sdk-python/src/supermemory_livekit/agent.py index a815088b..68183dc5 100644 --- a/packages/livekit-sdk-python/src/supermemory_livekit/agent.py +++ b/packages/livekit-sdk-python/src/supermemory_livekit/agent.py @@ -23,3 +23,7 @@ class SupermemoryAgent(Agent): async def on_user_turn_completed(self, turn_ctx: Any, new_message: Any) -> None: await self.memory.on_user_turn_completed(turn_ctx, new_message) + + async def llm_node(self, chat_ctx: Any, tools: list[Any], model_settings: Any) -> Any: + await self.memory.enrich(chat_ctx) + return Agent.default.llm_node(self, chat_ctx, tools, model_settings) diff --git a/packages/livekit-sdk-python/src/supermemory_livekit/memory.py b/packages/livekit-sdk-python/src/supermemory_livekit/memory.py index 023cf5ab..0cab7b2d 100644 --- a/packages/livekit-sdk-python/src/supermemory_livekit/memory.py +++ b/packages/livekit-sdk-python/src/supermemory_livekit/memory.py @@ -150,6 +150,29 @@ class SupermemoryLiveKit: except Exception: logger.warning("memory inject failed", exc_info=True) + async def enrich(self, chat_ctx: Any) -> None: + """Recall for a context that already contains the user message. + + ``session.run`` and ``generate_reply`` skip ``on_user_turn_completed``, so the + agent calls this from ``llm_node``. A voice turn that already injected memory + is left alone. + """ + if self._has_injection(chat_ctx): + return + user = self._last_user_item(chat_ctx) + query = message_text(user) if user is not None else None + if not query: + return + try: + text = await self._recall_text(query=query) + if not text: + return + created_at = getattr(user, "created_at", None) + before = created_at - 0.001 if isinstance(created_at, (int, float)) else None + self._inject(chat_ctx, text, created_at=before) + except Exception: + logger.warning("memory inject failed", exc_info=True) + def attach( self, session: Any, @@ -315,6 +338,19 @@ class SupermemoryLiveKit: except TypeError: chat_ctx.add_message(role="assistant", content=wrapped) + def _last_user_item(self, chat_ctx: Any) -> Any: + for item in reversed(list(getattr(chat_ctx, "items", []) or [])): + if message_role(item) == "user" and message_text(item): + return item + return None + + def _has_injection(self, chat_ctx: Any) -> bool: + for item in getattr(chat_ctx, "items", []) or []: + text = message_text(item) + if text and is_injected_memory(text): + return True + return False + def _strip_injected(self, chat_ctx: Any) -> None: items = list(getattr(chat_ctx, "items", []) or []) for item in items: diff --git a/packages/livekit-sdk-python/tests/test_memory.py b/packages/livekit-sdk-python/tests/test_memory.py index f1e0e2c5..f07fef71 100644 --- a/packages/livekit-sdk-python/tests/test_memory.py +++ b/packages/livekit-sdk-python/tests/test_memory.py @@ -174,6 +174,20 @@ class MemoryTests(unittest.TestCase): self.assertIn("Lives in Lisbon", ctx.items[0].content) self.assertNotIn("Name is Ada", ctx.items[0].content) + def test_enrich_injects_when_the_user_message_is_already_present(self): + client = FakeClient(FakeProfile(static=["The secret word is kelp"])) + plugin = memory(client, container_tag="user_1") + ctx = ChatCtx() + ctx.add_message(role="user", content="what is the secret word?", created_at=8.0) + + asyncio.run(plugin.enrich(ctx)) + + self.assertEqual(len(ctx.items), 2) + self.assertIn("kelp", ctx.items[1].content) + self.assertAlmostEqual(ctx.items[1].created_at, 7.999) + asyncio.run(plugin.enrich(ctx)) + self.assertEqual(len(client.profile.calls), 1) + def test_timeout_and_errors_do_not_fail_the_turn(self): slow = FakeClient(FakeProfile(static=["Name is Ada"], delay=0.05)) plugin = memory(slow, container_tag="user_1", params=InputParams(recall_timeout=0.01))