Files
hermes-agent/tests/gateway/test_compress_command.py
Siddharth Balyan de1626d666 Free tier: a rate limit ends a watched turn at once with the reset time (#134049)
* fix(agent): free-tier rate limits end an attended turn at once

A free-tier (no sign-in) session that hit the service-wide rate limit slept
up to 600 s, retried into the same refusal, slept again, and then sent its
third attempt on a guest JWT that had expired during the wait. The welcome
host answered 403 "invalid jwt", which read as the tier being switched off.
The user watched a spinner for about 18 minutes and then saw the wrong card.

- An attended session (desktop, TUI, the interactive CLI, ACP) on the free
  model ends the attempt cycle at once when the cooldown is longer than
  60 s: fallback first, else the reset time and the sign-in / model doors.
  "Attended" is keyed on the session source, so `hermes chat -q`, cron and
  messaging sessions keep the long wait. The source vocabulary moves from
  run_agent.py into agent/session_source.py, which adds is_attended().
- on_free_model() (welcome host + anonymous credential) is the one free-tier
  check, used by the cutoff, the "free model is busy" wait copy and the
  outage copy.
- A welcome-host 403 "invalid jwt" is its own route kind, session_expired:
  the existing one-shot route heal re-exchanges the credential and retries;
  if that fails the card is the retryable outage card.
- interruptible_backoff_sleep adopts a fresh Nous key after each wait.
  _adopt_nous_key_before_expiry owns its refresh failure, so both call
  sites drop their try/except.
- A retry wait over 60 s is announced in the transcript when it starts, for
  every provider; shorter waits stay buffered. Every wait line names the
  attempt that runs after it.
- max_retries_exhausted_result's max_retries becomes attempts (what it
  carries); a one-attempt rate-limit end gets its own lead.
- One Retry-After reader, retry_utils.provider_retry_after_seconds, shared
  by compute_error_backoff and the recovery ladder.
- _session_default_route replaces _session_default_model everywhere in
  tui_gateway, so a not-yet-built free-tier session reports nous/welcome on
  create, reuse, resume, branch and row persist, not the silent default.

* fix(agent): free-tier cutoff no longer ends subagent or library turns, paid waits look like main (review)

A delegated child of a TUI/Desktop session and a library caller (no platform) are unattended, so they keep waiting.
Rate-limit wait lines keep main's attempt numbers and stay buffered for every provider; only a non-rate-limit Retry-After over 60 s is announced, as on main.
The unattended free-tier wait line keeps the reset time beside "The free model is busy".
The free-tier cutoff names the reset time for any cooldown it fires on (over 60 s), not only from 120 s.
session.info for an env-seeded model off the free tier carries the model alone again, as on main.
Behaviour tests for the cutoff, the subagent and unattended waits, and a paid long wait.
2026-10-06 12:31:21 -07:00

582 lines
24 KiB
Python

