Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions agent/error_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
42 changes: 42 additions & 0 deletions tests/acp/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading