{"repo":"NousResearch/hermes-agent","public_id":"29148","display_label":"Merging consecutive assistant tool_calls messages during session replay","summary":"The members share one theme: consecutive assistant messages containing tool calls, such as parallel calls stored separately, appear to cause problems when a session is replayed to an API like DeepSeek. Three are fix-oriented pull requests proposing to merge those messages before replay or before orphan-tool dropping, and one is an issue describing the problem.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55603,"merged_at":"2026-06-30T11:22:57+00:00"},"resolution":{"state":"resolved","by":"pr-55603"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T11:22:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":29148,"pr_number":55603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T11:22:57+00:00"},{"issue_number":49147,"pr_number":55603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T11:22:57+00:00"}],"members":[{"kind":"issue","number":29148,"title":"Parallel tool calls stored as separate assistant messages break DeepSeek session replay","url":null,"state":"closed"},{"kind":"pr","number":29168,"title":"fix(agent): merge consecutive assistant tool_calls before orphan-tool drop (#29148)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cbe397ef458bc715ce82451e722ee0e45a67687e","number":0},{"kind":"pr","value":"#55603","number":55603},{"kind":"pr","value":"#29148","number":29148},{"kind":"pr","value":"#49147","number":49147},{"kind":"pr","value":"#49162","number":49162},{"kind":"pr","value":"#34510","number":34510}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34510,"title":"fix(agent): merge consecutive assistant tool_calls messages on session replay","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29168","number":29168},{"kind":"pr","value":"#29148","number":29148}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":55603,"title":"fix(agent): merge consecutive assistant messages before API replay (#29148, #49147)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_message_sequence_repair.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -368,6 +368,18 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:\n     host code) can feed in already-broken histories.\n \n     Repairs applied:\n+      0. Consecutive ``assistant`` messages with no intervening\n+         ``tool``/``user`` turn — merged into a single assistant turn\n+         (union of ``tool_calls``, concatenated ``content``). Strict\n+         OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject\n+         a history where an ``assistant`` message carrying ``tool_calls``\n+         is immediately followed by another ``assistant`` message instead\n+         of its ``tool`` results — HTTP 400 \"An assistant message with\n+         'tool_calls' must be followed by tool messages…\". The split\n+         shape is produced by recovery/continuation paths that append an\n+         interim assistant turn (thinking-prefill, codex\n+         incomplete-continuation) or by host-fed / legacy-persisted /\n+         resumed histories. Refs #29148, #49147.\n       1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match\n          any preceding assistant tool_call — dropped.\n       2. Consecutive ``user`` messages — merged with newline separator","@@ -387,12 +399,74 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:\n \n     repairs = 0\n \n+    # Pass 0: merge consecutive assistant messages. Runs BEFORE Pass 1 so\n+    # the merged turn's union of tool_call ids is known when Pass 1\n+    # validates which tool-result messages are orphans. Two assistant\n+    # messages are only adjacent here when nothing (no tool result, no\n+    # user turn) separates them — an intervening ``tool`` message means\n+    # two distinct, valid tool-call rounds that must NOT be merged.\n+    #\n+    # Codex Responses interim turns are exempt: the codex_responses\n+    # api_mode legitimately keeps multiple consecutive incomplete\n+    # assistant turns in history, each carrying its own encrypted\n+    # continuation state (codex_reasoning_items / codex_message_items)\n+    # that must be replayed verbatim. Collapsing them corrupts the\n+    # Responses replay chain (the duplicate-detection logic at\n+    # conversation_loop.py already de-dups identical codex interims).\n+    def _is_codex_interim(m: Dict) -> bool:\n+        return bool(\n+            m.get(\"codex_reasoning_items\")\n+            or m.get(\"codex_message_items\")\n+            or m.get(\"finish_reason\") == \"incomplete\"\n+        )\n+\n+    collapsed: List[Dict] = []\n+    for msg in messages:\n+        if (\n+            collapsed\n+            and isinstance(msg, dict)\n+            and msg.get(\"role\") == \"assistant\"\n+            and isinstance(collapsed[-1], dict)\n+            and collapsed[-1].get(\"role\") == \"assistant\"\n+            and not _is_codex_interim(msg)\n+            and not _is_codex_interim(collapsed[-1])\n+        ):\n+            prev = collapsed[-1]\n+            # Union tool_calls (preserve order, both may carry them).\n+            prev_calls = list(prev.get(\"tool_calls\") or [])\n+            new_calls = list(msg.get(\"tool_calls\") or [])\n+            if new_calls:\n+                prev[\"tool_calls\"] = prev_calls + new_calls\n+            elif prev_calls:\n+                prev[\"tool_calls\"] = prev_calls\n+            # Concatenate plain-text content; leave multimodal (list)\n+            # content on either side alone to avoid mangling attachment\n+            # blocks — fall back to keeping the existing content.\n+            prev_content = prev.get(\"content\")\n+            new_content = msg.get(\"content\")\n+            if isinstance(prev_content, str) and isinstance(new_content, str):\n+                joined = \"\\n\".join(\n+                    p for p in (prev_content.strip(), new_content.strip()) if p\n+                )\n+                prev[\"content\"] = joined\n+            elif not prev_content and new_content is not None:\n+                prev[\"content\"] = new_content\n+            # Carry reasoning_content from the later turn only if the\n+            # earlier turn lacks it (strict thinking providers require a\n+            # reasoning_content on the merged tool-call turn; the first\n+            # non-empty one suffices).\n+            if not prev.get(\"reasoning_content\") and msg.get(\"reasoning_content\"):\n+                prev[\"reasoning_content\"] = msg[\"reasoning_content\"]\n+            repairs += 1\n+            continue\n+        collapsed.append(msg)\n+\n     # Pass 1: drop stray tool messages that don't follow a known\n     # assistant tool_call_id. Uses a rolling set of known ids refreshed\n     # on each assistant message.\n     known_tool_ids: set = set()\n     filtered: List[Dict] = []\n-    for msg in messages:\n+    for msg in collapsed:\n         if not isinstance(msg, dict):\n             filtered.append(msg)\n             continue"]},{"file":"tests/run_agent/test_message_sequence_repair.py","hunks":["@@ -298,3 +298,229 @@ def append_message(self, **kw):\n \n     # min(5, 2) = 2 → nothing skipped below start_idx, cursor settles at 2\n     assert agent._last_flushed_db_idx == 2\n+\n+\n+# ── Pass 0: merge consecutive assistant messages (issue #29148, #49147) ─────\n+\n+def test_repair_merges_parallel_tool_calls_split_across_assistants():\n+    \"\"\"Two adjacent assistant(tool_calls) collapse into one turn (#29148).\n+\n+    DeepSeek v4 rejects a replayed history where parallel calls appear as\n+    separate assistant turns:\n+        assistant(tc=[A]) → assistant(tc=[B]) → tool(A) → tool(B)\n+    The repair must produce:\n+        assistant(tc=[A, B]) → tool(A) → tool(B)\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"run both\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"call_A\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"session_search\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"call_B\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"search_files\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_A\", \"content\": \"A\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_B\", \"content\": \"B\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs >= 1\n+    assistant_msgs = [m for m in messages if m.get(\"role\") == \"assistant\"]\n+    assert len(assistant_msgs) == 1\n+    assert {tc[\"id\"] for tc in assistant_msgs[0][\"tool_calls\"]} == {\"call_A\", \"call_B\"}\n+    # Both tool results survive Pass 1 (their ids are in the merged union).\n+    assert sum(1 for m in messages if m.get(\"role\") == \"tool\") == 2\n+\n+\n+def test_repair_merges_content_then_toolcalls_split():\n+    \"\"\"content-only assistant followed by tool_calls-only assistant merge (#49147).\n+\n+    The recovery/continuation paths can leave:\n+        assistant(content=\"Let me search\") → assistant(tool_calls=[A]) → tool(A)\n+    which must become:\n+        assistant(content=\"Let me search\", tool_calls=[A]) → tool(A)\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"search\"},\n+        {\"role\": \"assistant\", \"content\": \"Let me search for that.\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"call_1\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"session_search\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"found\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs >= 1\n+    assistant_msgs = [m for m in messages if m.get(\"role\") == \"assistant\"]\n+    assert len(assistant_msgs) == 1\n+    merged = assistant_msgs[0]\n+    assert merged[\"content\"] == \"Let me search for that.\"\n+    assert len(merged[\"tool_calls\"]) == 1\n+    assert merged[\"tool_calls\"][0][\"id\"] == \"call_1\"\n+    # Tool result still follows immediately.\n+    assert messages[-1][\"role\"] == \"tool\"\n+\n+\n+def test_repair_merges_three_consecutive_assistant_tool_calls():\n+    \"\"\"Three adjacent assistant(tool_calls) turns all collapse into one.\"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"run three\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"c1\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"c2\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"y\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"c3\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"z\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"c1\", \"content\": \"r1\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"c2\", \"content\": \"r2\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"c3\", \"content\": \"r3\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs >= 2\n+    assistant_msgs = [m for m in messages if m.get(\"role\") == \"assistant\"]\n+    assert len(assistant_msgs) == 1\n+    assert len(assistant_msgs[0][\"tool_calls\"]) == 3\n+    assert sum(1 for m in messages if m.get(\"role\") == \"tool\") == 3\n+\n+\n+def test_repair_does_NOT_merge_tool_calls_separated_by_tool_result():\n+    \"\"\"A tool result between two assistant(tool_calls) marks distinct rounds.\n+\n+    This is the critical guard: two sequential tool-call rounds must NOT be\n+    collapsed, or the second round's tool result would orphan.\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"go\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"t1\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"f\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"t1\", \"content\": \"done\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"t2\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"g\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"t2\", \"content\": \"done2\"},\n+    ]\n+    before = sum(1 for m in messages if m.get(\"role\") == \"assistant\")\n+\n+    AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert sum(1 for m in messages if m.get(\"role\") == \"assistant\") == before\n+    # Both tool results survive (neither orphaned).\n+    assert sum(1 for m in messages if m.get(\"role\") == \"tool\") == 2\n+\n+\n+def test_repair_does_NOT_merge_assistant_separated_by_user():\n+    \"\"\"A user turn between two assistants blocks the merge (normal dialog).\"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"q1\"},\n+        {\"role\": \"assistant\", \"content\": \"a1\"},\n+        {\"role\": \"user\", \"content\": \"q2\"},\n+        {\"role\": \"assistant\", \"content\": \"a2\"},\n+    ]\n+\n+    AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert sum(1 for m in messages if m.get(\"role\") == \"assistant\") == 2\n+\n+\n+def test_repair_merges_two_text_only_assistants():\n+    \"\"\"Two consecutive text-only assistants (no tool_calls) still merge.\n+\n+    The empty-response / thinking-prefill paths can leave two adjacent\n+    text assistants; strict providers reject consecutive same-role turns.\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"q\"},\n+        {\"role\": \"assistant\", \"content\": \"First part.\"},\n+        {\"role\": \"assistant\", \"content\": \"Second part.\"},\n+    ]\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs >= 1\n+    assistant_msgs = [m for m in messages if m.get(\"role\") == \"assistant\"]\n+    assert len(assistant_msgs) == 1\n+    assert assistant_msgs[0][\"content\"] == \"First part.\\nSecond part.\"\n+\n+\n+def test_repair_preserves_reasoning_content_on_merge():\n+    \"\"\"Merged tool-call turn keeps a reasoning_content (DeepSeek/Kimi replay).\"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"go\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"reasoning_content\": \"thinking A\",\n+         \"tool_calls\": [{\"id\": \"a\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"f\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [{\"id\": \"b\", \"type\": \"function\",\n+                         \"function\": {\"name\": \"g\", \"arguments\": \"{}\"}}]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"a\", \"content\": \"ra\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"b\", \"content\": \"rb\"},\n+    ]\n+\n+    AIAgent._repair_message_sequence(agent, messages)\n+\n+    merged = [m for m in messages if m.get(\"role\") == \"assistant\"][0]\n+    assert merged.get(\"reasoning_content\") == \"thinking A\"\n+\n+\n+def test_repair_noop_on_valid_parallel_format():\n+    \"\"\"A correctly-formatted single assistant with multiple tool_calls is unchanged.\"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"run both\"},\n+        {\"role\": \"assistant\", \"content\": \"\",\n+         \"tool_calls\": [\n+             {\"id\": \"call_A\", \"type\": \"function\",\n+              \"function\": {\"name\": \"session_search\", \"arguments\": \"{}\"}},\n+             {\"id\": \"call_B\", \"type\": \"function\",\n+              \"function\": {\"name\": \"search_files\", \"arguments\": \"{}\"}},\n+         ]},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_A\", \"content\": \"A\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_B\", \"content\": \"B\"},\n+    ]\n+    original_len = len(messages)\n+\n+    repairs = AIAgent._repair_message_sequence(agent, messages)\n+\n+    assert repairs == 0\n+    assert len(messages) == original_len\n+\n+\n+def test_repair_does_NOT_merge_codex_interim_assistants():\n+    \"\"\"Codex Responses interim turns stay separate (encrypted replay state).\n+\n+    The codex_responses api_mode keeps multiple consecutive incomplete\n+    assistant turns, each carrying distinct codex_reasoning_items /\n+    codex_message_items that must replay verbatim. Pass 0 must exempt them.\n+    Refs test_run_agent_codex_responses.py duplicate-detection tests.\n+    \"\"\"\n+    agent = _bare_agent()\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"think hard\"},\n+        {\"role\": \"assistant\", \"content\": \"\", \"finish_reason\": \"incomplete\",\n+         \"codex_reasoning_items\": [{\"encrypted_content\": \"enc_first\"}]},\n+        {\"role\": \"assistant\", \"content\": \"\", \"finish_reason\": \"incomplete\",\n+         \"codex_reasoning_items\": [{\"encrypted_content\": \"enc_second\"}]},\n+        {\"role\": \"assistant\", \"content\": \"Final answer.\"},\n+    ]\n+\n+    AIAgent._repair_message_sequence(agent, messages)\n+\n+    interim = [m for m in messages if m.get(\"finish_reason\") == \"incomplete\"]\n+    assert len(interim) == 2\n+    encs = [m[\"codex_reasoning_items\"][0][\"encrypted_content\"] for m in interim]\n+    assert \"enc_first\" in encs and \"enc_second\" in encs\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}