"""Tests for gateway /compress user-facing messaging."""
import asyncio
import threading
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.event import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
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()
runner._session_db = None
return runner
@pytest.mark.asyncio
async def test_compress_command_works_when_auto_compaction_disabled():
"""compression.enabled: false disables *automatic* compaction only.
The gateway /compress handler has never gated on the flag — pin that
contract (every manual-compress surface must allow manual compression
regardless of the auto toggle, #64438) and the force=True cooldown
bypass that manual compression relies on."""
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "compressed summary"},
history[-1],
]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.compression_enabled = False
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
# Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
agent_instance._compression_skipped_due_to_lock = False
def _estimate(messages, **_kwargs):
return 100 if messages == history else 60
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event())
assert "disabled" not in result.lower()
assert "Compressed:" in result
agent_instance._compress_context.assert_called_once()
assert agent_instance._compress_context.call_args.kwargs.get("force") is True
@pytest.mark.asyncio
@pytest.mark.parametrize("warning_notifications", [True, False])
async def test_compress_command_surfaces_aux_model_failure_even_when_recovered(tmp_path, monkeypatch, warning_notifications):
"""When the user's configured ``auxiliary.compression.model`` errors out
but compression recovers by retrying on the main model, /compress must
STILL inform the user. Silent recovery hides broken config the user
needs to fix."""
import gateway.run as gateway_run
(tmp_path / "config.yaml").write_text(f"display: {{warning_notifications: {str(warning_notifications).lower()}}}")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
history = _make_history()
# Compressed transcript — normal successful compression, no placeholder.
compressed = [
history[0],
{"role": "assistant", "content": "summary via main model"},
history[-1],
]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
# Fallback placeholder was NOT used — recovery succeeded.
agent_instance.context_compressor._last_compress_aborted = False
agent_instance.context_compressor._last_summary_fallback_used = False
agent_instance.context_compressor._last_summary_dropped_count = 0
agent_instance.context_compressor._last_summary_error = None
# But the configured aux model DID fail before the retry succeeded.
agent_instance.context_compressor._last_aux_model_failure_model = (
"gemini-3-flash-preview"
)
agent_instance.context_compressor._last_aux_model_failure_error = (
"404 model not found: gemini-3-flash-preview"
)
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
agent_instance._compression_skipped_due_to_lock = False
def _estimate(messages, **_kwargs):
if messages == history:
return 100
if messages == compressed:
return 60
raise AssertionError(f"unexpected transcript: {messages!r}")
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_request_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event())
# Compression succeeded
assert "Compressed:" in result
# The broken aux model is surfaced to the user
assert "gemini-3-flash-preview" in result
assert "404" in result
agent_instance.shutdown_memory_provider.assert_called_once()
agent_instance.close.assert_called_once()
@pytest.mark.asyncio
async def test_compress_command_in_place_skips_destructive_rewrite():
"""In-place compaction (compression.in_place / #38763) persists via
archive_and_compact() inside _compress_context — the previous active rows
are soft-archived and the compacted set inserted. Calling
rewrite_transcript() afterwards would invoke
replace_messages(active_only=False), DELETEing the just-archived rows
(silent data loss, #61145). The handler must skip the rewrite and still
report success."""
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "compacted summary"},
history[-1],
]
runner = _make_runner(history)
runner._session_db = object()
session_entry = runner.session_store.get_or_create_session.return_value
runner.session_store.rewrite_transcript = MagicMock()
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
# In-place compaction: session_id is UNCHANGED but marked as a success.
agent_instance._last_compaction_in_place = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
agent_instance._compression_skipped_due_to_lock = False
def _estimate(messages, **_kwargs):
if messages == history:
return 100
if messages == compressed:
return 60
raise AssertionError(f"unexpected transcript: {messages!r}")
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_request_tokens_rough", side_effect=_estimate),
):
result = await runner._handle_compress_command(_make_event())
assert "Compressed:" in result
# The destructive rewrite must NOT run — archive_and_compact() already
# persisted, and rewrite_transcript would wipe the archived rows.
runner.session_store.rewrite_transcript.assert_not_called()
assert session_entry.session_id == "sess-1"
agent_instance.shutdown_memory_provider.assert_called_once()
agent_instance.close.assert_called_once()
@pytest.mark.asyncio
async def test_compress_command_preserves_platform_and_gateway_session_key():
"""The temporary compression agent must carry the originating source's
platform and stable gateway session key, matching a normal gateway turn.
Without them ``session_source_for`` falls back to a default "cli"
host source, so an external context engine misattributes the retained
transcript tail and later duplicates it on resume (#50422)."""
history = _make_history()
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
agent_instance._compression_skipped_due_to_lock = False
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance) as mock_agent,
patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100),
):
await runner._handle_compress_command(_make_event())
assert mock_agent.call_count == 1
_, kwargs = mock_agent.call_args
# Platform preserved as the live turn's config key (TELEGRAM -> "telegram"),
# not the unbound "cli"/"local" fallback.
assert kwargs.get("platform") == "telegram"
# Stable gateway session key preserved, identical to a normal gateway turn.
assert kwargs.get("gateway_session_key") == runner._session_key_for_source(_make_source())
assert kwargs["gateway_session_key"]
@pytest.mark.asyncio
async def test_compress_command_agent_receives_configured_reasoning():
"""#85153 class: the throwaway /compress agent is an ``AIAgent()`` built from gateway config, so
``agent.reasoning_effort: none`` must reach it like a normal gateway turn — otherwise the transport
applies its default effort (a 400 on non-reasoning models)."""
history = _make_history()
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
agent_instance._compression_skipped_due_to_lock = False
with (
patch("gateway.run._load_gateway_config", return_value={"agent": {"reasoning_effort": "none"}}),
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="gpt-4o-mini"),
patch("run_agent.AIAgent", return_value=agent_instance) as mock_agent,
patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100),
):
await runner._handle_compress_command(_make_event())
assert mock_agent.call_count == 1
_, kwargs = mock_agent.call_args
assert kwargs["reasoning_config"] == {"enabled": False}
@pytest.mark.asyncio
async def test_compress_command_passes_tool_messages_to_compressor():
"""Tool results must reach _compress_context (#3854).
Filtering the transcript to user/assistant-only starved the
compressor's tool-result pruning — tool messages are usually the bulk
of the context.
"""
history = [
{"role": "user", "content": "run it"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "x", "arguments": "{}"}}],
},
{"role": "tool", "content": "BIG RESULT " * 50, "tool_call_id": "t1"},
{"role": "assistant", "content": "done"},
{"role": "user", "content": "thanks"},
{"role": "assistant", "content": "np"},
]
runner = _make_runner(history)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (list(history), "")
with (
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
patch("run_agent.AIAgent", return_value=agent_instance),
patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100),
):
await runner._handle_compress_command(_make_event())
args, _kwargs = agent_instance._compress_context.call_args
passed = args[0]
roles = [m.get("role") for m in passed]
assert "tool" in roles, f"tool messages filtered out: {roles}"
# Assistant tool_calls stubs (content=None) must survive too, or the
# tool message would dangle without its call.
assert any(m.get("tool_calls") for m in passed), "assistant tool_calls stub dropped"
@pytest.mark.asyncio
async def test_compress_command_multiplexed_runs_under_profile_secret_scope(tmp_path):
"""Manual /compress must install the source profile's secret scope.
Multiplexed gateways resolve credentials fail-closed (Workstream A):
``get_secret`` raises ``UnscopedSecretError`` on any read outside a
``set_secret_scope`` block. The agent turn is scoped by ``_run_agent``'s
wrapper, but slash-command dispatch is not — manual /compress reached the
compressor's provider resolution unscoped and died with
``get_secret('OPENROUTER_BASE_URL') called with no profile secret scope
active``. The credential read happens inside the executor hop, so this
also pins that the handler uses the contextvar-preserving executor
(``_run_in_executor_with_context``), not a bare ``run_in_executor``.
"""
from agent import secret_scope as ss
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "compressed summary"},
history[-1],
]
runner = _make_runner(history)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")},
multiplex_profiles=True,
)
profile_home = tmp_path / "profiles" / "milo"
profile_home.mkdir(parents=True)
(profile_home / ".env").write_text(
"OPENROUTER_BASE_URL=https://scoped.example/v1\n"
)
runner._resolve_profile_home_for_source = MagicMock(return_value=profile_home)
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance.close = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.context_compressor._last_compress_aborted = False
agent_instance.context_compressor._last_summary_fallback_used = False
agent_instance.context_compressor._last_summary_dropped_count = 0
agent_instance.context_compressor._last_summary_error = None
agent_instance.context_compressor._last_aux_model_failure_model = None
agent_instance.context_compressor._last_aux_model_failure_error = None
agent_instance.session_id = "sess-1"
agent_instance._compression_skipped_due_to_lock = False
seen: dict[str, str | None] = {}
def _compress(*_args, **_kwargs):
# Runs in the executor thread — exactly where the aux client
# resolves provider credentials. Fail-closed get_secret raises
# here unless the profile scope survived the thread hop.
seen["base_url"] = ss.get_secret("OPENROUTER_BASE_URL")
return (compressed, "")
agent_instance._compress_context.side_effect = _compress
ss.set_multiplex_active(True)
try:
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_request_tokens_rough", return_value=100),
):
result = await runner._handle_compress_command(_make_event())
finally:
ss.set_multiplex_active(False)
runner._shutdown_executor()
assert "failed" not in result.lower(), result
assert seen["base_url"] == "https://scoped.example/v1"
runner._resolve_profile_home_for_source.assert_called_once()
@pytest.mark.asyncio
async def test_compress_command_cleanup_does_not_block_event_loop():
"""Manual /compress must not run agent teardown on the gateway event loop.
#53175 offloaded session-expiry, hygiene, and shutdown cleanup, but the
manual /compress finally still called ``_cleanup_agent_resources`` inline.
A slow ``agent.close()`` there freezes the whole loop and stops the
runtime-status heartbeat from advancing — the same wedge class as the
original incident.
Observation must happen from a side thread: if cleanup blocks the event
loop, an ``await``-based waiter cannot sample ticks until close returns,
which falsely looks healthy after the block ends.
"""
import time
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "compressed summary"},
history[-1],
]
runner = _make_runner(history)
release_close = threading.Event()
agent_instance = MagicMock()
agent_instance.shutdown_memory_provider = MagicMock()
agent_instance._cached_system_prompt = ""
agent_instance.tools = None
agent_instance.context_compressor.has_content_to_compress.return_value = True
agent_instance.context_compressor._last_compress_aborted = False
agent_instance.context_compressor._last_summary_fallback_used = False
agent_instance.context_compressor._last_summary_dropped_count = 0
agent_instance.context_compressor._last_summary_error = None
agent_instance.context_compressor._last_aux_model_failure_model = None
agent_instance.context_compressor._last_aux_model_failure_error = None
agent_instance.session_id = "sess-1"
agent_instance._compress_context.return_value = (compressed, "")
agent_instance._compression_skipped_due_to_lock = False
agent_instance._session_messages = None
ticks = {"n": 0}
stop = threading.Event()
observed = {}
async def _heartbeat():
while not stop.is_set():
ticks["n"] += 1
await asyncio.sleep(0.005)
def slow_close():
observed["close_started"] = True
baseline = ticks["n"]
def _observer():
# Start observation from the cleanup call itself. This excludes all
# unrelated setup/import time before teardown begins.
time.sleep(0.12)
observed["ticks_during_block"] = ticks["n"] - baseline
release_close.set()
observer = threading.Thread(target=_observer, name="compress-cleanup-observer", daemon=True)
observer.start()
release_close.wait()
observer.join()
agent_instance.close = slow_close
hb = asyncio.create_task(_heartbeat())
with (
patch.object(
runner,
"_resolve_session_agent_runtime",
return_value=("test-model", {"api_key": "***"}),
),
patch.object(
runner,
"_build_manual_compression_agent",
AsyncMock(return_value=agent_instance),
),
patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100),
):
result = await runner._handle_compress_command(_make_event())
stop.set()
await hb
runner._shutdown_executor()
assert "Compressed:" in result
assert observed.get("close_started") is True
assert observed.get("ticks_during_block", 0) >= 5, (
"event loop was blocked during manual /compress cleanup: only "
f"{observed.get('ticks_during_block')} ticks while agent.close() was running"
)
@pytest.mark.parametrize("profile", ["main", "fitness"])
def test_rotated_compress_keeps_atomically_published_foreign_tail(tmp_path, monkeypatch, profile):
"""A rotated /compress must NOT rewrite the atomically-published child.
publish_compression_child() writes handoff + cloned foreign tail in one transaction;
the second rewrite_transcript(active_only=False) would DELETE the cloned tail (it is not
in the in-memory handoff) and its failure surfaced as a false "failed to persist
compressed transcript" even though the compression had already committed. The
``fitness`` row is a multiplexed named profile: its child is published in
``profiles/fitness/state.db`` before the routing index knows the child id.
"""
import hermes_state
from gateway.slash_commands_session import GatewaySessionCommandsMixin
from gateway.session import AsyncSessionStore, SessionStore
root = tmp_path / "hermes"
(root / "profiles" / "fitness").mkdir(parents=True)
(root / "profiles" / "fitness" / "config.yaml").write_text("{}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH)
store = SessionStore(sessions_dir=root / "sessions", config=GatewayConfig(multiplex_profiles=True))
parent, child = "parent", "child"
entry = SessionEntry(
session_key=f"agent:{profile}:discord:thread:123:123", session_id=parent,
created_at=datetime.now(), updated_at=datetime.now(),
platform=Platform.DISCORD, chat_type="thread",
)
store._entries[entry.session_key] = entry
db = store._db_for_key(entry.session_key)
db.create_session(parent, "discord")
db.append_message(parent, "assistant", "old turn")
watermark = db.get_active_message_watermark(parent)
db.append_message(parent, "assistant", "concurrent foreign turn")
ceiling = db.get_active_message_watermark(parent)
handoff = [{"role": "assistant", "content": "compressed summary"}]
db.publish_compression_child(
parent_session_id=parent, child_session_id=child, source="discord",
messages=handoff, watermark=watermark, watermark_ceiling=ceiling,
require_compression_lease=False,
)
def _destructive_rewrite(*_args, **_kwargs):
raise AssertionError("published child must not be rewritten")
store.rewrite_transcript = _destructive_rewrite
runner = SimpleNamespace(
async_session_store=AsyncSessionStore(store),
_sync_telegram_topic_binding=MagicMock(),
)
agent = SimpleNamespace(session_id=child)
try:
asyncio.run(GatewaySessionCommandsMixin._persist_manual_compression(
runner, agent, entry, _make_source(), handoff))
assert entry.session_id == child
assert [m["content"] for m in db.get_messages(child)] == [
"compressed summary", "concurrent foreign turn",
]
finally:
store.close_all_db_handles()