{"repo":"NousResearch/hermes-agent","public_id":"26045","display_label":"Memory tool replace/remove overwriting external MEMORY.md edits","summary":"These members center on one theme: memory(action=replace/remove) may overwrite changes made to MEMORY.md (and USER.md) outside the tool, such as by patch tools, shell appends, or concurrent sessions. One item reports the data-loss race, and the others appear to be fix proposals aimed at guarding against or preserving external writes.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30877,"merged_at":"2026-05-23T09:51:29+00:00"},"resolution":{"state":"resolved","by":"pr-30877"},"freshness":{"state":"verified","last_verified_at":"2026-05-23T09:51:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":26045,"pr_number":30877,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-23T09:51:29+00:00"}],"members":[{"kind":"issue","number":26045,"title":"memory(action=replace) silently clobbers external writes to MEMORY.md (data-loss race with patch tool / shell appends / concurrent sessions)","url":null,"state":"closed"},{"kind":"pr","number":26059,"title":"fix(memory): preserve external MEMORY.md writes during replace/remove","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6855d177531f9abfbb00d0da1f2db6ff91bdb872","number":0},{"kind":"pr","value":"#30877","number":30877},{"kind":"pr","value":"#26045","number":26045}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26065,"title":"fix(memory): prevent data loss on memory(replace/remove) ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26059","number":26059}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":30877,"title":"fix(memory): guard against external drift in MEMORY.md/USER.md (#26045)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_memory_tool.py","tools/memory_tool.py"],"fix_hunks":[{"file":"tests/tools/test_memory_tool.py","hunks":["@@ -255,3 +255,128 @@ def test_replace_requires_old_text(self, store):\n     def test_remove_requires_old_text(self, store):\n         result = json.loads(memory_tool(action=\"remove\", store=store))\n         assert result[\"success\"] is False\n+\n+\n+# =========================================================================\n+# External drift guard (#26045)\n+#\n+# An external writer — patch tool, shell append, manual edit, or sister\n+# session — can grow MEMORY.md beyond the tool's mental model: no §\n+# delimiters, content that would all collapse into a single \"entry\" larger\n+# than the char limit. Pre-fix, the next memory(action=replace) from a\n+# session with stale in-memory state truncated that giant entry, silently\n+# discarding the appended bytes. Reproduced in production on 2026-05-14 —\n+# ~8KB of structured vendor / standing-orders / pinboard content destroyed\n+# by a sister session's replace.\n+# =========================================================================\n+\n+\n+class TestExternalDriftGuard:\n+    \"\"\"Mutations must refuse to flush when on-disk content shows external drift.\"\"\"\n+\n+    def _plant_drift(self, store, target=\"memory\"):\n+        \"\"\"Append free-form content (no § delimiters) past char_limit.\"\"\"\n+        path = store._path_for(target)\n+        path.parent.mkdir(parents=True, exist_ok=True)\n+        # 800 chars per entry × 3 sections == ~2.4KB without delimiters,\n+        # well over the test fixture's 500-char limit.\n+        block = \"\\n\\n## Vendor Master\\n\" + \"x\" * 800\n+        block += \"\\n\\n## Standing Orders\\n\" + \"y\" * 800\n+        block += \"\\n\\n## Pin Board\\n\" + \"z\" * 800\n+        existing = path.read_text(encoding=\"utf-8\") if path.exists() else \"\"\n+        path.write_text(existing + block, encoding=\"utf-8\")\n+        return path\n+\n+    def test_replace_refuses_on_drift(self, store):\n+        store.add(\"memory\", \"User likes brevity.\")\n+        path = self._plant_drift(store)\n+        original_size = path.stat().st_size\n+\n+        result = store.replace(\"memory\", \"User likes\", \"User prefers concise.\")\n+\n+        assert result[\"success\"] is False\n+        assert \"drift_backup\" in result\n+        # On-disk file is UNTOUCHED — that's the point.\n+        assert path.stat().st_size == original_size\n+        assert \"Vendor Master\" in path.read_text()\n+        # Backup exists with the drifted content.\n+        bak = result[\"drift_backup\"]\n+        assert Path(bak).exists()\n+        assert \"Vendor Master\" in Path(bak).read_text()\n+\n+    def test_add_refuses_on_drift(self, store):\n+        store.add(\"memory\", \"Existing.\")\n+        path = self._plant_drift(store)\n+        original = path.read_text()\n+\n+        result = store.add(\"memory\", \"New entry under drift.\")\n+\n+        assert result[\"success\"] is False\n+        assert \"drift_backup\" in result\n+        assert path.read_text() == original  # untouched\n+\n+    def test_remove_refuses_on_drift(self, store):\n+        store.add(\"memory\", \"Target entry to remove.\")\n+        path = self._plant_drift(store)\n+        original = path.read_text()\n+\n+        result = store.remove(\"memory\", \"Target entry\")\n+\n+        assert result[\"success\"] is False\n+        assert \"drift_backup\" in result\n+        assert path.read_text() == original  # untouched\n+\n+    def test_clean_file_does_not_trigger_drift(self, store):\n+        \"\"\"A normally-written file (just below char_limit, §-delimited) is fine.\"\"\"\n+        # Two tool-shaped entries totaling under the 500-char limit.\n+        store.add(\"memory\", \"Entry one — normal length.\")\n+        store.add(\"memory\", \"Entry two — also normal.\")\n+\n+        result = store.add(\"memory\", \"Entry three.\")\n+        assert result[\"success\"] is True\n+        assert \"drift_backup\" not in result\n+\n+        result = store.replace(\"memory\", \"Entry two\", \"Entry two replaced.\")\n+        assert result[\"success\"] is True\n+\n+    def test_error_message_points_at_remediation(self, store):\n+        \"\"\"The error string must reference the backup AND remediation steps.\"\"\"\n+        store.add(\"memory\", \"Initial.\")\n+        self._plant_drift(store)\n+\n+        result = store.replace(\"memory\", \"Initial\", \"Replacement.\")\n+        assert result[\"success\"] is False\n+        # The model has to know what file to look at and what to do.\n+        assert \".bak.\" in result[\"error\"]\n+        assert \"remediation\" in result\n+        assert \"26045\" in result[\"error\"]  # tracking-issue back-reference\n+\n+    def test_drift_guard_also_protects_user_target(self, store):\n+        \"\"\"USER.md gets the same guarantee as MEMORY.md.\"\"\"\n+        store.add(\"user\", \"Some preference.\")\n+        path = self._plant_drift(store, target=\"user\")\n+        original_size = path.stat().st_size\n+\n+        result = store.replace(\"user\", \"Some preference\", \"New preference.\")\n+        assert result[\"success\"] is False\n+        assert path.stat().st_size == original_size\n+\n+    def test_drift_backup_filename_is_unique_per_invocation(self, store):\n+        \"\"\"Two drift refusals close together must not collide on bak.<ts>.\n+\n+        If two refusals share the same epoch second, the second call would\n+        overwrite the first .bak. The current implementation accepts that\n+        — both files describe the same on-disk state — but pin the path\n+        format here so any future change has to think about it.\n+        \"\"\"\n+        store.add(\"memory\", \"Initial.\")\n+        self._plant_drift(store)\n+\n+        r1 = store.replace(\"memory\", \"Initial\", \"Replacement.\")\n+        r2 = store.add(\"memory\", \"Another.\")\n+        assert r1.get(\"drift_backup\")\n+        assert r2.get(\"drift_backup\")\n+        # Same epoch second is the expected collision case — both point\n+        # at the same snapshot. Different second is also fine.\n+        assert \".bak.\" in r1[\"drift_backup\"]\n+        assert \".bak.\" in r2[\"drift_backup\"]"]},{"file":"tools/memory_tool.py","hunks":["@@ -28,6 +28,7 @@\n import os\n import re\n import tempfile\n+import time\n from contextlib import contextmanager\n from pathlib import Path\n from hermes_constants import get_hermes_home","@@ -104,6 +105,36 @@ def _scan_memory_content(content: str) -> Optional[str]:\n     return None\n \n \n+def _drift_error(path: \"Path\", bak_path: str) -> Dict[str, Any]:\n+    \"\"\"Build the error dict returned when external drift is detected.\n+\n+    The on-disk memory file contains content that wouldn't round-trip\n+    through the tool's parser/serializer — flushing would discard the\n+    appended/edited content from a patch tool, shell append, manual edit,\n+    or sister-session write. We refuse the mutation, point the operator at\n+    the .bak.<ts> snapshot we took, and tell them what to do next.\n+    \"\"\"\n+    return {\n+        \"success\": False,\n+        \"error\": (\n+            f\"Refusing to write {path.name}: file on disk has content that \"\n+            f\"wouldn't round-trip through the memory tool (likely added by \"\n+            f\"the patch tool, a shell append, a manual edit, or a \"\n+            f\"concurrent session). A snapshot was saved to {bak_path}. \"\n+            f\"Resolve the drift first — either rewrite the file as a clean \"\n+            f\"§-delimited list of entries, or move the extra content out — \"\n+            f\"then retry. This guard exists to prevent silent data loss \"\n+            f\"(issue #26045).\"\n+        ),\n+        \"drift_backup\": bak_path,\n+        \"remediation\": (\n+            \"Open the .bak file, integrate the missing entries into the \"\n+            \"memory tool one at a time via memory(action=add, content=...), \"\n+            \"then remove or rewrite the original file to a clean state.\"\n+        ),\n+    }\n+\n+\n class MemoryStore:\n     \"\"\"\n     Bounded curated memory with file persistence. One instance per AIAgent.","@@ -185,14 +216,23 @@ def _path_for(target: str) -> Path:\n             return mem_dir / \"USER.md\"\n         return mem_dir / \"MEMORY.md\"\n \n-    def _reload_target(self, target: str):\n+    def _reload_target(self, target: str) -> Optional[str]:\n         \"\"\"Re-read entries from disk into in-memory state.\n \n         Called under file lock to get the latest state before mutating.\n+        Returns the backup path if external drift was detected (the on-disk\n+        file contains content that wouldn't round-trip through our\n+        parser/serializer, OR an entry larger than the store's char limit).\n+        When drift is detected the caller must abort the mutation —\n+        flushing would discard the un-roundtrippable content.\n+        Returns None on clean reload.\n         \"\"\"\n-        fresh = self._read_file(self._path_for(target))\n+        path = self._path_for(target)\n+        bak = self._detect_external_drift(target)\n+        fresh = self._read_file(path)\n         fresh = list(dict.fromkeys(fresh))  # deduplicate\n         self._set_entries(target, fresh)\n+        return bak\n \n     def save_to_disk(self, target: str):\n         \"\"\"Persist entries to the appropriate file. Called after every mutation.\"\"\"","@@ -233,8 +273,13 @@ def add(self, target: str, content: str) -> Dict[str, Any]:\n             return {\"success\": False, \"error\": scan_error}\n \n         with self._file_lock(self._path_for(target)):\n-            # Re-read from disk under lock to pick up writes from other sessions\n-            self._reload_target(target)\n+            # Re-read from disk under lock to pick up writes from other sessions.\n+            # If external drift was detected, the file was backed up to .bak.<ts>\n+            # — refuse the mutation so we don't clobber the un-roundtrippable\n+            # content the patch tool / shell append / sister session wrote.\n+            bak = self._reload_target(target)\n+            if bak:\n+                return _drift_error(self._path_for(target), bak)\n \n             entries = self._entries_for(target)\n             limit = self._char_limit(target)","@@ -281,7 +326,9 @@ def replace(self, target: str, old_text: str, new_content: str) -> Dict[str, Any\n             return {\"success\": False, \"error\": scan_error}\n \n         with self._file_lock(self._path_for(target)):\n-            self._reload_target(target)\n+            bak = self._reload_target(target)\n+            if bak:\n+                return _drift_error(self._path_for(target), bak)\n \n             entries = self._entries_for(target)\n             matches = [(i, e) for i, e in enumerate(entries) if old_text in e]","@@ -331,7 +378,9 @@ def remove(self, target: str, old_text: str) -> Dict[str, Any]:\n             return {\"success\": False, \"error\": \"old_text cannot be empty.\"}\n \n         with self._file_lock(self._path_for(target)):\n-            self._reload_target(target)\n+            bak = self._reload_target(target)\n+            if bak:\n+                return _drift_error(self._path_for(target), bak)\n \n             entries = self._entries_for(target)\n             matches = [(i, e) for i, e in enumerate(entries) if old_text in e]","@@ -430,6 +479,61 @@ def _read_file(path: Path) -> List[str]:\n         entries = [e.strip() for e in raw.split(ENTRY_DELIMITER)]\n         return [e for e in entries if e]\n \n+    def _detect_external_drift(self, target: str) -> Optional[str]:\n+        \"\"\"Return a backup-path string if on-disk content shows external drift.\n+\n+        The memory file is supposed to be a list of small entries the tool\n+        wrote, joined by §. Detect drift via two signals:\n+\n+        1. Round-trip mismatch — re-parsing and re-serializing the file\n+           doesn't produce identical bytes (rare; would catch oddly-encoded\n+           delimiters).\n+        2. Entry-size overflow — any single parsed entry exceeds the\n+           store's whole-file char limit. The tool budgets the ENTIRE store\n+           against that limit; no single tool-written entry can exceed it.\n+           When we see one entry larger than the limit, an external writer\n+           (patch tool, shell append, manual edit, sister session) appended\n+           free-form content into what the tool will treat as one entry.\n+           Flushing would then truncate that entry to the model's new\n+           content, discarding the appended bytes — issue #26045.\n+\n+        Returns the absolute path of the .bak file when drift was found and\n+        backed up; returns None when the file looks tool-shaped.\n+\n+        Note: this is an INSTANCE method (not static) because we need the\n+        per-target char_limit for signal #2.\n+        \"\"\"\n+        path = self._path_for(target)\n+        if not path.exists():\n+            return None\n+        try:\n+            raw = path.read_text(encoding=\"utf-8\")\n+        except (OSError, IOError):\n+            return None\n+        if not raw.strip():\n+            return None\n+\n+        parsed = [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]\n+        roundtrip = ENTRY_DELIMITER.join(parsed)\n+\n+        char_limit = self._char_limit(target)\n+        max_entry_len = max((len(e) for e in parsed), default=0)\n+\n+        drift_detected = (raw.strip() != roundtrip) or (max_entry_len > char_limit)\n+        if not drift_detected:\n+            return None\n+\n+        # Drift confirmed — snapshot the file so the operator can recover\n+        # whatever the external writer added, then return the .bak path so\n+        # the caller can refuse the mutation.\n+        ts = int(time.time())\n+        bak_path = path.with_suffix(path.suffix + f\".bak.{ts}\")\n+        try:\n+            bak_path.write_text(raw, encoding=\"utf-8\")\n+        except (OSError, IOError):\n+            return str(bak_path) + \" (BACKUP FAILED — file unchanged on disk)\"\n+        return str(bak_path)\n+\n     @staticmethod\n     def _write_file(path: Path, entries: List[str]):\n         \"\"\"Write entries to a memory file using atomic temp-file + rename.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}