fix(router): address review findings B1-B4 and S1-S9 with full regression suite

This commit is contained in:
Hermes Team 2026-08-20 17:04:49 +07:00
parent 7926de9ad2
commit 65482e8eee
12 changed files with 374 additions and 115 deletions

View file

@ -45,37 +45,34 @@ def check_version_consistency() -> tuple[bool, str]:
return True, f"Version {ver} is consistent across all manifests"
def check_p0_release_gate() -> tuple[bool, str]:
res = subprocess.run(
[sys.executable, "-m", "pytest", "-v", "tests/test_p0_release_gate.py"],
def _run_pytest(args: list[str]) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["PYTHONPATH"] = str(ROOT / "src")
return subprocess.run(
[sys.executable, "-m", "pytest"] + args,
cwd=str(ROOT),
env=env,
capture_output=True,
text=True,
)
def check_p0_release_gate() -> tuple[bool, str]:
res = _run_pytest(["-v", "tests/test_p0_release_gate.py"])
if res.returncode != 0:
return False, f"P0 tests failed:\n{res.stdout}\n{res.stderr}"
return True, "9/9 P0 release blockers verified"
return True, "12/12 P0 release blockers & regression checks verified"
def check_updater_and_rollback() -> tuple[bool, str]:
res = subprocess.run(
[sys.executable, "-m", "pytest", "-v", "tests/test_updater.py"],
cwd=str(ROOT),
capture_output=True,
text=True,
)
res = _run_pytest(["-v", "tests/test_updater.py"])
if res.returncode != 0:
return False, f"Updater tests failed:\n{res.stdout}\n{res.stderr}"
return True, "Auto-updater, SHA-256 verification, and rollback verified"
def check_full_test_suite() -> tuple[bool, str]:
res = subprocess.run(
[sys.executable, "-m", "pytest", "-v"],
cwd=str(ROOT),
capture_output=True,
text=True,
)
res = _run_pytest(["-v"])
if res.returncode != 0:
return False, f"Offline pytest suite failed:\n{res.stdout}\n{res.stderr}"
return True, "All unit and integration tests passed offline"
@ -102,15 +99,24 @@ def check_zero_hardcoded_paths() -> tuple[bool, str]:
def check_security_zero_secrets() -> tuple[bool, str]:
secret_files = list(ROOT.rglob("auth.json")) + list(ROOT.rglob("*.secret")) + list(ROOT.rglob("*.key"))
secret_files = list(ROOT.rglob("auth.json")) + list(ROOT.rglob("*.secret")) + list(ROOT.rglob("*.key")) + list(ROOT.rglob(".env*"))
tracked_secrets = []
for sf in secret_files:
if ".git" not in str(sf) and "venv" not in str(sf) and "scratch" not in str(sf):
if ".git" not in str(sf) and "venv" not in str(sf) and "scratch" not in str(sf) and "example" not in str(sf):
tracked_secrets.append(str(sf.relative_to(ROOT)))
if tracked_secrets:
return False, f"Found sensitive secret files in repository:\n" + "\n".join(tracked_secrets)
return True, "Zero secret/credential files tracked in repository"
# Check for hardcoded OpenAI / OpenCode live API keys in src/
src_dir = ROOT / "src"
live_key_pattern = re.compile(r"""(?:sk-[a-zA-Z0-9]{32,}|opencode-[a-zA-Z0-9]{20,})""")
for f in src_dir.rglob("*.py"):
text = f.read_text(encoding="utf-8", errors="ignore")
if live_key_pattern.search(text):
return False, f"Found potential live API key in source file: {f.relative_to(ROOT)}"
return True, "Zero secret/credential files or live API keys tracked in repository"
def run_release_gate():

View file

@ -37,6 +37,20 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
role = kwargs.get("role") or request.get("role")
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)
completion = {
"model": str(request.get("model") or DEFAULT_MODEL),
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
return openai_completion_object(completion)
except Exception as router_exc:
logger.debug("Router invocation fell back to default provider: %s", router_exc)
@ -46,6 +60,20 @@ def antigravity_llm_execution(**kwargs: Any) -> Any:
return next_call(request) if callable(next_call) else request
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)
completion = {
"model": str(request.get("model") or DEFAULT_MODEL),
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": f"Antigravity error: {err_msg}"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
}
except Exception as exc:
logger.exception("agy_generate raised: %s", exc)
completion = {

View file

@ -56,17 +56,34 @@ class AntigravityAdapter(BaseProviderAdapter):
res = agy_generate(req, custom_env=custom_env)
if isinstance(res, dict) and "error" in res:
err_dict = res["error"]
err_dict = res.get("error")
err_msg = err_dict.get("message", "Antigravity provider error") if isinstance(err_dict, dict) else str(err_dict)
err_lower = err_msg.lower()
if any(k in err_lower for k in ("quota", "resource_exhausted", "429", "limit", "exhausted")):
raise QuotaExceededError(err_msg, provider="antigravity", profile_id=profile.profile_id)
elif any(k in err_lower for k in ("auth", "401", "403", "expired", "token", "unauthorized")):
# 1. Auth errors
if any(k in err_lower for k in ("auth", "401", "403", "expired", "token", "unauthorized", "login", "keychain")):
raise AuthExpiredError(err_msg, provider="antigravity", profile_id=profile.profile_id)
elif "rate" in err_lower:
# 2. Rate limiting (Check BEFORE general quota so "429: rate limit exceeded" gets 60s cooldown)
if any(k in err_lower for k in ("rate", "too many requests", "rate_limit")):
raise RateLimitedError(err_msg, provider="antigravity", profile_id=profile.profile_id)
else:
raise ProviderUnavailableError(err_msg, provider="antigravity", profile_id=profile.profile_id)
# 3. Quota Exhaustion (Parse reset duration e.g. "resets in 2h")
if any(k in err_lower for k in ("quota", "resource_exhausted", "429", "limit", "exhausted")):
reset_sec = 1800
m_hr = re.search(r"(\d+)\s*(?:hours?|h\b)", err_lower)
m_min = re.search(r"(\d+)\s*(?:minutes?|m\b)", err_lower)
m_sec = re.search(r"(\d+)\s*(?:seconds?|s\b)", err_lower)
if m_hr:
reset_sec = int(m_hr.group(1)) * 3600
elif m_min:
reset_sec = int(m_min.group(1)) * 60
elif m_sec:
reset_sec = int(m_sec.group(1))
raise QuotaExceededError(err_msg, provider="antigravity", profile_id=profile.profile_id, reset_in_sec=reset_sec)
raise ProviderUnavailableError(err_msg, provider="antigravity", profile_id=profile.profile_id)
return res

View file

@ -59,6 +59,37 @@ DEFAULT_SLOT_ROLES = {
}
CANONICAL_ROLE_MAP = {
"orchestrator": "orchestrator",
"orchestrator_primary": "orchestrator",
"orchestrator_fallback": "orchestrator",
"главный оркестратор": "orchestrator",
"резервный оркестратор": "orchestrator",
"coder": "coder-primary",
"coder_1": "coder-primary",
"coder-1": "coder-primary",
"coder-primary": "coder-primary",
"кодер 1": "coder-primary",
"coder_2": "coder-secondary",
"coder-2": "coder-secondary",
"coder-secondary": "coder-secondary",
"кодер 2": "coder-secondary",
"reviewer": "reviewer",
"ревьюер": "reviewer",
"research": "research",
"researcher": "research",
"исследователь": "research",
"fast": "fast",
"fast_agent": "fast",
"быстрый агент": "fast",
"general": "fast",
"universal_subagent": "fast",
"универсальный субагент": "fast",
"tester": "fast",
"тестировщик": "fast",
}
class AutoAssigner:
"""Manages team view structure, auto-slot allocation, and duplicate checking."""
@ -172,13 +203,19 @@ class AutoAssigner:
@staticmethod
def assign_profile_to_role(profile_id: str, role_name: str, is_primary: bool = True) -> Tuple[bool, str]:
"""Assign a profile to a specified logical role, updating fallback chains and persisting config."""
"""Assign a profile to a canonical router role, updating fallback chains and persisting config."""
config = load_router_config()
pcfg = config.get_profile(profile_id)
if not pcfg:
return False, f"Профиль '{profile_id}' не найден"
rpolicy = config.get_role_policy(role_name)
clean_role = role_name.strip().lower()
canonical_role = CANONICAL_ROLE_MAP.get(clean_role, clean_role)
if canonical_role not in config.roles:
return False, f"Неизвестная роль маршрутизатора: '{role_name}'"
rpolicy = config.roles[canonical_role]
chain = list(rpolicy.preferred_chain)
if profile_id in chain:
chain.remove(profile_id)
@ -189,13 +226,13 @@ class AutoAssigner:
chain.append(profile_id)
rpolicy.preferred_chain = chain
config.roles[role_name] = rpolicy
config.roles[canonical_role] = rpolicy
save_router_config(config)
return True, f"Профиль '{profile_id}' назначен на роль '{role_name}' ({'основной' if is_primary else 'резервный'})"
return True, f"Профиль '{profile_id}' назначен на роль '{canonical_role}' ({'основной' if is_primary else 'резервный'})"
@staticmethod
def auto_assign_all() -> Dict[str, Any]:
"""Automatically distribute all authenticated profiles across logical roles."""
"""Automatically distribute all authenticated profiles across canonical router roles."""
config = load_router_config()
authenticated_profiles = []
for pid, pcfg in config.profiles.items():
@ -206,10 +243,10 @@ class AutoAssigner:
authenticated_profiles.append((pid, pcfg))
changes = []
roles_order = ["orchestrator", "coder", "reviewer", "researcher", "tester", "general"]
canonical_roles_order = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]
for idx, (pid, pcfg) in enumerate(authenticated_profiles):
target_role = roles_order[idx % len(roles_order)]
ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(roles_order)))
target_role = canonical_roles_order[idx % len(canonical_roles_order)]
ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(canonical_roles_order)))
if ok:
changes.append({"profile_id": pid, "role": target_role, "message": msg})

