diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index 11466ba217..6ea61b9fe6 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -270,6 +270,26 @@ class TestMemoryStorePersistence: assert len(store.memory_entries) == 2 +class TestMemoryStoreCharLimitOnLoad: + @pytest.mark.parametrize("filename, target", [("MEMORY.md", "memory"), ("USER.md", "user")]) + def test_over_limit_file_loads_but_warns(self, tmp_path, monkeypatch, caplog, filename, target): + """An externally written over-budget file is kept (no silent data loss) and named in a + warning; an in-budget file loads quietly (#10877).""" + import logging + monkeypatch.setattr("tools.memory_tool.get_memory_dir", lambda: tmp_path) + (tmp_path / filename).write_text("x" * 600, encoding="utf-8") + with caplog.at_level(logging.WARNING): + store = MemoryStore(memory_char_limit=500, user_char_limit=300) + store.load_from_disk() + assert filename in caplog.text and "exceeds" in caplog.text + assert len(store._entries_for(target)) == 1 + caplog.clear() + (tmp_path / filename).write_text("short", encoding="utf-8") + with caplog.at_level(logging.WARNING): + MemoryStore(memory_char_limit=500, user_char_limit=300).load_from_disk() + assert "exceeds" not in caplog.text + + class TestMemoryStoreSnapshot: def test_snapshot_frozen_at_load(self, store): assert store.format_for_system_prompt("memory") is None # empty store diff --git a/tools/memory_tool_store.py b/tools/memory_tool_store.py index 4575944443..ccc3d691e8 100644 --- a/tools/memory_tool_store.py +++ b/tools/memory_tool_store.py @@ -130,6 +130,13 @@ class MemoryStore: # Deduplicate (order-preserving, first occurrence wins). entries = list(dict.fromkeys(self._read_file(path))) self._set_entries(target, entries) + # External writers (MCP bridges, hand edits) can exceed the cap; the limit only fires on + # add/replace, so the oversized block would silently ride in the prompt while every later + # add is refused with no visible cause (#10877). Warn; never truncate a user's memories. + if (count := self._char_count(target)) > (limit := self._char_limit(target)): + logger.warning("%s exceeds its char limit on load: %d/%d chars. Entries stay loaded; " + "further additions are blocked until it is back under the limit.", + path.name, count, limit) self._system_prompt_snapshot[target] = self._render_block(target, [_sanitize(e, path.name) for e in entries]) @staticmethod