Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9578,7 +9578,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
_hyg_new_sid = _hyg_agent.session_id
_hyg_rotated = _hyg_new_sid != session_entry.session_id
_hyg_in_place = bool(
getattr(_hyg_agent, "compression_in_place", False)
getattr(_hyg_agent, "_last_compaction_in_place", False)
)
if _hyg_rotated:
session_entry.session_id = _hyg_new_sid
Expand Down
2 changes: 1 addition & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2859,7 +2859,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
# transcript replaced with the compacted set).
new_session_id = tmp_agent.session_id
rotated = new_session_id != session_entry.session_id
_in_place = bool(getattr(tmp_agent, "compression_in_place", False))
_in_place = bool(getattr(tmp_agent, "_last_compaction_in_place", False))
if rotated:
session_entry.session_id = new_session_id
self.session_store._save()
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,7 @@ def _ensure_hermes_home_managed(home: Path):
# exact route is affected — gpt-5.5 on OpenAI's
# direct API, OpenRouter, and Copilot keep the
# global threshold regardless.
"in_place": False, # When True, compaction rewrites the message
"in_place": True, # When True, compaction rewrites the message
# list and rebuilds the system prompt WITHOUT
# rotating the session id — the conversation
# keeps one durable id for its whole life
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44",
"joaomarcosdias444@gmail.com": "JoaoMarcos44",
"286497132+srojk34@users.noreply.github.com": "srojk34",
"srojk34@users.noreply.github.com": "srojk34", # legacy prefix-less noreply (PR #50098 salvage; #38763)
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
"zheng@omegasys.eu": "omegazheng",
"220877172+james47kjv@users.noreply.github.com": "james47kjv",
Expand Down
4 changes: 4 additions & 0 deletions tests/agent/test_compression_concurrent_fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ def _compress_with_overlap(*_a, **_kw):
compressor._last_aux_model_failure_model = None
compressor._last_aux_model_failure_error = None
agent.context_compressor = compressor
# These tests cover the ROTATION fallback path (forking, child sessions,
# lock contention) — pin in_place=False so they keep exercising it
# regardless of the global default (which flipped to True in #38763).
agent.compression_in_place = False
return agent


Expand Down
4 changes: 4 additions & 0 deletions tests/agent/test_compression_logging_session_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def _build_agent_with_db(db: SessionDB, session_id: str):
compressor._last_aux_model_failure_model = None
compressor._last_aux_model_failure_error = None
agent.context_compressor = compressor
# This test covers the ROTATION fallback (logging session-context follows
# the id rotation) — pin in_place=False so it keeps exercising rotation
# regardless of the global default (flipped to True in #38763).
agent.compression_in_place = False
return agent


Expand Down
3 changes: 3 additions & 0 deletions tests/agent/test_compression_rotation_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = "telegr
compressor._last_aux_model_failure_model = None
compressor._last_aux_model_failure_error = None
agent.context_compressor = compressor
# ROTATION fallback path — pin in_place=False so these keep covering fork
# rotation regardless of the global default (flipped to True in #38763).
agent.compression_in_place = False
return agent


Expand Down
4 changes: 4 additions & 0 deletions tests/gateway/test_compression_concurrent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ def _compress_with_overlap(*_a, **_kw):
compressor._last_aux_model_failure_model = None
compressor._last_aux_model_failure_error = None
agent.context_compressor = compressor
# ROTATION fallback path — pin in_place=False so these keep covering the
# concurrent-rotation lock contract regardless of the global default
# (flipped to True in #38763).
agent.compression_in_place = False
return agent


Expand Down
99 changes: 99 additions & 0 deletions tests/gateway/test_session_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,105 @@ def _compress_context(self, messages, *_args, **_kwargs):
runner.session_store.rewrite_transcript.assert_not_called()


@pytest.mark.asyncio
async def test_session_hygiene_preserves_transcript_when_in_place_configured_but_no_db(monkeypatch, tmp_path):
"""Regression: when compression.in_place is True but the hygiene agent has
no session_db, archive_and_compact cannot run — _last_compaction_in_place
stays False. The guard must read the *result* flag, not the *config* flag,
otherwise the transcript is unconditionally rewritten with only the summary
(permanent data loss identical to #21301)."""
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)

