diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 5cdb590dd5d..ba5f02ba867 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -278,17 +278,31 @@ def interruptible_api_call(agent, api_kwargs: dict): _elapsed = time.time() - _call_start if _elapsed > _stale_timeout: _est_ctx = estimate_request_context_tokens(api_kwargs) + _silent_hint: Optional[str] = None + _hint_fn = getattr(agent, "_codex_silent_hang_hint", None) + if callable(_hint_fn): + try: + _silent_hint = _hint_fn(model=api_kwargs.get("model")) + except Exception: + _silent_hint = None logger.warning( "Non-streaming API call stale for %.0fs (threshold %.0fs). " "model=%s context=~%s tokens. Killing connection.", _elapsed, _stale_timeout, api_kwargs.get("model", "unknown"), f"{_est_ctx:,}", ) - agent._emit_status( - f"⚠️ No response from provider for {int(_elapsed)}s " - f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). " - f"Aborting call." - ) + if _silent_hint: + agent._emit_status( + f"⚠️ No response from provider for {int(_elapsed)}s " + f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). " + f"{_silent_hint}" + ) + else: + agent._emit_status( + f"⚠️ No response from provider for {int(_elapsed)}s " + f"(non-streaming, model: {api_kwargs.get('model', 'unknown')}). " + f"Aborting call." + ) try: if agent.api_mode == "anthropic_messages": agent._anthropic_client.close() @@ -303,10 +317,17 @@ def interruptible_api_call(agent, api_kwargs: dict): # Wait briefly for the thread to notice the closed connection. t.join(timeout=2.0) if result["error"] is None and result["response"] is None: - result["error"] = TimeoutError( - f"Non-streaming API call timed out after {int(_elapsed)}s " - f"with no response (threshold: {int(_stale_timeout)}s)" - ) + if _silent_hint: + result["error"] = TimeoutError( + f"Non-streaming API call timed out after {int(_elapsed)}s " + f"with no response (threshold: {int(_stale_timeout)}s). " + f"{_silent_hint}" + ) + else: + result["error"] = TimeoutError( + f"Non-streaming API call timed out after {int(_elapsed)}s " + f"with no response (threshold: {int(_stale_timeout)}s)" + ) break if agent._interrupt_requested: diff --git a/run_agent.py b/run_agent.py index 6c44c2d9a48..d2d65314f75 100644 --- a/run_agent.py +++ b/run_agent.py @@ -927,6 +927,57 @@ class AIAgent: return max(stale_base, 150.0) return stale_base + def _codex_silent_hang_hint(self, model: Optional[str] = None) -> Optional[str]: + """Return an actionable hint when this request matches a known + Codex silent-reject configuration, else ``None``. + + The ChatGPT Codex backend (``chatgpt.com/backend-api/codex``) has + historically silently dropped certain model requests: the connection + is accepted but no stream events are emitted and no error is raised. + The stale-call detector ends the hang, but a generic "timed out" + message gives the user no path forward. + + This helper substitutes an actionable hint into the stale-timeout + warning when the request matches a known silent-reject pattern. + Currently flagged: ``gpt-5.5`` family on the Codex backend. See + hermes-agent #21444 for the symptom history. The upstream backend + behavior has historically come and gone with ChatGPT entitlement + changes — the heuristic stays in place as future-proofing even when + the symptom is dormant. + + Does NOT fix the backend issue. Only converts an opaque stale-timeout + into actionable text so users learn the workaround in seconds rather + than digging through logs. + """ + if self.api_mode != "codex_responses": + return None + is_codex_backend = ( + self.provider == "openai-codex" + or ( + getattr(self, "_base_url_hostname", "") == "chatgpt.com" + and "/backend-api/codex" in (getattr(self, "_base_url_lower", "") or "") + ) + ) + if not is_codex_backend: + return None + eff_model = (model if model is not None else self.model) or "" + model_lower = eff_model.lower() + # Match the gpt-5.5 family — bare ``gpt-5.5``, ``gpt-5.5-codex``, + # vendor-prefixed variants like ``openai/gpt-5.5``, and any future + # ``gpt-5.5-*`` SKU. Anchor at a word boundary on either side so + # unrelated tokens like ``gpt-5.50`` do not match. + if not re.search(r"(?:^|[/\-_])gpt-5\.5(?:$|[\-_])", model_lower): + return None + return ( + f"Codex backend appears to be silently rejecting {eff_model!r} " + "on chatgpt.com/backend-api/codex (no stream events, no error). " + "This is a known backend-side pattern that has affected ChatGPT " + "Plus accounts intermittently. " + "Workaround: try `gpt-5.4-codex` on the same OAuth profile, " + "or switch to a different model/provider in your fallback chain. " + "See hermes-agent#21444 for symptom history." + ) + def _is_openrouter_url(self) -> bool: """Return True when the base URL targets OpenRouter.""" return base_url_host_matches(self._base_url_lower, "openrouter.ai") diff --git a/scripts/release.py b/scripts/release.py index 2f6f41a7157..be323e148d5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -595,6 +595,7 @@ AUTHOR_MAP = { "mgparkprint@gmail.com": "vlwkaos", "1317078257maroon@gmail.com": "Oxidane-bot", "tranquil_flow@protonmail.com": "Tranquil-Flow", + "66773372+Tranquil-Flow@users.noreply.github.com": "Tranquil-Flow", "LyleLengyel@gmail.com": "mcndjxlefnd", "wangshengyang2004@163.com": "Wangshengyang2004", "hasan.ali13381@gmail.com": "H-Ali13381", diff --git a/tests/run_agent/test_codex_silent_hang_hint.py b/tests/run_agent/test_codex_silent_hang_hint.py new file mode 100644 index 00000000000..db10da61aa2 --- /dev/null +++ b/tests/run_agent/test_codex_silent_hang_hint.py @@ -0,0 +1,121 @@ +"""Tests for the ``_codex_silent_hang_hint`` heuristic. + +The helper substitutes an actionable hint into the stale-call timeout +warning when the request matches a known Codex silent-reject pattern +(gpt-5.5 family on the ChatGPT Codex backend). See issue #21444 for +symptom history. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _make_agent(tmp_path: Path, **overrides): + from run_agent import AIAgent + kwargs = dict( + model="gpt-5.5", + provider="openai-codex", + api_key="sk-dummy", + base_url="https://chatgpt.com/backend-api/codex", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + platform="cli", + ) + kwargs.update(overrides) + return AIAgent(**kwargs) + + +@pytest.fixture(autouse=True) +def _isolate_hermes_home(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / ".env").write_text("", encoding="utf-8") + + +# ── positive cases: hint fires ───────────────────────────────────────────── + + +def test_hint_fires_for_bare_gpt_5_5_on_codex(tmp_path): + agent = _make_agent(tmp_path) + agent.api_mode = "codex_responses" + hint = agent._codex_silent_hang_hint(model="gpt-5.5") + assert hint is not None + assert "gpt-5.4-codex" in hint + assert "fallback chain" in hint + + +def test_hint_fires_for_vendor_prefixed_gpt_5_5(tmp_path): + agent = _make_agent(tmp_path, model="openai/gpt-5.5") + agent.api_mode = "codex_responses" + hint = agent._codex_silent_hang_hint(model="openai/gpt-5.5") + assert hint is not None + + +def test_hint_fires_for_gpt_5_5_codex_suffix(tmp_path): + agent = _make_agent(tmp_path, model="gpt-5.5-codex") + agent.api_mode = "codex_responses" + hint = agent._codex_silent_hang_hint(model="gpt-5.5-codex") + assert hint is not None + + +def test_hint_fires_when_model_arg_omitted(tmp_path): + """The helper falls back to ``self.model`` when ``model=`` not passed.""" + agent = _make_agent(tmp_path) + agent.api_mode = "codex_responses" + hint = agent._codex_silent_hang_hint() + assert hint is not None + + +# ── negative cases: hint stays None ──────────────────────────────────────── + + +def test_hint_skipped_for_gpt_5_4_codex(tmp_path): + """gpt-5.4-codex is the recommended workaround — must not trigger.""" + agent = _make_agent(tmp_path, model="gpt-5.4-codex") + agent.api_mode = "codex_responses" + assert agent._codex_silent_hang_hint(model="gpt-5.4-codex") is None + + +def test_hint_skipped_for_gpt_5_50_false_positive(tmp_path): + """``gpt-5.50`` (hypothetical future SKU) must not regex-match gpt-5.5.""" + agent = _make_agent(tmp_path, model="gpt-5.50") + agent.api_mode = "codex_responses" + assert agent._codex_silent_hang_hint(model="gpt-5.50") is None + + +def test_hint_skipped_for_non_codex_api_mode(tmp_path): + """Hint only fires on the Codex Responses path.""" + agent = _make_agent(tmp_path) + agent.api_mode = "chat_completions" + assert agent._codex_silent_hang_hint(model="gpt-5.5") is None + + +def test_hint_skipped_for_non_codex_provider(tmp_path): + """Same model on a non-Codex provider does not trigger.""" + agent = _make_agent( + tmp_path, + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + model="openai/gpt-5.5", + ) + agent.api_mode = "codex_responses" + assert agent._codex_silent_hang_hint(model="openai/gpt-5.5") is None + + +def test_hint_skipped_for_empty_model(tmp_path): + """Explicit empty string ``model`` short-circuits the regex.""" + agent = _make_agent(tmp_path, model="gpt-5.4-codex") # self.model non-matching + agent.api_mode = "codex_responses" + # Explicit empty string: regex won't match + assert agent._codex_silent_hang_hint(model="") is None + # model=None falls back to self.model which is gpt-5.4-codex, also no match + assert agent._codex_silent_hang_hint(model=None) is None + + +def test_hint_skipped_for_unrelated_model_on_codex(tmp_path): + agent = _make_agent(tmp_path, model="gpt-4-turbo") + agent.api_mode = "codex_responses" + assert agent._codex_silent_hang_hint(model="gpt-4-turbo") is None