Spaces:
Sleeping
Sleeping
yc1838 commited on
Commit ·
6b4f07b
1
Parent(s): 5ef0b76
better persistent memory fix
Browse files- src/lilith_agent/memory.py +43 -14
- tests/test_memory_persistence.py +66 -0
src/lilith_agent/memory.py
CHANGED
|
@@ -13,6 +13,34 @@ log = logging.getLogger(__name__)
|
|
| 13 |
LILITH_HOME = Path(os.getenv("LILITH_HOME", ".lilith"))
|
| 14 |
MEMORY_DB_PATH = LILITH_HOME / "long_term_memory.sqlite"
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
class MemoryStore:
|
| 17 |
def __init__(self, db_path: Path = MEMORY_DB_PATH):
|
| 18 |
self.db_path = db_path
|
|
@@ -95,8 +123,7 @@ def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
|
|
| 95 |
try:
|
| 96 |
# 1. Get existing memories from our local store
|
| 97 |
existing_rows = _store.get_all_memories()
|
| 98 |
-
|
| 99 |
-
existing_memories = [{"content": m["content"]} for m in existing_rows]
|
| 100 |
|
| 101 |
# 2. Initialize langmem manager (it's a Runnable)
|
| 102 |
# We use the default schema which is basically content strings
|
|
@@ -118,13 +145,13 @@ def extract_and_compress_facts(messages: List[BaseMessage], model) -> None:
|
|
| 118 |
# result elements can be ExtractedMemory objects or simple dicts
|
| 119 |
content = getattr(item, "content", None) or (item[1] if isinstance(item, tuple) else item.get("content"))
|
| 120 |
if content:
|
| 121 |
-
updated_facts.append({"content":
|
| 122 |
|
| 123 |
_store.save_memories(updated_facts)
|
| 124 |
log.info(f"[memory] langmem updated store to {len(updated_facts)} facts.")
|
| 125 |
|
| 126 |
-
except Exception
|
| 127 |
-
log.
|
| 128 |
# Fallback to summarize episode if manager fails
|
| 129 |
summarize_episode(messages, model)
|
| 130 |
|
|
@@ -135,15 +162,17 @@ def summarize_episode(messages: List[BaseMessage], model) -> None:
|
|
| 135 |
initial_question = ""
|
| 136 |
outcome = "success"
|
| 137 |
for m in messages:
|
|
|
|
| 138 |
if isinstance(m, HumanMessage) and not initial_question:
|
| 139 |
-
initial_question =
|
| 140 |
-
if "ERROR" in
|
| 141 |
outcome = "failed/struggled"
|
| 142 |
|
| 143 |
conv_parts = []
|
| 144 |
for m in messages:
|
| 145 |
-
|
| 146 |
-
|
|
|
|
| 147 |
conv_str = "\n".join(conv_parts)
|
| 148 |
|
| 149 |
prompt = f"""
|
|
@@ -159,10 +188,10 @@ Briefly explain:
|
|
| 159 |
Keep it under 150 words.
|
| 160 |
"""
|
| 161 |
response = model.invoke(prompt)
|
| 162 |
-
_store.add_episode(initial_question, response.content, outcome)
|
| 163 |
log.info("[memory] Episode saved.")
|
| 164 |
-
except Exception
|
| 165 |
-
log.
|
| 166 |
|
| 167 |
def retrieve_relevant_context(query: str) -> str:
|
| 168 |
"""Fetches all facts and recent episodes to inject into the prompt."""
|
|
@@ -180,6 +209,6 @@ def retrieve_relevant_context(query: str) -> str:
|
|
| 180 |
context_parts.append(f"<past_experiences>\n{epi_lines}\n</past_experiences>")
|
| 181 |
|
| 182 |
return "\n\n".join(context_parts)
|
| 183 |
-
except Exception
|
| 184 |
-
log.
|
| 185 |
return ""
|
|
|
|
| 13 |
LILITH_HOME = Path(os.getenv("LILITH_HOME", ".lilith"))
|
| 14 |
MEMORY_DB_PATH = LILITH_HOME / "long_term_memory.sqlite"
|
| 15 |
|
| 16 |
+
def _content_to_text(content: Any) -> str:
|
| 17 |
+
if content is None:
|
| 18 |
+
return ""
|
| 19 |
+
if isinstance(content, str):
|
| 20 |
+
return content
|
| 21 |
+
if isinstance(content, list):
|
| 22 |
+
parts = []
|
| 23 |
+
for item in content:
|
| 24 |
+
if isinstance(item, str):
|
| 25 |
+
parts.append(item)
|
| 26 |
+
elif isinstance(item, dict):
|
| 27 |
+
text = item.get("text")
|
| 28 |
+
if text is not None:
|
| 29 |
+
parts.append(str(text))
|
| 30 |
+
elif "content" in item:
|
| 31 |
+
nested = _content_to_text(item["content"])
|
| 32 |
+
if nested:
|
| 33 |
+
parts.append(nested)
|
| 34 |
+
else:
|
| 35 |
+
parts.append(str(item))
|
| 36 |
+
return "\n".join(part for part in parts if part)
|
| 37 |
+
return str(content)
|
| 38 |
+
|
| 39 |
+
def _memory_content_to_text(content: Any) -> str:
|
| 40 |
+
if hasattr(content, "content"):
|
| 41 |
+
return _content_to_text(content.content)
|
| 42 |
+
return _content_to_text(content)
|
| 43 |
+
|
| 44 |
class MemoryStore:
|
| 45 |
def __init__(self, db_path: Path = MEMORY_DB_PATH):
|
| 46 |
self.db_path = db_path
|
|
|
|
| 123 |
try:
|
| 124 |
# 1. Get existing memories from our local store
|
| 125 |
existing_rows = _store.get_all_memories()
|
| 126 |
+
existing_memories = [m["content"] for m in existing_rows]
|
|
|
|
| 127 |
|
| 128 |
# 2. Initialize langmem manager (it's a Runnable)
|
| 129 |
# We use the default schema which is basically content strings
|
|
|
|
| 145 |
# result elements can be ExtractedMemory objects or simple dicts
|
| 146 |
content = getattr(item, "content", None) or (item[1] if isinstance(item, tuple) else item.get("content"))
|
| 147 |
if content:
|
| 148 |
+
updated_facts.append({"content": _memory_content_to_text(content)})
|
| 149 |
|
| 150 |
_store.save_memories(updated_facts)
|
| 151 |
log.info(f"[memory] langmem updated store to {len(updated_facts)} facts.")
|
| 152 |
|
| 153 |
+
except Exception:
|
| 154 |
+
log.exception("[memory] langmem extraction failed")
|
| 155 |
# Fallback to summarize episode if manager fails
|
| 156 |
summarize_episode(messages, model)
|
| 157 |
|
|
|
|
| 162 |
initial_question = ""
|
| 163 |
outcome = "success"
|
| 164 |
for m in messages:
|
| 165 |
+
content = _content_to_text(m.content)
|
| 166 |
if isinstance(m, HumanMessage) and not initial_question:
|
| 167 |
+
initial_question = content
|
| 168 |
+
if "ERROR" in content.upper():
|
| 169 |
outcome = "failed/struggled"
|
| 170 |
|
| 171 |
conv_parts = []
|
| 172 |
for m in messages:
|
| 173 |
+
content = _content_to_text(m.content)
|
| 174 |
+
if content:
|
| 175 |
+
conv_parts.append(f"{m.type}: {content[:200]}...")
|
| 176 |
conv_str = "\n".join(conv_parts)
|
| 177 |
|
| 178 |
prompt = f"""
|
|
|
|
| 188 |
Keep it under 150 words.
|
| 189 |
"""
|
| 190 |
response = model.invoke(prompt)
|
| 191 |
+
_store.add_episode(initial_question, _content_to_text(response.content), outcome)
|
| 192 |
log.info("[memory] Episode saved.")
|
| 193 |
+
except Exception:
|
| 194 |
+
log.exception("[memory] Summarization failed")
|
| 195 |
|
| 196 |
def retrieve_relevant_context(query: str) -> str:
|
| 197 |
"""Fetches all facts and recent episodes to inject into the prompt."""
|
|
|
|
| 209 |
context_parts.append(f"<past_experiences>\n{epi_lines}\n</past_experiences>")
|
| 210 |
|
| 211 |
return "\n\n".join(context_parts)
|
| 212 |
+
except Exception:
|
| 213 |
+
log.exception("[memory] Retrieval failed")
|
| 214 |
return ""
|
tests/test_memory_persistence.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
import pytest
|
| 2 |
from pathlib import Path
|
|
|
|
| 3 |
from lilith_agent.config import Config
|
| 4 |
from lilith_agent.app import build_react_agent
|
|
|
|
| 5 |
|
| 6 |
def test_build_react_agent_uses_sqlite_saver(tmp_path):
|
| 7 |
cfg = Config.from_env()
|
|
@@ -18,3 +20,67 @@ def test_build_react_agent_uses_sqlite_saver(tmp_path):
|
|
| 18 |
# Check if DB file was created
|
| 19 |
db_path = tmp_path / ".lilith" / "threads.sqlite"
|
| 20 |
assert db_path.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
from pathlib import Path
|
| 3 |
+
import logging
|
| 4 |
from lilith_agent.config import Config
|
| 5 |
from lilith_agent.app import build_react_agent
|
| 6 |
+
from langchain_core.messages import HumanMessage
|
| 7 |
|
| 8 |
def test_build_react_agent_uses_sqlite_saver(tmp_path):
|
| 9 |
cfg = Config.from_env()
|
|
|
|
| 20 |
# Check if DB file was created
|
| 21 |
db_path = tmp_path / ".lilith" / "threads.sqlite"
|
| 22 |
assert db_path.exists()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_summarize_episode_stores_list_block_content_as_text(tmp_path, monkeypatch):
|
| 26 |
+
from lilith_agent import memory
|
| 27 |
+
|
| 28 |
+
class FakeModel:
|
| 29 |
+
def invoke(self, prompt):
|
| 30 |
+
class Response:
|
| 31 |
+
content = [
|
| 32 |
+
{"type": "text", "text": "Captured lesson"},
|
| 33 |
+
{"type": "non_text", "value": "ignored"},
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
return Response()
|
| 37 |
+
|
| 38 |
+
store = memory.MemoryStore(tmp_path / "long_term_memory.sqlite")
|
| 39 |
+
monkeypatch.setattr(memory, "_store", store)
|
| 40 |
+
|
| 41 |
+
memory.summarize_episode(
|
| 42 |
+
[HumanMessage(content=[{"type": "text", "text": "Remember this"}])],
|
| 43 |
+
FakeModel(),
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
episodes = store.get_recent_episodes()
|
| 47 |
+
assert episodes[0]["task"] == "Remember this"
|
| 48 |
+
assert episodes[0]["summary"] == "Captured lesson"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_summarize_episode_logs_traceback_on_failure(tmp_path, monkeypatch, caplog):
|
| 52 |
+
from lilith_agent import memory
|
| 53 |
+
|
| 54 |
+
class BrokenModel:
|
| 55 |
+
def invoke(self, prompt):
|
| 56 |
+
raise RuntimeError("boom")
|
| 57 |
+
|
| 58 |
+
store = memory.MemoryStore(tmp_path / "long_term_memory.sqlite")
|
| 59 |
+
monkeypatch.setattr(memory, "_store", store)
|
| 60 |
+
|
| 61 |
+
with caplog.at_level(logging.ERROR, logger="lilith_agent.memory"):
|
| 62 |
+
memory.summarize_episode([HumanMessage(content="Remember this")], BrokenModel())
|
| 63 |
+
|
| 64 |
+
record = next(r for r in caplog.records if "Summarization failed" in r.message)
|
| 65 |
+
assert record.exc_info is not None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_extract_and_compress_facts_passes_existing_memories_as_strings(tmp_path, monkeypatch):
|
| 69 |
+
from lilith_agent import memory
|
| 70 |
+
import langmem
|
| 71 |
+
|
| 72 |
+
captured = {}
|
| 73 |
+
|
| 74 |
+
class FakeManager:
|
| 75 |
+
def invoke(self, payload):
|
| 76 |
+
captured["existing"] = payload["existing"]
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
store = memory.MemoryStore(tmp_path / "long_term_memory.sqlite")
|
| 80 |
+
store.save_memories([{"id": "memory-1", "content": "Existing fact"}])
|
| 81 |
+
monkeypatch.setattr(memory, "_store", store)
|
| 82 |
+
monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager())
|
| 83 |
+
|
| 84 |
+
memory.extract_and_compress_facts([HumanMessage(content="New fact")], object())
|
| 85 |
+
|
| 86 |
+
assert captured["existing"] == ["Existing fact"]
|