#!/usr/bin/env python3 """Keep Hermes agent provider catalogs and managed profiles current.""" from __future__ import annotations import copy import json import os import shutil import subprocess import time from pathlib import Path from typing import Any, Iterable import yaml from provider_model_catalog import ( CAPABILITY_ROLES, CAPABILITY_TIERS, EFFORTS, EFFORT_TIERS, Catalog, apply_verified_evaluations, capability_pool, legacy_selector_matches, model_records, model_version, select_tier_model, unique_models, ) from provider_model_discovery import discover_claude_models, discover_codex_models from model_evaluation_evidence import load_store CODEX_BASELINE = "gpt-5.6-terra" CLAUDE_BASELINE = "claude-opus-5" ATLAS_FALLBACK = { "provider": "custom", "model": "qwen2.5:14b-instruct-q4_0", "base_url": "http://hermes-model-gate.hermes.svc.cluster.local:11434/v1", "api_key": "ollama", } # Backwards-compatible name used by the focused unit tests and status tooling. LOCAL_FALLBACK = ATLAS_FALLBACK SWITCHYARD_PROVIDER = "atlas-switchyard" SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1" SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum" ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip() MANAGED_ENV_KEYS = {"GIT_ASKPASS", "GIT_TERMINAL_PROMPT"} RUNTIME_SECRET_ENV_KEYS = { "ANTHROPIC_API_KEY", "API_SERVER_KEY", "CLAUDE_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "GITEA_TOKEN", "GITEA_USERNAME", "HERMES_IMAGE_BROKER_KEY", "OPENAI_API_KEY", } def choose_codex_model(models: Iterable[str], current: str = CODEX_BASELINE, *, balanced: bool = False) -> str: """Compatibility helper selecting a declared advanced or balanced Codex tier.""" effort = "medium" if balanced else "xhigh" tier = "balanced" if balanced else "advanced" return select_tier_model("codex", models, {}, tier, effort, current, legacy_compat=True)[0] def choose_codex_for_effort(models: Iterable[str], effort: str, current: str = CODEX_BASELINE) -> str: """Compatibility helper selecting an effort's generic Codex capability tier.""" if effort not in EFFORTS: raise ValueError(f"unsupported effort: {effort}") return select_tier_model("codex", models, {}, EFFORT_TIERS[effort], effort, current, legacy_compat=True)[0] def choose_claude_model(models: Iterable[str], current: str = CLAUDE_BASELINE) -> str: """Compatibility helper selecting the declared advanced Claude tier.""" return select_tier_model("claude", models, {}, "advanced", "xhigh", current, legacy_compat=True)[0] def choose_claude_for_effort( models: Iterable[str], effort: str, current: str = CLAUDE_BASELINE ) -> str: """Compatibility helper selecting an effort's generic Claude capability tier.""" if effort not in EFFORTS: raise ValueError(f"unsupported effort: {effort}") return select_tier_model("claude", models, {}, EFFORT_TIERS[effort], effort, current, legacy_compat=True)[0] # The coordinator retains these private spellings for compact call sites while # provider_model_catalog owns the metadata policy and its independent tests. _unique_models = unique_models _model_records = model_records _select_tier_model = select_tier_model def _read_yaml(path: Path) -> dict[str, Any]: """Read a mapping from YAML, returning an empty mapping when unavailable.""" if not path.is_file(): return {} try: value = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError): return {} return value if isinstance(value, dict) else {} def _atomic_write(path: Path, content: str, mode: int | None = None) -> bool: """Replace a file only when its content changes.""" path.parent.mkdir(parents=True, exist_ok=True) try: if path.read_text(encoding="utf-8") == content: if mode is not None: path.chmod(mode) return False except OSError: pass temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text(content, encoding="utf-8") if mode is not None: temporary.chmod(mode) os.replace(temporary, path) return True def _write_yaml(path: Path, value: dict[str, Any], mode: int | None = None) -> bool: """Serialize a mapping and atomically update the target YAML file.""" return _atomic_write(path, yaml.safe_dump(value, sort_keys=False), mode) def _read_json(path: Path) -> dict[str, Any]: """Read a JSON mapping without treating a partial write as valid state.""" try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, json.JSONDecodeError): return {} return value if isinstance(value, dict) else {} def _previous_provider_models( previous: dict[str, Any], provider: str ) -> tuple[dict[str, str], dict[str, str], list[str], dict[str, dict[str, Any]]]: """Return last-known-good effort, selector, models, and metadata values.""" providers = previous.get("providers", {}) record = providers.get(provider, {}) if isinstance(providers, dict) else {} if not isinstance(record, dict): return {}, {}, [], {} resolved = record.get("resolved", {}) tiers = record.get("tiers", {}) models = record.get("models", []) metadata = record.get("model_metadata", {}) return ( dict(resolved) if isinstance(resolved, dict) else {}, dict(tiers) if isinstance(tiers, dict) else {}, _unique_models(models if isinstance(models, list) else []), { str(model): dict(details) for model, details in metadata.items() if isinstance(model, str) and isinstance(details, dict) } if isinstance(metadata, dict) else {}, ) def build_routing_catalog( codex: Catalog, claude: Catalog, previous: dict[str, Any] | None = None, evaluations: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build a current catalog while retaining only outage-safe known routes.""" previous = previous or {} checked_at = int(time.time()) providers: dict[str, Any] = {} specifications = ( ( "codex", codex, {"luna": "economy", "terra": "balanced", "sol": "advanced"}, ), ( "claude", claude, { "haiku": "economy", "fable": "frontier", "sonnet": "balanced", "opus": "advanced", }, ), ) for name, discovered, legacy_selectors in specifications: old_resolved, old_tiers, old_models, old_metadata = _previous_provider_models( previous, name ) old_providers = previous.get("providers", {}) old_record = old_providers.get(name, {}) if isinstance(old_providers, dict) else {} previous_success = ( int(old_record.get("last_success_at")) if isinstance(old_record, dict) and isinstance(old_record.get("last_success_at"), int) else None ) source_models = discovered.models if discovered.live else old_models source_metadata = apply_verified_evaluations( name, source_models, discovered.metadata if discovered.live else old_metadata, evaluations.get("evaluations") if isinstance(evaluations, dict) else None, ) observations: dict[str, Any] = {} old_capabilities = old_record.get("capability_resolved", {}) if isinstance(old_record, dict) else {} old_capabilities = old_capabilities if isinstance(old_capabilities, dict) else {} capability_pools = { role: capability_pool(name, source_models, source_metadata, role) for role in CAPABILITY_ROLES } capability_resolved: dict[str, dict[str, str]] = {} for role in CAPABILITY_ROLES: prior = old_capabilities.get(role, {}) prior = prior if isinstance(prior, dict) else {} routes: dict[str, str] = {} for effort in EFFORTS: current = str(prior.get(effort) or ( old_resolved.get(effort) if role == EFFORT_TIERS[effort] else "" )) selected, observed = _select_tier_model( name, source_models, source_metadata, role, effort, current, allow_current_fallback=not discovered.live, ) routes[effort] = selected observations.update(observed) capability_resolved[role] = routes resolved = { effort: capability_resolved[EFFORT_TIERS[effort]][effort] for effort in EFFORTS } tiers: dict[str, str] = {} representative_effort = { "economy": "low", "balanced": "medium", "advanced": "high", "frontier": "xhigh", } for capability in CAPABILITY_TIERS: effort = representative_effort[capability] tiers[capability] = capability_resolved[capability][effort] for selector, capability in legacy_selectors.items(): # Explicit historical family picks are not generic capability # requests. They must remain exact or become unavailable, never # silently change to a newer family such as Astra. exact = [ model for model in source_models if legacy_selector_matches(name, selector, model) ] tiers[selector] = ( exact[0] if exact else ("" if discovered.live else str(old_tiers.get(selector) or "")) ) providers[name] = { "provenance": ( "live-account" if discovered.live else ("last-known-good" if old_models else "bootstrap-fallback") ), "checked_at": checked_at, "last_success_at": checked_at if discovered.live else previous_success, "state": discovered.state, "connected": discovered.connected, "live": discovered.live, "models": _unique_models( discovered.models if discovered.live else (old_models or discovered.models) ), "model_metadata": source_metadata, "candidates": observations, "capability_pools": capability_pools, "capability_resolved": capability_resolved, "resolved": resolved, "tiers": tiers, } return { "schema_version": 3, "updated_at": checked_at, "providers": providers, } def write_routing_catalog( path: Path, codex: Catalog, claude: Catalog ) -> dict[str, Any]: """Atomically publish the catalog consumed by hosted and worker brokers.""" evidence_store = load_store(path.with_name("model-evaluations.json")) evaluations: dict[str, dict[str, dict[str, Any]]] = {"codex": {}, "claude": {}} for record in evidence_store.get("evaluations", {}).values(): if not isinstance(record, dict): continue provider, model = record.get("provider"), record.get("model") if provider in evaluations and isinstance(model, str): evaluations[provider][model] = record catalog = build_routing_catalog( codex, claude, _read_json(path), {"evaluations": evaluations} ) _atomic_write(path, json.dumps(catalog, indent=2, sort_keys=True) + "\n", 0o644) return catalog def _read_env(path: Path) -> dict[str, str]: """Read the small dotenv subset used by Hermes provider credentials.""" values: dict[str, str] = {} try: lines = path.read_text(encoding="utf-8").splitlines() except OSError: return values for line in lines: value = line.strip() if not value or value.startswith("#") or "=" not in value: continue key, raw = value.removeprefix("export ").split("=", 1) raw = raw.strip() if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": raw = raw[1:-1] values[key.strip()] = raw return values def _update_profile_env(path: Path, source: dict[str, str]) -> None: """Refresh non-secret managed settings and remove stale credential copies.""" try: old_lines = path.read_text(encoding="utf-8").splitlines() except OSError: old_lines = [] kept = [ line for line in old_lines if not any( line.lstrip().startswith(f"{key}=") for key in MANAGED_ENV_KEYS | RUNTIME_SECRET_ENV_KEYS ) ] kept.extend( f"{key}={source[key]}" for key in sorted(MANAGED_ENV_KEYS) if source.get(key) ) _atomic_write(path, "\n".join(kept).rstrip() + "\n", 0o600) def codex_cli_authenticated() -> bool: """Return whether the installed Codex CLI has a usable local login.""" codex = shutil.which("codex") if codex: try: status = subprocess.run( [codex, "login", "status"], capture_output=True, check=False, text=True, timeout=10, ) detail = f"{status.stdout}\n{status.stderr}".lower() if status.returncode == 0 and ( "logged in" in detail or "authenticated" in detail ): return True except (OSError, subprocess.SubprocessError): pass return False def _switchyard_profile_config( base: dict[str, Any], route: str, effort: str ) -> dict[str, Any]: """Derive a profile that cannot bypass the Switchyard authority.""" config = copy.deepcopy(base) providers = config.setdefault("providers", {}) if not isinstance(providers, dict): providers = {} config["providers"] = providers providers[SWITCHYARD_PROVIDER] = { "name": "Atlas Switchyard", "api": SWITCHYARD_API, "api_key": "atlas-switchyard", "default_model": route, "transport": "chat_completions", } config["model"] = { "provider": SWITCHYARD_PROVIDER, "default": route, "model": route, } config["fallback_providers"] = [] config["model_catalog"] = {"enabled": True, "ttl_hours": 1} agent = config.setdefault("agent", {}) if isinstance(agent, dict): agent["reasoning_effort"] = effort config["toolsets"] = [] return config def _write_profile( root: Path, name: str, description: str, soul: str, config: dict[str, Any], env_values: dict[str, str], ) -> None: """Create or refresh a managed Hermes worker profile.""" profile = root / "profiles" / name for directory in ("logs", "sessions", "skills", "workspace", "home"): (profile / directory).mkdir(parents=True, exist_ok=True) _write_yaml(profile / "config.yaml", config) _write_yaml( profile / "profile.yaml", {"description": description, "description_auto": False}, ) _atomic_write(profile / "SOUL.md", soul.rstrip() + "\n") _update_profile_env(profile / ".env", env_values) def configure_routes( root: Path, codex: Catalog, claude: Catalog, catalog_path: Path | None = None, ) -> dict[str, Any]: """Refresh catalogs while keeping every Hermes profile on Switchyard.""" config_path = root / "config.yaml" base = _read_yaml(config_path) resolved_catalog_path = catalog_path or ( Path(ROUTING_CATALOG_PATH) if ROUTING_CATALOG_PATH else root / "routing-catalog.json" ) catalog = write_routing_catalog(resolved_catalog_path, codex, claude) providers = catalog["providers"] codex_models = providers["codex"]["resolved"] claude_models = providers["claude"]["resolved"] coordinator_toolsets = copy.deepcopy(base.get("toolsets")) base = _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "high") # The coordinator uses its configured toolsets. Worker profiles below are # deliberately toolset-empty so Hermes resolves their native defaults. if coordinator_toolsets is None: base.pop("toolsets", None) else: base["toolsets"] = coordinator_toolsets _write_yaml(config_path, base) env_values = _read_env(root / ".env") routes: dict[str, list[str]] = {} for effort in EFFORTS: codex_name = f"codex-{effort}" claude_name = f"claude-{effort}" codex_route = f"atlas/manual/codex/auto/{effort}" claude_route = f"atlas/manual/claude/auto/{effort}" _write_profile( root, codex_name, f"Codex implementation preference at {effort} effort, enforced by Switchyard.", "You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator.", _switchyard_profile_config(base, codex_route, effort), env_values, ) _write_profile( root, claude_name, f"Claude analysis preference at {effort} effort, enforced by Switchyard.", "You are an architecture and review worker. Analyze the assigned task deeply, change files only when asked, and return concise conclusions, evidence, and risks to the coordinator.", _switchyard_profile_config(base, claude_route, effort), env_values, ) routes[codex_name] = [codex_route] routes[claude_name] = [claude_route] _write_profile( root, "synthesis-xhigh", "Cross-provider synthesis and critical review, capped at xhigh effort.", "Synthesize the worker evidence into one answer. Resolve disagreements explicitly, verify high-risk claims, and never claim completion without cited validation.", _switchyard_profile_config(base, SWITCHYARD_AUTO_ROUTE, "xhigh"), env_values, ) routes["synthesis-xhigh"] = [SWITCHYARD_AUTO_ROUTE] _write_yaml( root / "profile.yaml", { "description": "Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries.", "description_auto": False, }, ) routes["coordinator"] = [SWITCHYARD_AUTO_ROUTE] routes["catalog"] = [ *(f"openai-codex/{model}" for model in codex_models.values()), *(f"anthropic/{model}" for model in claude_models.values()), ] return routes