{"repo":"NousResearch/hermes-agent","public_id":"58753","display_label":"Compression dropping required user turn bug fixes","summary":"Multiple fixes addressing a bug where message compression could eliminate the only user-role message, causing a crash. Changes ensure at least one user turn is preserved after compaction.","subsystem":null,"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":58974,"merged_at":"2026-07-05T16:27:38+00:00"},"resolution":{"state":"resolved","by":"pr-58974"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T16:27:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":58753,"pr_number":58974,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-05T16:27:38+00:00"}],"members":[{"kind":"issue","number":58753,"title":"Compression can drop the only user-role message, causing a non-retryable \"No user query found in messages\" crash — hits kanban workers hard","url":null,"state":"closed"},{"kind":"pr","number":58772,"title":"fix(compressor): keep a user turn when compression would drop the only one (#58753)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"abf9638f4eb3dc02d4159bae5c3af86457edd323","number":0},{"kind":"pr","value":"#58974","number":58974}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58974,"title":"fix(compressor): keep a user turn when compression would drop the only one (#58753)","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_compressor_zero_user_guard.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -2910,6 +2910,33 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f\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+        # Zero-user-turn guard (#58753). The #52160 guard above only fires\n+        # when the system prompt sits *inside* ``messages`` (the gateway\n+        # ``/compress`` path). The main auto-compression path passes the\n+        # transcript WITHOUT the system prompt (it is prepended at\n+        # request-build time), so ``last_head_role`` defaults to \"user\" and\n+        # the summary is emitted as role=\"assistant\". On a session whose only\n+        # genuine user turn falls into the compressed middle — e.g. a\n+        # ``hermes kanban`` worker seeded with a single short\n+        # ``\"work kanban task <id>\"`` prompt followed by nothing but\n+        # assistant/tool turns — that leaves the compressed transcript with\n+        # ZERO user-role messages. OpenAI-compatible backends (vLLM/Qwen)\n+        # reject such a request with a non-retryable\n+        # ``400 No user query found in messages``, crashing the worker with no\n+        # possible recovery (every resume replays the same poisoned history).\n+        # If no user-role message survives in either the protected head or the\n+        # preserved tail, the summary MUST carry role=\"user\" so the request\n+        # always has at least one user turn.\n+        if not _force_user_leading:\n+            _user_survives = any(\n+                messages[i].get(\"role\") == \"user\"\n+                for i in range(0, compress_start)\n+            ) or any(\n+                messages[i].get(\"role\") == \"user\"\n+                for i in range(compress_end, n_messages)\n+            )\n+            if not _user_survives:\n+                _force_user_leading = True\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\"} or _force_user_leading:"]},{"file":"tests/agent/test_compressor_zero_user_guard.py","hunks":["@@ -0,0 +1,180 @@\n+\"\"\"Regression coverage for #58753 — compression could drop the only\n+user-role message, leaving a transcript with ZERO user turns.\n+\n+The compressor already pins the handoff summary to ``role=\"user\"`` when\n+the only protected head message is the system prompt (#52160). But that\n+guard keys off ``last_head_role == \"system\"``, which is only true when\n+the system prompt actually sits inside ``messages`` — the gateway\n+``/compress`` path. The main auto-compression path passes the transcript\n+WITHOUT the system prompt (it is prepended at request-build time, see\n+``conversation_loop`` — ``api_messages = [{\"role\": \"system\", ...}] +\n+api_messages``). There ``last_head_role`` defaults to ``\"user\"`` and the\n+summary is emitted as ``role=\"assistant\"``.\n+\n+On a session whose only genuine user turn falls into the compressed\n+middle — the canonical shape being a ``hermes kanban`` worker seeded with\n+a single short ``\"work kanban task <id>\"`` prompt followed by nothing but\n+assistant/tool turns — the compressed output then contains no user-role\n+message at all. OpenAI-compatible backends (vLLM/Qwen) reject such a\n+request with a non-retryable ``400 No user query found in messages``,\n+crashing the worker with no possible recovery (every resume replays the\n+same poisoned history).\n+\n+The fix generalises the #52160 guard: when NO user-role message survives\n+in the protected head or preserved tail, the summary MUST carry\n+``role=\"user\"``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n[redacted:email]()\n+def compressor():\n+    from agent.context_compressor import ContextCompressor\n+\n+    with patch(\n+        \"agent.context_compressor.get_model_context_length\",\n+        return_value=100_000,\n+    ):\n+        c = ContextCompressor(\n+            model=\"test/model\",\n+            threshold_percent=0.50,\n+            protect_first_n=3,\n+            protect_last_n=20,\n+            quiet_mode=True,\n+        )\n+        c.tail_token_budget = 40\n+        return c\n+\n+\n+def _tool_turns(start: int, n: int) -> list[dict]:\n+    out: list[dict] = []\n+    for i in range(start, start + n):\n+        out.append(\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": None,\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": f\"c{i}\",\n+                        \"function\": {\"name\": \"read_task\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            }\n+        )\n+        out.append({\"role\": \"tool\", \"content\": \"x\" * 300, \"tool_call_id\": f\"c{i}\"})\n+    return out\n+\n+\n+def _role_hist(messages: list[dict]) -> dict[str, int]:\n+    hist: dict[str, int] = {}\n+    for m in messages:\n+        hist[m.get(\"role\")] = hist.get(m.get(\"role\"), 0) + 1\n+    return hist\n+\n+\n+class TestCompressAlwaysKeepsAUserTurn:\n+    def test_kanban_worker_recompaction_keeps_user_turn(self, compressor):\n+        \"\"\"The exact #58753 shape: no system prompt in the list, a\n+        re-compaction (``protect_first_n`` decayed to 0), and the only\n+        user turn old enough to fall into the compressed middle. Before\n+        the fix, the summary was emitted as ``assistant`` and the output\n+        had zero user-role messages.\"\"\"\n+        from agent.context_compressor import SUMMARY_PREFIX\n+\n+        c = compressor\n+        # A prior compaction has already happened → protect_first_n decays\n+        # to 0 so compress_start lands at 0 (no protected head).\n+        c.compression_count = 1\n+        # No system message: the main loop prepends it separately.\n+        messages = [{\"role\": \"user\", \"content\": \"work kanban task 42\"}]\n+        messages += _tool_turns(0, 12)\n+\n+        mocked = f\"{SUMMARY_PREFIX}\\nrolled-up summary of the tool work\"\n+        with patch.object(c, \"_generate_summary\", return_value=mocked):\n+            out = c.compress(messages, current_tokens=[redacted:secret-assignment]\n+\n+        hist = _role_hist(out)\n+        assert hist.get(\"user\", 0) >= 1, (\n+            \"REGRESSION (#58753): compression produced a transcript with \"\n+            f\"zero user-role messages, which vLLM/Qwen reject with a \"\n+            f\"non-retryable 400. Role histogram: {hist}\"\n+        )\n+\n+    def test_summary_pinned_to_user_when_no_user_survives(self, compressor):\n+        \"\"\"When the whole compressible region is assistant/tool and no\n+        user message survives in head or tail, the inserted summary\n+        itself must be the user turn.\"\"\"\n+        from agent.context_compressor import (\n+            SUMMARY_PREFIX,\n+            COMPRESSED_SUMMARY_METADATA_KEY,\n+        )\n+\n+        c = compressor\n+        c.compression_count = 1\n+        messages = [{\"role\": \"user\", \"content\": \"work kanban task 7\"}]\n+        messages += _tool_turns(0, 12)\n+\n+        mocked = f\"{SUMMARY_PREFIX}\\nsummary body\"\n+        with patch.object(c, \"_generate_summary\", return_value=mocked):\n+            out = c.compress(messages, current_tokens=[redacted:secret-assignment]\n+\n+        summary_rows = [m for m in out if m.get(COMPRESSED_SUMMARY_METADATA_KEY)]\n+        assert len(summary_rows) == 1\n+        assert summary_rows[0].get(\"role\") == \"user\", (\n+            \"The handoff summary must carry role=user when it is the only \"\n+            \"possible user turn in the compressed transcript (#58753).\"\n+        )\n+\n+    def test_no_consecutive_user_roles_introduced(self, compressor):\n+        \"\"\"Forcing the summary to role=user must not create two\n+        consecutive user-role messages (strict alternation invariant).\n+        When a user survives in the tail we do NOT force, so the pinned\n+        summary can never collide with a user-role neighbour.\"\"\"\n+        from agent.context_compressor import SUMMARY_PREFIX\n+\n+        c = compressor\n+        c.compression_count = 1\n+        messages = [{\"role\": \"user\", \"content\": \"work kanban task 9\"}]\n+        messages += _tool_turns(0, 12)\n+\n+        mocked = f\"{SUMMARY_PREFIX}\\nsummary body\"\n+        with patch.object(c, \"_generate_summary\", return_value=mocked):\n+            out = c.compress(messages, current_tokens=[redacted:secret-assignment]\n+\n+        for prev, cur in zip(out, out[1:]):\n+            assert not (\n+                prev.get(\"role\") == \"user\" and cur.get(\"role\") == \"user\"\n+            ), \"compression introduced consecutive user-role messages\"\n+\n+    def test_preserved_tail_user_is_not_overridden(self, compressor):\n+        \"\"\"When a genuine user message survives in the tail, the guard\n+        must NOT fire (the summary keeps its alternation-driven role) —\n+        the request already has a user turn.\"\"\"\n+        from agent.context_compressor import SUMMARY_PREFIX\n+\n+        c = compressor\n+        c.compression_count = 1\n+        c.tail_token_budget = 10  # tight tail so most turns compress\n+        messages = [{\"role\": \"user\", \"content\": \"old task\"}]\n+        messages += _tool_turns(0, 10)\n+        # A recent, genuine user turn that will be preserved in the tail.\n+        messages += [\n+            {\"role\": \"user\", \"content\": \"the latest live user question\"},\n+            {\"role\": \"assistant\", \"content\": \"on it\"},\n+        ]\n+\n+        mocked = f\"{SUMMARY_PREFIX}\\nsummary body\"\n+        with patch.object(c, \"_generate_summary\", return_value=mocked):\n+            out = c.compress(messages, current_tokens=[redacted:secret-assignment]\n+\n+        hist = _role_hist(out)\n+        assert hist.get(\"user\", 0) >= 1\n+        joined = \"\\n\".join(\n+            m.get(\"content\") for m in out if isinstance(m.get(\"content\"), str)\n+        )\n+        assert \"the latest live user question\" in joined\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}