Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions ms_agent/memory/unified/backends/file_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*<long-term-memory>.*?</long-term-memory>', re.DOTALL)
_LTM_BLOCK_RE = re.compile(r'\n*<long-term-memory>.*?</long-term-memory>',
re.DOTALL)

MEMORY_TOOL_DEF = {
'tool_name':
Expand All @@ -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)'),
Expand Down Expand Up @@ -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) ------------------------------

Expand Down
21 changes: 19 additions & 2 deletions ms_agent/memory/unified/storage/facts_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
122 changes: 122 additions & 0 deletions tests/memory/test_facts_hot_reload.py
Original file line number Diff line number Diff line change
@@ -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('<long-term-memory>') == 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'