diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 975c16008a7..2b2883591a7 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -313,6 +313,13 @@ def interruptible_api_call(agent, api_kwargs: dict): and _elapsed > _ttfb_timeout and getattr(agent, "_codex_stream_last_event_ts", None) is None ): + _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( "Codex stream produced no bytes within TTFB cutoff " "(%.0fs > %.0fs, model=%s). Backend accepted the connection " @@ -320,11 +327,18 @@ def interruptible_api_call(agent, api_kwargs: dict): "loop can reconnect.", _elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"), ) - agent._emit_status( - f"⚠️ No first byte from provider in {int(_elapsed)}s " - f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " - f"Reconnecting." - ) + if _silent_hint: + agent._emit_status( + f"⚠️ No first byte from provider in {int(_elapsed)}s " + f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " + f"Reconnecting. {_silent_hint}" + ) + else: + agent._emit_status( + f"⚠️ No first byte from provider in {int(_elapsed)}s " + f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " + f"Reconnecting." + ) try: _close_request_client_once("codex_ttfb_kill") except Exception: @@ -335,10 +349,16 @@ def interruptible_api_call(agent, api_kwargs: dict): # Wait briefly for the worker to notice the closed connection. t.join(timeout=2.0) if result["error"] is None and result["response"] is None: - result["error"] = TimeoutError( - f"Codex stream produced no bytes within {int(_elapsed)}s " - f"(TTFB threshold: {int(_ttfb_timeout)}s)" - ) + if _silent_hint: + result["error"] = TimeoutError( + f"Codex stream produced no bytes within {int(_elapsed)}s " + f"(TTFB threshold: {int(_ttfb_timeout)}s). {_silent_hint}" + ) + else: + result["error"] = TimeoutError( + f"Codex stream produced no bytes within {int(_elapsed)}s " + f"(TTFB threshold: {int(_ttfb_timeout)}s)" + ) break # Stale-call detector: kill the connection if no response diff --git a/run_agent.py b/run_agent.py index ed81d6b970b..9c130d8d294 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1006,8 +1006,9 @@ class AIAgent: "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, " + "Workaround: try `gpt-5.4` on the same OAuth profile, or `gpt-5.3-codex`, " "or switch to a different model/provider in your fallback chain. " + "Some ChatGPT Codex accounts do not support `gpt-5.4-codex`. " "See hermes-agent#21444 for symptom history." ) diff --git a/tests/agent/test_codex_ttfb_watchdog.py b/tests/agent/test_codex_ttfb_watchdog.py index 9898c46261f..f649e40b49a 100644 --- a/tests/agent/test_codex_ttfb_watchdog.py +++ b/tests/agent/test_codex_ttfb_watchdog.py @@ -102,6 +102,52 @@ def test_ttfb_kills_when_no_stream_event(tmp_path, monkeypatch): stop["flag"] = True +def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch): + """The no-first-byte watchdog should surface the same actionable hint as the + stale-call timeout path when the model matches the silent-hang heuristic.""" + from agent import chat_completion_helpers as h + + agent = _make_codex_agent(tmp_path, monkeypatch) + monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1") + + closes: list = [] + statuses: list[str] = [] + dummy_client = SimpleNamespace() + monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client) + monkeypatch.setattr(agent, "_emit_status", lambda msg: statuses.append(msg)) + monkeypatch.setattr( + agent, "_abort_request_openai_client", + lambda c, reason=None: closes.append(reason), + ) + monkeypatch.setattr( + agent, "_close_request_openai_client", + lambda c, reason=None: closes.append(reason), + ) + + stop = {"flag": False} + + def fake_hang(api_kwargs, client=None, on_first_delta=None): + deadline = time.time() + 30 + while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested: + time.sleep(0.02) + raise RuntimeError("connection closed") + + monkeypatch.setattr(agent, "_run_codex_stream", fake_hang) + + try: + with pytest.raises(TimeoutError) as excinfo: + h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"}) + message = str(excinfo.value) + assert "gpt-5.4" in message + assert "gpt-5.3-codex" in message + assert "gpt-5.4-codex" in message + assert "codex_ttfb_kill" in closes + assert statuses, "expected a user-facing watchdog status" + assert any("gpt-5.4" in s and "gpt-5.3-codex" in s for s in statuses) + finally: + stop["flag"] = True + + def test_ttfb_does_not_kill_when_events_flow(tmp_path, monkeypatch): """Once a stream event has arrived, a generation that runs past the TTFB cutoff is NOT killed by the watchdog — it completes normally.""" diff --git a/tests/run_agent/test_codex_silent_hang_hint.py b/tests/run_agent/test_codex_silent_hang_hint.py index db10da61aa2..6d9d8a1dea9 100644 --- a/tests/run_agent/test_codex_silent_hang_hint.py +++ b/tests/run_agent/test_codex_silent_hang_hint.py @@ -3,7 +3,8 @@ 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. +symptom history. The recommended workaround for ChatGPT Codex OAuth +accounts is `gpt-5.4` / `gpt-5.3-codex`, not `gpt-5.4-codex`. """ from __future__ import annotations @@ -43,6 +44,8 @@ def test_hint_fires_for_bare_gpt_5_5_on_codex(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" in hint + assert "gpt-5.3-codex" in hint assert "gpt-5.4-codex" in hint assert "fallback chain" in hint @@ -72,11 +75,11 @@ def test_hint_fires_when_model_arg_omitted(tmp_path): # ── 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") +def test_hint_skipped_for_gpt_5_4(tmp_path): + """gpt-5.4 is the recommended workaround — must not trigger.""" + agent = _make_agent(tmp_path, model="gpt-5.4") agent.api_mode = "codex_responses" - assert agent._codex_silent_hang_hint(model="gpt-5.4-codex") is None + assert agent._codex_silent_hang_hint(model="gpt-5.4") is None def test_hint_skipped_for_gpt_5_50_false_positive(tmp_path): @@ -107,11 +110,11 @@ def test_hint_skipped_for_non_codex_provider(tmp_path): 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 = _make_agent(tmp_path, model="gpt-5.4") # 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 + # model=None falls back to self.model which is gpt-5.4, also no match assert agent._codex_silent_hang_hint(model=None) is None