From 70d34c16422b5b592123ac976f7c91d4fe95d56a Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Thu, 20 Aug 2026 17:22:23 +0700 Subject: [PATCH] fix(router): connect persisted settings to runtime behavior --- .../router/router_engine.py | 30 +++++++-- .../router/settings_service.py | 66 +++++++++++++++++++ tests/test_p0_release_gate.py | 53 +++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 src/antigravity_provider/router/settings_service.py diff --git a/src/antigravity_provider/router/router_engine.py b/src/antigravity_provider/router/router_engine.py index b7c7c67..3475f2c 100644 --- a/src/antigravity_provider/router/router_engine.py +++ b/src/antigravity_provider/router/router_engine.py @@ -77,16 +77,28 @@ class RouterEngine: role_policy = self.config.get_role_policy(target_role) requested_model = request.get("model") - family = extract_model_family(requested_model) + # Read runtime settings from hub_settings.json + from .settings_service import get_hub_settings + hub_settings = get_hub_settings() + + affinity_enabled = bool(hub_settings.get("session_affinity", True)) and role_policy.session_affinity_enabled + auto_failover = bool(hub_settings.get("auto_failover", True)) + auto_return_primary = bool(hub_settings.get("auto_return_primary", True)) # 1. Check Session Affinity candidate_profiles: list[str] = [] - if target_session and role_policy.session_affinity_enabled: + if target_session and affinity_enabled: aff_rec = self.affinity.get_affinity(target_session) if aff_rec and aff_rec.profile_id in self.config.profiles: - aff_profile = self.config.profiles[aff_rec.profile_id] - if aff_profile.enabled and self.health.is_healthy(aff_rec.profile_id, requested_model): - candidate_profiles.append(aff_rec.profile_id) + # If auto_return_primary is active, check if primary chain slot is healthy again + primary_pid = role_policy.preferred_chain[0] if role_policy.preferred_chain else None + if auto_return_primary and primary_pid and primary_pid != aff_rec.profile_id and self.health.is_healthy(primary_pid, requested_model): + # Return to primary account + pass + else: + aff_profile = self.config.profiles[aff_rec.profile_id] + if aff_profile.enabled and self.health.is_healthy(aff_rec.profile_id, requested_model): + candidate_profiles.append(aff_rec.profile_id) # 2. Add remaining preferred chain candidates for pid in role_policy.preferred_chain: @@ -101,7 +113,11 @@ class RouterEngine: failover_trail: list[dict[str, Any]] = [] attempts = 0 - max_attempts = min(role_policy.max_failover_attempts, len(candidate_profiles)) + if auto_failover: + configured_attempts = hub_settings.get("failover_attempts", role_policy.max_failover_attempts) + max_attempts = min(int(configured_attempts), len(candidate_profiles)) + else: + max_attempts = 1 for pid in candidate_profiles: if attempts >= max_attempts: @@ -155,7 +171,7 @@ class RouterEngine: self.leases.release(pid) # Set / update session affinity - if target_session and role_policy.session_affinity_enabled: + if target_session and affinity_enabled: self.affinity.set_affinity(target_session, target_role, pid, exec_request.get("model")) # Attach router telemetry diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py new file mode 100644 index 0000000..71ebb13 --- /dev/null +++ b/src/antigravity_provider/router/settings_service.py @@ -0,0 +1,66 @@ +"""Hermes Hub — Central Hub Settings Service. + +Provides unified reading, saving, and querying of runtime settings from hub_settings.json. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + +from antigravity_provider.paths import get_hermes_home + +DEFAULT_SETTINGS: Dict[str, Any] = { + "session_affinity": True, + "auto_failover": True, + "failover_attempts": 3, + "auto_return_primary": True, + "auto_monitoring": True, + "auto_update": True, + "release_channel": "stable", + "model_timeout_seconds": 60, + "monitoring_interval_seconds": 30, +} + + +def get_settings_file() -> Path: + """Return the absolute path to hub_settings.json in HERMES_HOME.""" + return get_hermes_home() / "hub_settings.json" + + +def get_hub_settings() -> Dict[str, Any]: + """Load settings from hub_settings.json merged with standard defaults.""" + sfile = get_settings_file() + merged = dict(DEFAULT_SETTINGS) + if sfile.exists(): + try: + data = json.loads(sfile.read_text(encoding="utf-8")) + if isinstance(data, dict): + merged.update(data) + except Exception: + pass + + # Normalize numeric types + try: + merged["failover_attempts"] = int(merged.get("failover_attempts", 3)) + except (ValueError, TypeError): + merged["failover_attempts"] = 3 + + try: + merged["model_timeout_seconds"] = int(merged.get("model_timeout_seconds", 60)) + except (ValueError, TypeError): + merged["model_timeout_seconds"] = 60 + + try: + merged["monitoring_interval_seconds"] = int(merged.get("monitoring_interval_seconds", 30)) + except (ValueError, TypeError): + merged["monitoring_interval_seconds"] = 30 + + return merged + + +def save_hub_settings(settings: Dict[str, Any]) -> None: + """Persist settings dictionary into hub_settings.json.""" + sfile = get_settings_file() + sfile.parent.mkdir(parents=True, exist_ok=True) + sfile.write_text(json.dumps(settings, indent=2, ensure_ascii=False), encoding="utf-8") diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 0215f6b..f709563 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -364,3 +364,56 @@ def test_p0_11_rate_limit_vs_quota_classification(): classification = adapter.classify_error(exc_info.value) assert classification.category == ErrorCategory.QUOTA_EXHAUSTED assert classification.reset_duration_seconds == 7200 + + +@pytest.mark.unit +def test_r4_settings_runtime_influence(tmp_path, monkeypatch): + """R4: Verify that hub_settings.json dynamically modifies RouterEngine behavior.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from antigravity_provider.router.settings_service import save_hub_settings + from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter + from antigravity_provider.router.adapters.codex_adapter import CodexAdapter + + config = RouterConfig( + profiles={ + "ag-orch-primary": RouterProfileConfig(profile_id="ag-orch-primary", provider="antigravity", enabled=True), + "codex-orch-fallback": RouterProfileConfig(profile_id="codex-orch-fallback", provider="openai-codex", enabled=True), + }, + roles={ + "orchestrator": RolePolicy( + role_name="orchestrator", + preferred_chain=["ag-orch-primary", "codex-orch-fallback"], + max_failover_attempts=2, + ) + } + ) + save_router_config(config) + + # 1. With auto_failover=False in hub_settings.json, failover must NOT attempt fallback + save_hub_settings({"auto_failover": False}) + engine = RouterEngine(config=config) + + def mock_agy_quota(profile, req): + raise QuotaExceededError("Quota reached", provider="antigravity", profile_id=profile.profile_id) + + mock_codex = MagicMock() + + with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ + patch.object(CodexAdapter, "invoke", mock_codex): + + res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") + assert "error" in res or "choices" in res + # Codex fallback must NOT have been called because auto_failover was False! + assert mock_codex.call_count == 0 + + # 2. With auto_failover=True in hub_settings.json, failover attempts fallback + save_hub_settings({"auto_failover": True, "failover_attempts": 2}) + mock_codex.return_value = {"choices": [{"message": {"role": "assistant", "content": "Fallback OK"}}]} + + with patch.object(AntigravityAdapter, "invoke", side_effect=mock_agy_quota), \ + patch.object(CodexAdapter, "invoke", mock_codex): + + res = engine.route_request({"messages": [{"role": "user", "content": "Hello"}]}, role="orchestrator") + assert res["choices"][0]["message"]["content"] == "Fallback OK" + assert mock_codex.call_count == 1