View file

@ -38,8 +38,13 @@ class RouterConfig:
default_role: str = "orchestrator"
quota_cooldown_seconds: int = 1800 # 30 min default
rate_limit_cooldown_seconds: int = 60 # 1 min default
max_failover_attempts: int = 3
cooldown_base_seconds: int = 300
cooldown_max_seconds: int = 3600
session_affinity_ttl_seconds: int = 1800
roles: dict[str, RolePolicy] = field(default_factory=dict)
profiles: dict[str, RouterProfileConfig] = field(default_factory=dict)
raw_router_block: dict[str, Any] = field(default_factory=dict)
def get_profile(self, profile_id: str) -> Optional[RouterProfileConfig]:
return self.profiles.get(profile_id)
@ -146,24 +151,27 @@ def get_default_router_config() -> RouterConfig:
"ag-cold-1": RouterProfileConfig(
profile_id="ag-cold-1",
provider="antigravity",
account_id="ag-acc-cold1",
capabilities=["cold-spare"],
account_id="ag-acc-c1",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
"ag-cold-2": RouterProfileConfig(
profile_id="ag-cold-2",
provider="antigravity",
account_id="ag-acc-cold2",
capabilities=["cold-spare"],
account_id="ag-acc-c2",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
"ag-cold-3": RouterProfileConfig(
profile_id="ag-cold-3",
provider="antigravity",
account_id="ag-acc-cold3",
capabilities=["cold-spare"],
account_id="ag-acc-c3",
capabilities=["cold-spare", "coding", "reasoning"],
preferred_models=["gemini-3.5-flash"],
enabled=False,
max_concurrency=1,
),
@ -172,25 +180,25 @@ def get_default_router_config() -> RouterConfig:
profile_id="opengo-1",
provider="opencode-go",
account_id="opengo-acc-1",
capabilities=["research", "search", "fast", "review"],
preferred_models=["qwen3.8-max", "glm-5.3", "deepseek-v4-flash", "grok-4.5"],
max_concurrency=3,
capabilities=["coding", "fast", "multimodal"],
preferred_models=["deepseek-r1", "qwen-2.5-coder-32b", "deepseek-v3"],
max_concurrency=5,
),
"opengo-2": RouterProfileConfig(
profile_id="opengo-2",
provider="opencode-go",
account_id="opengo-acc-2",
capabilities=["reviewer", "review", "coding", "reasoning"],
preferred_models=["deepseek-v4-pro", "grok-4.5", "qwen3.7-max"],
max_concurrency=3,
capabilities=["research", "coding", "multimodal"],
preferred_models=["deepseek-v3", "qwen-2.5-coder-32b", "deepseek-r1"],
max_concurrency=5,
),
"opengo-3": RouterProfileConfig(
profile_id="opengo-3",
provider="opencode-go",
account_id="opengo-acc-3",
capabilities=["coder-fallback", "orchestrator", "coding", "reasoning"],
preferred_models=["kimi-k2.7-code", "deepseek-v4-pro", "qwen3.8-max"],
max_concurrency=3,
capabilities=["fallback", "coding", "reasoning"],
preferred_models=["deepseek-r1", "deepseek-v3", "qwen-2.5-coder-32b"],
max_concurrency=5,
),
}
@ -267,6 +275,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
r_block = data.get("router") if isinstance(data.get("router"), dict) else {}
profiles_raw = data.get("profiles", {})
profiles: dict[str, RouterProfileConfig] = {}
for pid, pdata in profiles_raw.items():
@ -295,13 +305,27 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
default_model=rdata.get("default_model"),
)
enabled = bool(r_block.get("enabled", data.get("enabled", True)))
default_role = str(r_block.get("default_role", data.get("default_role", "orchestrator")))
max_failover = int(r_block.get("max_failover_attempts", data.get("max_failover_attempts", 3)))
cooldown_base = int(r_block.get("cooldown_base_seconds", data.get("cooldown_base_seconds", 300)))
cooldown_max = int(r_block.get("cooldown_max_seconds", data.get("cooldown_max_seconds", 3600)))
session_ttl = int(r_block.get("session_affinity_ttl_seconds", data.get("session_affinity_ttl_seconds", 1800)))
quota_cooldown = int(r_block.get("quota_cooldown_seconds", data.get("quota_cooldown_seconds", 1800)))
rate_cooldown = int(r_block.get("rate_limit_cooldown_seconds", data.get("rate_limit_cooldown_seconds", 60)))
return RouterConfig(
enabled=bool(data.get("enabled", True)),
default_role=str(data.get("default_role", "orchestrator")),
quota_cooldown_seconds=int(data.get("quota_cooldown_seconds", 1800)),
rate_limit_cooldown_seconds=int(data.get("rate_limit_cooldown_seconds", 60)),
enabled=enabled,
default_role=default_role,
quota_cooldown_seconds=quota_cooldown,
rate_limit_cooldown_seconds=rate_cooldown,
max_failover_attempts=max_failover,
cooldown_base_seconds=cooldown_base,
cooldown_max_seconds=cooldown_max,
session_affinity_ttl_seconds=session_ttl,
roles=roles or get_default_router_config().roles,
profiles=profiles or get_default_router_config().profiles,
raw_router_block=r_block,
)
except Exception as e:
# Fall back gracefully to built-in defaults on YAML error
@ -309,7 +333,7 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig:
def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) -> bool:
"""Save RouterConfig to YAML file."""
"""Save RouterConfig to YAML file preserving canonical router block schema."""
if config_path is None:
env_config = os.environ.get("HERMES_ROUTER_CONFIG", "").strip()
if env_config:
@ -341,6 +365,7 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None)
roles_data = {}
for rname, rpol in config.roles.items():
roles_data[rname] = {
"role_name": rname,
"preferred_chain": rpol.preferred_chain,
"fallback_capabilities": rpol.fallback_capabilities,
"max_failover_attempts": rpol.max_failover_attempts,
@ -349,11 +374,18 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None)
if rpol.default_model:
roles_data[rname]["default_model"] = rpol.default_model
data = {
router_block = dict(config.raw_router_block) if config.raw_router_block else {}
router_block.update({
"enabled": config.enabled,
"default_role": config.default_role,
"quota_cooldown_seconds": config.quota_cooldown_seconds,
"rate_limit_cooldown_seconds": config.rate_limit_cooldown_seconds,
"max_failover_attempts": config.max_failover_attempts,
"cooldown_base_seconds": config.cooldown_base_seconds,
"cooldown_max_seconds": config.cooldown_max_seconds,
"session_affinity_ttl_seconds": config.session_affinity_ttl_seconds,
})
data = {
"router": router_block,
"roles": roles_data,
"profiles": profiles_data,
}

View file

@ -35,6 +35,10 @@ class AddAccountWizard(HubModal):
self._show_step_1_provider()
def destroy(self):
self._polling_active = False
super().destroy()
def _clear_body(self):
for w in self.body.winfo_children():
w.destroy()

View file

@ -48,6 +48,17 @@ class SettingsView(ctk.CTkFrame):
def _save_settings(self):
try:
if hasattr(self, "aff_sw"):
self.settings["session_affinity"] = bool(self.aff_sw.get())
if hasattr(self, "fo_sw"):
self.settings["auto_failover"] = bool(self.fo_sw.get())
if hasattr(self, "fo_menu"):
self.settings["failover_attempts"] = str(self.fo_menu.get())
if hasattr(self, "ret_sw"):
self.settings["auto_return_primary"] = bool(self.ret_sw.get())
if hasattr(self, "mon_sw"):
self.settings["auto_monitoring"] = bool(self.mon_sw.get())
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
self.settings_file.write_text(json.dumps(self.settings, indent=2), encoding="utf-8")
except Exception:
@ -75,25 +86,25 @@ class SettingsView(ctk.CTkFrame):
r1 = ctk.CTkFrame(c1, fg_color="transparent")
r1.pack(fill="x", padx=16, pady=4)
ctk.CTkLabel(r1, text="Сессионная привязка (Session Affinity)", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
aff_sw = ctk.CTkSwitch(r1, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
aff_sw.pack(side="right")
self.aff_sw = ctk.CTkSwitch(r1, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
self.aff_sw.pack(side="right")
if self.settings.get("session_affinity"):
aff_sw.select()
self.aff_sw.select()
# Auto Failover switch
r2 = ctk.CTkFrame(c1, fg_color="transparent")
r2.pack(fill="x", padx=16, pady=4)
ctk.CTkLabel(r2, text="Автоматический Failover при исчерпании квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
fo_sw = ctk.CTkSwitch(r2, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
fo_sw.pack(side="right")
self.fo_sw = ctk.CTkSwitch(r2, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
self.fo_sw.pack(side="right")
if self.settings.get("auto_failover"):
fo_sw.select()
self.fo_sw.select()
# Failover Attempts
r3 = ctk.CTkFrame(c1, fg_color="transparent")
r3.pack(fill="x", padx=16, pady=(4, 12))
ctk.CTkLabel(r3, text="Лимит попыток failover на запрос", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
fo_menu = ctk.CTkOptionMenu(
self.fo_menu = ctk.CTkOptionMenu(
r3,
values=["1", "2", "3", "4", "5"],
width=80,
@ -102,8 +113,8 @@ class SettingsView(ctk.CTkFrame):
button_color=Theme.ACCENT,
text_color=Theme.TEXT_PRIMARY,
)
fo_menu.set(str(self.settings.get("failover_attempts", "3")))
fo_menu.pack(side="right")
self.fo_menu.set(str(self.settings.get("failover_attempts", "3")))
self.fo_menu.pack(side="right")
# ── 2. Recovery & Monitoring ──
c2 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
@ -113,18 +124,18 @@ class SettingsView(ctk.CTkFrame):
r4 = ctk.CTkFrame(c2, fg_color="transparent")
r4.pack(fill="x", padx=16, pady=4)
ctk.CTkLabel(r4, text="Возвращаться на основной аккаунт после сброса квоты", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
ret_sw = ctk.CTkSwitch(r4, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
ret_sw.pack(side="right")
self.ret_sw = ctk.CTkSwitch(r4, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
self.ret_sw.pack(side="right")
if self.settings.get("auto_return_primary"):
ret_sw.select()
self.ret_sw.select()
r5 = ctk.CTkFrame(c2, fg_color="transparent")
r5.pack(fill="x", padx=16, pady=(4, 12))
ctk.CTkLabel(r5, text="Автоматический фоновый мониторинг здоровья", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left")
mon_sw = ctk.CTkSwitch(r5, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
mon_sw.pack(side="right")
self.mon_sw = ctk.CTkSwitch(r5, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT)
self.mon_sw.pack(side="right")
if self.settings.get("auto_monitoring"):
mon_sw.select()
self.mon_sw.select()
# ── 3. Updates & Release Channel ──
c_upd = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)

View file

@ -115,13 +115,41 @@ def openai_completion_object(completion: dict[str, Any]) -> SimpleNamespace:
"""Return an object compatible with Hermes' ChatCompletionsTransport."""
completion = dict(completion)
choices = []
for raw_choice in completion.get("choices") or []:
choice = dict(raw_choice)
message = dict(choice.get("message") or {})
message.setdefault("content", None)
message.setdefault("tool_calls", None)
choice["message"] = message
choices.append(choice)
# 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)
choices.append({
"index": 0,
"message": {
"role": "assistant",
"content": f"Antigravity error: {err_msg}",
"tool_calls": None,
},
"finish_reason": "stop",
})
else:
for raw_choice in completion.get("choices") or []:
choice = dict(raw_choice)
message = dict(choice.get("message") or {})
message.setdefault("content", None)
message.setdefault("tool_calls", None)
choice["message"] = message
choices.append(choice)
# Fallback to ensure choices is never empty
if not choices:
choices.append({
"index": 0,
"message": {
"role": "assistant",
"content": "Empty or unrecognized completion response from provider.",
"tool_calls": None,
},
"finish_reason": "stop",
})
completion["choices"] = choices
completion.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
return _namespace(completion)

View file

@ -188,10 +188,12 @@ class UpdateManager:
with zipfile.ZipFile(package_zip, "r") as zf:
zf.extractall(dest)
# 3. Verify syntax and integrity of updated python files
# 3. Verify syntax and integrity of updated python files in src/
import py_compile
for py_file in dest.rglob("*.py"):
py_compile.compile(str(py_file), doraise=True)
target_src = dest / "src"
if target_src.exists():
for py_file in target_src.rglob("*.py"):
py_compile.compile(str(py_file), doraise=True)
# Also run quick import smoke test if python executable is available
py_exec = paths.get_hermes_agent_venv() / "Scripts" / "python.exe"

View file

@ -8,9 +8,14 @@ Enforces:
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
REPO_SRC = Path(__file__).resolve().parent.parent / "src"
if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC):
sys.path.insert(0, str(REPO_SRC))
@pytest.fixture(autouse=True)
def isolate_hermes_environment(tmp_path, monkeypatch):

View file

@ -1,15 +1,17 @@
"""Hermes Hub — P0 Release Gate Verification Suite.
Validates all 9 critical release blockers:
Validates all critical release blockers and regressions:
- P0-1: customtkinter / Pillow clean install verification
- P0-2: ProfileAuthManager.get_profile_dir unified API
- P0-3: Wizard json import & API-key saving flow
- P0-4: AutoAssigner.auto_assign_all implementation
- P0-5: Antigravity failover on quota exhaustion (typed exceptions, no fake success text)
- P0-4: AutoAssigner.auto_assign_all implementation with canonical roles
- P0-5: Antigravity failover on quota exhaustion + non-router error handling (B1)
- P0-6: OAuth session status unification and fast error reaction
- P0-7: Role assignment action and persistence
- P0-7: Role assignment canonical mapping and unknown role rejection (B2)
- P0-8: Wizard role application to live config
- P0-9: Real API validation / removal of fake validation
- P0-10: YAML round-trip config preservation (B3)
- P0-11: Rate limit (60s) vs Quota exhaustion duration parsing (B4, S1)
"""
from __future__ import annotations
@ -20,7 +22,7 @@ from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.paths import get_hermes_home, get_profile_dir
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.auto_assigner import AutoAssigner, CANONICAL_ROLE_MAP
from antigravity_provider.router.exceptions import (
AuthExpiredError,
AuthRequiredError,
@ -33,6 +35,7 @@ from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
get_default_router_config,
load_router_config,
save_router_config,
)
@ -43,6 +46,7 @@ from antigravity_provider.version import __version__
@pytest.mark.unit
def test_p0_1_installer_dependencies():
"""P0-1: Verify that required UI dependencies are importable in runtime."""
pytest.importorskip("customtkinter")
import customtkinter
from PIL import Image
import psutil
@ -88,7 +92,7 @@ def test_p0_3_wizard_api_key_save(tmp_path, monkeypatch):
}
saved_path = ProfileAuthManager.save_profile_auth("openai-codex", "codex-test-1", auth_data)
assert saved_path.exists()
loaded = ProfileAuthManager.load_profile_auth("openai-codex", "codex-test-1")
assert loaded is not None
assert loaded["api_key"] == "sk-test12345678901234567890"
@ -96,7 +100,7 @@ def test_p0_3_wizard_api_key_save(tmp_path, monkeypatch):
@pytest.mark.unit
def test_p0_4_auto_assign_all(tmp_path, monkeypatch):
"""P0-4: Verify AutoAssigner.auto_assign_all executes without AttributeError and assigns roles."""
"""P0-4: Verify AutoAssigner.auto_assign_all assigns to canonical roles without creating generic roles."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Save mock auth for 2 profiles
@ -108,6 +112,11 @@ def test_p0_4_auto_assign_all(tmp_path, monkeypatch):
assert result.get("success") is True
assert "assigned_count" in result
# Verify only canonical roles exist in config
cfg = load_router_config()
for rname in cfg.roles:
assert rname in {"orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"}
@pytest.mark.unit
def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
@ -137,7 +146,6 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
)
save_router_config(config)
# Mock antigravity adapter to return a quota error
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
@ -167,11 +175,43 @@ def test_p0_5_antigravity_failover_on_quota(tmp_path, monkeypatch):
assert res["router_metadata"]["profile_id"] == "codex-orch-fallback"
@pytest.mark.unit
def test_p0_5_b1_non_router_error_fallback(tmp_path, monkeypatch):
"""B1: Verify that non-router path in hermes_plugin handles error payloads without crashing with IndexError."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from antigravity_provider.hermes_plugin import antigravity_llm_execution
from antigravity_provider.router import get_router_engine
# Ensure router is disabled
engine = get_router_engine()
prev_enabled = engine.config.enabled
engine.config.enabled = False
try:
# Simulate agy_generate returning error dict
error_completion = {"error": {"message": "Resource exhausted: 429 quota reached"}}
with patch("antigravity_provider.hermes_plugin.agy_generate", return_value=error_completion):
res = antigravity_llm_execution(
provider="google-antigravity",
request={"model": "gemini-2.5-pro", "messages": [{"role": "user", "content": "hello"}]},
)
# Must have choices[0] and message.content without IndexError!
assert hasattr(res, "choices")
assert len(res.choices) > 0
assert res.choices[0].message.content is not None
content_lower = str(res.choices[0].message.content).lower()
assert "429" in content_lower or "exhausted" in content_lower or "error" in content_lower
finally:
engine.config.enabled = prev_enabled
@pytest.mark.unit
def test_p0_6_oauth_session_status_unification():
"""P0-6: Verify OAuth statuses are unified and error triggers fast failure."""
valid_statuses = {"pending", "success", "completed", "failed", "error", "cancelled", "timeout"}
# Verify our status classifier recognises all terminal failure states
error_statuses = {"failed", "error", "cancelled", "timeout"}
for st in error_statuses:
assert st in valid_statuses
@ -179,27 +219,29 @@ def test_p0_6_oauth_session_status_unification():
@pytest.mark.unit
def test_p0_7_assign_role_action(tmp_path, monkeypatch):
"""P0-7: Verify assign_profile_to_role modifies role chains and persists to disk."""
"""P0-7 & B2: Verify assign_profile_to_role maps human names to canonical roles and rejects unknown roles."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
config = RouterConfig(
profiles={
"ag-w1": RouterProfileConfig(profile_id="ag-w1", provider="antigravity", enabled=True),
},
roles={
"coder": RolePolicy(role_name="coder", preferred_chain=[]),
}
)
config = get_default_router_config()
save_router_config(config)
# 1. Assign "coder" -> must update "coder-primary"
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "coder", is_primary=True)
assert ok is True
# Reload from disk and verify
reloaded = load_router_config()
coder_chain = reloaded.roles["coder"].preferred_chain
assert "ag-w1" in coder_chain
assert coder_chain[0] == "ag-w1"
assert reloaded.roles["coder-primary"].preferred_chain[0] == "ag-w1"
assert "coder" not in reloaded.roles # Must NOT create a non-canonical role
# 2. Assign "researcher" -> must update "research"
ok, msg = AutoAssigner.assign_profile_to_role("ag-w2", "researcher", is_primary=True)
assert ok is True
reloaded = load_router_config()
assert reloaded.roles["research"].preferred_chain[0] == "ag-w2"
# 3. Unknown role -> must return False and reject
ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", "completely_unknown_role_xyz")
assert ok is False
assert "Неизвестная роль" in msg
@pytest.mark.unit
@ -207,21 +249,16 @@ def test_p0_8_wizard_role_application(tmp_path, monkeypatch):
"""P0-8: Verify Wizard step 4 role assignment is applied directly to configuration."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
config = RouterConfig(
profiles={
"codex-worker-1": RouterProfileConfig(profile_id="codex-worker-1", provider="openai-codex", enabled=True),
},
roles={
"reviewer": RolePolicy(role_name="reviewer", preferred_chain=[]),
}
)
config = get_default_router_config()
save_router_config(config)
# Apply role
AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True)
ok, msg = AutoAssigner.assign_profile_to_role("codex-worker-1", "reviewer", is_primary=True)
assert ok is True
reloaded = load_router_config()
assert "codex-worker-1" in reloaded.roles["reviewer"].preferred_chain
assert reloaded.roles["reviewer"].preferred_chain[0] == "codex-worker-1"
@pytest.mark.unit
@ -237,3 +274,54 @@ def test_p0_9_real_api_key_validation():
valid, masked, models = ProfileAuthManager.verify_codex_token(valid_key)
assert valid is True
assert masked.startswith("sk-...")
@pytest.mark.unit
def test_p0_10_yaml_round_trip_preservation(tmp_path, monkeypatch):
"""B3: Verify YAML load -> save -> load preserves router block and settings without loss."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
cfg_file = tmp_path / "test_profiles.yaml"
cfg = get_default_router_config()
cfg.max_failover_attempts = 5
cfg.session_affinity_ttl_seconds = 2400
save_router_config(cfg, config_path=cfg_file)
assert cfg_file.exists()
reloaded = load_router_config(config_path=cfg_file)
assert reloaded.enabled is True
assert reloaded.max_failover_attempts == 5
assert reloaded.session_affinity_ttl_seconds == 2400
assert len(reloaded.roles) == len(cfg.roles)
assert len(reloaded.profiles) == len(cfg.profiles)
@pytest.mark.unit
def test_p0_11_rate_limit_vs_quota_classification():
"""B4 & S1: Verify rate limiting gets 60s cooldown and quota parsing extracts hours/minutes."""
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
from antigravity_provider.router.adapters.base_adapter import ErrorCategory
from antigravity_provider.router.router_config import RouterProfileConfig
adapter = AntigravityAdapter()
profile = RouterProfileConfig(profile_id="ag-w1", provider="antigravity")
# 1. Rate Limit Error -> must be RATE_LIMITED with 60s cooldown
rate_resp = {"error": {"message": "429 Too Many Requests: rate limit exceeded"}}
with patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value=rate_resp):
with pytest.raises(RateLimitedError) as exc_info:
adapter.invoke(profile, {"messages": []})
classification = adapter.classify_error(exc_info.value)
assert classification.category == ErrorCategory.RATE_LIMITED
assert classification.retry_delay_seconds == 60
# 2. Quota with "resets in 2h" -> must parse 7200s cooldown
quota_resp = {"error": {"message": "individual quota reached, resets in 2h"}}
with patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value=quota_resp):
with pytest.raises(QuotaExceededError) as exc_info:
adapter.invoke(profile, {"messages": []})
assert exc_info.value.reset_in_sec == 7200
classification = adapter.classify_error(exc_info.value)
assert classification.category == ErrorCategory.QUOTA_EXHAUSTED
assert classification.reset_duration_seconds == 7200

View file

@ -5,9 +5,10 @@ from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
from antigravity_provider.router.hermes_hub_app import do_test_profile, do_set_main, do_set_orchestrator
from antigravity_provider.router.profile_manager import ProfileAuthManager