mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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.
217 lines
8.3 KiB
Python
217 lines
8.3 KiB
Python
"""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
|
|
|
|
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 {
|
|
"compression": {
|
|
"enabled": True,
|
|
"threshold": 0.50,
|
|
"target_ratio": 0.20,
|
|
"protect_first_n": 3,
|
|
"protect_last_n": 20,
|
|
"codex_gpt55_autoraise": True,
|
|
"codex_gpt55_autoraise_notice": show_notice,
|
|
},
|
|
"prompt_caching": {"cache_ttl": "5m"},
|
|
"sessions": {},
|
|
"bedrock": {},
|
|
}
|
|
|
|
|
|
def _make_codex_agent(monkeypatch, tmp_path: Path, *, show_notice: bool):
|
|
"""Construct a real Codex gpt-5.5 agent under an isolated config."""
|
|
from hermes_cli import config as config_mod
|
|
|
|
monkeypatch.setattr(config_mod, "load_config", lambda: _config(show_notice=show_notice))
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
stdout = io.StringIO()
|
|
|
|
with contextlib.redirect_stdout(stdout):
|
|
agent = AIAgent(
|
|
base_url="https://chatgpt.com/backend-api/codex",
|
|
api_key="test-key",
|
|
provider="openai-codex",
|
|
model="gpt-5.5",
|
|
enabled_toolsets=[],
|
|
disabled_toolsets=[],
|
|
quiet_mode=False,
|
|
skip_memory=True,
|
|
session_db=db,
|
|
session_id="codex-notice-test",
|
|
)
|
|
|
|
return agent, stdout.getvalue()
|
|
|
|
|
|
def _threshold_ratio(agent: AIAgent) -> float:
|
|
compressor = getattr(agent, "context_compressor")
|
|
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)
|
|
|
|
assert _threshold_ratio(agent) == 0.85
|
|
warning = getattr(agent, "_compression_warning")
|
|
assert warning is not None
|
|
assert "auto-compaction was raised" in warning
|
|
assert "auto-compaction was raised" in stdout
|
|
|
|
|
|
def test_codex_gpt55_autoraise_notice_can_be_suppressed_without_disabling_autoraise(
|
|
monkeypatch, tmp_path
|
|
):
|
|
agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=False)
|
|
|
|
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
|