{"repo":"NousResearch/hermes-agent","public_id":"15843","display_label":"Memory tool parameter naming and schema validation bugs","summary":"Most members concern mismatches between parameter names (old_text vs old_string), missing schema constraints for replace/remove actions, and broken memory tool operations due to unrecognized parameters. A smaller cluster addresses send_message schema fields not matching runtime enforcement.","subsystem":"agent","counts":{"members":29,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":7,"merged_prs":2,"attempt_prs":17},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21238},"resolution":{"state":"partially_resolved","by":"pr-21238,pr-49997"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T06:46:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.06679802955665023},"fix_edges":[{"issue_number":19171,"pr_number":21238,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":43412,"pr_number":49997,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":49466,"pr_number":49997,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":15843,"title":"[Bug]: Memory Tool Cannot Delete Old Data - memory tool remove/replace operations failing","url":null,"state":"closed"},{"kind":"issue","number":19171,"title":"memory tool: replace action schema missing old_text/content as required","url":null,"state":"closed"},{"kind":"issue","number":20735,"title":"Inconsistent parameter naming: `old_text` vs `old_string` across tools","url":null,"state":"open"},{"kind":"issue","number":21844,"title":"Bug: memory tool replace/remove operations fail — old_text parameter not recognized","url":null,"state":"closed"},{"kind":"issue","number":23381,"title":"memory tool replace/remove fails with \"old_text is required\" even when old_text is provided","url":null,"state":"closed"},{"kind":"issue","number":32376,"title":"[Bug]: send_message schema declares `required: []` but handler enforces `target`/`message` at runtime","url":null,"state":"closed"},{"kind":"issue","number":43412,"title":"[bug] memory tool remove action rejects valid old_text arg with \"old_text is required\" error","url":null,"state":"closed"},{"kind":"issue","number":49466,"title":"bug: memory remove and replace actions broken in v0.17.0","url":null,"state":"closed"},{"kind":"issue","number":55095,"title":"[Bug] Holographic memory plugin: on_memory_write only mirrors \"add\", silently drops \"replace\" and \"remove\"","url":null,"state":"open"},{"kind":"issue","number":59772,"title":"[API Consistency] memory, patch, and skill_manage use different field names for the same semantic operation","url":null,"state":"open"},{"kind":"pr","number":15859,"title":"fix(memory): accept common LLM aliases for action / new_text","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15843","number":15843}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15957,"title":"fix(tools): align memory tool handler parameter name with schema (old_text → old_string)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19180,"title":"fix: require memory schema fields by action","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12604","number":12604},{"kind":"pr","value":"#15957","number":15957}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":21238,"title":"fix(memory): require schema fields by action (salvage #19180)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_memory_tool_schema.py","tools/memory_tool.py"],"fix_hunks":[{"file":"tests/tools/test_memory_tool_schema.py","hunks":["@@ -0,0 +1,39 @@\n+import json\n+from tools.memory_tool import MEMORY_SCHEMA\n+\n+\n+def test_memory_schema_requires_content_and_old_text_for_replace_action():\n+    schema = MEMORY_SCHEMA[\"parameters\"]\n+    assert schema[\"required\"] == [\"action\", \"target\"]\n+\n+    all_of = schema.get(\"allOf\")\n+    assert all_of, \"memory schema should use conditional requirements\"\n+\n+    replace_requirements = [\n+        branch[\"then\"].get(\"required\", [])\n+        for branch in all_of\n+        if branch.get(\"if\", {}).get(\"properties\", {}).get(\"action\", {}).get(\"const\") == \"replace\"\n+    ]\n+    assert replace_requirements == [[\"old_text\", \"content\"]]\n+\n+\n+def test_memory_schema_requires_content_for_add_action():\n+    add_requirements = [\n+        branch[\"then\"].get(\"required\", [])\n+        for branch in MEMORY_SCHEMA[\"parameters\"].get(\"allOf\", [])\n+        if branch.get(\"if\", {}).get(\"properties\", {}).get(\"action\", {}).get(\"const\") == \"add\"\n+    ]\n+    assert add_requirements == [[\"content\"]]\n+\n+\n+def test_memory_schema_requires_old_text_for_remove_action():\n+    remove_requirements = [\n+        branch[\"then\"].get(\"required\", [])\n+        for branch in MEMORY_SCHEMA[\"parameters\"].get(\"allOf\", [])\n+        if branch.get(\"if\", {}).get(\"properties\", {}).get(\"action\", {}).get(\"const\") == \"remove\"\n+    ]\n+    assert remove_requirements == [[\"old_text\"]]\n+\n+\n+def test_memory_schema_is_json_serializable():\n+    json.dumps(MEMORY_SCHEMA)"]},{"file":"tools/memory_tool.py","hunks":["@@ -560,6 +560,29 @@ def check_memory_requirements() -> bool:\n             },\n         },\n         \"required\": [\"action\", \"target\"],\n+        \"allOf\": [\n+            {\n+                \"if\": {\n+                    \"properties\": {\"action\": {\"const\": \"add\"}},\n+                    \"required\": [\"action\"],\n+                },\n+                \"then\": {\"required\": [\"content\"]},\n+            },\n+            {\n+                \"if\": {\n+                    \"properties\": {\"action\": {\"const\": \"replace\"}},\n+                    \"required\": [\"action\"],\n+                },\n+                \"then\": {\"required\": [\"old_text\", \"content\"]},\n+            },\n+            {\n+                \"if\": {\n+                    \"properties\": {\"action\": {\"const\": \"remove\"}},\n+                    \"required\": [\"action\"],\n+                },\n+                \"then\": {\"required\": [\"old_text\"]},\n+            },\n+        ],\n     },\n }\n \n"]}]},{"kind":"pr","number":21450,"title":"fix(memory): accept old_string as old_text alias","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21875,"title":"fix(memory): normalize replace/remove alias forwarding","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21844","number":21844}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21896,"title":"fix(tools): resolve old_string/new_text parameter aliases in memory tool","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":23399,"title":"fix(memory): add if/then schema constraint requiring old_text for rep…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6bf6bda90a2bba718f94dc8fc69dcaf7828819e","number":0},{"kind":"commit","value":"5a3e5b23d251829629736641284bce2d5be7132a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32381,"title":"fix(tools): validate send_message send args in schema (#32376)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6c8abbadb802dc389d8488a8847f9aa93bf9350","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32385,"title":"fix(send_message): declare required params in schema so model-side validator catches malformed calls","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32381","number":32381},{"kind":"pr","value":"#32376","number":32376},{"kind":"pr","value":"#21311","number":21311},{"kind":"pr","value":"#21316","number":21316}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32425,"title":"fix(tools): require send_message delivery fields in schema","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"90aaa84942dabc96f6648abb7b20404ffe01b6d5","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":32435,"title":"fix(tools): send_message schema required fields match runtime enforcement","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32376","number":32376}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":32577,"title":"fix(memory): accept old_string as alias for old_text in memory tool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4cd1790db965","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":43426,"title":"fix(memory): preserve old_text in memory tool schema (#43412)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c6bf6bda90a2bba718f94dc8fc69dcaf7828819e","number":0},{"kind":"pr","value":"#49997","number":49997},{"kind":"pr","value":"#43412","number":43412},{"kind":"pr","value":"#43412","number":43412}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43484,"title":"fix(tools): add old_text to memory tool required schema fields (#43412)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43426","number":43426},{"kind":"pr","value":"#43412","number":43412},{"kind":"pr","value":"#43426","number":43426}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":43526,"title":"fix(memory): add old_text to schema required list (#43412)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43426","number":43426},{"kind":"pr","value":"#43412","number":43412}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":49997,"title":"fix(memory): recover from missing old_text on single-op replace/remove","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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":["@@ -435,12 +435,33 @@ def test_add_via_tool(self, store):\n         assert result[\"success\"] is True\n \n     def test_replace_requires_old_text(self, store):\n+        # Missing old_text on a single-op replace is recoverable, not a dead-end:\n+        # return the current inventory + a retry instruction so the model can\n+        # reissue with old_text set. (issues #43412, #49466)\n+        store.add(\"memory\", \"fact A\")\n+        store.add(\"memory\", \"fact B\")\n         result = json.loads(memory_tool(action=\"replace\", content=\"new\", store=store))\n         assert result[\"success\"] is False\n+        assert \"old_text\" in result[\"error\"]\n+        assert result[\"current_entries\"] == [\"fact A\", \"fact B\"]\n+        assert \"usage\" in result\n \n     def test_remove_requires_old_text(self, store):\n+        store.add(\"memory\", \"fact A\")\n         result = json.loads(memory_tool(action=\"remove\", store=store))\n         assert result[\"success\"] is False\n+        assert \"old_text\" in result[\"error\"]\n+        assert result[\"current_entries\"] == [\"fact A\"]\n+        assert \"usage\" in result\n+\n+    def test_replace_missing_content_still_distinct_error(self, store):\n+        # When old_text IS present but content is missing, keep the original\n+        # content-specific error (don't route through the old_text recovery path).\n+        store.add(\"memory\", \"fact A\")\n+        result = json.loads(memory_tool(action=\"replace\", old_text=\"fact A\", store=store))\n+        assert result[\"success\"] is False\n+        assert \"content is required\" in result[\"error\"]\n+        assert \"current_entries\" not in result\n \n \n class TestMemoryBatch:"]},{"file":"tools/memory_tool.py","hunks":["@@ -835,6 +835,38 @@ def _apply_batch_write_gate(target: str, operations: List[Dict[str, Any]]) -> Op\n     )\n \n \n+def _missing_old_text_error(store: \"MemoryStore\", target: str, action: str) -> str:\n+    \"\"\"Build a recoverable error for a replace/remove call that arrived without\n+    ``old_text``.\n+\n+    ``replace``/``remove`` are inherently targeted -- without ``old_text`` there\n+    is no entry to act on, so we cannot fulfil the call. But returning a bare\n+    \"old_text is required\" is a dead-end: some structured-output clients omit the\n+    optional ``old_text`` field (it isn't, and can't be, schema-required without\n+    a top-level combinator the Codex backend rejects -- see\n+    tests/tools/test_memory_tool_schema.py). So instead we return the current\n+    entry inventory plus an explicit retry instruction, letting the model reissue\n+    the call with ``old_text`` set to a unique substring of the entry it means.\n+    Mirrors the batch path's ``_batch_error`` shape. (issues #43412, #49466)\n+    \"\"\"\n+    entries = store._entries_for(target)\n+    current = store._char_count(target)\n+    limit = store._char_limit(target)\n+    return json.dumps(\n+        {\n+            \"success\": False,\n+            \"error\": (\n+                f\"'{action}' needs old_text -- a short unique substring of the entry \"\n+                f\"to {action}. None was provided. Reissue the {action} with old_text \"\n+                f\"set to part of one of the current_entries below.\"\n+            ),\n+            \"current_entries\": entries,\n+            \"usage\": f\"{current:,}/{limit:,}\",\n+        },\n+        ensure_ascii=False,\n+    )\n+\n+\n def memory_tool(\n     action: str = None,\n     target: str = \"memory\",","@@ -876,9 +908,15 @@ def memory_tool(\n         return tool_error(\"Content is required for 'add' action.\", success=False)\n     if action == \"replace\" and (not old_text or not content):\n         missing = \"old_text\" if not old_text else \"content\"\n+        if not old_text:\n+            # The client/model omitted old_text. Replace is inherently targeted\n+            # -- we can't guess which entry. Return the current inventory plus a\n+            # retry instruction so the model can reissue with old_text set,\n+            # instead of hitting a dead-end error. (issues #43412, #49466)\n+            return _missing_old_text_error(store, target, \"replace\")\n         return tool_error(f\"{missing} is required for 'replace' action.\", success=False)\n     if action == \"remove\" and not old_text:\n-        return tool_error(\"old_text is required for 'remove' action.\", success=False)\n+        return _missing_old_text_error(store, target, \"remove\")\n \n     # Approval gate: when on, stages the write (background/gateway) or prompts\n     # inline (interactive CLI); when off (default) passes straight through.","@@ -971,7 +1009,7 @@ def apply_memory_pending(payload: Dict[str, Any], store: \"MemoryStore\") -> Dict[\n             },\n             \"old_text\": {\n                 \"type\": \"string\",\n-                \"description\": \"Short unique substring identifying the entry to replace or remove (single-op shape).\"\n+                \"description\": \"REQUIRED for 'replace' and 'remove' (single-op shape): a short unique substring identifying the existing entry to modify. Omit only for 'add'.\"\n             },\n             \"operations\": {\n                 \"type\": \"array\",\n"]}]},{"kind":"pr","number":55098,"title":"fix(holographic): handle replace/remove in on_memory_write mirror","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#56274","number":56274},{"kind":"pr","value":"#55095","number":55095},{"kind":"pr","value":"#56274","number":56274}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":56274,"title":"fix(memory): handle replace and remove in holographic on_memory_write","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55098","number":55098}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}