mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
fix(compress): don't reach into ContextCompressor privates from /compress (#15039)
Manual /compress crashed with 'LCMEngine' object has no attribute '_align_boundary_forward' when any context-engine plugin was active. The gateway handler reached into _align_boundary_forward and _find_tail_cut_by_tokens on tmp_agent.context_compressor, but those are ContextCompressor-specific — not part of the generic ContextEngine ABC — so every plugin engine (LCM, etc.) raised AttributeError. - Add optional has_content_to_compress(messages) to ContextEngine ABC with a safe default of True (always attempt). - Override it in the built-in ContextCompressor using the existing private helpers — preserves exact prior behavior for 'compressor'. - Rewrite gateway /compress preflight to call the ABC method, deleting the private-helper reach-in. - Add focus_topic to the ABC compress() signature. Make _compress_context retry without focus_topic on TypeError so older strict-sig plugins don't crash on manual /compress <focus>. - Regression test with a fake ContextEngine subclass that only implements the ABC (mirrors LCM's surface). Reported by @selfhostedsoul (Discord, Apr 22).
This commit is contained in:
parent
4350668ae4
commit
a9a4416c7c
8 changed files with 297 additions and 17 deletions
173
tests/gateway/test_compress_plugin_engine.py
Normal file
173
tests/gateway/test_compress_plugin_engine.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Regression test: /compress works with context engine plugins.
|
||||
|
||||
Reported by @selfhostedsoul (Discord, Apr 2026) with the LCM plugin installed:
|
||||
|
||||
Compression failed: 'LCMEngine' object has no attribute '_align_boundary_forward'
|
||||
|
||||
Root cause: the gateway /compress handler used to reach into
|
||||
ContextCompressor-specific private helpers (_align_boundary_forward,
|
||||
_find_tail_cut_by_tokens) for its preflight check. Those helpers are not
|
||||
part of the generic ContextEngine ABC, so any plugin engine (LCM, etc.)
|
||||
raised AttributeError.
|
||||
|
||||
The fix promotes the preflight into an optional ABC method
|
||||
(has_content_to_compress) with a safe default of True.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.context_engine import ContextEngine
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
class _FakePluginEngine(ContextEngine):
|
||||
"""Minimal ContextEngine that only implements the ABC — no private helpers.
|
||||
|
||||
Mirrors the shape of a third-party context engine plugin such as LCM.
|
||||
If /compress reaches into any ContextCompressor-specific internals this
|
||||
engine will raise AttributeError, just like the real bug.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fake-plugin"
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
return False
|
||||
|
||||
def compress(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
current_tokens: int = None,
|
||||
focus_topic: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Pretend we dropped a middle turn.
|
||||
self.compression_count += 1
|
||||
if len(messages) >= 3:
|
||||
return [messages[0], messages[-1]]
|
||||
return list(messages)
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str = "/compress") -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def _make_runner(history: list[dict[str, str]]):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = history
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner.session_store._save = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_works_with_plugin_context_engine():
|
||||
"""/compress must not call ContextCompressor-only private helpers.
|
||||
|
||||
Uses a fake ContextEngine subclass that only implements the ABC —
|
||||
matches what a real plugin (LCM, etc.) exposes. If the gateway
|
||||
reaches into ``_align_boundary_forward`` or ``_find_tail_cut_by_tokens``
|
||||
on this engine, AttributeError propagates and the test fails with the
|
||||
exact user-visible error selfhostedsoul reported.
|
||||
"""
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
runner = _make_runner(history)
|
||||
|
||||
plugin_engine = _FakePluginEngine()
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
# Real plugin engine — no MagicMock auto-attributes masking missing helpers.
|
||||
agent_instance.context_compressor = plugin_engine
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event("/compress"))
|
||||
|
||||
# No AttributeError surfaced as "Compression failed: ..."
|
||||
assert "Compression failed" not in result
|
||||
assert "_align_boundary_forward" not in result
|
||||
assert "_find_tail_cut_by_tokens" not in result
|
||||
# Happy path fired
|
||||
agent_instance._compress_context.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_respects_plugin_has_content_to_compress_false():
|
||||
"""If a plugin reports no compressible content, gateway skips the LLM call."""
|
||||
|
||||
class _EmptyEngine(_FakePluginEngine):
|
||||
def has_content_to_compress(self, messages):
|
||||
return False
|
||||
|
||||
history = _make_history()
|
||||
runner = _make_runner(history)
|
||||
|
||||
plugin_engine = _EmptyEngine()
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor = plugin_engine
|
||||
agent_instance.session_id = "sess-1"
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event("/compress"))
|
||||
|
||||
assert "Nothing to compress" in result
|
||||
agent_instance._compress_context.assert_not_called()
|
||||
Loading…
Add table
Add a link
Reference in a new issue