{"repo":"NousResearch/hermes-agent","public_id":"31000","display_label":"OpenViking plugin memory write bug fixes","summary":"Multiple issues and fixes addressing silent failures and incorrect behavior in the OpenViking plugin's on_memory_write handler, covering action handling, error visibility, and memory management gating.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50833},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-22T14:00:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":12791,"pr_number":50833,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":31000,"pr_number":50833,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":31000,"title":"OpenViking plugin: on_memory_write mirroring silently fails — 3 bugs","url":null,"state":"closed"},{"kind":"pr","number":31006,"title":"fix(openviking): on_memory_write handles replace/remove + visible errors (#31000)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50833","number":50833}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31030,"title":"fix(openviking): on_memory_write handles all actions + visible errors","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33273,"title":"fix(openviking): on_memory_write handles all actions + visible errors","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31030","number":31030},{"kind":"pr","value":"#31006","number":31006},{"kind":"pr","value":"#31000","number":31000}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50833,"title":"fix(openviking): gate memory writes behind MemoryManager + add viking_forget","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/memory_manager.py","agent/tool_executor.py","plugins/memory/openviking/README.md","plugins/memory/openviking/__init__.py","tests/agent/test_memory_provider.py","tests/agent/test_memory_write_bridge.py","tests/plugins/memory/test_openviking_provider.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1838,32 +1838,18 @@ def _execute(next_args: dict) -> Any:\n                 operations=operations,\n                 store=agent._memory_store,\n             )\n-            # Bridge: notify external memory provider of built-in memory writes.\n-            # Covers both the single-op shape and each add/replace inside a batch.\n+            # Mirror successful built-in memory writes to external providers.\n+            # All gating/op-expansion lives behind the manager interface\n+            # (MemoryManager.notify_memory_tool_write).\n             if agent._memory_manager:\n-                if operations:\n-                    _mem_ops = [\n-                        op for op in operations\n-                        if isinstance(op, dict) and op.get(\"action\") in {\"add\", \"replace\"}\n-                    ]\n-                else:\n-                    _mem_ops = (\n-                        [{\"action\": next_args.get(\"action\"), \"content\": next_args.get(\"content\")}]\n-                        if next_args.get(\"action\") in {\"add\", \"replace\"} else []\n-                    )\n-                for _op in _mem_ops:\n-                    try:\n-                        agent._memory_manager.on_memory_write(\n-                            _op.get(\"action\", \"\"),\n-                            target,\n-                            _op.get(\"content\", \"\") or \"\",\n-                            metadata=agent._build_memory_write_metadata(\n-                                task_id=effective_task_id,\n-                                tool_call_id=tool_call_id,\n-                            ),\n-                        )\n-                    except Exception:\n-                        pass\n+                agent._memory_manager.notify_memory_tool_write(\n+                    result,\n+                    next_args,\n+                    build_metadata=lambda: agent._build_memory_write_metadata(\n+                        task_id=effective_task_id,\n+                        tool_call_id=tool_call_id,\n+                    ),\n+                )\n             return _finish_agent_tool(result, next_args)\n     elif agent._memory_manager and agent._memory_manager.has_tool(function_name):\n         def _execute(next_args: dict) -> Any:"]},{"file":"agent/memory_manager.py","hunks":["@@ -25,12 +25,13 @@\n \n from __future__ import annotations\n \n+import json\n import logging\n import re\n import inspect\n import threading\n from concurrent.futures import ThreadPoolExecutor\n-from typing import Any, Dict, List, Optional\n+from typing import Any, Callable, Dict, List, Optional\n \n from agent.memory_provider import MemoryProvider\n from agent.skill_commands import extract_user_instruction_from_skill_message","@@ -850,6 +851,87 @@ def on_memory_write(\n                     provider.name, e,\n                 )\n \n+    # Actions the bridge mirrors to external providers. The built-in memory\n+    # tool can also return non-mutating shapes (errors, staged-for-approval\n+    # records); those are filtered out by ``notify_memory_tool_write`` before\n+    # we ever reach a provider.\n+    _MIRRORED_MEMORY_ACTIONS = {\"add\", \"replace\", \"remove\"}\n+\n+    @staticmethod\n+    def _memory_tool_result_succeeded(result: Any) -> bool:\n+        \"\"\"True only when the built-in memory tool actually committed a write.\n+\n+        Fails closed: a string that isn't JSON, a non-dict result, a missing\n+        ``success``, or a write staged for approval (``staged is True``) all\n+        return False so external providers are never told about a write that\n+        did not land.\n+        \"\"\"\n+        if isinstance(result, str):\n+            try:\n+                result = json.loads(result)\n+            except Exception:\n+                return False\n+        if not isinstance(result, dict):\n+            return False\n+        return result.get(\"success\") is True and result.get(\"staged\") is not True\n+\n+    def notify_memory_tool_write(\n+        self,\n+        tool_result: Any,\n+        tool_args: Dict[str, Any],\n+        *,\n+        build_metadata: Optional[Callable[[], Dict[str, Any]]] = None,\n+    ) -> None:\n+        \"\"\"Mirror a built-in memory tool call to external providers.\n+\n+        This is the single entry point the agent loop calls after running the\n+        built-in ``memory`` tool. All the decisions about *whether* and *what*\n+        to mirror live here, behind the manager interface — the loop only hands\n+        over the raw tool result and args:\n+\n+        * gate on a committed (non-staged, successful) write,\n+        * expand the single-op and batched (``operations``) shapes,\n+        * keep only mutating actions (add/replace/remove),\n+        * build per-op provenance metadata and forward ``old_text``.\n+\n+        ``build_metadata`` is an optional agent-side callable (the loop knows\n+        session/task/tool-call provenance the manager does not) invoked once per\n+        mirrored op.\n+        \"\"\"\n+        if not self._memory_tool_result_succeeded(tool_result):\n+            return\n+\n+        target = str(tool_args.get(\"target\") or \"memory\")\n+        operations = tool_args.get(\"operations\")\n+        if isinstance(operations, list) and operations:\n+            raw_operations = operations\n+        else:\n+            raw_operations = [{\n+                \"action\": tool_args.get(\"action\"),\n+                \"content\": tool_args.get(\"content\"),\n+                \"old_text\": tool_args.get(\"old_text\"),\n+            }]\n+\n+        for op in raw_operations:\n+            if not isinstance(op, dict):\n+                continue\n+            action = str(op.get(\"action\") or \"\")\n+            if action not in self._MIRRORED_MEMORY_ACTIONS:\n+                continue\n+            try:\n+                metadata = dict(build_metadata() if build_metadata else {})\n+                old_text = op.get(\"old_text\")\n+                if old_text:\n+                    metadata[\"old_text\"] = str(old_text)\n+                self.on_memory_write(\n+                    action,\n+                    target,\n+                    str(op.get(\"content\") or \"\"),\n+                    metadata=metadata,\n+                )\n+            except Exception as e:\n+                logger.debug(\"notify_memory_tool_write failed for op %s: %s\", action, e)\n+\n     def on_delegation(self, task: str, result: str, *,\n                       child_session_id: str = \"\", **kwargs) -> None:\n         \"\"\"Notify all providers that a subagent completed.\"\"\""]},{"file":"agent/tool_executor.py","hunks":["@@ -1046,32 +1046,18 @@ def _execute(next_args: dict) -> Any:\n                     operations=operations,\n                     store=agent._memory_store,\n                 )\n-                # Bridge: notify external memory provider of built-in memory writes.\n-                # Covers both the single-op shape and each add/replace inside a batch.\n+                # Mirror successful built-in memory writes to external\n+                # providers. All gating/op-expansion lives behind the manager\n+                # interface (MemoryManager.notify_memory_tool_write).\n                 if agent._memory_manager:\n-                    if operations:\n-                        _mem_ops = [\n-                            op for op in operations\n-                            if isinstance(op, dict) and op.get(\"action\") in {\"add\", \"replace\"}\n-                        ]\n-                    else:\n-                        _mem_ops = (\n-                            [{\"action\": next_args.get(\"action\"), \"content\": next_args.get(\"content\")}]\n-                            if next_args.get(\"action\") in {\"add\", \"replace\"} else []\n-                        )\n-                    for _op in _mem_ops:\n-                        try:\n-                            agent._memory_manager.on_memory_write(\n-                                _op.get(\"action\", \"\"),\n-                                target,\n-                                _op.get(\"content\", \"\") or \"\",\n-                                metadata=agent._build_memory_write_metadata(\n-                                    task_id=effective_task_id,\n-                                    tool_call_id=getattr(tool_call, \"id\", None),\n-                                ),\n-                            )\n-                        except Exception:\n-                            pass\n+                    agent._memory_manager.notify_memory_tool_write(\n+                        result,\n+                        next_args,\n+                        build_metadata=lambda: agent._build_memory_write_metadata(\n+                            task_id=effective_task_id,\n+                            tool_call_id=getattr(tool_call, \"id\", None),\n+                        ),\n+                    )\n                 return result\n             function_result, function_args = _run_agent_tool_execution_middleware(\n                 agent,"]},{"file":"plugins/memory/openviking/README.md","hunks":["@@ -47,5 +47,37 @@ Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers.\n | `viking_search` | Semantic search with fast/deep/auto modes |\n | `viking_read` | Read content at a viking:// URI (abstract/overview/full) |\n | `viking_browse` | Filesystem-style navigation (list/tree/stat) |\n-| `viking_remember` | Store a fact for extraction on session commit |\n+| `viking_remember` | Store a fact directly with OpenViking `content/write` |\n+| `viking_forget` | Delete one exact `viking://` memory file URI |\n | `viking_add_resource` | Ingest URLs/docs into the knowledge base |\n+\n+## Memory Writes And Deletes\n+\n+`viking_remember` writes directly to OpenViking with `POST /api/v1/content/write`\n+and `mode=create`. It creates peer-scoped memory files under\n+`viking://user/peers/${OPENVIKING_AGENT}/memories/...`; OpenViking may return a\n+canonical user-scoped form such as\n+`viking://user/default/peers/${OPENVIKING_AGENT}/memories/...` in API-key mode.\n+Explicit remembers do not depend on session commit extraction.\n+\n+Hermes built-in `memory` tool additions are mirrored to OpenViking after the\n+local memory operation succeeds:\n+\n+| Hermes action | OpenViking operation |\n+|---------------|----------------------|\n+| `add` | `content/write` with `mode=create` under the configured peer memory namespace |\n+\n+Built-in `replace` and `remove` operations are not mirrored because Hermes\n+native memory entries do not yet carry stable OpenViking file URIs. Use\n+`viking_forget` when the user explicitly asks to delete a specific OpenViking\n+memory URI.\n+\n+`viking_forget` is intentionally narrow. It only accepts concrete user memory\n+file URIs, such as\n+`viking://user/peers/hermes/memories/preferences/mem_abc123.md` or the canonical\n+`viking://user/default/peers/hermes/memories/preferences/mem_abc123.md`. Files\n+directly under `memories/`, such as `viking://user/default/memories/profile.md`,\n+are also allowed because OpenViking supports them. The tool rejects directories,\n+resources, skills, sessions, generated summary files, and URIs with query\n+strings or fragments. Use OpenViking's MCP, CLI, or admin APIs for broader\n+resource and directory cleanup."]},{"file":"plugins/memory/openviking/__init__.py","hunks":["@@ -91,6 +91,12 @@\n     \"user\": \"preferences\",\n     \"memory\": \"patterns\",\n }\n+# OpenViking-generated markdown summaries. Non-.md sidecars such as\n+# .relations.json are rejected earlier by the exact memory-file check.\n+_GENERATED_MEMORY_SUMMARY_FILENAMES = {\n+    \".abstract.md\",\n+    \".overview.md\",\n+}\n _LOCAL_OPENVIKING_HOSTS = {\"localhost\", \"127.0.0.1\", \"::1\"}\n _LOCAL_OPENVIKING_AUTOSTART_TIMEOUT = 60.0\n _OPENVIKING_SERVER_LOG_RELATIVE_PATH = Path(\"logs\") / \"openviking-server.log\"","@@ -320,6 +326,13 @@ def post(self, path: str, payload: dict = None, **kwargs) -> dict:\n             )\n         )\n \n+    def delete(self, path: str, **kwargs) -> dict:\n+        return self._send_with_trusted_identity_retry(\n+            lambda headers: self._httpx.delete(\n+                self._url(path), headers=headers, timeout=_TIMEOUT, **kwargs\n+            )\n+        )\n+\n     def upload_temp_file(self, file_path: Path) -> str:\n         mime_type = mimetypes.guess_type(file_path.name)[0] or \"application/octet-stream\"\n ","@@ -460,6 +473,26 @@ def validate_root_access(self) -> dict:\n     },\n }\n \n+FORGET_SCHEMA = {\n+    \"name\": \"viking_forget\",\n+    \"description\": (\n+        \"Delete one OpenViking memory file by exact viking:// URI. \"\n+        \"Use only when the user explicitly asks to forget or delete a specific \"\n+        \"memory and you have the exact memory file URI. Resources, skills, \"\n+        \"sessions, directories, generated summaries, and broad deletes are rejected.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"uri\": {\n+                \"type\": \"string\",\n+                \"description\": \"Exact viking:// memory file URI ending in .md.\",\n+            },\n+        },\n+        \"required\": [\"uri\"],\n+    },\n+}\n+\n ADD_RESOURCE_SCHEMA = {\n     \"name\": \"viking_add_resource\",\n     \"description\": (","@@ -552,6 +585,46 @@ def _is_remote_resource_source(value: str) -> bool:\n     return value.startswith(_REMOTE_RESOURCE_PREFIXES)\n \n \n+def _memory_segment_index(parts: List[str]) -> Optional[int]:\n+    if len(parts) >= 2 and parts[0] == \"user\" and parts[1] == \"memories\":\n+        return 1\n+    if len(parts) >= 3 and parts[0] == \"user\" and parts[2] == \"memories\":\n+        return 2\n+    if len(parts) >= 4 and parts[0] == \"user\" and parts[1] == \"peers\" and parts[3] == \"memories\":\n+        return 3\n+    if len(parts) >= 5 and parts[0] == \"user\" and parts[2] == \"peers\" and parts[4] == \"memories\":\n+        return 4\n+    return None\n+\n+\n+def _validate_forget_memory_uri(raw_uri: Any) -> tuple[Optional[str], Optional[str]]:\n+    if not isinstance(raw_uri, str):\n+        return None, \"uri is required\"\n+\n+    uri = raw_uri.strip()\n+    if not uri:\n+        return None, \"uri is required\"\n+\n+    parsed = urlparse(uri)\n+    if parsed.scheme != \"viking\" or not uri.startswith(\"viking://\"):\n+        return None, \"viking_forget only accepts viking:// memory file URIs\"\n+    if parsed.query or parsed.fragment:\n+        return None, \"viking_forget requires an exact URI without query or fragment\"\n+    if uri.endswith(\"/\") or not uri.endswith(\".md\"):\n+        return None, \"viking_forget only deletes concrete .md memory files\"\n+\n+    parts = [part for part in uri[len(\"viking://\") :].split(\"/\") if part]\n+    memories_idx = _memory_segment_index(parts)\n+    if memories_idx is None or len(parts) < memories_idx + 2:\n+        return None, \"viking_forget only deletes user memory file URIs\"\n+\n+    filename = uri.rsplit(\"/\", 1)[-1]\n+    if filename in _GENERATED_MEMORY_SUMMARY_FILENAMES:\n+        return None, \"viking_forget cannot delete generated memory summary files\"\n+\n+    return uri, None\n+\n+\n def _is_local_path_reference(value: str) -> bool:\n     if not value or \"\\n\" in value or \"\\r\" in value:\n         return False","@@ -1719,6 +1792,8 @@ def __init__(self):\n         self._prefetch_thread: Optional[threading.Thread] = None\n         self._runtime_start_lock = threading.Lock()\n         self._runtime_start_thread: Optional[threading.Thread] = None\n+        self._memory_write_lock = threading.Lock()\n+        self._memory_write_threads: Set[threading.Thread] = set()\n         # All prefetch threads ever spawned (daemon, short-lived). Tracked so\n         # shutdown() can drain them and rapid re-queues don't orphan a still-\n         # running thread by overwriting the single _prefetch_thread slot.","@@ -2047,7 +2122,8 @@ def system_prompt_block(self) -> str:\n                 f\"Active. Endpoint: {self._endpoint}\\n\"\n                 \"Use viking_search to find information, viking_read for details \"\n                 \"(abstract/overview/full), viking_browse to explore.\\n\"\n-                \"Use viking_remember to store facts, viking_add_resource to index URLs/docs.\"\n+                \"Use viking_remember to store facts, viking_forget to delete exact memory \"\n+                \"file URIs, and viking_add_resource to index URLs/docs.\"\n             )\n         except Exception as e:\n             logger.warning(\"OpenViking system_prompt_block failed: %s\", e)","@@ -2055,7 +2131,7 @@ def system_prompt_block(self) -> str:\n                 \"# OpenViking Knowledge Base\\n\"\n                 f\"Active. Endpoint: {self._endpoint}\\n\"\n                 \"Use viking_search, viking_read, viking_browse, \"\n-                \"viking_remember, viking_add_resource.\"\n+                \"viking_remember, viking_forget, viking_add_resource.\"\n             )\n \n     def prefetch(self, query: str, *, session_id: str = \"\") -> str:","@@ -2806,7 +2882,7 @@ def on_memory_write(\n         content: str,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> None:\n-        \"\"\"Mirror built-in memory writes to OpenViking via content/write.\"\"\"\n+        \"\"\"Mirror successful built-in memory additions to OpenViking.\"\"\"\n         if not self._client or action != \"add\" or not content:\n             return\n ","@@ -2826,12 +2902,30 @@ def _write():\n                 })\n             except Exception as e:\n                 logger.debug(\"OpenViking memory mirror failed: %s\", e)\n+            finally:\n+                with self._memory_write_lock:\n+                    self._memory_write_threads.discard(threading.current_thread())\n \n         t = threading.Thread(target=_write, daemon=True, name=\"openviking-memwrite\")\n-        t.start()\n+        with self._memory_write_lock:\n+            if self._shutting_down:\n+                return\n+            self._memory_write_threads.add(t)\n+            try:\n+                t.start()\n+            except Exception as e:\n+                self._memory_write_threads.discard(t)\n+                logger.debug(\"OpenViking memory mirror worker failed to start: %s\", e)\n \n     def get_tool_schemas(self) -> List[Dict[str, Any]]:\n-        return [SEARCH_SCHEMA, READ_SCHEMA, BROWSE_SCHEMA, REMEMBER_SCHEMA, ADD_RESOURCE_SCHEMA]\n+        return [\n+            SEARCH_SCHEMA,\n+            READ_SCHEMA,\n+            BROWSE_SCHEMA,\n+            REMEMBER_SCHEMA,\n+            FORGET_SCHEMA,\n+            ADD_RESOURCE_SCHEMA,\n+        ]\n \n     def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:\n         if not self._client:","@@ -2846,6 +2940,8 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:\n                 return self._tool_browse(args)\n             elif tool_name == \"viking_remember\":\n                 return self._tool_remember(args)\n+            elif tool_name == \"viking_forget\":\n+                return self._tool_forget(args)\n             elif tool_name == \"viking_add_resource\":\n                 return self._tool_add_resource(args)\n             return tool_error(f\"Unknown tool: {tool_name}\")","@@ -2865,6 +2961,8 @@ def shutdown(self) -> None:\n             deferred_workers = list(self._deferred_commit_threads)\n         with self._prefetch_lock:\n             prefetch_workers = list(self._prefetch_threads)\n+        with self._memory_write_lock:\n+            memory_write_workers = list(self._memory_write_threads)\n         for t in all_workers:\n             if t.is_alive():\n                 t.join(timeout=5.0)","@@ -2874,6 +2972,9 @@ def shutdown(self) -> None:\n         for t in prefetch_workers:\n             if t.is_alive():\n                 t.join(timeout=5.0)\n+        for t in memory_write_workers:\n+            if t.is_alive():\n+                t.join(timeout=5.0)\n         # Clear atexit reference so it doesn't double-commit.\n         global _last_active_provider\n         if _last_active_provider is self:","@@ -3097,6 +3198,31 @@ def _tool_remember(self, args: dict) -> str:\n             logger.error(\"OpenViking content/write failed: %s\", e)\n             return tool_error(f\"Failed to store memory: {e}\")\n \n+    def _tool_forget(self, args: dict) -> str:\n+        uri, error = _validate_forget_memory_uri(args.get(\"uri\"))\n+        if error:\n+            return tool_error(error)\n+\n+        resp = self._client.delete(\n+            \"/api/v1/fs\",\n+            params={\"uri\": uri, \"recursive\": False},\n+        )\n+        result = self._unwrap_result(resp)\n+        payload: Dict[str, Any] = {\"status\": \"deleted\", \"uri\": uri}\n+        if isinstance(result, dict):\n+            payload[\"uri\"] = result.get(\"uri\") or uri\n+            for key in (\n+                \"estimated_deleted_count\",\n+                \"memory_cleanup\",\n+                \"semantic_root_uri\",\n+                \"semantic_status\",\n+                \"queue_status\",\n+            ):\n+                if key in result:\n+                    payload[key] = result[key]\n+\n+        return json.dumps(payload, ensure_ascii=False)\n+\n     def _tool_add_resource(self, args: dict) -> str:\n         url = args.get(\"url\", \"\")\n         if not url:"]},{"file":"tests/agent/test_memory_provider.py","hunks":["@@ -1172,16 +1172,12 @@ def test_on_memory_write_replace(self):\n         mgr.on_memory_write(\"replace\", \"user\", \"updated pref\")\n         assert p.memory_writes == [(\"replace\", \"user\", \"updated pref\")]\n \n-    def test_on_memory_write_remove_not_bridged(self):\n-        \"\"\"The bridge intentionally skips 'remove' — only add/replace notify.\"\"\"\n-        # This tests the contract that run_agent.py checks:\n-        #   function_args.get(\"action\") in (\"add\", \"replace\")\n+    def test_on_memory_write_remove_supported_by_manager(self):\n+        \"\"\"The manager forwards remove actions when a caller elects to bridge them.\"\"\"\n         mgr = MemoryManager()\n         p = FakeMemoryProvider(\"ext\")\n         mgr.add_provider(p)\n \n-        # Manager itself doesn't filter — run_agent.py does.\n-        # But providers should handle remove gracefully.\n         mgr.on_memory_write(\"remove\", \"memory\", \"old fact\")\n         assert p.memory_writes == [(\"remove\", \"memory\", \"old fact\")]\n "]},{"file":"tests/agent/test_memory_write_bridge.py","hunks":["@@ -0,0 +1,145 @@\n+\"\"\"Behavior tests for the built-in memory → external provider bridge.\n+\n+The bridge lives behind the MemoryManager interface\n+(``MemoryManager.notify_memory_tool_write``): the agent loop hands over the raw\n+built-in memory tool result + args, and the manager decides whether/what to\n+mirror to external providers. These tests drive that method with a fake\n+external provider and assert which ``on_memory_write`` calls land.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+\n+from agent.memory_manager import MemoryManager\n+from agent.memory_provider import MemoryProvider\n+\n+\n+class _RecordingProvider(MemoryProvider):\n+    \"\"\"Minimal external provider that records on_memory_write calls.\"\"\"\n+\n+    def __init__(self) -> None:\n+        self.calls = []\n+\n+    @property\n+    def name(self) -> str:\n+        return \"recording\"\n+\n+    def is_available(self) -> bool:\n+        return True\n+\n+    def initialize(self, session_id: str, **kwargs) -> None:\n+        pass\n+\n+    def get_tool_schemas(self):\n+        return []\n+\n+    def shutdown(self) -> None:\n+        pass\n+\n+    def on_memory_write(self, action, target, content, metadata=None):\n+        self.calls.append({\n+            \"action\": action,\n+            \"target\": target,\n+            \"content\": content,\n+            \"metadata\": dict(metadata or {}),\n+        })\n+\n+\n+def _manager_with_provider():\n+    mgr = MemoryManager()\n+    provider = _RecordingProvider()\n+    mgr.add_provider(provider)\n+    return mgr, provider\n+\n+\n+def test_notifies_remove_with_old_text_after_success():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": True}),\n+        {\"action\": \"remove\", \"target\": \"memory\", \"old_text\": \"stale preference entry\"},\n+    )\n+    assert provider.calls == [\n+        {\n+            \"action\": \"remove\",\n+            \"target\": \"memory\",\n+            \"content\": \"\",\n+            \"metadata\": {\"old_text\": \"stale preference entry\"},\n+        }\n+    ]\n+\n+\n+def test_skips_failed_memory_write():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": False, \"error\": \"No entry matched\"}),\n+        {\"action\": \"remove\", \"target\": \"memory\", \"old_text\": \"stale preference entry\"},\n+    )\n+    assert provider.calls == []\n+\n+\n+def test_skips_staged_memory_write():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": True, \"staged\": True, \"pending_id\": \"abc123\"}),\n+        {\"action\": \"remove\", \"target\": \"memory\", \"old_text\": \"stale preference entry\"},\n+    )\n+    assert provider.calls == []\n+\n+\n[redacted:email](\"tool_result\", [None, [], object(), \"not-json\"])\n+def test_skips_unrecognized_tool_result_shape(tool_result):\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        tool_result,\n+        {\"action\": \"add\", \"target\": \"memory\", \"content\": \"new fact\"},\n+    )\n+    assert provider.calls == []\n+\n+\n+def test_preserves_old_text_for_replace_and_remove_batch():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": True}),\n+        {\n+            \"target\": \"user\",\n+            \"operations\": [\n+                {\"action\": \"replace\", \"old_text\": \"old preference\", \"content\": \"updated\"},\n+                {\"action\": \"remove\", \"old_text\": \"obsolete preference\"},\n+                {\"action\": \"add\", \"content\": \"new fact\"},\n+            ],\n+        },\n+    )\n+    assert provider.calls == [\n+        {\"action\": \"replace\", \"target\": \"user\", \"content\": \"updated\",\n+         \"metadata\": {\"old_text\": \"old preference\"}},\n+        {\"action\": \"remove\", \"target\": \"user\", \"content\": \"\",\n+         \"metadata\": {\"old_text\": \"obsolete preference\"}},\n+        {\"action\": \"add\", \"target\": \"user\", \"content\": \"new fact\", \"metadata\": {}},\n+    ]\n+\n+\n+def test_non_mutating_actions_are_not_mirrored():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": True}),\n+        {\"action\": \"read\", \"target\": \"memory\"},\n+    )\n+    assert provider.calls == []\n+\n+\n+def test_build_metadata_callback_is_merged_per_op():\n+    mgr, provider = _manager_with_provider()\n+    mgr.notify_memory_tool_write(\n+        json.dumps({\"success\": True}),\n+        {\"action\": \"add\", \"target\": \"memory\", \"content\": \"fact\"},\n+        build_metadata=lambda: {\"session_id\": \"s1\", \"tool_name\": \"memory\"},\n+    )\n+    assert provider.calls == [\n+        {\n+            \"action\": \"add\",\n+            \"target\": \"memory\",\n+            \"content\": \"fact\",\n+            \"metadata\": {\"session_id\": \"s1\", \"tool_name\": \"memory\"},\n+        }\n+    ]"]},{"file":"tests/plugins/memory/test_openviking_provider.py","hunks":["@@ -1459,6 +1459,137 @@ def test_tool_add_resource_sends_git_remote_sources_as_path(url):\n     })\n \n \n+def test_get_tool_schemas_includes_narrow_forget_tool():\n+    provider = OpenVikingMemoryProvider()\n+\n+    names = [schema[\"name\"] for schema in provider.get_tool_schemas()]\n+\n+    assert \"viking_forget\" in names\n+\n+\n+def test_handle_tool_call_forget_deletes_exact_memory_file_uri():\n+    uri = \"viking://user/peers/hermes/memories/preferences/mem_abc123.md\"\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._client.delete.return_value = {\n+        \"status\": \"ok\",\n+        \"result\": {\"uri\": uri, \"estimated_deleted_count\": 1},\n+    }\n+\n+    result = json.loads(provider.handle_tool_call(\"viking_forget\", {\"uri\": uri}))\n+\n+    provider._client.delete.assert_called_once_with(\n+        \"/api/v1/fs\",\n+        params={\"uri\": uri, \"recursive\": False},\n+    )\n+    assert result == {\n+        \"status\": \"deleted\",\n+        \"uri\": uri,\n+        \"estimated_deleted_count\": 1,\n+    }\n+\n+\n+def test_handle_tool_call_forget_deletes_exact_memory_file_under_memories_root():\n+    uri = \"viking://user/default/memories/profile.md\"\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._client.delete.return_value = {\n+        \"status\": \"ok\",\n+        \"result\": {\"uri\": uri, \"estimated_deleted_count\": 1},\n+    }\n+\n+    result = json.loads(provider.handle_tool_call(\"viking_forget\", {\"uri\": uri}))\n+\n+    provider._client.delete.assert_called_once_with(\n+        \"/api/v1/fs\",\n+        params={\"uri\": uri, \"recursive\": False},\n+    )\n+    assert result == {\n+        \"status\": \"deleted\",\n+        \"uri\": uri,\n+        \"estimated_deleted_count\": 1,\n+    }\n+\n+\n+def test_handle_tool_call_forget_allows_non_generated_dot_md_memory_file():\n+    uri = \"viking://user/default/memories/preferences/.full.md\"\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._client.delete.return_value = {\n+        \"status\": \"ok\",\n+        \"result\": {\"uri\": uri, \"estimated_deleted_count\": 1},\n+    }\n+\n+    result = json.loads(provider.handle_tool_call(\"viking_forget\", {\"uri\": uri}))\n+\n+    provider._client.delete.assert_called_once_with(\n+        \"/api/v1/fs\",\n+        params={\"uri\": uri, \"recursive\": False},\n+    )\n+    assert result == {\n+        \"status\": \"deleted\",\n+        \"uri\": uri,\n+        \"estimated_deleted_count\": 1,\n+    }\n+\n+\n[redacted:email](\"uri\", [\n+    \"\",\n+    \"https://example.com/mem.md\",\n+    \"viking:/user/memories/preferences/mem_abc123.md\",\n+    \"viking://resources/project/doc.md\",\n+    \"viking://resources/project/memories/mem_abc123.md\",\n+    \"viking://memories/preferences/mem_abc123.md\",\n+    \"viking://agent/hermes/memories/preferences/mem_abc123.md\",\n+    \"viking://user/skills/example/SKILL.md\",\n+    \"viking://user/sessions/session-1/messages.jsonl\",\n+    \"viking://user/memories/preferences/\",\n+    \"viking://user/memories/preferences/.overview.md\",\n+    \"viking://user/memories/preferences/.abstract.md\",\n+    \"viking://user/memories/preferences/mem_abc123.md?recursive=true\",\n+])\n+def test_handle_tool_call_forget_rejects_non_memory_file_uris(uri):\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+\n+    result = json.loads(provider.handle_tool_call(\"viking_forget\", {\"uri\": uri}))\n+\n+    assert \"error\" in result\n+    provider._client.delete.assert_not_called()\n+\n+\n+def test_viking_client_delete_uses_identity_headers(monkeypatch):\n+    client = _VikingClient(\n+        \"https://example.com\",\n+        api_key=\"[redacted:secret-assignment]\",\n+        account=\"acct\",\n+        user=\"alice\",\n+        agent=\"hermes\",\n+    )\n+    captured = {}\n+\n+    def capture_delete(url, **kwargs):\n+        captured[\"url\"] = url\n+        captured[\"kwargs\"] = kwargs\n+        return SimpleNamespace(\n+            status_code=200,\n+            text=\"\",\n+            json=lambda: {\"status\": \"ok\", \"result\": {\"uri\": \"viking://user/memories/x.md\"}},\n+            raise_for_status=lambda: None,\n+        )\n+\n+    monkeypatch.setattr(client._httpx, \"delete\", capture_delete)\n+\n+    assert client.delete(\"/api/v1/fs\", params={\"uri\": \"viking://user/memories/x.md\"}) == {\n+        \"status\": \"ok\",\n+        \"result\": {\"uri\": \"viking://user/memories/x.md\"},\n+    }\n+    assert captured[\"url\"] == \"https://example.com/api/v1/fs\"\n+    assert captured[\"kwargs\"][\"params\"] == {\"uri\": \"viking://user/memories/x.md\"}\n+    assert captured[\"kwargs\"][\"headers\"][\"Authorization\"] == \"Bearer test-key\"\n+    assert captured[\"kwargs\"][\"headers\"][\"X-OpenViking-Actor-Peer\"] == \"hermes\"\n+\n+\n def test_viking_client_upload_temp_file_uses_multipart_identity_headers(tmp_path, monkeypatch):\n     sample = tmp_path / \"sample.md\"\n     sample.write_text(\"# Local resource\\n\", encoding=\"utf-8\")","@@ -2637,6 +2768,94 @@ def post(self, path, payload=None, **kwargs):\n     )\n \n \n+def test_shutdown_waits_for_memory_write_worker(monkeypatch):\n+    import threading\n+\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+\n+    worker_started = threading.Event()\n+    release_worker = threading.Event()\n+    worker_finished = threading.Event()\n+    shutdown_returned = threading.Event()\n+\n+    class StubClient:\n+        def __init__(self, *a, **kw):\n+            pass\n+\n+        def post(self, path, payload=None, **kwargs):\n+            assert path == \"/api/v1/content/write\"\n+            worker_started.set()\n+            release_worker.wait(timeout=2.0)\n+            worker_finished.set()\n+            return {}\n+\n+    monkeypatch.setattr(openviking_module, \"_VikingClient\", StubClient)\n+\n+    provider.on_memory_write(\"add\", \"user\", \"remember this\")\n+    assert worker_started.wait(timeout=2.0), \"worker never entered post()\"\n+\n+    shutdown_thread = threading.Thread(\n+        target=lambda: (provider.shutdown(), shutdown_returned.set()),\n+        daemon=True,\n+    )\n+    shutdown_thread.start()\n+\n+    returned_before_worker_finished = shutdown_returned.wait(timeout=0.1)\n+    release_worker.set()\n+    assert shutdown_returned.wait(timeout=2.0), \"shutdown did not return after worker finished\"\n+    shutdown_thread.join(timeout=2.0)\n+\n+    assert not returned_before_worker_finished\n+    assert worker_finished.is_set()\n+    assert provider._memory_write_threads == set()\n+\n+\n[redacted:email](\n+    (\"action\", \"content\"),\n+    [\n+        (\"replace\", \"updated memory\"),\n+        (\"remove\", \"\"),\n+        (\"forget\", \"\"),\n+        (\"delete\", \"\"),\n+    ],\n+)\n+def test_on_memory_write_ignores_non_add_actions(action, content, monkeypatch):\n+    provider = OpenVikingMemoryProvider()\n+    provider._client = MagicMock()\n+    provider._endpoint = \"http://test\"\n+    provider._api_key = \"\"\n+    provider._account = \"acct\"\n+    provider._user = \"usr\"\n+    provider._agent = \"hermes\"\n+    uri = \"viking://user/peers/hermes/memories/preferences/mem_abc123.md\"\n+    spawned = []\n+\n+    class StubThread:\n+        def __init__(self, *args, **kwargs):\n+            spawned.append((args, kwargs))\n+\n+        def start(self):\n+            raise AssertionError(\"non-URI remove should not spawn a mirror thread\")\n+\n+    import plugins.memory.openviking as _mod\n+    monkeypatch.setattr(_mod.threading, \"Thread\", StubThread)\n+\n+    provider.on_memory_write(\n+        action,\n+        \"memory\",\n+        content,\n+        metadata={\"uri\": uri, \"old_text\": \"stale fact\"},\n+    )\n+\n+    assert spawned == []\n+\n+\n # ---------------------------------------------------------------------------\n # Prefetch staleness: a prefetch worker that finishes AFTER a session switch\n # must drop its result instead of repopulating the new session with stale"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -23,6 +23,7 @@\n import run_agent\n from run_agent import AIAgent\n from agent.error_classifier import FailoverReason\n+from agent.memory_manager import MemoryManager\n from agent.prompt_builder import DEFAULT_AGENT_IDENTITY\n \n ","@@ -2082,6 +2083,41 @@ def test_single_tool_executed(self, agent):\n         assert messages[0][\"role\"] == \"tool\"\n         assert \"search result\" in messages[0][\"content\"]\n \n+    def test_sequential_memory_remove_notifies_provider_with_tool_result(self, agent):\n+        old_text = \"stale preference entry\"\n+        tc = _mock_tool_call(\n+            name=\"memory\",\n+            arguments=json.dumps({\n+                \"action\": \"remove\",\n+                \"target\": \"memory\",\n+                \"old_text\": old_text,\n+            }),\n+            call_id=\"mem-1\",\n+        )\n+        mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc])\n+        messages = []\n+        calls = []\n+\n+        class FakeMemoryManager(MemoryManager):\n+            def has_tool(self, tool_name):\n+                return False\n+\n+            def on_memory_write(self, action, target, content, metadata=None):\n+                calls.append((action, target, content, metadata or {}))\n+\n+        agent._memory_manager = FakeMemoryManager()\n+        agent._memory_store = object()\n+\n+        with patch(\"tools.memory_tool.memory_tool\", return_value=json.dumps({\"success\": True})):\n+            agent._execute_tool_calls_sequential(mock_msg, messages, \"task-1\")\n+\n+        assert len(calls) == 1\n+        action, target, content, metadata = calls[0]\n+        assert (action, target, content) == (\"remove\", \"memory\", \"\")\n+        assert metadata[\"old_text\"] == old_text\n+        assert metadata[\"tool_call_id\"] == \"mem-1\"\n+        assert messages[-1][\"tool_call_id\"] == \"mem-1\"\n+\n     def test_keyboard_interrupt_emits_cancelled_post_tool_hook(self, agent, monkeypatch):\n         tc = _mock_tool_call(name=\"web_search\", arguments='{\"q\":\"test\"}', call_id=\"c1\")\n         mock_msg = _mock_assistant_msg(content=\"\", tool_calls=[tc])","@@ -2797,6 +2833,68 @@ def test_blocked_memory_tool_does_not_reset_counter(self, agent, monkeypatch):\n         assert json.loads(result) == {\"error\": \"Blocked\"}\n         assert agent._turns_since_memory == 5\n \n+    def test_invoke_tool_memory_remove_notifies_provider_with_old_text(self, agent, monkeypatch):\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            lambda *args, **kwargs: None,\n+        )\n+        calls = []\n+\n+        class FakeMemoryManager(MemoryManager):\n+            def has_tool(self, tool_name):\n+                return False\n+\n+            def on_memory_write(self, action, target, content, metadata=None):\n+                calls.append((action, target, content, metadata or {}))\n+\n+        old_text = \"stale preference entry\"\n+        agent._memory_manager = FakeMemoryManager()\n+        agent._memory_store = object()\n+\n+        with patch(\"tools.memory_tool.memory_tool\", return_value=json.dumps({\"success\": True})):\n+            agent._invoke_tool(\n+                \"memory\",\n+                {\"action\": \"remove\", \"target\": \"memory\", \"old_text\": old_text},\n+                \"task-1\",\n+                tool_call_id=\"mem-1\",\n+            )\n+\n+        assert len(calls) == 1\n+        action, target, content, metadata = calls[0]\n+        assert (action, target, content) == (\"remove\", \"memory\", \"\")\n+        assert metadata[\"old_text\"] == old_text\n+        assert metadata[\"tool_call_id\"] == \"mem-1\"\n+\n+    def test_invoke_tool_memory_failed_remove_skips_provider_notification(self, agent, monkeypatch):\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            lambda *args, **kwargs: None,\n+        )\n+        notify = MagicMock(side_effect=AssertionError(\"should not notify\"))\n+\n+        class FakeMemoryManager(MemoryManager):\n+            def has_tool(self, tool_name):\n+                return False\n+\n+            on_memory_write = notify\n+\n+        manager = FakeMemoryManager()\n+        agent._memory_manager = manager\n+        agent._memory_store = object()\n+\n+        with patch(\n+            \"tools.memory_tool.memory_tool\",\n+            return_value=json.dumps({\"success\": False, \"error\": \"No entry matched\"}),\n+        ):\n+            agent._invoke_tool(\n+                \"memory\",\n+                {\"action\": \"remove\", \"target\": \"memory\", \"old_text\": \"missing\"},\n+                \"task-1\",\n+                tool_call_id=\"mem-1\",\n+            )\n+\n+        notify.assert_not_called()\n+\n     def test_concurrent_blocked_write_skips_checkpoint(self, agent, monkeypatch):\n         \"\"\"Concurrent path: blocked write_file should not trigger checkpoint.\"\"\"\n         tc1 = _mock_tool_call(name=\"write_file\",\n"]}]},{"kind":"pr","number":100187,"title":"fix(openviking): mirror native memory lifecycle","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}