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.
This commit is contained in:
Dhravya 2026-09-25 21:22:25 +00:00
parent 2d3c706a3e
commit 9b8308beee
4 changed files with 55 additions and 1 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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))