485 lines
18 KiB
Python
485 lines
18 KiB
Python
#!/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
|