diff --git a/ms_agent/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py index 81f7d2fa3..48b435aba 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -31,8 +31,8 @@ #: The memory section this backend appends to the system prompt. Matched so a #: block from an earlier round can be replaced instead of accumulating. -_LTM_BLOCK_RE = re.compile( - r'\n*.*?', re.DOTALL) +_LTM_BLOCK_RE = re.compile(r'\n*.*?', + re.DOTALL) MEMORY_TOOL_DEF = { 'tool_name': @@ -45,14 +45,16 @@ 'type': 'object', 'properties': { 'action': { - 'type': 'string', + 'type': + 'string', 'enum': ['add', 'replace', 'remove'], 'description': ('add = append a new entry, replace = replace an existing ' 'entry, remove = delete an entry'), }, 'content': { - 'type': 'string', + 'type': + 'string', 'description': ('content to add (add), or the existing content to match ' '(replace/remove)'), @@ -282,6 +284,7 @@ def invalidate(self) -> None: self._prompt_snapshot = None self._snapshot_dirty = True self._file_storage.invalidate_cache() + self._facts_storage.invalidate_cache() # -- LLM injection (backend-specific) ------------------------------ diff --git a/ms_agent/memory/unified/storage/facts_storage.py b/ms_agent/memory/unified/storage/facts_storage.py index a67c24a85..df626dc2b 100644 --- a/ms_agent/memory/unified/storage/facts_storage.py +++ b/ms_agent/memory/unified/storage/facts_storage.py @@ -46,6 +46,7 @@ def __init__(self, config: MemoryConfig): self.confidence_threshold = config.confidence_threshold self.security_scan = config.security_scan self._cache: Optional[Dict[str, Any]] = None + self._cache_stat: Optional[tuple] = None # ------------------------------------------------------------------ # MemoryStorage protocol @@ -209,12 +210,21 @@ def _fact_to_entry(f: Dict) -> MemoryEntry: ) def _load(self) -> Dict[str, Any]: - if self._cache is not None: + # Facts are a prompt source too: external edits and deletions must + # invalidate cached data just as they do for MEMORY.md. + try: + st = self.facts_path.stat() + stat_key = (st.st_mtime_ns, st.st_size) + except OSError: + stat_key = None + if (self._cache is not None and stat_key is not None + and self._cache_stat == stat_key): return self._cache - if self.facts_path.exists(): + if stat_key is not None: try: data = json.loads(self.facts_path.read_text(encoding='utf-8')) self._cache = data + self._cache_stat = stat_key return data except (json.JSONDecodeError, OSError) as e: logger.warning( @@ -225,11 +235,18 @@ def _load(self) -> Dict[str, Any]: 'facts': [] } self._cache = default + self._cache_stat = None return default def _save(self, data: Dict[str, Any]) -> None: atomic_write_json(self.facts_path, data) self._cache = data + try: + st = self.facts_path.stat() + self._cache_stat = (st.st_mtime_ns, st.st_size) + except OSError: + self._cache_stat = None def invalidate_cache(self) -> None: self._cache = None + self._cache_stat = None diff --git a/tests/memory/test_facts_hot_reload.py b/tests/memory/test_facts_hot_reload.py new file mode 100644 index 000000000..a0de7ea03 --- /dev/null +++ b/tests/memory/test_facts_hot_reload.py @@ -0,0 +1,122 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Both sources of the file backend's prompt must reflect disk changes.""" +import asyncio +import json +import os + +import pytest + +from ms_agent.memory.unified.backends.file_based import FileBasedBackend +from ms_agent.memory.unified.config import MemoryConfig +from ms_agent.memory.unified.protocols import MemoryEntry +from ms_agent.memory.unified.storage.facts_storage import FactsStorage + + +def _save(storage, content): + asyncio.run(storage.save([MemoryEntry(content=content, confidence=1.0)])) + + +@pytest.mark.parametrize('strategy', ['fts', 'hybrid']) +@pytest.mark.parametrize('change', ['replace', 'clear', 'delete']) +def test_inject_reflects_changed_facts(tmp_path, strategy, change): + config = MemoryConfig(base_dir=str(tmp_path), retrieval_strategy=strategy, + auto_retrieve=False) + writer = FactsStorage(config) + _save(writer, 'Use Python') + backend = FileBasedBackend(config) + messages = [{'role': 'system', 'content': 'System'}, + {'role': 'user', 'content': 'Hello'}] + first = asyncio.run(backend.inject(messages)) + assert 'Use Python' in first[0]['content'] + + if change == 'delete': + writer.facts_path.unlink() + else: + asyncio.run(writer.clear()) + if change == 'replace': + _save(writer, 'Use JavaScript') + + second = asyncio.run(backend.inject(first)) + assert 'Use Python' not in second[0]['content'] + if change == 'replace': + assert 'Use JavaScript' in second[0]['content'] + assert second[0]['content'].count('') == 1 + else: + assert second[0]['content'] == 'System' + + +def test_facts_created_after_initial_missing_read(tmp_path): + config = MemoryConfig(base_dir=str(tmp_path)) + reader = FactsStorage(config) + assert asyncio.run(reader.list_all()) == [] + _save(FactsStorage(config), 'Created later') + assert reader.format_for_prompt() == '- [knowledge] Created later' + + +def test_save_preserves_external_changes(tmp_path): + config = MemoryConfig(base_dir=str(tmp_path)) + first = FactsStorage(config) + _save(first, 'Original') + second = FactsStorage(config) + _save(second, 'External addition') + _save(first, 'Later addition') + persisted = json.loads(first.facts_path.read_text(encoding='utf-8')) + assert {fact['content'] for fact in persisted['facts']} == { + 'Original', 'External addition', 'Later addition'} + + +def test_invalidate_forces_facts_reload_with_same_stat(tmp_path): + config = MemoryConfig(base_dir=str(tmp_path), retrieval_strategy='hybrid', + auto_retrieve=False) + writer = FactsStorage(config) + _save(writer, 'Old fact') + backend = FileBasedBackend(config) + assert 'Old fact' in backend._get_or_build_snapshot() + path = writer.facts_path + stat = path.stat() + path.write_text(path.read_text(encoding='utf-8').replace('Old fact', 'New fact'), + encoding='utf-8') + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + backend.invalidate() + assert 'New fact' in backend._get_or_build_snapshot() + assert 'Old fact' not in backend._get_or_build_snapshot() + + +def test_unchanged_facts_reuse_cache(tmp_path, monkeypatch): + storage = FactsStorage(MemoryConfig(base_dir=str(tmp_path))) + _save(storage, 'Stable fact') + assert 'Stable fact' in storage.format_for_prompt() + + def unexpected_read(*args, **kwargs): + pytest.fail('Unchanged facts should use the cached data') + + monkeypatch.setattr(type(storage.facts_path), 'read_text', unexpected_read) + assert 'Stable fact' in storage.format_for_prompt() + + +@pytest.mark.parametrize('change', ['delete', 'invalid_json']) +def test_facts_recover_after_unavailable_file(tmp_path, change): + config = MemoryConfig(base_dir=str(tmp_path)) + reader = FactsStorage(config) + _save(reader, 'Original fact') + if change == 'delete': + reader.facts_path.unlink() + else: + reader.facts_path.write_text('{', encoding='utf-8') + assert asyncio.run(reader.list_all()) == [] + + writer = FactsStorage(config) + asyncio.run(writer.clear()) + _save(writer, 'Restored fact') + assert reader.format_for_prompt() == '- [knowledge] Restored fact' + + +def test_external_edit_with_same_size_reloads(tmp_path): + storage = FactsStorage(MemoryConfig(base_dir=str(tmp_path))) + _save(storage, 'Old fact') + path = storage.facts_path + stat = path.stat() + path.write_text(path.read_text(encoding='utf-8').replace('Old fact', 'New fact'), + encoding='utf-8') + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + assert storage.format_for_prompt() == '- [knowledge] New fact'