From c2bc93588f10f18c5611f8b68f0754d2d9c7a9fa Mon Sep 17 00:00:00 2001 From: lll Date: Sat, 12 Sep 2026 09:55:22 -0400 Subject: [PATCH] fix(memory): exact whole-entry match beats substring match in replace/remove/batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A short entry whose full text is contained inside a longer sibling entry was unaddressable: remove('test') against entries ['test', '...tests pass...'] hit _find_unique_match's substring scan, reported 'Multiple entries matched', and refused — the entry could never be targeted. apply_batch hit the same matcher and aborted the whole operation. _find_unique_match() now prefers whole-entry EXACT matches (old_text == entry) and only falls back to substring matches when no entry equals old_text. Both call sites (_edit for single replace/remove, _apply_batch_op for batch) share the matcher, so the whole bug class is fixed at one seam. Substring partial matching is unchanged. Reproduced red on current main (2 invariant tests), 53/53 green after the fix; 8 sibling memory suites green via scripts/run_tests.sh. --- tests/tools/test_memory_tool.py | 22 ++++++++++++++++++++++ tools/memory_tool_store.py | 9 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index c32cdcc13a..7207002cb6 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -225,6 +225,28 @@ class TestMemoryStoreRemove: assert store.remove("memory", " ")["success"] is False +class TestExactWholeEntryMatchPriority: + """A short entry whose full text is contained inside a longer sibling entry + must stay addressable: old_text that equals an entry wins outright, and + substring matches only apply when no entry equals old_text. Without this, + remove('test') against entries ['test', '...tests pass...'] reported + ambiguity and the entry could never be addressed.""" + + def test_remove_exact_entry_beats_substring_collision(self, store): + store.add("memory", "test") + store.add("memory", "echo-reply tests pass via local twins and are false positives") + result = store.remove("memory", "test") + assert result["success"] is True + assert store.memory_entries == ["echo-reply tests pass via local twins and are false positives"] + + def test_batch_remove_exact_entry_beats_substring_collision(self, store): + store.add("memory", "test") + store.add("memory", "echo-reply tests pass via local twins and are false positives") + result = store.apply_batch("memory", [{"action": "remove", "old_text": "test"}]) + assert result["success"] is True + assert store.memory_entries == ["echo-reply tests pass via local twins and are false positives"] + + class TestMemoryConsolidationGracefulDegrade: """Fix #3 for #42405: a failed at-capacity consolidation must never loop the turn to budget exhaustion — after a per-turn cap of failures, memory ops diff --git a/tools/memory_tool_store.py b/tools/memory_tool_store.py index 6f998e620e..36f8026c4e 100644 --- a/tools/memory_tool_store.py +++ b/tools/memory_tool_store.py @@ -56,9 +56,14 @@ def _read_failed_error(path: Path) -> Dict[str, Any]: def _find_unique_match(entries: List[str], old_text: str) -> Tuple[Optional[int], bool]: - """``(index, ambiguous)`` for entries containing *old_text*. Exact-duplicate + """``(index, ambiguous)`` for entries matching *old_text*. A whole-entry + EXACT match (``old_text == entry``) takes absolute priority — substring + matches are only considered when no entry equals *old_text*, so a short + entry stays addressable even when its full text is contained inside a + longer sibling entry (remove('test') vs '...tests pass...'). Exact-duplicate matches are safe (first wins); distinct matches → ``(None, True)``.""" - matches = [i for i, e in enumerate(entries) if old_text in e] + exact = [i for i, e in enumerate(entries) if e == old_text] + matches = exact if exact else [i for i, e in enumerate(entries) if old_text in e] if len({entries[i] for i in matches}) > 1: return None, True return (matches[0] if matches else None), False