{"repo":"NousResearch/hermes-agent","public_id":"2670","display_label":"Memory flush agent overwrite and output fixes","summary":"These issues and pull requests address a bug where the memory flush agent overwrites live memory on session reset or gateway restart, along with related fixes for suppressing unwanted terminal output from background agents and enforcing add-only memory constraints.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2687},"resolution":{"state":"resolved","by":"pr-2687"},"freshness":{"state":"stale","last_verified_at":"2026-03-27T00:40:31+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":2670,"pr_number":2687,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2670,"pr_number":3297,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2670,"title":"Memory flush agent overwrites live memory on session reset/gateway restart","url":null,"state":"closed"},{"kind":"pr","number":2675,"title":"fix: prevent stale memory overwrites on session reset (#2670)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2687","number":2687}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2676,"title":"fix: Prevent memory overwrite by flush agent (add-only constraint)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2687","number":2687}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2687,"title":"fix(gateway): prevent stale memory overwrites by flush agent (#2670)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_flush_memory_stale_guard.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -525,6 +525,12 @@ def _flush_memories_for_session(\n         Synchronous worker — meant to be called via run_in_executor from\n         an async context so it doesn't block the event loop.\n         \"\"\"\n+        # Skip cron sessions — they run headless with no meaningful user\n+        # conversation to extract memories from.\n+        if old_session_id and old_session_id.startswith(\"cron_\"):\n+            logger.debug(\"Skipping memory flush for cron session: %s\", old_session_id)\n+            return\n+\n         try:\n             history = self.session_store.load_transcript(old_session_id)\n             if not history or len(history) < 4:","@@ -557,6 +563,23 @@ def _flush_memories_for_session(\n                 if m.get(\"role\") in (\"user\", \"assistant\") and m.get(\"content\")\n             ]\n \n+            # Read live memory state from disk so the flush agent can see\n+            # what's already saved and avoid overwriting newer entries.\n+            _current_memory = \"\"\n+            try:\n+                from tools.memory_tool import MEMORY_DIR\n+                for fname, label in [\n+                    (\"MEMORY.md\", \"MEMORY (your personal notes)\"),\n+                    (\"USER.md\", \"USER PROFILE (who the user is)\"),\n+                ]:\n+                    fpath = MEMORY_DIR / fname\n+                    if fpath.exists():\n+                        content = fpath.read_text(encoding=\"utf-8\").strip()\n+                        if content:\n+                            _current_memory += f\"\\n\\n## Current {label}:\\n{content}\"\n+            except Exception:\n+                pass  # Non-fatal — flush still works, just without the guard\n+\n             # Give the agent a real turn to think about what to save\n             flush_prompt = (\n                 \"[System: This session is about to be automatically reset due to \"","@@ -568,6 +591,20 @@ def _flush_memories_for_session(\n                 \"2. If you discovered a reusable workflow or solved a non-trivial \"\n                 \"problem, consider saving it as a skill.\\n\"\n                 \"3. If nothing is worth saving, that's fine — just skip.\\n\\n\"\n+            )\n+\n+            if _current_memory:\n+                flush_prompt += (\n+                    \"IMPORTANT — here is the current live state of memory. Other \"\n+                    \"sessions, cron jobs, or the user may have updated it since this \"\n+                    \"conversation ended. Do NOT overwrite or remove entries unless \"\n+                    \"the conversation above reveals something that genuinely \"\n+                    \"supersedes them. Only add new information that is not already \"\n+                    \"captured below.\"\n+                    f\"{_current_memory}\\n\\n\"\n+                )\n+\n+            flush_prompt += (\n                 \"Do NOT respond to the user. Just use the memory and skill_manage \"\n                 \"tools if needed, then stop.]\"\n             )"]},{"file":"tests/gateway/test_flush_memory_stale_guard.py","hunks":["@@ -0,0 +1,167 @@\n+\"\"\"Tests for memory flush stale-overwrite prevention (#2670).\n+\n+Verifies that:\n+1. Cron sessions are skipped (no flush for headless cron runs)\n+2. Current memory state is injected into the flush prompt so the\n+   flush agent can see what's already saved and avoid overwrites\n+3. The flush still works normally when memory files don't exist\n+\"\"\"\n+\n+import pytest\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch, call\n+\n+\n+def _make_runner():\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner._honcho_managers = {}\n+    runner._honcho_configs = {}\n+    runner._running_agents = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner.adapters = {}\n+    runner.hooks = MagicMock()\n+    runner.session_store = MagicMock()\n+    return runner\n+\n+\n+_TRANSCRIPT_4_MSGS = [\n+    {\"role\": \"user\", \"content\": \"hello\"},\n+    {\"role\": \"assistant\", \"content\": \"hi there\"},\n+    {\"role\": \"user\", \"content\": \"remember my name is Alice\"},\n+    {\"role\": \"assistant\", \"content\": \"Got it, Alice!\"},\n+]\n+\n+\n+class TestCronSessionBypass:\n+    \"\"\"Cron sessions should never trigger a memory flush.\"\"\"\n+\n+    def test_cron_session_skipped(self):\n+        runner = _make_runner()\n+        runner._flush_memories_for_session(\"cron_job123_20260323_120000\")\n+        # session_store.load_transcript should never be called\n+        runner.session_store.load_transcript.assert_not_called()\n+\n+    def test_cron_session_with_honcho_key_skipped(self):\n+        runner = _make_runner()\n+        runner._flush_memories_for_session(\"cron_daily_20260323\", \"some-honcho-key\")\n+        runner.session_store.load_transcript.assert_not_called()\n+\n+    def test_non_cron_session_proceeds(self):\n+        \"\"\"Non-cron sessions should still attempt the flush.\"\"\"\n+        runner = _make_runner()\n+        runner.session_store.load_transcript.return_value = []\n+        runner._flush_memories_for_session(\"session_abc123\")\n+        runner.session_store.load_transcript.assert_called_once_with(\"session_abc123\")\n+\n+\n+class TestMemoryInjection:\n+    \"\"\"The flush prompt should include current memory state from disk.\"\"\"\n+\n+    def test_memory_content_injected_into_flush_prompt(self, tmp_path):\n+        \"\"\"When memory files exist, their content appears in the flush prompt.\"\"\"\n+        runner = _make_runner()\n+        runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS\n+\n+        tmp_agent = MagicMock()\n+        memory_dir = tmp_path / \"memories\"\n+        memory_dir.mkdir()\n+        (memory_dir / \"MEMORY.md\").write_text(\"Agent knows Python\\n§\\nUser prefers dark mode\")\n+        (memory_dir / \"USER.md\").write_text(\"Name: Alice\\n§\\nTimezone: PST\")\n+\n+        with (\n+            patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"k\"}),\n+            patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+            patch(\"run_agent.AIAgent\", return_value=tmp_agent),\n+            # Intercept `from tools.memory_tool import MEMORY_DIR` inside the function\n+            patch.dict(\"sys.modules\", {\"tools.memory_tool\": MagicMock(MEMORY_DIR=memory_dir)}),\n+        ):\n+            runner._flush_memories_for_session(\"session_123\")\n+\n+        tmp_agent.run_conversation.assert_called_once()\n+        call_kwargs = tmp_agent.run_conversation.call_args.kwargs\n+        flush_prompt = call_kwargs.get(\"user_message\", \"\")\n+        \n+        # Verify both memory sections appear in the prompt\n+        assert \"Agent knows Python\" in flush_prompt\n+        assert \"User prefers dark mode\" in flush_prompt\n+        assert \"Name: Alice\" in flush_prompt\n+        assert \"Timezone: PST\" in flush_prompt\n+        # Verify the stale-overwrite warning is present\n+        assert \"Do NOT overwrite or remove entries\" in flush_prompt\n+        assert \"current live state of memory\" in flush_prompt\n+\n+    def test_flush_works_without_memory_files(self, tmp_path):\n+        \"\"\"When no memory files exist, flush still runs without the guard.\"\"\"\n+        runner = _make_runner()\n+        runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS\n+\n+        tmp_agent = MagicMock()\n+        empty_dir = tmp_path / \"no_memories\"\n+        empty_dir.mkdir()\n+\n+        with (\n+            patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"k\"}),\n+            patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+            patch(\"run_agent.AIAgent\", return_value=tmp_agent),\n+            patch.dict(\"sys.modules\", {\"tools.memory_tool\": MagicMock(MEMORY_DIR=empty_dir)}),\n+        ):\n+            runner._flush_memories_for_session(\"session_456\")\n+\n+        # Should still run, just without the memory guard section\n+        tmp_agent.run_conversation.assert_called_once()\n+        flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get(\"user_message\", \"\")\n+        assert \"Do NOT overwrite or remove entries\" not in flush_prompt\n+        assert \"Review the conversation above\" in flush_prompt\n+\n+    def test_empty_memory_files_no_injection(self, tmp_path):\n+        \"\"\"Empty memory files should not trigger the guard section.\"\"\"\n+        runner = _make_runner()\n+        runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS\n+\n+        tmp_agent = MagicMock()\n+        memory_dir = tmp_path / \"memories\"\n+        memory_dir.mkdir()\n+        (memory_dir / \"MEMORY.md\").write_text(\"\")\n+        (memory_dir / \"USER.md\").write_text(\"  \\n  \")  # whitespace only\n+\n+        with (\n+            patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"k\"}),\n+            patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+            patch(\"run_agent.AIAgent\", return_value=tmp_agent),\n+            patch.dict(\"sys.modules\", {\"tools.memory_tool\": MagicMock(MEMORY_DIR=memory_dir)}),\n+        ):\n+            runner._flush_memories_for_session(\"session_789\")\n+\n+        tmp_agent.run_conversation.assert_called_once()\n+        flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get(\"user_message\", \"\")\n+        # No memory content → no guard section\n+        assert \"current live state of memory\" not in flush_prompt\n+\n+\n+class TestFlushPromptStructure:\n+    \"\"\"Verify the flush prompt retains its core instructions.\"\"\"\n+\n+    def test_core_instructions_present(self):\n+        \"\"\"The flush prompt should still contain the original guidance.\"\"\"\n+        runner = _make_runner()\n+        runner.session_store.load_transcript.return_value = _TRANSCRIPT_4_MSGS\n+\n+        tmp_agent = MagicMock()\n+\n+        with (\n+            patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"k\"}),\n+            patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+            patch(\"run_agent.AIAgent\", return_value=tmp_agent),\n+            # Make the import fail gracefully so we test without memory files\n+            patch.dict(\"sys.modules\", {\"tools.memory_tool\": MagicMock(MEMORY_DIR=Path(\"/nonexistent\"))}),\n+        ):\n+            runner._flush_memories_for_session(\"session_struct\")\n+\n+        flush_prompt = tmp_agent.run_conversation.call_args.kwargs.get(\"user_message\", \"\")\n+        assert \"automatically reset\" in flush_prompt\n+        assert \"Save any important facts\" in flush_prompt\n+        assert \"consider saving it as a skill\" in flush_prompt\n+        assert \"Do NOT respond to the user\" in flush_prompt\n"]}]},{"kind":"pr","number":3045,"title":"Fix/flush agent terminal output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3297","number":3297}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3297,"title":"fix(gateway): silence background agent terminal output","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/display.py","gateway/run.py","run_agent.py","tests/gateway/test_flush_memory_stale_guard.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}