From a007ac55c6676d369ea56d2890dd56a8d8485fc0 Mon Sep 17 00:00:00 2001 From: babak <28210818+bnikanjam@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:25:46 -0700 Subject: [PATCH] fix(compress): allow manual /compress when auto-compaction is disabled [overflow error directs users there] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compression.enabled: false is documented (agent/conversation_loop.py overflow path) as disabling *automatic* compaction only — the terminal context-overflow error explicitly tells users to run /compress manually, and the gateway handler has never gated on the flag. But the classic CLI (_manual_compress) and the ACP adapter (/compact) refused with 'Compression is disabled', leaving users at a full context with no manual escape hatch on those surfaces. Remove the stale gates (they predate the overflow-path design; the CLI gate came from the original /compress commit's boilerplate) and unify force=True across all manual-compaction call sites: ACP /compact and the TUI's _compress_session_history (manual-only helper) now bypass the summary-failure cooldown exactly like the CLI and gateway already did. Also reword the ACP /context status line so a disabled-compression agent no longer implies /compact is unavailable. Co-Authored-By: Claude Fable 5 --- acp_adapter/server.py | 11 ++++++--- cli.py | 8 ++++--- tests/acp/test_server.py | 37 ++++++++++++++++++++++++++++++- tests/cli/test_manual_compress.py | 34 ++++++++++++++++++++++++++++ tests/test_tui_gateway_server.py | 28 +++++++++++++++++++++++ tui_gateway/server.py | 6 +++++ 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 76f71ea7cb90..3dfc710b1a82 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -2021,7 +2021,10 @@ class HermesACPAgent(acp.Agent): lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens") if getattr(agent, "compression_enabled", True) is False: - lines.append("Compression is disabled for this agent.") + lines.append( + "Auto-compaction is disabled (compression.enabled: false); " + "/compact still compresses manually." + ) else: lines.append("Tip: run /compress to compress manually before the threshold.") @@ -2048,8 +2051,9 @@ class HermesACPAgent(acp.Agent): return "Nothing to compress — conversation is empty." try: agent = state.agent - if not getattr(agent, "compression_enabled", True): - return "Context compression is disabled for this agent." + # No compression_enabled gate: the flag disables *automatic* + # compaction only; manual /compact must keep working (matches + # the CLI /compress and gateway handlers). if not hasattr(agent, "_compress_context"): return "Context compression not available for this agent." @@ -2074,6 +2078,7 @@ class HermesACPAgent(acp.Agent): getattr(agent, "_cached_system_prompt", "") or "", approx_tokens=approx_tokens, task_id=state.session_id, + force=True, ) finally: agent._session_db = original_session_db diff --git a/cli.py b/cli.py index 870fd468b3aa..62bab605a24f 100644 --- a/cli.py +++ b/cli.py @@ -9929,9 +9929,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): print("(._.) No active agent -- send a message first.") return - if not self.agent.compression_enabled: - print("(._.) Compression is disabled in config.") - return + # No compression_enabled gate here: the config flag disables + # *automatic* compaction only. Manual /compress is an explicit user + # action — the context-overflow error path (conversation_loop.py) + # directs users here when auto-compaction is off, and the gateway's + # /compress handler has never gated on the flag. from hermes_cli.partial_compress import ( extract_compress_flags, diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 3333dc967e35..c2a40d7bf056 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -1723,12 +1723,13 @@ class TestSlashCommands: original_session_db = object() state.agent._session_db = original_session_db - def _compress_context(messages, system_prompt, *, approx_tokens, task_id): + def _compress_context(messages, system_prompt, *, approx_tokens, task_id, force): assert state.agent._session_db is None assert messages == state.history assert system_prompt == "system" assert approx_tokens == 40 assert task_id == state.session_id + assert force is True return [{"role": "user", "content": "summary"}], "new-system" state.agent._compress_context = MagicMock(side_effect=_compress_context) @@ -1756,9 +1757,43 @@ class TestSlashCommands: "system", approx_tokens=40, task_id=state.session_id, + force=True, ) mock_save.assert_called_once_with(state.session_id) + def test_compact_works_when_auto_compaction_disabled(self, agent, mock_manager): + """compression.enabled: false disables *automatic* compaction only — + manual /compact must still compress (matches CLI /compress and the + gateway handler).""" + state = self._make_state(mock_manager) + state.history = [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + {"role": "assistant", "content": "four"}, + ] + state.agent.compression_enabled = False + state.agent._cached_system_prompt = "system" + state.agent.tools = None + state.agent._session_db = None + state.agent._compress_context = MagicMock( + return_value=([{"role": "user", "content": "summary"}], "new-system") + ) + + with ( + patch.object(agent.session_manager, "save_session"), + patch( + "agent.model_metadata.estimate_request_tokens_rough", + side_effect=[40, 12], + ), + ): + result = agent._handle_slash_command("/compact", state) + + assert "disabled" not in result.lower() + assert "Context compressed: 4 -> 1 messages" in result + state.agent._compress_context.assert_called_once() + assert state.agent._compress_context.call_args.kwargs.get("force") is True + def test_unknown_command_returns_none(self, agent, mock_manager): state = self._make_state(mock_manager) result = agent._handle_slash_command("/nonexistent", state) diff --git a/tests/cli/test_manual_compress.py b/tests/cli/test_manual_compress.py index 054b1a6005c6..79ff8edb6b20 100644 --- a/tests/cli/test_manual_compress.py +++ b/tests/cli/test_manual_compress.py @@ -188,6 +188,40 @@ def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged() shell.agent._flush_messages_to_session_db.assert_not_called() +def test_manual_compress_runs_when_auto_compaction_disabled(capsys): + """compression.enabled: false disables *automatic* compaction only. + + Manual /compress must still work: the context-overflow error path + (agent/conversation_loop.py) explicitly directs users to /compress when + auto-compaction is off, and the gateway's /compress handler has never + gated on the flag. Regression for the CLI refusing with "Compression is + disabled in config." + """ + shell = _make_cli() + history = _make_history() + compressed = [ + {"role": "user", "content": "[summary]"}, + history[-1], + ] + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = False + shell.agent._cached_system_prompt = "" + shell.agent.tools = None + shell.agent.session_id = shell.session_id + shell.agent._compress_context.return_value = (compressed, "") + + with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100): + shell._manual_compress() + + output = capsys.readouterr().out + assert "Compression is disabled" not in output + shell.agent._compress_context.assert_called_once() + # Manual compression bypasses the summary-failure cooldown. + assert shell.agent._compress_context.call_args.kwargs.get("force") is True + assert shell.conversation_history == compressed + + def test_manual_compress_no_sync_when_session_id_unchanged(): """If compression is a no-op (agent.session_id didn't change), the CLI must NOT clear _pending_title or otherwise disturb session state. diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e3e5efd0fe59..5eadd319731f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5864,6 +5864,34 @@ def test_config_set_personality_preserves_history_and_returns_info(monkeypatch): assert ("session.info", "sid", {"model": "?"}) in emits +def test_compress_session_history_passes_force(): + """_compress_session_history is manual-only (session.compress RPC, slash + compress/compact, slash-worker mirror) — it must bypass the + summary-failure cooldown via force=True, matching the CLI and gateway + manual-compress handlers.""" + from unittest.mock import MagicMock + + agent = MagicMock() + agent.context_compressor = None # keep _get_usage on the simple path + compressed = [{"role": "user", "content": "summary"}] + agent._compress_context.return_value = (compressed, "") + session = _session( + agent=agent, + history=[ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + {"role": "assistant", "content": "four"}, + ], + ) + + removed, _usage = server._compress_session_history(session) + + assert removed == 3 + assert session["history"] == compressed + assert agent._compress_context.call_args.kwargs.get("force") is True + + def test_session_compress_uses_compress_helper(monkeypatch): agent = types.SimpleNamespace() server._sessions["sid"] = _session(agent=agent) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 81e598de8c23..84535d8c0471 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3644,12 +3644,18 @@ def _compress_session_history( # cached prompt (which already contains the agent identity block) # makes the rebuild append the identity a second time. Mirrors the # CLI's _manual_compress fix for issue #15281. + # force=True: every caller of this helper is a manual /compress path + # (session.compress RPC, slash compress/compact, slash-worker mirror) — + # auto-compaction runs inside the agent loop, not here. Manual + # compaction bypasses the summary-failure cooldown, matching the CLI + # and gateway handlers. try: compressed, _ = agent._compress_context( history, None, approx_tokens=approx_tokens, focus_topic=focus_topic or None, + force=True, defer_context_engine_notification=True, ) except Exception: