fix(agent): dedupe Codex gpt-5.5 autoraise notice across agent inits

The Codex gpt-5.5 compaction-threshold autoraise notice re-fired on every
agent init. Because the gateway rebuilds the agent per inbound message, the
notice spammed long-running Discord/Telegram/etc. sessions, and the only
documented remedy (`compression.codex_gpt55_autoraise false`) disables the
useful autoraise behavior itself.

Gate both emission surfaces — the CLI startup print and the gateway
`_compression_warning` replay — on a persisted per-profile marker under
`$HERMES_HOME` (`.codex_gpt55_autoraise_notice`), keyed on the from→to
percentages the notice displays. The notice now shows at most once per
profile; the autoraise still fires and `codex_gpt55_autoraise: false` still
disables it; and a later change to the raised threshold re-notifies once.
Docs updated to match.
This commit is contained in:
Sanidhya Singh 2026-07-02 13:15:52 +05:30 committed by Teknium
parent bdca94e749
commit fff2408961
3 changed files with 231 additions and 14 deletions

View file

@ -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": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
@ -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 fromto 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

View file

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

View file

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