# NousResearch/hermes-agent · Fix message list mutation and multimodal content loss

## Open now

- 1 issue, 0 PRs open on GitHub.
- #56303: persist override still mutates live message list on tool-loop flush path (sibling of #48677)

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.

## TL;DR

- 14 member(s): 1 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #56300. Verify it addresses your case.
- **Recommended:** read PR #56300 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-47907,pr-56300`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #56303: persist override still mutates live message list on tool-loop flush path (sibling of #48677)

## Closed / duplicate issues

- #44242: ACP image content blocks dropped before API call (persist_user_message override clobbers multimodal content)
- #48677: observe_unmentioned_group_messages: observed context silently dropped before the API call (early persist mutates the shared messages list)

## Prior & competing PRs

- **#47907: fix(agent): preserve multimodal user content through crash-resilience persist**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `run_agent.py`, `scripts/release.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -1483,7 +1483,15 @@ def _apply_persist_user_message_override(self, messages: List[Dict]) -> None:
             if 0 <= idx < len(messages):
                 msg = messages[idx]
                 if isinstance(msg, dict) and msg.get("role") == "user":
    -                if override is not None:
    +                # Text-only call paths may pass a synthetic API-facing prompt
    +                # and a cleaner transcript string separately. Multimodal
    +                # turns, however, keep image/audio blocks in the live
    +                # messages list that is still used for the API request after
    +                # early crash-resilience persistence. Do not replace those
    +                # blocks with the text-only persistence override before the
    +                # model call is built. The paired timestamp override still
    +                # applies — it is metadata, not content.
    +                if override is not None and not isinstance(msg.get("content"), list):
                         msg["content"] = override
                     if timestamp is not None:
                         msg["timestamp"] = timestamp
    ```
  - `scripts/release.py`

    ```diff
    @@ -243,6 +243,7 @@
         "32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
         "189737461+basilalshukaili@users.noreply.github.com": "basilalshukaili",
         "liuhao1024@users.noreply.github.com": "liuhao1024",
    +    "Rivuza@users.noreply.github.com": "Rivuza",
         "annguyenNous@users.noreply.github.com": "annguyenNous",
         "285874597+annguyenNous@users.noreply.github.com": "annguyenNous",
         "kylekahraman@users.noreply.github.com": "kylekahraman",
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -75,6 +75,33 @@ def agent():
             return a
     
     
    +def test_persist_user_message_override_rewrites_text_turns(agent):
    +    messages = [{"role": "user", "content": "API-only synthetic prefix\nhello"}]
    +    agent._persist_user_message_idx = 0
    +    agent._persist_user_message_override = "hello"
    +
    +    agent._apply_persist_user_message_override(messages)
    +
    +    assert messages == [{"role": "user", "content": "hello"}]
    +
    +
    +def test_persist_user_message_override_preserves_multimodal_turns(agent):
    +    multimodal_content = [
    +        {"type": "text", "text": "What color is this?"},
    +        {
    +            "type": "image_url",
    +            "image_url": {"url": "data:image/png;base64,AAAA"},
    +        },
    +    ]
    +    messages = [{"role": "user", "content": multimodal_content}]
    +    agent._persist_user_message_idx = 0
    +    agent._persist_user_message_override = "What color is this? [Image attachment]"
    +
    +    agent._apply_persist_user_message_override(messages)
    +
    +    assert messages == [{"role": "user", "content": multimodal_content}]
    +
    +
     @pytest.fixture()
     def agent_with_memory_tool():
         """Agent whose valid_tool_names includes 'memory'."""
    ```
- **#48694: fix(gateway): stop early persist from dropping observed context before the API call**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7eb9716ad7c91c449501234acbdc36d5df2a127c`, `#48677`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56300: fix(agent): persist on a copy so early persist can't strip live user message (salvage #48817)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `run_agent.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -1649,9 +1649,17 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict
             """Save session state to both JSON log and SQLite on any exit path.
     
             Ensures conversations are never lost, even on errors or early returns.
    +
    +        Trailing empty-response scaffolding is dropped from the live list in
    +        place (it is ephemeral junk the real transcript should shed). The
    +        persist user-message *override* is NOT applied here — it is resolved
    +        inside ``_flush_messages_to_session_db`` and written only to the DB row,
    +        never mutating the live message list used by the API call (#48677 is
    +        thus closed for every persist caller, not just this one).
             """
    +        # Scaffolding removal mutates the live list (desired — ephemeral
    +        # retry/failure sentinels must not survive into the real transcript).
             self._drop_trailing_empty_response_scaffolding(messages)
    -        self._apply_persist_user_message_override(messages)
             self._session_messages = messages
             self._save_session_log(messages)
             self._flush_messages_to_session_db(messages, conversation_history)
    ```

    ```diff
    @@ -1742,7 +1750,18 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
                 return
             if not self._session_db:
                 return
    -        self._apply_persist_user_message_override(messages)
    +        # Persist user-message override (#48677 chokepoint): historically this
    +        # mutated the live `messages` list in place, which — on the early
    +        # crash-resilience persist that runs BEFORE the API call is built —
    +        # stripped observed group-chat context off the live user message and
    +        # silently dropped it. Instead, resolve the override here and apply it
    +        # ONLY to the value written to the DB (see the write loop below); the
    +        # live dict is never mutated, so every caller (early persist, mid-loop
    +        # flush, /resume, /branch) is protected uniformly. Timestamp override is
    +        # metadata and is likewise applied only to the written row.
    +        _ov_idx = getattr(self, "_persist_user_message_idx", None)
    +        _ov_content = getattr(self, "_persist_user_message_override", None)
    +        _ov_timestamp = getattr(self, "_persist_user_message_timestamp", None)
             try:
                 # Retry row creation if the earlier attempt failed transiently.
                 if not self._session_db_created:
    ```

    ```diff
    @@ -1784,7 +1803,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
                     if isinstance(item, dict)
                 }
     
    -            for msg in messages:
    +            for _msg_idx, msg in enumerate(messages):
                     if not isinstance(msg, dict):
                         continue
                     # Never write ephemeral recovery scaffolding to the session
    ```

    ```diff
    @@ -1808,6 +1827,16 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
                         continue
                     role = msg.get("role", "unknown")
                     content = msg.get("content")
    +                _row_timestamp = msg.get("timestamp")
    +                # Apply the persist override to THIS row's written values only
    +                # (never to the live dict). Match the original guard: text-only
    +                # content is replaced; multimodal (list) content is left intact
    +                # so image/audio blocks aren't clobbered by the text override.
    +                if _ov_idx == _msg_idx and msg.get("role") == "user":
    +                    if _ov_content is not None and not isinstance(content, list):
    +                        content = _ov_content
    +                    if _ov_timestamp is not None:
    +                        _row_timestamp = _ov_timestamp
                     # Persist multimodal tool results as their text summary only —
                     # base64 images would bloat the session DB and aren't useful
                     # for cross-session replay.
    ```

    ```diff
    @@ -1843,7 +1872,7 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
                         reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
                         codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
                         codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
    -                    timestamp=msg.get("timestamp"),
    +                    timestamp=_row_timestamp,
                     )
                     msg[_DB_PERSISTED_MARKER] = True
                 # The intrinsic markers are now the sole source of truth. Reset the
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -7151,7 +7151,16 @@ def test_persist_session_rewrites_current_turn_user_message(self, agent):
     
             agent._persist_session(messages, [])
     
    -        assert messages[0]["content"] == "Hello there"
    +        # The original messages list must NOT be mutated — the persist
    +        # override is applied only to the DB row (resolved inside the flush
    +        # chokepoint), so the live list keeps the original content for the
    +        # API call (#48677).
    +        assert (
    +            messages[0]["content"]
    +            == "[Voice input — respond concisely and conversationally, "
    +            "2-3 sentences max. No code blocks or markdown.] Hello there"
    +        )
    +        # But the DB write must get the override.
             first_db_write = agent._session_db.append_message.call_args_list[0].kwargs
             assert first_db_write["content"] == "Hello there"
     
    ```
- **#56318: fix(agent): copy messages in _flush_messages_to_session_db to prevent persist-override mutation**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7eb9716ad7c91c449501234acbdc36d5df2a127c`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#44247: fix(agent): skip persist_user_message override when content is a multimodal list**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#47907`, `#44249`, `#44242`
- **#44248: fix(agent): don't clobber multimodal user content with persist_user_message override**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#44247`
- **#44249: fix: preserve ACP multimodal content during persistence**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `3d21666b2f`, `#47907`, `#44242`
- **#44371: fix(agent): preserve multimodal content blocks in persist override (#44242)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#44247`, `#44242`
- **#48817: fix(agent): persist_session mutates copy, not live messages (#48677)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#48694`, `#48677`
- **#56326: fix(agent): avoid mutating live persist overrides**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#56475: fix(agent): make persist user-message override non-mutating**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#56303`, `#56318`, `#56326`

## Fix status

- Merged to main in PR #56300 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `agent`
