{"repo":"NousResearch/hermes-agent","public_id":"51218","display_label":"ContextCompressor orphan tool_call handling and related compressor fixes","summary":"One issue reports that ContextCompressor.compress() can leave orphan tool_call blocks, which is associated with an API 400 error on the following turn. Two pull requests propose compressor changes: one strips orphan tool_calls rather than inserting stubs, and a batch change also touches cooldown abort flags and the system-head summary role.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56197,"merged_at":"2026-07-01T08:54:42+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-01T08:54:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":42768,"pr_number":56197,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-01T08:54:42+00:00"},{"issue_number":51218,"pr_number":56197,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-01T08:54:42+00:00"}],"members":[{"kind":"issue","number":51218,"title":"ContextCompressor.compress() leaves orphan tool_call blocks — API 400 (2013) on next turn","url":null,"state":"closed"},{"kind":"pr","number":51225,"title":"fix(compressor): strip orphan tool_calls instead of inserting stubs (#51218)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"32b23bfb0","number":0},{"kind":"pr","value":"#56197","number":56197}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":56197,"title":"fix(compressor): batch salvage \\u2014 orphan tool_calls, cooldown abort flags, system-head summary role (#51225 #52056 #52167)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","tests/agent/test_context_compressor.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -2092,8 +2092,16 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,\n            The API rejects this because every tool_call must be followed by\n            a tool result with the matching call_id.\n \n-        This method removes orphaned results and inserts stub results for\n-        orphaned calls so the message list is always well-formed.\n+        This method removes orphaned results and strips orphaned tool_calls\n+        from assistant messages so the message list is always well-formed.\n+\n+        Previous approach inserted stub ``role=\"tool\"`` results for orphaned\n+        tool_calls.  That caused a secondary failure: the pre-API\n+        ``repair_message_sequence()`` uses ``tc.get(\"id\")`` to track known\n+        call IDs while this sanitizer uses ``call_id || id``.  When the two\n+        disagree (Codex Responses API format: ``id != call_id``), stubs get\n+        silently dropped by the repair pass, re-exposing the original orphans.\n+        Stripping at the source avoids this entire class of mismatch.\n         \"\"\"\n         surviving_call_ids: set = set()\n         for msg in messages:","@@ -2120,24 +2128,34 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str,\n             if not self.quiet_mode:\n                 logger.info(\"Compression sanitizer: removed %d orphaned tool result(s)\", len(orphaned_results))\n \n-        # 2. Add stub results for assistant tool_calls whose results were dropped\n+        # 2. Strip orphaned tool_calls from assistant messages whose results\n+        #    were dropped.  Stripping is preferred over inserting stub results\n+        #    because stubs can be dropped by downstream repair_message_sequence\n+        #    when call_id != id (Codex Responses API format), re-exposing orphans.\n         missing_results = surviving_call_ids - result_call_ids\n         if missing_results:\n-            patched: List[Dict[str, Any]] = []\n             for msg in messages:\n-                patched.append(msg)\n-                if msg.get(\"role\") == \"assistant\":\n-                    for tc in msg.get(\"tool_calls\") or []:\n-                        cid = self._get_tool_call_id(tc)\n-                        if cid in missing_results:\n-                            patched.append({\n-                                \"role\": \"tool\",\n-                                \"content\": \"[Result from earlier conversation — see context summary above]\",\n-                                \"tool_call_id\": cid,\n-                            })\n-            messages = patched\n+                if msg.get(\"role\") != \"assistant\":\n+                    continue\n+                tcs = msg.get(\"tool_calls\")\n+                if not tcs:\n+                    continue\n+                kept = [tc for tc in tcs if self._get_tool_call_id(tc) not in missing_results]\n+                if len(kept) != len(tcs):\n+                    if kept:\n+                        msg[\"tool_calls\"] = kept\n+                    else:\n+                        msg.pop(\"tool_calls\", None)\n+                        # Ensure the assistant message still has visible\n+                        # content so the API does not reject an empty turn.\n+                        content = msg.get(\"content\")\n+                        if not content or (isinstance(content, str) and not content.strip()):\n+                            msg[\"content\"] = \"(tool call removed)\"\n             if not self.quiet_mode:\n-                logger.info(\"Compression sanitizer: added %d stub tool result(s)\", len(missing_results))\n+                logger.info(\n+                    \"Compression sanitizer: stripped %d orphaned tool_call(s) from assistant messages\",\n+                    len(missing_results),\n+                )\n \n         return messages\n ","@@ -2595,8 +2613,16 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         self._last_aux_model_failure_error = None\n         self._last_aux_model_failure_model = None\n         self._last_compress_aborted = False\n-        self._last_summary_auth_failure = False\n-        self._last_summary_network_failure = False\n+        # NOTE: do NOT reset _last_summary_auth_failure or\n+        # _last_summary_network_failure here.  These flags are set by\n+        # _generate_summary() on a terminal failure and are already cleared on\n+        # a successful summary.  Resetting them eagerly defeats the cooldown\n+        # protection: _generate_summary() returns None from the cooldown\n+        # early-return without re-asserting these flags, so the abort guard\n+        # below would see False and fall through to the destructive\n+        # static-fallback — the exact data-loss #29559 describes.  Letting them\n+        # persist across compress() calls is safe because a successful summary\n+        # always clears both.\n \n         # Manual /compress (force=True) bypasses the failure cooldown so the\n         # user can retry immediately after an auto-compress abort.  Without","@@ -2792,9 +2818,17 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         _merge_summary_into_tail = False\n         last_head_role = messages[compress_start - 1].get(\"role\", \"user\") if compress_start > 0 else \"user\"\n         first_tail_role = messages[compress_end].get(\"role\", \"user\") if compress_end < n_messages else \"user\"\n+        # When the only protected head message is the system prompt, the\n+        # summary becomes the first *visible* message in the API request\n+        # (most adapters — Anthropic, Bedrock — send the system prompt as\n+        # a separate ``system`` parameter, not inside ``messages[]``).\n+        # Anthropic unconditionally rejects requests whose first message\n+        # is not role=user, so we must pin the summary to \"user\" and\n+        # prevent the flip logic below from reverting it (#52160).\n+        _force_user_leading = last_head_role == \"system\"\n         # Pick a role that avoids consecutive same-role with both neighbors.\n         # Priority: avoid colliding with head (already committed), then tail.\n-        if last_head_role in {\"assistant\", \"tool\"}:\n+        if last_head_role in {\"assistant\", \"tool\"} or _force_user_leading:\n             summary_role = \"user\"\n         else:\n             summary_role = \"assistant\"","@@ -2802,7 +2836,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\n         # collide with the head, flip it.\n         if summary_role == first_tail_role:\n             flipped = \"assistant\" if summary_role == \"user\" else \"user\"\n-            if flipped != last_head_role:\n+            if flipped != last_head_role and not _force_user_leading:\n                 summary_role = flipped\n             else:\n                 # Both roles would create consecutive same-role messages"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -2919,3 +2919,284 @@ def test_no_orphan_after_full_compaction_cycle(self, compressor):\n                 f\"Orphan user turn at tail start: {tail[0]['content']!r} — \"\n                 f\"next role is {tail[1].get('role') if len(tail) > 1 else 'nothing'}\"\n             )\n+\n+\n+class TestSanitizerStripsOrphanedToolCalls:\n+    \"\"\"PR #51218 (salvaged from #51225): orphaned tool_calls are stripped from\n+    assistant messages instead of having stub tool results inserted, avoiding\n+    the call_id != id mismatch that let downstream repair_message_sequence drop\n+    the stubs and re-expose orphans.\"\"\"\n+\n+    def test_sanitizer_strips_orphaned_tool_calls(self, compressor):\n+        \"\"\"Orphaned tool_calls (no matching tool result) are stripped from\n+        assistant messages instead of having stubs inserted.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"user\", \"content\": \"never mind\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        # Orphaned tool_call should be stripped, not stub-inserted\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert not asst.get(\"tool_calls\"), \"orphaned tool_calls should be stripped\"\n+        # No stub tool messages should be added\n+        assert not any(m.get(\"role\") == \"tool\" for m in sanitized)\n+        # Empty assistant should get placeholder content\n+        assert asst.get(\"content\") == \"(tool call removed)\"\n+\n+    def test_sanitizer_strips_orphaned_keeps_valid(self, compressor):\n+        \"\"\"When an assistant has both valid and orphaned tool_calls, only\n+        the orphans are stripped.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_valid\", \"function\": {\"name\": \"read_file\", \"arguments\": \"{}\"}},\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"tc_valid\", \"content\": \"file content\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert len(asst[\"tool_calls\"]) == 1\n+        assert asst[\"tool_calls\"][0][\"id\"] == \"tc_valid\"\n+        # Valid tool result preserved\n+        tool_msgs = [m for m in sanitized if m.get(\"role\") == \"tool\"]\n+        assert len(tool_msgs) == 1\n+        assert tool_msgs[0][\"tool_call_id\"] == \"tc_valid\"\n+\n+    def test_sanitizer_strips_orphaned_preserves_text_content(self, compressor):\n+        \"\"\"When an assistant has text content AND orphaned tool_calls,\n+        the text is preserved and only tool_calls are stripped.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"Let me search for that.\",\n+                \"tool_calls\": [\n+                    {\"id\": \"tc_orphan\", \"function\": {\"name\": \"search\", \"arguments\": \"{}\"}},\n+                ],\n+            },\n+            {\"role\": \"user\", \"content\": \"thanks\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert asst[\"content\"] == \"Let me search for that.\"\n+        assert not asst.get(\"tool_calls\")\n+        # The placeholder must NOT overwrite existing text content.\n+        assert asst[\"content\"] != \"(tool call removed)\"\n+\n+    def test_sanitizer_strips_orphaned_with_call_id_mismatch(self, compressor):\n+        \"\"\"Stubs with call_id != id used to be dropped by downstream\n+        repair_message_sequence, re-exposing orphans.  Stripping avoids\n+        this entirely.  #51218\"\"\"\n+        msgs = [\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"fc_abc\",\n+                        \"call_id\": \"call_abc\",\n+                        \"function\": {\"name\": \"search\", \"arguments\": \"{}\"},\n+                    },\n+                ],\n+            },\n+            # No tool result for call_abc — orphaned\n+            {\"role\": \"user\", \"content\": \"next\"},\n+        ]\n+\n+        sanitized = compressor._sanitize_tool_pairs(msgs)\n+\n+        asst = next(m for m in sanitized if m.get(\"role\") == \"assistant\")\n+        assert not asst.get(\"tool_calls\")\n+        # No stub tool messages (which would have call_id != id mismatch)\n+\n+\n+class TestCooldownReentryAbort:\n+    \"\"\"Regression: a second compress() call during the failure cooldown must\n+    still abort when the original failure was a network/auth error.\n+\n+    Before the fix, compress() unconditionally reset _last_summary_network_failure\n+    and _last_summary_auth_failure at the top of every call.  When\n+    _generate_summary() returned None from the cooldown early-return (without\n+    re-setting the flags), the abort guard saw False and fell through to the\n+    destructive static-fallback path — reproducing the data-loss scenario from\n+    #29559 / #25585 that PR #51881 originally fixed.\n+    \"\"\"\n+\n+    def _msgs(self, n=12):\n+        return [\n+            {\"role\": \"user\" if i % 2 == 0 else \"assistant\", \"content\": f\"msg {i}\"}\n+            for i in range(n)\n+        ]\n+\n+    def test_network_failure_cooldown_reentry_still_aborts(self):\n+        \"\"\"ConnectionError → first compress aborts (PR #51881).  Second\n+        compress within the 30s cooldown must ALSO abort — not drop the\n+        middle window via the static-fallback path.\"\"\"\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\",\n+                quiet_mode=True,\n+                protect_first_n=2,\n+                protect_last_n=2,\n+                abort_on_summary_failure=False,\n+            )\n+        msgs = self._msgs(12)\n+\n+        with patch(\n+            \"agent.context_compressor.call_llm\",\n+            side_effect=ConnectionError(\"Connection error.\"),\n+        ):\n+            first = c.compress(msgs, current_tokens=[redacted:secret-assignment] force=True)\n+        assert first == msgs\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_network_failure is True\n+\n+        second = c.compress(msgs, current_tokens=[redacted:secret-assignment]\n+        assert second == msgs, (\n+            \"Second compress during cooldown must abort (preserve messages), \"\n+            \"not drop the middle window via static-fallback\"\n+        )\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_fallback_used is False\n+\n+    def test_auth_failure_cooldown_reentry_still_aborts(self):\n+        \"\"\"Same re-entry hole for auth failures: a 401 sets the flag, cooldown\n+        returns None, second compress must still abort.\"\"\"\n+        err = Exception(\"Error code: 401 - invalid api key\")\n+        err.status_code = 401\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\",\n+                quiet_mode=True,\n+                protect_first_n=2,\n+                protect_last_n=2,\n+                abort_on_summary_failure=False,\n+            )\n+        msgs = self._msgs(12)\n+\n+        with patch(\"agent.context_compressor.call_llm\", side_effect=err):\n+            first = c.compress(msgs, current_tokens=[redacted:secret-assignment] force=True)\n+        assert first == msgs\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_auth_failure is True\n+\n+        second = c.compress(msgs, current_tokens=[redacted:secret-assignment]\n+        assert second == msgs, (\n+            \"Second compress during cooldown must abort (preserve messages), \"\n+            \"not drop the middle window via static-fallback\"\n+        )\n+        assert c._last_compress_aborted is True\n+        assert c._last_summary_fallback_used is False\n+\n+\n+class TestDoubleCompactionSummaryRole:\n+    \"\"\"PR #52160 (salvaged from #52167): when only the system prompt is\n+    protected, the summary must lead with role=user (Anthropic/Bedrock send\n+    system as a separate param, so the summary is the first visible message).\"\"\"\n+\n+    def test_double_compaction_summary_must_be_user_when_only_system_protected(self):\n+        \"\"\"After the first compression, protect_first_n decays to 0.\n+\n+        On the second compression the only protected head message is the\n+        system prompt (role=system).  The summary becomes the first\n+        *visible* message in the API request because adapters like\n+        Anthropic and Bedrock send the system prompt as a separate\n+        ``system`` parameter.  The summary MUST be role=user or the\n+        provider rejects with HTTP 400 (#52160).\n+        \"\"\"\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"summary of earlier turns\"\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2,\n+            )\n+        # Simulate second compression: protect_first_n decays to 0.\n+        c.compression_count = 1\n+\n+        # compress_start will be 1 (system only), last_head_role = \"system\".\n+        # Without the fix, summary_role would be \"assistant\".\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 2\"},\n+            {\"role\": \"user\", \"content\": \"msg 3\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        # The system message must still be at index 0.\n+        assert result[0][\"role\"] == \"system\"\n+        # The summary (first non-system message) must be role=user.\n+        non_system = [m for m in result if m.get(\"role\") != \"system\"]\n+        assert non_system, \"expected at least one non-system message\"\n+        assert non_system[0][\"role\"] == \"user\", (\n+            f\"first non-system message must be role=user for Anthropic \"\n+            f\"compatibility, got role={non_system[0]['role']!r}\"\n+        )\n+\n+    def test_double_compaction_user_tail_merges_into_tail(self):\n+        \"\"\"When the summary is forced to role=user (system-only head) and\n+        the first tail message is also user, the summary must merge into\n+        the tail rather than flipping back to assistant (#52160).\n+        \"\"\"\n+        mock_response = MagicMock()\n+        mock_response.choices = [MagicMock()]\n+        mock_response.choices[0].message.content = \"summary of earlier turns\"\n+\n+        with patch(\"agent.context_compressor.get_model_context_length\", return_value=100000):\n+            c = ContextCompressor(\n+                model=\"test\", quiet_mode=True, protect_first_n=2, protect_last_n=2,\n+            )\n+        c.compression_count = 1  # decay protect_first_n\n+\n+        # tail starts with user → would collide with forced summary_role=user.\n+        # The fix should merge into tail instead of flipping to assistant.\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+            {\"role\": \"user\", \"content\": \"msg 1\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 2\"},\n+            {\"role\": \"user\", \"content\": \"msg 3\"},\n+            {\"role\": \"assistant\", \"content\": \"msg 4\"},\n+            {\"role\": \"user\", \"content\": \"msg 5\"},       # tail start (user)\n+            {\"role\": \"assistant\", \"content\": \"msg 6\"},\n+            {\"role\": \"user\", \"content\": \"msg 7\"},\n+        ]\n+        with patch(\"agent.context_compressor.call_llm\", return_value=mock_response):\n+            result = c.compress(msgs)\n+\n+        # No standalone summary message should exist (merged into tail).\n+        summary_msgs = [\n+            m for m in result\n+            if m.get(\"_compressed_summary\") and \"msg 5\" not in (m.get(\"content\") or \"\")\n+        ]\n+        assert len(summary_msgs) == 0, (\n+            \"summary should be merged into tail, not standalone\"\n+        )\n+        # The first non-system message must be role=user.\n+        non_system = [m for m in result if m.get(\"role\") != \"system\"]\n+        assert non_system[0][\"role\"] == \"user\"\n+        # The merged tail should contain the summary text.\n+        assert any(\n+            \"summary of earlier turns\" in (m.get(\"content\") or \"\")\n+            for m in result\n+        )\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}