diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index a418088916a95..bbaa688df7e02 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1388,7 +1388,15 @@ def restore_primary_runtime(agent) -> bool: ) # ── Rebuild client for the primary provider ── - if agent.api_mode == "anthropic_messages": + if agent.provider == "moa": + # MoA is a virtual chat-completions provider. It never has real + # OpenAI client kwargs; restoring it after a fallback must recreate + # the facade, not call OpenAI() with an empty api_key. + from agent.moa_loop import MoAClient + + agent.client = MoAClient(agent.model or "default") + agent._anthropic_client = None + elif agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 50e3a0087244a..2a0b3df870993 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -81,6 +81,66 @@ def test_moa_runtime_provider_uses_virtual_endpoint(): assert runtime["api_key"] == "moa-virtual-provider" +def test_moa_primary_restore_rebuilds_virtual_facade(monkeypatch, tmp_path): + """MoA sessions must restore from fallback without constructing OpenAI(). + + Regression for a long-lived MoA session that failed over to a real provider: + the next turn restored provider/model to MoA but tried to rebuild the shared + client from MoA's empty client_kwargs, raising "api_key client option must be + set" and then "Failed to recreate closed OpenAI client". + """ + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + """ +moa: + default_preset: review + presets: + review: + reference_models: + - provider: openai-codex + model: gpt-5.5 + aggregator: + provider: openrouter + model: anthropic/claude-opus-4.8 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + + agent = AIAgent( + api_key="moa-virtual-provider", + base_url="moa://local", + model="review", + provider="moa", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=["file"], + max_iterations=1, + ) + primary_client = agent.client + + def fail_openai_rebuild(*_args, **_kwargs): + raise AssertionError("MoA restore must not build a real OpenAI client") + + monkeypatch.setattr(agent, "_create_openai_client", fail_openai_rebuild) + setattr(agent, "_fallback_activated", True) + setattr(agent, "provider", "zai") + setattr(agent, "model", "glm-5.2") + agent.base_url = "https://api.z.ai/api/coding/paas/v4" + agent.api_key = "fallback-key" + setattr(agent, "_client_kwargs", {"api_key": "fallback-key", "base_url": agent.base_url}) + agent.client = SimpleNamespace(close=lambda: None, _client=SimpleNamespace(is_closed=True)) + + assert agent._restore_primary_runtime() is True + assert getattr(agent, "provider") == "moa" + assert getattr(agent, "model") == "review" + assert agent.client is not primary_client + assert hasattr(agent.client.chat, "completions") + assert getattr(agent, "_fallback_activated") is False + + def test_moa_does_not_cap_output_tokens(monkeypatch, tmp_path): """MoA must not inject an output cap on reference or aggregator calls.