mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
bdca94e749
commit
fff2408961
3 changed files with 231 additions and 14 deletions
|
|
@ -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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue