diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 6f1e17a07f57..954971481160 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -634,6 +634,10 @@ def _make_agent( try: runtime = resolve_runtime_provider(requested=requested_provider or config_provider) + # Match gateway/CLI: pass credential_pool so long-lived ACP sessions + # can refresh OAuth (xAI returns 403 bad-credentials for stale tokens). + # Without the pool, idle Buzz ACP processes abort while Telegram/Desktop + # keep working on the same HERMES_HOME. kwargs.update( { "provider": runtime.get("provider"), @@ -642,6 +646,7 @@ def _make_agent( "api_key": runtime.get("api_key"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), + "credential_pool": runtime.get("credential_pool"), } ) except Exception: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a764501febab..97ae345a78ce 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3271,13 +3271,19 @@ def _perform_api_call(next_api_kwargs): if ( agent.api_mode == "codex_responses" and agent.provider in {"openai-codex", "xai-oauth"} - and status_code == 401 + # xAI often returns 403 bad-credentials for a stale access + # token (not only 401). Refresh on both when the classifier + # says auth (spending-limit 403 is billing and is excluded). + and status_code in {401, 403} + and classified.reason == FailoverReason.auth and not _retry.codex_auth_retry_attempted ): _retry.codex_auth_retry_attempted = True if agent._try_refresh_codex_client_credentials(force=True): _label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex" - agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...") + agent._buffer_vprint( + f"🔐 {_label} auth refreshed after {status_code}. Retrying request..." + ) continue if ( agent.api_mode == "chat_completions" diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f5458560..dba9cae5d91e 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -968,9 +968,21 @@ def _classify_by_status( should_rotate_credential=True, should_fallback=True, ) + # Default: auth without rotate — preserve historical behaviour for + # generic providers (see test_non_xai_403_generic_billing_code_remains_auth). + # OAuth providers that return 403 for stale access tokens (xAI + # ``bad-credentials``, etc.) need rotate/refresh like 401 so long-lived + # ACP sessions recover. Billing shapes above already returned. + _stale_oauth_403 = ( + provider in {"xai-oauth", "openai-codex", "nous"} + or "bad-credentials" in error_msg + or "oauth2 access token could not be validated" in error_msg + or "wke=unauthenticated" in error_msg + ) return result_fn( FailoverReason.auth, retryable=False, + should_rotate_credential=_stale_oauth_403, should_fallback=True, ) diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 199454b39dba..c58e7a215a85 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -130,6 +130,48 @@ def __init__(self, **kwargs): class TestWslCwdTranslation: + def test_make_agent_forwards_credential_pool_from_runtime(self, monkeypatch): + """ACP must wire the runtime credential pool like gateway/CLI. + + Without the pool, long-lived ACP sessions cannot refresh stale + xAI OAuth tokens (403 bad-credentials) while Telegram keeps working. + """ + captured = {} + fake_pool = object() + + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace( + model=kwargs.get("model"), + session_cwd=None, + _print_fn=None, + ) + + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider": "xai-oauth", "default": "grok-4.5"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested=None, **kwargs: { + "provider": "xai-oauth", + "api_mode": "codex_responses", + "base_url": "https://api.x.ai/v1", + "api_key": "stale-token", + "command": None, + "args": [], + "credential_pool": fake_pool, + }, + ) + with patch("run_agent.AIAgent", side_effect=fake_agent): + agent = SessionManager(db=None)._make_agent( + session_id="sess-pool", + cwd="/tmp/work", + ) + + assert captured.get("credential_pool") is fake_pool + assert captured.get("provider") == "xai-oauth" + def test_translate_acp_cwd_converts_windows_drive_path_when_wsl(self, monkeypatch): monkeypatch.setattr("hermes_constants._wsl_detected", True)