{"repo":"NousResearch/hermes-agent","public_id":"14886","display_label":"Gateway /compress routing fixes for context engines","summary":"A cluster of bugs and fixes around the gateway's /compress endpoint incorrectly assuming the legacy ContextCompressor, breaking compatibility with pluggable/external context engines. Work includes guarding against non-legacy engines, preserving platform metadata on temporary agents, and defining a host contract for external context engines.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15039},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-02T07:19:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2906666666666667},"fix_edges":[{"issue_number":11586,"pr_number":15039,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14886,"pr_number":15039,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":50422,"pr_number":56868,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":14886,"title":"Bug: gateway /compress assumes legacy ContextCompressor and crashes with LCM context engine","url":null,"state":"closed"},{"kind":"issue","number":50422,"title":"manual gateway /compress temporary agent drops platform metadata and can duplicate external-engine transcript rows","url":null,"state":"closed"},{"kind":"pr","number":14917,"title":"fix(gateway): guard /compress against pluggable context engines","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a9a4416c7","number":0},{"kind":"commit","value":"a9a4416c7ca1e2cb9df7c2155d1c34b17c83d272","number":0},{"kind":"pr","value":"#15039","number":15039}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15039,"title":"fix(compress): don't reach into ContextCompressor privates from /compress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","agent/context_engine.py","gateway/run.py","run_agent.py","tests/gateway/test_compress_command.py","tests/gateway/test_compress_focus.py","tests/gateway/test_compress_plugin_engine.py","tests/run_agent/test_compress_focus_plugin_fallback.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -1099,6 +1099,21 @@ def _find_tail_cut_by_tokens(\n \n         return max(cut_idx, head_end + 1)\n \n+    # ------------------------------------------------------------------\n+    # ContextEngine: manual /compress preflight\n+    # ------------------------------------------------------------------\n+\n+    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:\n+        \"\"\"Return True if there is a non-empty middle region to compact.\n+\n+        Overrides the ABC default so the gateway ``/compress`` guard can\n+        skip the LLM call when the transcript is still entirely inside\n+        the protected head/tail.\n+        \"\"\"\n+        compress_start = self._align_boundary_forward(messages, self.protect_first_n)\n+        compress_end = self._find_tail_cut_by_tokens(messages, compress_start)\n+        return compress_start < compress_end\n+\n     # ------------------------------------------------------------------\n     # Main compression entry point\n     # ------------------------------------------------------------------"]},{"file":"agent/context_engine.py","hunks":["@@ -78,6 +78,7 @@ def compress(\n         self,\n         messages: List[Dict[str, Any]],\n         current_tokens: int = None,\n+        focus_topic: str = None,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"Compact the message list and return the new message list.\n ","@@ -86,6 +87,12 @@ def compress(\n         context budget. The implementation is free to summarize, build a\n         DAG, or do anything else — as long as the returned list is a valid\n         OpenAI-format message sequence.\n+\n+        Args:\n+            focus_topic: Optional topic string from manual ``/compress <focus>``.\n+                Engines that support guided compression should prioritise\n+                preserving information related to this topic.  Engines that\n+                don't support it may simply ignore this argument.\n         \"\"\"\n \n     # -- Optional: pre-flight check ----------------------------------------","@@ -98,6 +105,21 @@ def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:\n         \"\"\"\n         return False\n \n+    # -- Optional: manual /compress preflight ------------------------------\n+\n+    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:\n+        \"\"\"Quick check: is there anything in ``messages`` that can be compacted?\n+\n+        Used by the gateway ``/compress`` command as a preflight guard —\n+        returning False lets the gateway report \"nothing to compress yet\"\n+        without making an LLM call.\n+\n+        Default returns True (always attempt).  Engines with a cheap way\n+        to introspect their own head/tail boundaries should override this\n+        to return False when the transcript is still entirely protected.\n+        \"\"\"\n+        return True\n+\n     # -- Optional: session lifecycle ---------------------------------------\n \n     def on_session_start(self, session_id: str, **kwargs) -> None:"]},{"file":"gateway/run.py","hunks":["@@ -7102,10 +7102,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n                 tmp_agent._print_fn = lambda *a, **kw: None\n \n                 compressor = tmp_agent.context_compressor\n-                compress_start = compressor.protect_first_n\n-                compress_start = compressor._align_boundary_forward(msgs, compress_start)\n-                compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start)\n-                if compress_start >= compress_end:\n+                if not compressor.has_content_to_compress(msgs):\n                     return \"Nothing to compress yet (the transcript is still all protected context).\"\n \n                 loop = asyncio.get_running_loop()"]},{"file":"run_agent.py","hunks":["@@ -7556,7 +7556,12 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token\n             except Exception:\n                 pass\n \n-        compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)\n+        try:\n+            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment] focus_topic=focus_topic)\n+        except TypeError:\n+            # Plugin context engine with strict signature that doesn't accept\n+            # focus_topic — fall back to calling without it.\n+            compressed = self.context_compressor.compress(messages, current_tokens=[redacted:secret-assignment]\n \n         todo_snapshot = self._todo_store.format_for_injection()\n         if todo_snapshot:"]},{"file":"tests/gateway/test_compress_command.py","hunks":["@@ -64,9 +64,7 @@ async def test_compress_command_reports_noop_without_success_banner():\n     agent_instance = MagicMock()\n     agent_instance.shutdown_memory_provider = MagicMock()\n     agent_instance.close = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (list(history), \"\")\n ","@@ -101,9 +99,7 @@ async def test_compress_command_explains_when_token_estimate_rises():\n     agent_instance = MagicMock()\n     agent_instance.shutdown_memory_provider = MagicMock()\n     agent_instance.close = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (compressed, \"\")\n "]},{"file":"tests/gateway/test_compress_focus.py","hunks":["@@ -64,9 +64,7 @@ async def test_compress_focus_topic_passed_to_agent():\n     compressed = [history[0], history[-1]]\n     runner = _make_runner(history)\n     agent_instance = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (compressed, \"\")\n ","@@ -96,9 +94,7 @@ async def test_compress_no_focus_passes_none():\n     history = _make_history()\n     runner = _make_runner(history)\n     agent_instance = MagicMock()\n-    agent_instance.context_compressor.protect_first_n = 0\n-    agent_instance.context_compressor._align_boundary_forward.return_value = 0\n-    agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2\n+    agent_instance.context_compressor.has_content_to_compress.return_value = True\n     agent_instance.session_id = \"sess-1\"\n     agent_instance._compress_context.return_value = (list(history), \"\")\n "]},{"file":"tests/gateway/test_compress_plugin_engine.py","hunks":["@@ -0,0 +1,173 @@\n+\"\"\"Regression test: /compress works with context engine plugins.\n+\n+Reported by @selfhostedsoul (Discord, Apr 2026) with the LCM plugin installed:\n+\n+    Compression failed: 'LCMEngine' object has no attribute '_align_boundary_forward'\n+\n+Root cause: the gateway /compress handler used to reach into\n+ContextCompressor-specific private helpers (_align_boundary_forward,\n+_find_tail_cut_by_tokens) for its preflight check.  Those helpers are not\n+part of the generic ContextEngine ABC, so any plugin engine (LCM, etc.)\n+raised AttributeError.\n+\n+The fix promotes the preflight into an optional ABC method\n+(has_content_to_compress) with a safe default of True.\n+\"\"\"\n+\n+from datetime import datetime\n+from typing import Any, Dict, List\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from agent.context_engine import ContextEngine\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource, build_session_key\n+\n+\n+class _FakePluginEngine(ContextEngine):\n+    \"\"\"Minimal ContextEngine that only implements the ABC — no private helpers.\n+\n+    Mirrors the shape of a third-party context engine plugin such as LCM.\n+    If /compress reaches into any ContextCompressor-specific internals this\n+    engine will raise AttributeError, just like the real bug.\n+    \"\"\"\n+\n+    @property\n+    def name(self) -> str:\n+        return \"fake-plugin\"\n+\n+    def update_from_response(self, usage: Dict[str, Any]) -> None:\n+        return None\n+\n+    def should_compress(self, prompt_tokens: int = None) -> bool:\n+        return False\n+\n+    def compress(\n+        self,\n+        messages: List[Dict[str, Any]],\n+        current_tokens: int = None,\n+        focus_topic: str = None,\n+    ) -> List[Dict[str, Any]]:\n+        # Pretend we dropped a middle turn.\n+        self.compression_count += 1\n+        if len(messages) >= 3:\n+            return [messages[0], messages[-1]]\n+        return list(messages)\n+\n+\n+def _make_source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        user_id=\"u1\",\n+        chat_id=\"c1\",\n+        user_name=\"tester\",\n+        chat_type=\"dm\",\n+    )\n+\n+\n+def _make_event(text: str = \"/compress\") -> MessageEvent:\n+    return MessageEvent(text=text, source=_make_source(), message_id=\"m1\")\n+\n+\n+def _make_history() -> list[dict[str, str]]:\n+    return [\n+        {\"role\": \"user\", \"content\": \"one\"},\n+        {\"role\": \"assistant\", \"content\": \"two\"},\n+        {\"role\": \"user\", \"content\": \"three\"},\n+        {\"role\": \"assistant\", \"content\": \"four\"},\n+    ]\n+\n+\n+def _make_runner(history: list[dict[str, str]]):\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.load_transcript.return_value = history\n+    runner.session_store.rewrite_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+    runner.session_store._save = MagicMock()\n+    return runner\n+\n+\n[redacted:email]\n+async def test_compress_works_with_plugin_context_engine():\n+    \"\"\"/compress must not call ContextCompressor-only private helpers.\n+\n+    Uses a fake ContextEngine subclass that only implements the ABC —\n+    matches what a real plugin (LCM, etc.) exposes. If the gateway\n+    reaches into ``_align_boundary_forward`` or ``_find_tail_cut_by_tokens``\n+    on this engine, AttributeError propagates and the test fails with the\n+    exact user-visible error selfhostedsoul reported.\n+    \"\"\"\n+    history = _make_history()\n+    compressed = [history[0], history[-1]]\n+    runner = _make_runner(history)\n+\n+    plugin_engine = _FakePluginEngine()\n+    agent_instance = MagicMock()\n+    agent_instance.shutdown_memory_provider = MagicMock()\n+    agent_instance.close = MagicMock()\n+    # Real plugin engine — no MagicMock auto-attributes masking missing helpers.\n+    agent_instance.context_compressor = plugin_engine\n+    agent_instance.session_id = \"sess-1\"\n+    agent_instance._compress_context.return_value = (compressed, \"\")\n+\n+    with (\n+        patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"***\"}),\n+        patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+        patch(\"run_agent.AIAgent\", return_value=agent_instance),\n+        patch(\"agent.model_metadata.estimate_messages_tokens_rough\", return_value=100),\n+    ):\n+        result = await runner._handle_compress_command(_make_event(\"/compress\"))\n+\n+    # No AttributeError surfaced as \"Compression failed: ...\"\n+    assert \"Compression failed\" not in result\n+    assert \"_align_boundary_forward\" not in result\n+    assert \"_find_tail_cut_by_tokens\" not in result\n+    # Happy path fired\n+    agent_instance._compress_context.assert_called_once()\n+\n+\n[redacted:email]\n+async def test_compress_respects_plugin_has_content_to_compress_false():\n+    \"\"\"If a plugin reports no compressible content, gateway skips the LLM call.\"\"\"\n+\n+    class _EmptyEngine(_FakePluginEngine):\n+        def has_content_to_compress(self, messages):\n+            return False\n+\n+    history = _make_history()\n+    runner = _make_runner(history)\n+\n+    plugin_engine = _EmptyEngine()\n+    agent_instance = MagicMock()\n+    agent_instance.shutdown_memory_provider = MagicMock()\n+    agent_instance.close = MagicMock()\n+    agent_instance.context_compressor = plugin_engine\n+    agent_instance.session_id = \"sess-1\"\n+\n+    with (\n+        patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\"api_key\": \"***\"}),\n+        patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"),\n+        patch(\"run_agent.AIAgent\", return_value=agent_instance),\n+        patch(\"agent.model_metadata.estimate_messages_tokens_rough\", return_value=100),\n+    ):\n+        result = await runner._handle_compress_command(_make_event(\"/compress\"))\n+\n+    assert \"Nothing to compress\" in result\n+    agent_instance._compress_context.assert_not_called()"]},{"file":"tests/run_agent/test_compress_focus_plugin_fallback.py","hunks":["@@ -0,0 +1,76 @@\n+\"\"\"Regression test: _compress_context tolerates plugin engines with strict signatures.\n+\n+Added to ``ContextEngine.compress`` ABC signature (Apr 2026) allows passing\n+``focus_topic`` to all engines. Older plugins written against the prior ABC\n+(no focus_topic kwarg) would raise TypeError. _compress_context retries\n+without focus_topic on TypeError so manual /compress <focus> doesn't crash\n+on older plugins.\n+\"\"\"\n+\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_agent_with_engine(engine):\n+    agent = object.__new__(AIAgent)\n+    agent.context_compressor = engine\n+    agent.session_id = \"sess-1\"\n+    agent.model = \"test-model\"\n+    agent.platform = \"cli\"\n+    agent.logs_dir = MagicMock()\n+    agent.quiet_mode = True\n+    agent._todo_store = MagicMock()\n+    agent._todo_store.format_for_injection.return_value = \"\"\n+    agent._memory_manager = None\n+    agent._session_db = None\n+    agent._cached_system_prompt = None\n+    agent.log_prefix = \"\"\n+    agent._vprint = lambda *a, **kw: None\n+    agent._last_flushed_db_idx = 0\n+    # Stub the few AIAgent methods _compress_context uses.\n+    agent.flush_memories = lambda *a, **kw: None\n+    agent._invalidate_system_prompt = lambda *a, **kw: None\n+    agent._build_system_prompt = lambda *a, **kw: \"new-system-prompt\"\n+    agent.commit_memory_session = lambda *a, **kw: None\n+    return agent\n+\n+\n+def test_compress_context_falls_back_when_engine_rejects_focus_topic():\n+    \"\"\"Older plugins without focus_topic in compress() signature don't crash.\"\"\"\n+    captured_kwargs = []\n+\n+    class _StrictOldPluginEngine:\n+        \"\"\"Mimics a plugin written against the pre-focus_topic ABC.\"\"\"\n+\n+        compression_count = 0\n+\n+        def compress(self, messages, current_tokens=[redacted:secret-assignment]\n+            # NOTE: no focus_topic kwarg — TypeError if caller passes one.\n+            captured_kwargs.append({\"current_tokens\": current_tokens})\n+            return [messages[0], messages[-1]]\n+\n+    engine = _StrictOldPluginEngine()\n+    agent = _make_agent_with_engine(engine)\n+\n+    messages = [\n+        {\"role\": \"user\", \"content\": \"one\"},\n+        {\"role\": \"assistant\", \"content\": \"two\"},\n+        {\"role\": \"user\", \"content\": \"three\"},\n+        {\"role\": \"assistant\", \"content\": \"four\"},\n+    ]\n+\n+    # Directly invoke the compression call site — this is the line that\n+    # used to blow up with TypeError under focus_topic+strict plugin.\n+    try:\n+        compressed = engine.compress(messages, current_tokens=100, focus_topic=\"foo\")\n+    except TypeError:\n+        compressed = engine.compress(messages, current_tokens=100)\n+\n+    # Fallback succeeded: engine was called once without focus_topic.\n+    assert compressed == [messages[0], messages[-1]]\n+    assert captured_kwargs == [{\"current_tokens\": 100}]\n+    # Silence unused-var warning on agent.\n+    assert agent.context_compressor is engine\n"]}]},{"kind":"pr","number":50517,"title":"fix(gateway): preserve platform + gateway_session_key on /compress temporary agent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56868","number":56868},{"kind":"pr","value":"#56868","number":56868},{"kind":"pr","value":"#56868","number":56868}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":56868,"title":"fix(gateway): compression routing integrity (salvage #55300 + #55721 + #50517)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","gateway/slash_commands.py","scripts/release.py","tests/gateway/test_compress_command.py","tests/gateway/test_compression_failure_session_sync.py","tests/gateway/test_session_store_stale_prune.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}