diff --git a/agent/agent_init.py b/agent/agent_init.py index 107e78cd511..36bea43f44b 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -68,7 +68,7 @@ def _ra(): return run_agent -def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, float]) -> str: +def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str: """Build the one-time notice shown when Codex gpt-5.x raises compaction. ``autoraise`` is ``{"model": , "from": , "to": }``. @@ -125,6 +125,61 @@ def _resolve_compression_threshold( return model_cthresh, None +def _codex_gpt55_autoraise_notice_marker(): + """Path to the per-profile marker recording that the autoraise notice ran. + + Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other + internal markers like ``.container-mode`` β€” so it is not a user-facing config + key, and every profile tracks its own notice state independently. + """ + return get_hermes_home() / ".codex_gpt55_autoraise_notice" + + +def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str: + """Stable identity for one autoraise notice, keyed on what it displays. + + Uses the model slug plus the same fromβ†’to percentages the notice text + shows, so an unchanged threshold stays silent across restarts while a + later change (the user edits their global ``threshold``, or switches to a + different autoraised Codex model) re-notifies once. + """ + model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1] + from_pct = int(round(float(autoraise["from"]) * 100)) + to_pct = int(round(float(autoraise["to"]) * 100)) + return f"{model}:{from_pct}:{to_pct}" + + +def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool: + """True if this exact autoraise notice was already shown for this profile. + + A missing/unreadable marker (or one recording a different threshold) reads + as unseen, so the notice shows. + """ + try: + current = _codex_gpt55_autoraise_notice_state(autoraise) + return _codex_gpt55_autoraise_notice_marker().read_text( + encoding="utf-8" + ).strip() == current + except (OSError, KeyError, TypeError, ValueError): + return False + + +def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None: + """Persist that the autoraise notice was shown for this profile/config state. + + Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice + may show again next init, which is preferable to breaking agent init. + """ + try: + marker = _codex_gpt55_autoraise_notice_marker() + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text( + _codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8" + ) + except (OSError, KeyError, TypeError, ValueError): + pass + + def _normalized_custom_base_url(value: Any) -> str: if not isinstance(value, str): return "" @@ -1940,29 +1995,47 @@ def init_agent( agent._ollama_num_ctx, ) + # Codex gpt-5.x autoraise notice: show at most once per profile/config + # state. Without the persisted marker the notice re-fires on every agent + # init β€” and the gateway rebuilds the agent per inbound message, so Discord + # etc. saw it repeatedly (#54432). A change in the raised threshold (or the + # autoraised model) updates the marker state and re-notifies once. The + # config display gate (compression.codex_gpt55_autoraise_notice) still + # suppresses the banner entirely without disabling the threshold autoraise. + _autoraise = getattr(agent, "_compression_threshold_autoraised", None) + _show_autoraise_notice = ( + bool(_autoraise) + and compression_enabled + and _codex_gpt55_autoraise_notice + and not _codex_gpt55_autoraise_notice_seen(_autoraise) + ) + if not agent.quiet_mode: if compression_enabled: print(f"πŸ“Š Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})") else: print(f"πŸ“Š Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") - # One-time notice when the Codex gpt-5.5 autoraise kicked in, with the - # exact opt-back-out command. Printed inline at startup for CLI users; - # gateway users get the same text replayed via _compression_warning on - # turn 1 (set below, after the warning slot is initialized). - _autoraise = getattr(agent, "_compression_threshold_autoraised", None) - if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: + # Notice with the exact opt-back-out command. Printed inline at startup + # for CLI users; gateway users get the same text replayed via + # _compression_warning on turn 1 (set below). + if _show_autoraise_notice: print(_build_codex_gpt5_autoraise_notice(_autoraise)) # Check immediately so CLI users see the warning at startup. # Gateway status_callback is not yet wired, so any warning is stored # in _compression_warning and replayed in the first run_conversation(). agent._compression_warning = None - # Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print + # Gateway parity for the Codex gpt-5.x autoraise notice: the startup print # above only reaches the CLI, so stash the same text here to be replayed # through status_callback on the first turn (Telegram/Discord/Slack/etc.). - _autoraise = getattr(agent, "_compression_threshold_autoraised", None) - if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice: + if _show_autoraise_notice: agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise) + + # Mark shown so repeated inits in this profile (e.g. every gateway message) + # stay silent. Recorded once, whether the notice went to the CLI print or + # the gateway replay slot. + if _show_autoraise_notice: + _record_codex_gpt55_autoraise_notice(_autoraise) # Lazy feasibility check: deferred to the first turn that approaches the # compression threshold. Running it eagerly here costs ~400ms cold (network # probe of the auxiliary provider chain + /models lookup) on every agent diff --git a/tests/agent/test_codex_gpt55_autoraise_notice.py b/tests/agent/test_codex_gpt55_autoraise_notice.py index 46e733a4e11..2638407fea6 100644 --- a/tests/agent/test_codex_gpt55_autoraise_notice.py +++ b/tests/agent/test_codex_gpt55_autoraise_notice.py @@ -1,4 +1,18 @@ -"""Regression tests for the Codex gpt-5.5 autoraise notice gate.""" +"""Regression tests for the Codex gpt-5.x autoraise notice gate. + +Covers two layers: + +1. The config display gate (``compression.codex_gpt55_autoraise_notice``) β€” + suppresses the banner without disabling the threshold autoraise. +2. The per-profile dedupe marker (#54432) β€” the notice must show at most once + per profile/config state. Before the fix it re-fired on every agent init, + and because the gateway rebuilds the agent per inbound message it spammed + Discord etc. The gate persists a marker under ``$HERMES_HOME`` + (profile-scoped, isolated to a tempdir by the conftest autouse fixture) + keyed on the model slug + displayed fromβ†’to percentages, so an unchanged + threshold stays silent across restarts while a changed threshold (or a + different autoraised Codex model) re-notifies once. +""" from __future__ import annotations @@ -6,9 +20,22 @@ import contextlib import io from pathlib import Path +import pytest + +from hermes_constants import get_hermes_home from hermes_state import SessionDB from run_agent import AIAgent +from agent.agent_init import ( + _codex_gpt55_autoraise_notice_marker, + _codex_gpt55_autoraise_notice_seen, + _codex_gpt55_autoraise_notice_state, + _record_codex_gpt55_autoraise_notice, +) + +# The dict agent_init stashes when the Codex gpt-5.5 override fires. +AUTORAISE = {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + def _config(*, show_notice: bool) -> dict: return { @@ -57,6 +84,9 @@ def _threshold_ratio(agent: AIAgent) -> float: return round(compressor.threshold_tokens / compressor.context_length, 2) +# ── config display gate ────────────────────────────────────────────────────── + + def test_codex_gpt55_autoraise_notice_enabled_by_default(monkeypatch, tmp_path): agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) @@ -75,3 +105,113 @@ def test_codex_gpt55_autoraise_notice_can_be_suppressed_without_disabling_autora assert _threshold_ratio(agent) == 0.85 assert getattr(agent, "_compression_warning") is None assert "auto-compaction was raised" not in stdout + + +def test_codex_gpt55_autoraise_notice_deduped_across_agent_inits(monkeypatch, tmp_path): + # Gateway spam scenario (#54432): the gateway rebuilds the agent per + # inbound message. The first init shows the notice; the second stays + # silent because the per-profile marker was recorded. + agent1, stdout1 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) + assert "auto-compaction was raised" in stdout1 + assert getattr(agent1, "_compression_warning") is not None + + agent2, stdout2 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True) + assert _threshold_ratio(agent2) == 0.85 # autoraise still applies + assert "auto-compaction was raised" not in stdout2 + assert getattr(agent2, "_compression_warning") is None + + +# ── per-profile dedupe marker (#54432) ─────────────────────────────────────── + + +def test_marker_lives_under_hermes_home() -> None: + marker = _codex_gpt55_autoraise_notice_marker() + assert marker.parent == get_hermes_home() + assert marker.name == ".codex_gpt55_autoraise_notice" + + +def test_state_keyed_on_model_and_displayed_percentages() -> None: + # Same percentages the notice text renders (int(round(ratio * 100))), + # prefixed with the bare model slug. + assert _codex_gpt55_autoraise_notice_state(AUTORAISE) == "gpt-5.5:50:85" + assert ( + _codex_gpt55_autoraise_notice_state( + {"model": "openai/gpt-5.4", "from": 0.75, "to": 0.85} + ) + == "gpt-5.4:75:85" + ) + + +def test_unseen_before_anything_is_recorded() -> None: + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +def test_seen_after_record() -> None: + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + _record_codex_gpt55_autoraise_notice(AUTORAISE) + # A "restart" is just another call: the marker persists on disk. + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True + + +def test_changed_threshold_renotifies_once() -> None: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True + # User raises their global threshold -> "from" changes -> notice re-fires. + changed = {"model": "gpt-5.5", "from": 0.60, "to": 0.85} + assert _codex_gpt55_autoraise_notice_seen(changed) is False + _record_codex_gpt55_autoraise_notice(changed) + assert _codex_gpt55_autoraise_notice_seen(changed) is True + # And the old state is now considered unseen (marker moved forward). + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +def test_changed_model_renotifies_once() -> None: + # Switching to a different autoraised Codex model re-fires the notice + # (the banner names the model, so it displays new information). + _record_codex_gpt55_autoraise_notice(AUTORAISE) + other_model = {"model": "gpt-5.4", "from": 0.50, "to": 0.85} + assert _codex_gpt55_autoraise_notice_seen(other_model) is False + _record_codex_gpt55_autoraise_notice(other_model) + assert _codex_gpt55_autoraise_notice_seen(other_model) is True + + +def test_record_is_idempotent() -> None: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + _record_codex_gpt55_autoraise_notice(AUTORAISE) + assert ( + _codex_gpt55_autoraise_notice_marker().read_text(encoding="utf-8") + == "gpt-5.5:50:85" + ) + + +def test_malformed_marker_reads_as_unseen() -> None: + marker = _codex_gpt55_autoraise_notice_marker() + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("not-a-state", encoding="utf-8") + assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False + + +@pytest.mark.parametrize("bad", [{}, {"from": 0.5}, {"from": None, "to": None}]) +def test_seen_tolerates_malformed_autoraise(bad) -> None: + # Never raises even if the stashed dict is missing/garbage keys. + assert _codex_gpt55_autoraise_notice_seen(bad) is False + + +def test_full_init_gate_shows_once_then_stays_silent() -> None: + # Mirror the decision agent_init makes on each build: + # show = bool(autoraise) and compression_enabled and not seen(autoraise) + def decide(compression_enabled: bool) -> bool: + show = ( + bool(AUTORAISE) + and compression_enabled + and not _codex_gpt55_autoraise_notice_seen(AUTORAISE) + ) + if show: + _record_codex_gpt55_autoraise_notice(AUTORAISE) + return show + + # First init (any surface) shows; every subsequent init in this profile + # stays silent β€” the gateway spam scenario from the issue. + assert decide(compression_enabled=True) is True + assert decide(compression_enabled=True) is False + assert decide(compression_enabled=True) is False diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 907731f846a..d9f4f6aac05 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -113,9 +113,13 @@ The ChatGPT Codex OAuth backend hard-caps gpt-5.5 at a **272K** context window GitHub Copilot). At the default 50% trigger, compaction would fire at ~136K β€” half the window the model can actually use. When the active route is Codex OAuth (`provider: openai-codex`) and the model is gpt-5.5, Hermes raises the -trigger to **85%** (~231K) and prints a one-time notice with the opt-out -command. Only this exact route is affected; gpt-5.5 on any other provider keeps -your global `threshold`. To opt back down to the global value: +trigger to **85%** (~231K) and shows a notice with the opt-out command. The +notice is shown once per profile β€” a marker under `$HERMES_HOME` +(`.codex_gpt55_autoraise_notice`) records that it ran, so repeated agent/session +inits (e.g. every inbound gateway message) don't re-emit it; if the raised +threshold later changes it re-notifies once. Only this exact route is affected; +gpt-5.5 on any other provider keeps your global `threshold`. To opt back down to +the global value: ```bash hermes config set compression.codex_gpt55_autoraise false