Compression autoraise and silent wipe bug reports
Cluster 48621 · agent. Data as of
Nothing open on GitHub. Fix merged in #59814 on 6 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #59814. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #59814. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members concern a reported bug where gpt-5.3-codex-spark lacks a compression threshold autoraise and silently wipes context when protect_last_n is too large, along with several fix attempts addressing autoraise coverage for Codex model variants.
Open issues0
Open PRs0
Fixes on record
-
#59814 fix(compression): Codex autoraise cluster — gpt-5.4/spark coverage, never-lower, notice dedupe
files:
agent/agent_init.py,agent/auxiliary_client.py,hermes_cli/config.py,scripts/release.py,tests/agent/test_arcee_trinity_overrides.py,tests/agent/test_auxiliary_main_first.py,tests/agent/test_codex_gpt55_autoraise_notice.py,tests/run_agent/test_infinite_compaction_loop.py,tests/run_agent/test_plugin_context_engine_init.py,website/docs/developer-guide/context-compression-and-caching.mdShow the diff
-
agent/agent_init.py@@ -68,24 +68,118 @@ def _ra(): return run_agent -def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str: - """Build the one-time notice shown when Codex gpt-5.5 raises compaction. +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 ``{"from": <old_ratio>, "to": <new_ratio>}``. The same - text is printed inline for CLI users and replayed via ``status_callback`` - for gateway users, so it must be self-contained and include the exact - opt-back-out command. + ``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``. + The same text is printed inline for CLI users and replayed via + ``status_callback`` for gateway users, so it must be self-contained and + include the exact opt-back-out command. """ + model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] + # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is + # capped at 272K by the Codex OAuth backend. + cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) return ( - f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised " + f"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised " f"to {to_pct}% (from {from_pct}%) to use more of the window before " f"summarizing.\n" f" Opt back out: hermes config set compression.codex_gpt55_autoraise false" ) +def _resolve_compression_threshold( + global_threshold: float, + model_cthresh: Optional[float], + *, + model: Optional[str] = None, + is_codex_autoraise: bool, +) -> tuple[float, Optional[Dict[str, Any]]]: + """Combine the user's global compaction threshold with a per-model override. + + Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is + ``{"model": <slug>, "from": <old>, "to": <new>}`` only when a Codex + autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises + the threshold, otherwise ``None``. + + The Codex overrides are *autoraises*: they must never LOWER a higher + user-configured threshold. A user who already set ``compression.threshold`` + above the raised value deliberately keeps more raw context, and silently + dropping them would both waste usable window and contradict the feature's + purpose (use more of the window). Other overrides (e.g. Arcee Trinity) + keep their existing unconditional behaviour. + """ + if model_cthresh is None: + return global_threshold, None + if is_codex_autoraise: + if model_cthresh <= global_threshold + 1e-9: + # Autoraise never lowers; keep the user's higher/equal threshold. + return global_threshold, None + return model_cthresh, { + "model": model, + "from": global_threshold, + "to": model_cthresh, + } + 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 "" @@ -1409,14 +1503,14 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: if not isinstance(_compression_cfg, dict): _compression_cfg = {} compression_threshold = float(_compression_cfg.get("threshold", 0.50)) - # Per-model/route compaction-threshold override. Codex gpt-5.5 raises to - # 85% (the Codex backend caps the window at 272K, so the default 50% would - # compact at ~136K — half the usable context). Gated by an opt-out config - # flag so the user can fall back to the global threshold; when the override - # fires we stash a one-time notification (replayed on the first turn) that - # tells the user what changed and how to revert. The notice has its own - # display gate so users can keep the threshold autoraise without getting - # the banner on gateway turns. + # Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5 + # raise to 85% (the Codex backend caps both families at 272K, so the + # default 50% would compact at ~136K — half the usable context). Gated by + # an opt-out config flag so the user can fall back to the global threshold; + # when the override fires we stash a one-time notification (replayed on the + # first turn) that tells the user what changed and how to revert. The + # notice has its own display gate so users can keep the threshold + # autoraise without getting the banner on gateway turns. _codex_gpt55_autoraise = str( _compression_cfg.get("codex_gpt55_autoraise", True) ).lower() in {"true", "1", "yes"} @@ -1427,28 +1521,30 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: try: from agent.auxiliary_client import ( _compression_threshold_for_model as _cthresh_fn, - _is_codex_gpt55 as _is_codex_gpt55_fn, + _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn, + _is_codex_spark as _is_codex_spark_fn, ) _model_cthresh = _cthresh_fn( agent.model, agent.provider, allow_codex_gpt55_autoraise=_codex_gpt55_autoraise, ) - if _model_cthresh is not None: - _prev_threshold = compression_threshold - compression_threshold = _model_cthresh - # Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity - # override is a long-standing silent default). Skip the notice when - # the user's global threshold already meets/exceeds the raised - # value, since nothing actually changed for them. - if ( - _is_codex_gpt55_fn(agent.model, agent.provider) - and _model_cthresh > _prev_threshold + 1e-9 - ): - agent._compression_threshold_autoraised = { - "from": _prev_threshold, - "to": _model_cthresh, - } + # The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark) + # apply only when they RAISE (never lower a user's higher global + # threshold). The notice is populated only when it actually fires, and + # carries the model slug so the banner names the right family. Arcee + # Trinity keeps its long-standing unconditional behaviour. + compression_threshold, agent._compression_threshold_autoraised = ( + _resolve_compression_threshold( + compression_threshold, + _model_cthresh, + model=agent.model, + is_codex_autoraise=( + _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) + or _is_codex_spark_fn(agent.model, agent.provider) + ), + ) + ) except Exception: pass compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} @@ -1677,6 +1773,12 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: if _selected_engine is not None: agent.context_compressor = _selected_engine + # External engines own compaction policy: the host compression + # threshold (including the Codex gpt-5.5 autoraise above) only + # configures the built-in ContextCompressor and never reaches the + # plugin, so the autoraise notice would announce a change that does + # not apply. Drop it. (#44439) + agent._compression_threshold_autoraised = None # Resolve context_length for plugin engines — mirrors switch_model() path from agent.model_metadata import get_model_context_length _plugin_ctx_len = get_model_context_length( @@ -1899,29 +2001,53 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: 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:,})") + # Report the active engine's own threshold — for a plugin engine + # the host compression_threshold is not in effect, and mixing the + # two printed a percent that contradicted the token count. (#44439) + _active_threshold_pct = getattr( + agent.context_compressor, "threshold_percent", compression_threshold + ) + print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*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: - print(_build_codex_gpt55_autoraise_notice(_autoraise)) + # 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: - agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise) + 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 -
agent/auxiliary_client.py@@ -314,33 +314,64 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: return bare == "trinity-large-thinking" -# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5. +# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5. # The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the # Codex backend hard-caps at 272K (verified live: a ~330K-token request to # chatgpt.com/backend-api/codex/responses is rejected with # ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the # default 50% compaction trigger fires at ~136K — wasteful, since the model # can hold far more raw context before summarization actually buys anything. -# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5 -# sessions use the window they actually have. -_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85 +# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/ +# gpt-5.5 sessions use the window they actually have. +_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger fires at +# ~64K — wasting half the usable window, often before the session has enough +# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so +# spark sessions use more of the window before summarization, while still +# leaving ~38K headroom for the summary and continued conversation before +# the 128K hard limit. +_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 -def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: - """True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend. + +def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: + """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. Matches only the Codex OAuth route (provider ``openai-codex``), not the direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a larger context window for the same slug and must keep the user's default - compaction threshold. ``gpt-5.5-pro`` and dated snapshots - (``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the - family without re-listing every variant. + compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots + are matched via prefix so the override tracks both 272K-capped families + without re-listing every variant. """ prov = (provider or "").strip().lower() if prov != "openai-codex": return False bare = (model or "").strip().lower().rsplit("/", 1)[-1] - return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.") + return ( + bare == "gpt-5.4" + or bare.startswith("gpt-5.4-") + or bare.startswith("gpt-5.4.") + or bare == "gpt-5.5" + or bare.startswith("gpt-5.5-") + or bare.startswith("gpt-5.5.") + ) + + +def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool: + """True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend. + + The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native + 128K context window. Only the Codex OAuth route (provider + ``openai-codex``) is matched — the slug is not available on other + routes. + """ + prov = (provider or "").strip().lower() + if prov != "openai-codex": + return False + bare = (model or "").strip().lower().rsplit("/", 1)[-1] + return bare == "gpt-5.3-codex-spark" def _fixed_temperature_for_model( @@ -379,18 +410,26 @@ def _compression_threshold_for_model( Per-model/route overrides: - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context). - - gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window - at 272K and the default 50% trigger would compact at ~136K. Gated by - ``allow_codex_gpt55_autoraise`` so the user can opt back down to the - global default (the caller passes the config flag through here). + - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps + both families at 272K and the default 50% trigger would compact at + ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key + name kept for backward compatibility) so the user can opt back down to + the global default (the caller passes the config flag through here). + - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model + has a native 128K window and the default 50% trigger would compact at + ~64K — wasting half the usable context. Not gated by the gpt-5.5 + opt-out flag: 128K is the model's native window, so the raise is + unambiguously correct. Returns a float in (0, 1] to override the global ``compression.threshold`` config value, or ``None`` to leave the user's config value unchanged. """ if _is_arcee_trinity_thinking(model): return 0.75 - if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider): - return _CODEX_GPT55_COMPACTION_THRESHOLD + if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider): + return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD + if _is_codex_spark(model, provider): + return _CODEX_SPARK_COMPACTION_THRESHOLD return None # Default auxiliary models for direct API-key providers (cheap/fast for side tasks) @@ -4151,7 +4190,15 @@ def resolve_provider_client( # main_model also empty), the branches still hit their own # missing-credentials returns and ``_resolve_auto`` falls through to # the Step-2 chain as before. - if not model: + # + # Prefer explicit caller model, then provider-scoped aux model, then main model. + # Do NOT pre-fill a blank ``auto`` request from the config/main default here. + # ``auto`` has its own main-runtime resolver below; pre-filling first can pair + # a stale configured model with a live fallback provider (e.g. Claude model + # sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto() + # return the actual current runtime model when the caller did not explicitly + # request one. (# compression-current-model) + if not model and provider != "auto": model = _get_aux_model_for_provider(provider) or _read_main_model() or model def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool: -
hermes_cli/config.py@@ -1362,17 +1362,18 @@ def _ensure_hermes_home_managed(home: Path): # Default False matches historical behavior; set to # True if you'd rather pause than silently lose # context turns when your aux model is flaky. - "codex_gpt55_autoraise": True, # When True, gpt-5.5 on the ChatGPT Codex OAuth - # route raises its compaction trigger to 85% (vs the - # global `threshold` above). Codex hard-caps gpt-5.5 - # at a 272K window, so the default 50% would compact - # at ~136K and waste half the usable context. Set to - # False to opt back down to the global threshold - # (e.g. 0.50) for Codex gpt-5.5 sessions. Only this - # exact route is affected — gpt-5.5 on OpenAI's - # direct API, OpenRouter, and Copilot keep the - # global threshold regardless. - "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.5 + "codex_gpt55_autoraise": True, # Historical key name kept for compatibility. + # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex + # OAuth route raise their compaction trigger to 85% + # (vs the global `threshold` above). Codex hard-caps + # both families at a 272K window, so the default 50% + # would compact at ~136K and waste half the usable + # context. Set to False to opt back down to the global + # threshold (e.g. 0.50) for those Codex sessions. + # Only this exact route is affected — gpt-5.4 / 5.5 + # on OpenAI's direct API, OpenRouter, and Copilot keep + # the global threshold regardless. + "codex_gpt55_autoraise_notice": True, # Display the one-time Codex gpt-5.4/5.5 # autoraise banner. Set False to keep the # 85% threshold autoraise but suppress the # user-facing notice in CLI/gateway output. -
scripts/release.py@@ -285,6 +285,7 @@ "[email protected]": "itsflownium", "[email protected]": "dirtyren", "[redacted:email]": "bigstar0920", + "[redacted:email]": "tanmayxchoudhary", "[email protected]": "AIalliAI", "[email protected]": "waseemshahwan", "[email protected]": "hellno", -
tests/agent/test_arcee_trinity_overrides.py@@ -13,11 +13,13 @@ import pytest +from agent.agent_init import _resolve_compression_threshold from agent.auxiliary_client import ( _compression_threshold_for_model, _fixed_temperature_for_model, _is_arcee_trinity_thinking, - _is_codex_gpt55, + _is_codex_gpt54_or_gpt55, + _is_codex_spark, ) @@ -78,14 +80,14 @@ def test_compression_threshold_default_none_for_other_models() -> None: # --------------------------------------------------------------------------- -# Codex gpt-5.5 compaction-threshold autoraise +# Codex gpt-5.4 / gpt-5.5 compaction-threshold autoraise # -# ChatGPT's Codex OAuth backend caps gpt-5.5 at a 272K window (verified live: -# ~330K-token request rejected with context_length_exceeded, ~250K accepted). -# The default 50% compaction trigger would fire at ~136K — half the usable -# window — so this route raises the trigger to 85%. Only the Codex OAuth route -# is affected; the same slug on OpenAI direct / OpenRouter / Copilot exposes a -# larger window and keeps the user's global threshold. +# ChatGPT's Codex OAuth backend caps both families at a 272K window (verified +# live via the Codex /models resolver and per-slug fallback table). The default +# 50% compaction trigger would fire at ~136K — half the usable window — so this +# route raises the trigger to 85%. Only the Codex OAuth route is affected; the +# same slugs on OpenAI direct / OpenRouter / Copilot expose a larger window and +# keep the user's global threshold. # --------------------------------------------------------------------------- @@ -99,32 +101,40 @@ def test_compression_threshold_default_none_for_other_models() -> None: "openai/gpt-5.5", # aggregator-prefixed (still on the codex route) "GPT-5.5", # case-insensitive " gpt-5.5 ", # whitespace tolerant + "gpt-5.4", # base 5.4 (272K-capped) + "gpt-5.4-pro", # pro 5.4 variant (272K-capped) + "gpt-5.4-2026-01-01", # dated 5.4 snapshot + "openai/gpt-5.4", # aggregator-prefixed 5.4 ], ) -def test_is_codex_gpt55_matches_on_codex_provider(model: str) -> None: - assert _is_codex_gpt55(model, "openai-codex") is True +def test_is_codex_gpt54_or_gpt55_matches_on_codex_provider(model: str) -> None: + assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is True @pytest.mark.parametrize( "provider", ["openrouter", "openai", "copilot", "openai-api", "", None], ) -def test_is_codex_gpt55_rejects_non_codex_providers(provider) -> None: - # gpt-5.5 on any non-Codex route keeps the larger window — no override. - assert _is_codex_gpt55("gpt-5.5", provider) is False +def test_is_codex_gpt54_or_gpt55_rejects_non_codex_providers(provider) -> None: + # gpt-5.4 / gpt-5.5 on any non-Codex route keep the larger window. + assert _is_codex_gpt54_or_gpt55("gpt-5.5", provider) is False + assert _is_codex_gpt54_or_gpt55("gpt-5.4", provider) is False @pytest.mark.parametrize( "model", - ["gpt-5.4", "gpt-5", "gpt-5.55", "gpt-5.50", "", None], + ["gpt-5", "gpt-5.55", "gpt-5.50", "gpt-5.45", "gpt-5.40", "", None], ) -def test_is_codex_gpt55_rejects_non_55_models(model) -> None: - # gpt-5.55 / gpt-5.50 are different families and must NOT match — the - # "gpt-5.5-" / "gpt-5.5." prefix guards require a separator after "5.5". - assert _is_codex_gpt55(model, "openai-codex") is False +def test_is_codex_gpt54_or_gpt55_rejects_non_54_55_models(model) -> None: + # Close numeric neighbours must NOT match — the prefix guards require a + # separator after "5.4" / "5.5" so e.g. gpt-5.45 and gpt-5.55 stay out. + assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is False def test_compression_threshold_for_codex_gpt55() -> None: + assert _compression_threshold_for_model("gpt-5.4", "openai-codex") == 0.85 + assert _compression_threshold_for_model("gpt-5.4-pro", "openai-codex") == 0.85 + assert _compression_threshold_for_model("openai/gpt-5.4", "openai-codex") == 0.85 assert _compression_threshold_for_model("gpt-5.5", "openai-codex") == 0.85 assert _compression_threshold_for_model("gpt-5.5-pro", "openai-codex") == 0.85 assert _compression_threshold_for_model("openai/gpt-5.5", "openai-codex") == 0.85 @@ -132,14 +142,24 @@ def test_compression_threshold_for_codex_gpt55() -> None: def test_compression_threshold_codex_gpt55_other_routes_unaffected() -> None: # Same slug, different route → no override (keep the user's config value). + assert _compression_threshold_for_model("gpt-5.4", "openrouter") is None + assert _compression_threshold_for_model("gpt-5.4", "openai") is None + assert _compression_threshold_for_model("gpt-5.4", "copilot") is None assert _compression_threshold_for_model("gpt-5.5", "openrouter") is None assert _compression_threshold_for_model("gpt-5.5", "openai") is None assert _compression_threshold_for_model("gpt-5.5", "copilot") is None + assert _compression_threshold_for_model("openai/gpt-5.4") is None # no provider assert _compression_threshold_for_model("openai/gpt-5.5") is None # no provider def test_compression_threshold_codex_gpt55_opt_out() -> None: - # allow_codex_gpt55_autoraise=False reverts to the global default (None). + # Historical flag name still governs both Codex families. + assert ( + _compression_threshold_for_model( + "gpt-5.4", "openai-codex", allow_codex_gpt55_autoraise=False + ) + is None + ) assert ( _compression_threshold_for_model( "gpt-5.5", "openai-codex", allow_codex_gpt55_autoraise=False @@ -157,3 +177,140 @@ def test_compression_threshold_opt_out_does_not_disable_trinity() -> None: ) == 0.75 ) + + +# --------------------------------------------------------------------------- +# Codex gpt-5.3-codex-spark compaction-threshold autoraise +# +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger would fire +# at ~64K — wasting half the usable window, often before the session has +# accumulated enough turns to summarize meaningfully. This route raises the +# trigger to 70% (~90K) to preserve more raw context while leaving ~38K +# headroom before the 128K hard limit. +# --------------------------------------------------------------------------- + + [redacted:email]( + "model", + [ + "gpt-5.3-codex-spark", + "openai/gpt-5.3-codex-spark", # aggregator-prefixed (still on the codex route) + "GPT-5.3-CODEX-SPARK", # case-insensitive + " gpt-5.3-codex-spark ", # whitespace tolerant + ], +) +def test_is_codex_spark_matches_on_codex_provider(model: str) -> None: + assert _is_codex_spark(model, "openai-codex") is True + + [redacted:email]( + "provider", + ["openrouter", "openai", "copilot", "openai-api", "", None], +) +def test_is_codex_spark_rejects_non_codex_providers(provider) -> None: + # spark on any non-Codex route is not a real slug — no override. + assert _is_codex_spark("gpt-5.3-codex-spark", provider) is False + + [redacted:email]( + "model", + [ + "gpt-5.5", # different family + "gpt-5.3-codex", # sibling, not spark + "gpt-5.3", # bare 5.3, not spark + "gpt-5.3-codex-spark-mini", # hypothetical variant — not matched yet + "", None, + ], +) +def test_is_codex_spark_rejects_non_spark_models(model) -> None: + assert _is_codex_spark(model, "openai-codex") is False + + +def test_compression_threshold_for_codex_spark() -> None: + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai-codex") == 0.70 + assert _compression_threshold_for_model("openai/gpt-5.3-codex-spark", "openai-codex") == 0.70 + + +def test_compression_threshold_codex_spark_other_routes_unaffected() -> None: + # Same slug, different route → no override (keep the user's config value). + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openrouter") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark") is None # no provider + + +def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None: + # The spark autoraise is independent of the gpt-5.5 opt-out flag — 128K is + # the model's native window, so 70% is unambiguously correct regardless of + # whether the user opted out of the (artificial-cap) gpt-5.5 autoraise. + assert ( + _compression_threshold_for_model( + "gpt-5.3-codex-spark", "openai-codex", allow_codex_gpt55_autoraise=False + ) + == 0.70 + ) + + +# ── _resolve_compression_threshold (init_agent application logic) ──────────── +# +# The Codex overrides are *autoraises*: they raise the trigger (0.85 for the +# gpt-5.4/5.5 272K family, 0.70 for spark) but must never LOWER a higher +# user-configured global threshold. + + +def test_resolve_codex_autoraise_raises_from_default() -> None: + # Default 0.50 global → raised to 0.85, notice emitted. + effective, notice = _resolve_compression_threshold( + 0.50, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.85 + assert notice == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + + +def test_resolve_codex_autoraise_never_lowers_higher_threshold() -> None: + # Regression: a user who set compression.threshold above 0.85 must keep it. + # The autoraise previously clobbered it down to 0.85 (and silently, since + # the notice was suppressed when nothing "raised"). + effective, notice = _resolve_compression_threshold( + 0.90, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.90 + assert notice is None + + +def test_resolve_codex_spark_autoraise_never_lowers_higher_threshold() -> None: + # Same never-lower contract for the spark autoraise (0.70). + effective, notice = _resolve_compression_threshold( + 0.80, 0.70, model="gpt-5.3-codex-spark", is_codex_autoraise=True + ) + assert effective == 0.80 + assert notice is None + + +def test_resolve_codex_autoraise_equal_threshold_is_noop() -> None: + # User already at exactly the raised value: keep it, no notice. + effective, notice = _resolve_compression_threshold( + 0.85, 0.85, model="gpt-5.5", is_codex_autoraise=True + ) + assert effective == 0.85 + assert notice is None + + +def test_resolve_no_override_keeps_global() -> None: + # No per-model override (model_cthresh is None) → global threshold, no notice. + effective, notice = _resolve_compression_threshold( + 0.50, None, is_codex_autoraise=False + ) + assert effective == 0.50 + assert notice is None + + +def test_resolve_non_codex_override_applies_unconditionally() -> None: + # Arcee Trinity (0.75) keeps its long-standing unconditional behaviour: it + # applies even when it lowers the user's global value, and never emits the + # codex autoraise notice. + effective, notice = _resolve_compression_threshold( + 0.90, 0.75, is_codex_autoraise=False + ) + assert effective == 0.75 + assert notice is None -
tests/agent/test_auxiliary_main_first.py@@ -279,6 +279,34 @@ def test_runtime_override_wins_over_config(self, monkeypatch): assert mock_resolve.call_args.args[0] == "anthropic" assert mock_resolve.call_args.args[1] == "runtime-model" + def test_resolve_provider_auto_returns_runtime_model_not_stale_config_default(self): + """Blank auto aux requests must not pair a stale config model with live fallback provider.""" + runtime_client = MagicMock() + with patch( + "agent.auxiliary_client._read_main_model", + return_value="claude-opus-4-8", + ) as mock_read_main_model, patch( + "agent.auxiliary_client._resolve_auto", + return_value=(runtime_client, "gpt-5.5"), + ) as mock_resolve_auto: + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "auto", + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.5", + "base_url": "", + "api_key": "", + "api_mode": "codex_responses", + }, + ) + + assert client is runtime_client + assert model == "gpt-5.5" + mock_read_main_model.assert_not_called() + mock_resolve_auto.assert_called_once() + def test_runtime_base_url_passed_for_named_api_key_provider(self): """Named API-key providers inherit the live session endpoint for aux work.""" token_plan_url = "[redacted:secret-assignment]" -
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 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 + + [redacted:email]("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 -
tests/run_agent/test_infinite_compaction_loop.py@@ -283,3 +283,58 @@ def test_no_cooldown_allows(self): comp.last_prompt_tokens = [redacted:secret-assignment] assert comp._summary_failure_cooldown_until == 0.0 assert comp.should_compress(65_000) + + +# --------------------------------------------------------------------------- +# Test: #48621 — gpt-5.3-codex-spark short-session boundary +# +# Issue #48621 Bug 2 claims that a short high-token session (15-20 messages, +# ~90k tokens on a 128k model with protect_last_n=20) hits +# compress_start >= compress_end, causing a silent context wipe. The +# raw-budget fallback added in the #40803 fix already mitigates this: the +# boundary logic always exposes a minimal compressible window. This test +# locks that behavior in for the exact #48621 parameters. +# --------------------------------------------------------------------------- + +class TestCodexSparkShortSessionBoundary: + """Verify that gpt-5.3-codex-spark's short-session scenario always yields + a non-empty compressible window (no silent wipe).""" + + def test_short_high_token_session_has_compressible_window(self): + """16 messages with large tool outputs on a 128k model must leave + a compressible middle (compress_start < compress_end).""" + comp = _make_compressor( + model="gpt-5.3-codex-spark", + threshold_percent=0.70, + protect_first_n=3, + protect_last_n=20, + config_context_length=128000, + ) + # Build system + 3 head pairs + 3 tool groups (large outputs) + tail pair + big_tool = "x" * 20000 # ~5k tokens each + messages = [{"role": "system", "content": "You are a helpful agent."}] + for i in range(3): + messages.append({"role": "user", "content": f"Question {i}"}) + messages.append({"role": "assistant", "content": f"Answer {i}"}) + for i in range(3): + messages.append({"role": "user", "content": f"Run command {i}"}) + messages.append({ + "role": "assistant", "content": "", + "tool_calls": [{ + "id": f"tc{i}", "type": "function", + "function": {"name": "terminal", "arguments": "{}"}, + }], + }) + messages.append({"role": "tool", "tool_call_id": f"tc{i}", "content": big_tool}) + messages.append({"role": "user", "content": "Final question"}) + messages.append({"role": "assistant", "content": "Final answer"}) + + head = comp._protect_head_size(messages) + compress_start = comp._align_boundary_forward(messages, head) + compress_end = comp._find_tail_cut_by_tokens(messages, compress_start) + + assert compress_start < compress_end, ( + f"No compressible window: start={compress_start}, end={compress_end}. " + f"This would cause the silent context wipe described in #48621." + ) + assert comp.has_content_to_compress(messages) is True -
tests/run_agent/test_plugin_context_engine_init.py@@ -139,3 +139,101 @@ def test_plugin_engine_update_model_args(): assert "model" in kw assert "provider" in kw assert "api_mode" in kw + + +def _codex_agent_kwargs(): + return dict( + model="gpt-5.5", + provider="openai-codex", + api_key="[redacted:secret-assignment]", + base_url="https://chatgpt.com/backend-api/codex", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +def test_codex_gpt55_autoraise_suppressed_for_plugin_engine(): + """Codex gpt-5.5 autoraise must not fire when an external engine is active. + + Regression test for #44439 — the host compression threshold (including + the 0.85 autoraise) never reaches a plugin context engine, so the notice + announced a change that did not apply. + """ + engine = _StubEngine() + cfg = { + "context": {"engine": "stub"}, + "compression": {"enabled": True, "threshold": 0.75}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("plugins.context_engine.load_context_engine", return_value=engine), + patch("agent.model_metadata.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent.context_compressor is engine + assert agent._compression_threshold_autoraised is None + assert agent._compression_warning is None + # The engine's own policy is untouched by the host threshold. + assert engine.threshold_percent == 0.75 + + +def test_codex_gpt55_autoraise_still_applies_to_builtin_compressor(): + """Stock built-in compressor keeps the 50% → 85% Codex gpt-5.5 autoraise.""" + cfg = { + "compression": {"enabled": True, "threshold": 0.50}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("agent.context_compressor.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent._compression_threshold_autoraised == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + assert agent.context_compressor.threshold_percent == 0.85 + # Gateway parity: the notice is stashed for replay on turn 1. + assert agent._compression_warning and "85%" in agent._compression_warning + + +def test_codex_gpt55_autoraise_applies_when_plugin_engine_missing(): + """If the configured engine fails to load, the built-in compressor is + active and the autoraise (plus its notice) must still apply.""" + cfg = { + "context": {"engine": "no-such-engine"}, + "compression": {"enabled": True, "threshold": 0.50}, + "agent": {}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch( + "plugins.context_engine.load_context_engine", + side_effect=ValueError("not found"), + ), + patch("hermes_cli.plugins.get_plugin_context_engine", return_value=None), + patch("agent.context_compressor.get_model_context_length", return_value=272_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent(**_codex_agent_kwargs()) + + assert agent._compression_threshold_autoraised == {"model": "gpt-5.5", "from": 0.50, "to": 0.85} + assert agent.context_compressor.threshold_percent == 0.85 -
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
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 1
- Closed#48621gpt-5.3-codex-spark: no compression threshold autoraise + silent context wipe when protect_last_n too large
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #59814
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 6 Jul 2026