fix(compress): allow manual /compress when auto-compaction is disabled [overflow error directs users there]

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 <noreply@anthropic.com>
This commit is contained in:
babak 2026-07-12 23:25:46 -07:00 committed by Teknium
parent e907ecccef
commit a007ac55c6
6 changed files with 117 additions and 7 deletions

View file

@ -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

8
cli.py
View file

@ -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,

View file

@ -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)

View file

@ -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.

View file

@ -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)

View file

@ -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: