{"repo":"NousResearch/hermes-agent","public_id":"44242","display_label":"Fix message list mutation and multimodal content loss","summary":"Most members address bugs where early persistence operations mutate shared or caller-owned message lists, silently dropping observed context or multimodal content before API calls. Several fixes involve copying message lists and skipping or correcting persist overrides when content is multimodal.","subsystem":"agent","counts":{"members":14,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56300,"merged_at":"2026-07-01T11:58:05+00:00"},"resolution":{"state":"partially_resolved","by":"pr-47907,pr-56300"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T11:58:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12879120879120878},"fix_edges":[{"issue_number":44242,"pr_number":47907,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-17T16:49:40+00:00"},{"issue_number":48677,"pr_number":56300,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-01T11:58:05+00:00"}],"members":[{"kind":"issue","number":44242,"title":"ACP image content blocks dropped before API call (persist_user_message override clobbers multimodal content)","url":null,"state":"closed"},{"kind":"issue","number":48677,"title":"observe_unmentioned_group_messages: observed context silently dropped before the API call (early persist mutates the shared messages list)","url":null,"state":"closed"},{"kind":"issue","number":56303,"title":"persist override still mutates live message list on tool-loop flush path (sibling of #48677)","url":null,"state":"open"},{"kind":"pr","number":44247,"title":"fix(agent): skip persist_user_message override when content is a multimodal list","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47907","number":47907},{"kind":"pr","value":"#44249","number":44249},{"kind":"pr","value":"#44242","number":44242}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44248,"title":"fix(agent): don't clobber multimodal user content with persist_user_message override","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44247","number":44247}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44249,"title":"fix: preserve ACP multimodal content during persistence","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3d21666b2f","number":0},{"kind":"pr","value":"#47907","number":47907},{"kind":"pr","value":"#44242","number":44242}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":44371,"title":"fix(agent): preserve multimodal content blocks in persist override (#44242)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44247","number":44247},{"kind":"pr","value":"#44242","number":44242}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47907,"title":"fix(agent): preserve multimodal user content through crash-resilience persist","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1483,7 +1483,15 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None:\n         if 0 <= idx < len(messages):\n             msg = messages[idx]\n             if isinstance(msg, dict) and msg.get(\"role\") == \"user\":\n-                if override is not None:\n+                # Text-only call paths may pass a synthetic API-facing prompt\n+                # and a cleaner transcript string separately. Multimodal\n+                # turns, however, keep image/audio blocks in the live\n+                # messages list that is still used for the API request after\n+                # early crash-resilience persistence. Do not replace those\n+                # blocks with the text-only persistence override before the\n+                # model call is built. The paired timestamp override still\n+                # applies — it is metadata, not content.\n+                if override is not None and not isinstance(msg.get(\"content\"), list):\n                     msg[\"content\"] = override\n                 if timestamp is not None:\n                     msg[\"timestamp\"] = timestamp"]},{"file":"scripts/release.py","hunks":["@@ -243,6 +243,7 @@\n     \"32869278+dusterbloom@users.noreply.github.com\": \"dusterbloom\",\n     \"189737461+basilalshukaili@users.noreply.github.com\": \"basilalshukaili\",\n     \"liuhao1024@users.noreply.github.com\": \"liuhao1024\",\n+    \"Rivuza@users.noreply.github.com\": \"Rivuza\",\n     \"annguyenNous@users.noreply.github.com\": \"annguyenNous\",\n     \"285874597+annguyenNous@users.noreply.github.com\": \"annguyenNous\",\n     \"kylekahraman@users.noreply.github.com\": \"kylekahraman\","]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -75,6 +75,33 @@ def agent():\n         return a\n \n \n+def test_persist_user_message_override_rewrites_text_turns(agent):\n+    messages = [{\"role\": \"user\", \"content\": \"API-only synthetic prefix\\nhello\"}]\n+    agent._persist_user_message_idx = 0\n+    agent._persist_user_message_override = \"hello\"\n+\n+    agent._apply_persist_user_message_override(messages)\n+\n+    assert messages == [{\"role\": \"user\", \"content\": \"hello\"}]\n+\n+\n+def test_persist_user_message_override_preserves_multimodal_turns(agent):\n+    multimodal_content = [\n+        {\"type\": \"text\", \"text\": \"What color is this?\"},\n+        {\n+            \"type\": \"image_url\",\n+            \"image_url\": {\"url\": \"data:image/png;base64,AAAA\"},\n+        },\n+    ]\n+    messages = [{\"role\": \"user\", \"content\": multimodal_content}]\n+    agent._persist_user_message_idx = 0\n+    agent._persist_user_message_override = \"What color is this? [Image attachment]\"\n+\n+    agent._apply_persist_user_message_override(messages)\n+\n+    assert messages == [{\"role\": \"user\", \"content\": multimodal_content}]\n+\n+\n @pytest.fixture()\n def agent_with_memory_tool():\n     \"\"\"Agent whose valid_tool_names includes 'memory'.\"\"\"\n"]}]},{"kind":"pr","number":48694,"title":"fix(gateway): stop early persist from dropping observed context before the API call","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7eb9716ad7c91c449501234acbdc36d5df2a127c","number":0},{"kind":"pr","value":"#48677","number":48677}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48817,"title":"fix(agent): persist_session mutates copy, not live messages (#48677)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48694","number":48694},{"kind":"pr","value":"#48677","number":48677}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56300,"title":"fix(agent): persist on a copy so early persist can't strip live user message (salvage #48817)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1649,9 +1649,17 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict\n         \"\"\"Save session state to both JSON log and SQLite on any exit path.\n \n         Ensures conversations are never lost, even on errors or early returns.\n+\n+        Trailing empty-response scaffolding is dropped from the live list in\n+        place (it is ephemeral junk the real transcript should shed). The\n+        persist user-message *override* is NOT applied here — it is resolved\n+        inside ``_flush_messages_to_session_db`` and written only to the DB row,\n+        never mutating the live message list used by the API call (#48677 is\n+        thus closed for every persist caller, not just this one).\n         \"\"\"\n+        # Scaffolding removal mutates the live list (desired — ephemeral\n+        # retry/failure sentinels must not survive into the real transcript).\n         self._drop_trailing_empty_response_scaffolding(messages)\n-        self._apply_persist_user_message_override(messages)\n         self._session_messages = messages\n         self._save_session_log(messages)\n         self._flush_messages_to_session_db(messages, conversation_history)","@@ -1742,7 +1750,18 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n             return\n         if not self._session_db:\n             return\n-        self._apply_persist_user_message_override(messages)\n+        # Persist user-message override (#48677 chokepoint): historically this\n+        # mutated the live `messages` list in place, which — on the early\n+        # crash-resilience persist that runs BEFORE the API call is built —\n+        # stripped observed group-chat context off the live user message and\n+        # silently dropped it. Instead, resolve the override here and apply it\n+        # ONLY to the value written to the DB (see the write loop below); the\n+        # live dict is never mutated, so every caller (early persist, mid-loop\n+        # flush, /resume, /branch) is protected uniformly. Timestamp override is\n+        # metadata and is likewise applied only to the written row.\n+        _ov_idx = getattr(self, \"_persist_user_message_idx\", None)\n+        _ov_content = getattr(self, \"_persist_user_message_override\", None)\n+        _ov_timestamp = getattr(self, \"_persist_user_message_timestamp\", None)\n         try:\n             # Retry row creation if the earlier attempt failed transiently.\n             if not self._session_db_created:","@@ -1784,7 +1803,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                 if isinstance(item, dict)\n             }\n \n-            for msg in messages:\n+            for _msg_idx, msg in enumerate(messages):\n                 if not isinstance(msg, dict):\n                     continue\n                 # Never write ephemeral recovery scaffolding to the session","@@ -1808,6 +1827,16 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                     continue\n                 role = msg.get(\"role\", \"unknown\")\n                 content = msg.get(\"content\")\n+                _row_timestamp = msg.get(\"timestamp\")\n+                # Apply the persist override to THIS row's written values only\n+                # (never to the live dict). Match the original guard: text-only\n+                # content is replaced; multimodal (list) content is left intact\n+                # so image/audio blocks aren't clobbered by the text override.\n+                if _ov_idx == _msg_idx and msg.get(\"role\") == \"user\":\n+                    if _ov_content is not None and not isinstance(content, list):\n+                        content = _ov_content\n+                    if _ov_timestamp is not None:\n+                        _row_timestamp = _ov_timestamp\n                 # Persist multimodal tool results as their text summary only —\n                 # base64 images would bloat the session DB and aren't useful\n                 # for cross-session replay.","@@ -1843,7 +1872,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n                     reasoning_details=msg.get(\"reasoning_details\") if role == \"assistant\" else None,\n                     codex_reasoning_items=msg.get(\"codex_reasoning_items\") if role == \"assistant\" else None,\n                     codex_message_items=msg.get(\"codex_message_items\") if role == \"assistant\" else None,\n-                    timestamp=msg.get(\"timestamp\"),\n+                    timestamp=_row_timestamp,\n                 )\n                 msg[_DB_PERSISTED_MARKER] = True\n             # The intrinsic markers are now the sole source of truth. Reset the"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -7151,7 +7151,16 @@ def test_persist_session_rewrites_current_turn_user_message(self, agent):\n \n         agent._persist_session(messages, [])\n \n-        assert messages[0][\"content\"] == \"Hello there\"\n+        # The original messages list must NOT be mutated — the persist\n+        # override is applied only to the DB row (resolved inside the flush\n+        # chokepoint), so the live list keeps the original content for the\n+        # API call (#48677).\n+        assert (\n+            messages[0][\"content\"]\n+            == \"[Voice input — respond concisely and conversationally, \"\n+            \"2-3 sentences max. No code blocks or markdown.] Hello there\"\n+        )\n+        # But the DB write must get the override.\n         first_db_write = agent._session_db.append_message.call_args_list[0].kwargs\n         assert first_db_write[\"content\"] == \"Hello there\"\n \n"]}]},{"kind":"pr","number":56318,"title":"fix(agent): copy messages in _flush_messages_to_session_db to prevent persist-override mutation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7eb9716ad7c91c449501234acbdc36d5df2a127c","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":56326,"title":"fix(agent): avoid mutating live persist overrides","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56475,"title":"fix(agent): make persist user-message override non-mutating","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56303","number":56303},{"kind":"pr","value":"#56318","number":56318},{"kind":"pr","value":"#56326","number":56326}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}