yc1838 commited on
Commit
6b4f07b
·
1 Parent(s): 5ef0b76

better persistent memory fix

Browse files
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
- # langmem manager expects a list of dictionaries or objects matching the schema
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": str(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 as e:
127
- log.error(f"[memory] langmem extraction failed: {e}")
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 = str(m.content)
140
- if "ERROR" in str(m.content).upper():
141
  outcome = "failed/struggled"
142
 
143
  conv_parts = []
144
  for m in messages:
145
- if m.content:
146
- conv_parts.append(f"{m.type}: {str(m.content)[:200]}...")
 
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 as e:
165
- log.error(f"[memory] Summarization failed: {e}")
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 as e:
184
- log.error(f"[memory] Retrieval failed: {e}")
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"]