class InPlaceConfiguredAgent:
last_instance = None

def __init__(self, **kwargs):
self.model = kwargs.get("model")
self.session_id = kwargs.get("session_id", "fake-session")
self.compression_in_place = True
self._last_compaction_in_place = False
self._print_fn = None
self.shutdown_memory_provider = MagicMock()
self.close = MagicMock()
type(self).last_instance = self

def _compress_context(self, messages, *_args, **_kwargs):
return ([{"role": "assistant", "content": "summary only"}], None)

fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = InPlaceConfiguredAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)

gateway_run = importlib.import_module("gateway.run")
GatewayRunner = gateway_run.GatewayRunner

adapter = HygieneCaptureAdapter()
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
)
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:group:-1001:17585",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="group",
)
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
runner.session_store.has_any_sessions.return_value = True
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.append_to_transcript = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)

monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100,
)
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "795544298")

event = MessageEvent(
text="hello",
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
thread_id="17585",
user_id="12345",
),
message_id="1",
)

result = await runner._handle_message(event)

assert result == "ok"
# The config says in_place=True, but the DB write failed (no session_db)
# so _last_compaction_in_place is False. Transcript must NOT be rewritten.
runner.session_store.rewrite_transcript.assert_not_called()


@pytest.mark.asyncio
async def test_session_hygiene_warns_user_when_compression_aborts(monkeypatch, tmp_path):
"""When auxiliary compression's summary LLM call fails, the compressor
Expand Down
10 changes: 8 additions & 2 deletions tests/run_agent/test_compression_boundary_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class TestCompressionBoundaryHook:
def _make_agent(self, session_db):
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
from run_agent import AIAgent
return AIAgent(
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
Expand All @@ -32,6 +32,9 @@ def _make_agent(self, session_db):
skip_context_files=True,
skip_memory=True,
)
# ROTATION fallback — pin in_place=False regardless of default (#38763).
agent.compression_in_place = False
return agent

def test_on_session_start_called_with_compression_boundary(self):
from hermes_state import SessionDB
Expand Down Expand Up @@ -167,7 +170,7 @@ class TestSessionCompressEvent:
def _make_agent(self, session_db, event_callback=None):
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
from run_agent import AIAgent
return AIAgent(
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
Expand All @@ -178,6 +181,9 @@ def _make_agent(self, session_db, event_callback=None):
skip_memory=True,
event_callback=event_callback,
)
# ROTATION fallback — pin in_place=False regardless of default (#38763).
agent.compression_in_place = False
return agent

def _stub_compressor(self):
compressor = MagicMock()
Expand Down
12 changes: 8 additions & 4 deletions tests/run_agent/test_in_place_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ def test_rotation_still_preflushes(self):
assert calls["n"] == 1


class TestRotationStillDefault:
class TestRotationFallbackWhenFlagOff:
def test_rotation_when_flag_off(self):
"""Regression guard: flag off => legacy rotation is unchanged."""
"""Rotation is now the OPT-OUT fallback (default flipped to in-place in
#38763). With in_place=False explicitly set, legacy rotation is
unchanged — forks a renamed continuation session."""
from hermes_state import SessionDB
from agent.conversation_compression import compress_context

Expand Down Expand Up @@ -247,10 +249,12 @@ def test_signal_set_on_in_place_unset_on_rotation(self):


class TestInPlaceConfigDefault:
def test_flag_defaults_off(self):
def test_flag_defaults_on(self):
"""In-place is the default as of #38763 (rotation is now opt-out via
compression.in_place: false)."""
from hermes_cli.config import DEFAULT_CONFIG

assert DEFAULT_CONFIG["compression"].get("in_place") is False
assert DEFAULT_CONFIG["compression"].get("in_place") is True


class TestCompactedTurnsStaySearchable:
Expand Down
Loading