From 925e6c668782f8125b33d315604761fce462ca35 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Thu, 20 Aug 2026 17:21:30 +0700 Subject: [PATCH] fix(router): correct spare assignment and error presentation --- src/antigravity_provider/hermes_plugin.py | 12 +++--- .../router/auto_assigner.py | 11 ++++++ .../router/ui/add_account_wizard.py | 11 +++++- src/antigravity_provider/runtime.py | 31 +++++++++++++-- tests/test_p0_release_gate.py | 39 +++++++++++++++++++ 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/antigravity_provider/hermes_plugin.py b/src/antigravity_provider/hermes_plugin.py index 619e1aa..3649a3e 100644 --- a/src/antigravity_provider/hermes_plugin.py +++ b/src/antigravity_provider/hermes_plugin.py @@ -6,7 +6,7 @@ from typing import Any from .agy_subprocess import agy_generate from .hermes_provider import DEFAULT_MODEL, PLACEHOLDER_API_KEY, PLACEHOLDER_API_KEY_ENV, PROVIDER_NAME, register_provider_profile -from .runtime import ensure_provider_profile_files, openai_completion_object +from .runtime import ensure_provider_profile_files, format_antigravity_error, openai_completion_object logger = logging.getLogger(__name__) @@ -38,14 +38,13 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: session_id = kwargs.get("session_id") or request.get("session_id") completion = engine.route_request(request, role=role, session_id=session_id) if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): - err = completion.get("error") - err_msg = err.get("message") if isinstance(err, dict) else str(err) + err_text = format_antigravity_error(completion.get("error")) completion = { "model": str(request.get("model") or DEFAULT_MODEL), "choices": [ { "index": 0, - "message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"}, + "message": {"role": "assistant", "content": err_text}, "finish_reason": "stop", } ], @@ -61,14 +60,13 @@ def antigravity_llm_execution(**kwargs: Any) -> Any: try: completion = agy_generate(request) if isinstance(completion, dict) and "error" in completion and not completion.get("choices"): - err = completion.get("error") - err_msg = err.get("message") if isinstance(err, dict) else str(err) + err_text = format_antigravity_error(completion.get("error")) completion = { "model": str(request.get("model") or DEFAULT_MODEL), "choices": [ { "index": 0, - "message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"}, + "message": {"role": "assistant", "content": err_text}, "finish_reason": "stop", } ], diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 1fa1fa1..6ea3a9f 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -210,6 +210,17 @@ class AutoAssigner: return False, f"Профиль '{profile_id}' не найден" clean_role = role_name.strip().lower() + if clean_role in {"spare", "резерв", "none", "unassigned"}: + # Remove profile from all active role chains to keep strictly as spare pool + for rname, rpolicy in config.roles.items(): + if profile_id in rpolicy.preferred_chain: + rpolicy.preferred_chain = [p for p in rpolicy.preferred_chain if p != profile_id] + config.roles[rname] = rpolicy + pcfg.enabled = True + config.profiles[profile_id] = pcfg + save_router_config(config) + return True, f"Профиль '{profile_id}' сохранен в пуле резерва (spare)" + canonical_role = CANONICAL_ROLE_MAP.get(clean_role, clean_role) if canonical_role not in config.roles: diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index 745f043..4f79c61 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -398,11 +398,18 @@ class AddAccountWizard(HubModal): ) # Apply role to live config - AutoAssigner.assign_profile_to_role(self.target_slot, target_role, is_primary=(chosen != "spare")) + ok, msg = AutoAssigner.assign_profile_to_role(self.target_slot, target_role, is_primary=(chosen != "spare")) + if not ok: + EventLogService.get().log( + "account", + f"Ошибка назначения профиля {self.target_slot}: {msg}", + level="warning", + ) + return EventLogService.get().log( "account", - f"Подключён аккаунт {self.discovered_identity} ({self.selected_provider}). Назначен на роль: {target_role}.", + f"Подключён аккаунт {self.discovered_identity} ({self.selected_provider}). {msg}.", level="success", ) self.destroy() diff --git a/src/antigravity_provider/runtime.py b/src/antigravity_provider/runtime.py index 00c475d..e8c7a24 100644 --- a/src/antigravity_provider/runtime.py +++ b/src/antigravity_provider/runtime.py @@ -111,20 +111,43 @@ def _namespace(value: Any) -> Any: return value +def format_antigravity_error(err: Any) -> str: + """Format an error message with 'Antigravity error: ' prefix without duplicate prefixes.""" + msg = err.get("message") if isinstance(err, dict) else str(err or "unknown error") + msg = msg.strip() + + prefixes_to_strip = [ + "Antigravity error:", + "Antigravity (agy) error:", + "agy error:", + "Antigravity error", + "agy error", + ] + changed = True + while changed: + changed = False + for p in prefixes_to_strip: + if msg.lower().startswith(p.lower()): + msg = msg[len(p):].strip(" :") + changed = True + break + + return f"Antigravity error: {msg}" if msg else "Antigravity error: unknown error" + + def openai_completion_object(completion: dict[str, Any]) -> SimpleNamespace: """Return an object compatible with Hermes' ChatCompletionsTransport.""" completion = dict(completion) choices = [] - + # Handle error payload without crashing choices[0] if "error" in completion and not completion.get("choices"): - err = completion.get("error") - err_msg = err.get("message") if isinstance(err, dict) else str(err) + err_text = format_antigravity_error(completion.get("error")) choices.append({ "index": 0, "message": { "role": "assistant", - "content": f"Antigravity error: {err_msg}", + "content": err_text, "tool_calls": None, }, "finish_reason": "stop", diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 2a66a61..0215f6b 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -244,6 +244,45 @@ def test_p0_7_assign_role_action(tmp_path, monkeypatch): assert "Неизвестная роль" in msg +@pytest.mark.unit +def test_n1_spare_assignment_mode(tmp_path, monkeypatch): + """N1: Verify that selecting spare mode removes profile from active roles without creating rogue roles.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + config = get_default_router_config() + # Put ag-w1 in coder-primary + config.roles["coder-primary"].preferred_chain = ["ag-w1", "ag-w2"] + save_router_config(config) + + # Assign ag-w1 to spare + ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "spare") + assert ok is True + assert "резерв" in msg.lower() or "spare" in msg.lower() + + reloaded = load_router_config() + assert "ag-w1" not in reloaded.roles["coder-primary"].preferred_chain + assert "spare" not in reloaded.roles # Canonical role set unchanged + assert reloaded.profiles["ag-w1"].enabled is True + + +@pytest.mark.unit +def test_n2_error_formatter_deduplication(): + """N2: Verify that format_antigravity_error never creates double 'Antigravity error:' prefixes.""" + from antigravity_provider.runtime import format_antigravity_error + + # Case 1: Raw exception message + assert format_antigravity_error("connection refused") == "Antigravity error: connection refused" + + # Case 2: Already prefixed with Antigravity error: + assert format_antigravity_error("Antigravity error: quota exceeded") == "Antigravity error: quota exceeded" + + # Case 3: Nested multiple prefixes + assert format_antigravity_error("Antigravity error: Antigravity error: agy error: 429") == "Antigravity error: 429" + + # Case 4: Dict error format + assert format_antigravity_error({"message": "Antigravity (agy) error: timeout"}) == "Antigravity error: timeout" + + @pytest.mark.unit def test_p0_8_wizard_role_application(tmp_path, monkeypatch): """P0-8: Verify Wizard step 4 role assignment is applied directly to configuration."""