2026-08-08 17:59:45 -03:00
#!/usr/bin/env python3
""" Keep Hermes agent provider catalogs and managed profiles current. """
from __future__ import annotations
import copy
2026-08-11 20:22:26 -03:00
import json
2026-08-08 17:59:45 -03:00
import os
2026-08-10 23:28:35 -03:00
import shutil
import subprocess
2026-08-11 20:22:26 -03:00
import time
2026-08-08 17:59:45 -03:00
from pathlib import Path
from typing import Any , Iterable
import yaml
2026-09-13 01:48:05 -05:00
from provider_model_catalog import (
2026-09-13 15:04:01 -05:00
CAPABILITY_ROLES ,
2026-09-13 01:48:05 -05:00
CAPABILITY_TIERS ,
EFFORTS ,
EFFORT_TIERS ,
Catalog ,
2026-09-13 15:04:01 -05:00
apply_verified_evaluations ,
capability_pool ,
legacy_selector_matches ,
2026-09-13 01:48:05 -05:00
model_records ,
model_version ,
select_tier_model ,
unique_models ,
)
from provider_model_discovery import discover_claude_models , discover_codex_models
2026-09-13 15:04:01 -05:00
from model_evaluation_evidence import load_store
2026-09-13 01:48:05 -05:00
2026-08-08 17:59:45 -03:00
CODEX_BASELINE = " gpt-5.6-terra "
CLAUDE_BASELINE = " claude-opus-5 "
ATLAS_FALLBACK = {
" provider " : " custom " ,
2026-08-11 05:20:18 -03:00
" model " : " qwen2.5:14b-instruct-q4_0 " ,
2026-08-08 17:59:45 -03:00
" 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
2026-08-11 20:22:26 -03:00
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 ( )
2026-09-13 01:48:05 -05:00
MANAGED_ENV_KEYS = { " GIT_ASKPASS " , " GIT_TERMINAL_PROMPT " }
2026-08-15 17:58:47 -03:00
RUNTIME_SECRET_ENV_KEYS = {
2026-09-13 01:48:05 -05:00
" ANTHROPIC_API_KEY " , " API_SERVER_KEY " , " CLAUDE_API_KEY " , " CLAUDE_CODE_OAUTH_TOKEN " ,
" GITEA_TOKEN " , " GITEA_USERNAME " , " HERMES_IMAGE_BROKER_KEY " , " OPENAI_API_KEY " ,
2026-08-08 17:59:45 -03:00
}
2026-09-13 01:48:05 -05:00
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 "
2026-09-13 15:04:01 -05:00
return select_tier_model ( " codex " , models , { } , tier , effort , current , legacy_compat = True ) [ 0 ]
2026-08-08 17:59:45 -03:00
2026-09-13 01:48:05 -05:00
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. """
2026-08-08 17:59:45 -03:00
if effort not in EFFORTS :
raise ValueError ( f " unsupported effort: { effort } " )
2026-09-13 15:04:01 -05:00
return select_tier_model ( " codex " , models , { } , EFFORT_TIERS [ effort ] , effort , current , legacy_compat = True ) [ 0 ]
2026-08-08 17:59:45 -03:00
def choose_claude_model ( models : Iterable [ str ] , current : str = CLAUDE_BASELINE ) - > str :
2026-09-13 01:48:05 -05:00
""" Compatibility helper selecting the declared advanced Claude tier. """
2026-09-13 15:04:01 -05:00
return select_tier_model ( " claude " , models , { } , " advanced " , " xhigh " , current , legacy_compat = True ) [ 0 ]
2026-08-08 17:59:45 -03:00
def choose_claude_for_effort (
models : Iterable [ str ] , effort : str , current : str = CLAUDE_BASELINE
) - > str :
2026-09-13 01:48:05 -05:00
""" Compatibility helper selecting an effort ' s generic Claude capability tier. """
2026-08-08 17:59:45 -03:00
if effort not in EFFORTS :
raise ValueError ( f " unsupported effort: { effort } " )
2026-09-13 15:04:01 -05:00
return select_tier_model ( " claude " , models , { } , EFFORT_TIERS [ effort ] , effort , current , legacy_compat = True ) [ 0 ]
2026-09-13 01:48:05 -05:00
# 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
2026-08-08 17:59:45 -03:00
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 )
2026-08-11 20:22:26 -03:00
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
2026-09-13 01:48:05 -05:00
) - > tuple [ dict [ str , str ] , dict [ str , str ] , list [ str ] , dict [ str , dict [ str , Any ] ] ] :
""" Return last-known-good effort, selector, models, and metadata values. """
2026-08-11 20:22:26 -03:00
providers = previous . get ( " providers " , { } )
record = providers . get ( provider , { } ) if isinstance ( providers , dict ) else { }
if not isinstance ( record , dict ) :
2026-09-13 01:48:05 -05:00
return { } , { } , [ ] , { }
2026-08-11 20:22:26 -03:00
resolved = record . get ( " resolved " , { } )
tiers = record . get ( " tiers " , { } )
models = record . get ( " models " , [ ] )
2026-09-13 01:48:05 -05:00
metadata = record . get ( " model_metadata " , { } )
2026-08-11 20:22:26 -03:00
return (
dict ( resolved ) if isinstance ( resolved , dict ) else { } ,
dict ( tiers ) if isinstance ( tiers , dict ) else { } ,
_unique_models ( models if isinstance ( models , list ) else [ ] ) ,
2026-09-13 01:48:05 -05:00
{
str ( model ) : dict ( details )
for model , details in metadata . items ( )
if isinstance ( model , str ) and isinstance ( details , dict )
}
if isinstance ( metadata , dict )
else { } ,
2026-08-11 20:22:26 -03:00
)
def build_routing_catalog (
2026-09-13 15:04:01 -05:00
codex : Catalog , claude : Catalog , previous : dict [ str , Any ] | None = None ,
evaluations : dict [ str , Any ] | None = None ,
2026-08-11 20:22:26 -03:00
) - > dict [ str , Any ] :
2026-09-13 01:48:05 -05:00
""" Build a current catalog while retaining only outage-safe known routes. """
2026-08-11 20:22:26 -03:00
previous = previous or { }
2026-09-13 01:48:05 -05:00
checked_at = int ( time . time ( ) )
2026-08-11 20:22:26 -03:00
providers : dict [ str , Any ] = { }
specifications = (
(
" codex " ,
codex ,
2026-09-13 01:48:05 -05:00
{ " luna " : " economy " , " terra " : " balanced " , " sol " : " advanced " } ,
2026-08-11 20:22:26 -03:00
) ,
(
" claude " ,
claude ,
2026-08-12 23:08:21 -03:00
{
2026-09-13 01:48:05 -05:00
" haiku " : " economy " ,
2026-09-13 15:04:01 -05:00
" fable " : " frontier " ,
2026-09-13 01:48:05 -05:00
" sonnet " : " balanced " ,
" opus " : " advanced " ,
2026-08-12 23:08:21 -03:00
} ,
2026-08-11 20:22:26 -03:00
) ,
)
2026-09-13 15:04:01 -05:00
for name , discovered , legacy_selectors in specifications :
2026-09-13 01:48:05 -05:00
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
)
2026-08-11 20:22:26 -03:00
source_models = discovered . models if discovered . live else old_models
2026-09-13 15:04:01 -05:00
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 ,
)
2026-09-13 01:48:05 -05:00
observations : dict [ str , Any ] = { }
2026-09-13 15:04:01 -05:00
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
}
2026-08-11 20:22:26 -03:00
tiers : dict [ str , str ] = { }
2026-09-13 15:04:01 -05:00
representative_effort = {
" economy " : " low " , " balanced " : " medium " , " advanced " : " high " , " frontier " : " xhigh " ,
}
2026-09-13 01:48:05 -05:00
for capability in CAPABILITY_TIERS :
2026-09-13 15:04:01 -05:00
effort = representative_effort [ capability ]
tiers [ capability ] = capability_resolved [ capability ] [ effort ]
2026-09-13 01:48:05 -05:00
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.
2026-09-13 15:04:01 -05:00
exact = [
model for model in source_models
if legacy_selector_matches ( name , selector , model )
]
2026-09-13 01:48:05 -05:00
tiers [ selector ] = (
exact [ 0 ]
if exact
else ( " " if discovered . live else str ( old_tiers . get ( selector ) or " " ) )
2026-08-11 20:22:26 -03:00
)
providers [ name ] = {
2026-09-13 01:48:05 -05:00
" 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 ,
2026-08-11 20:22:26 -03:00
" state " : discovered . state ,
" connected " : discovered . connected ,
" live " : discovered . live ,
" models " : _unique_models (
discovered . models
if discovered . live
else ( old_models or discovered . models )
) ,
2026-09-13 01:48:05 -05:00
" model_metadata " : source_metadata ,
" candidates " : observations ,
2026-09-13 15:04:01 -05:00
" capability_pools " : capability_pools ,
" capability_resolved " : capability_resolved ,
2026-08-11 20:22:26 -03:00
" resolved " : resolved ,
" tiers " : tiers ,
}
return {
2026-09-13 15:04:01 -05:00
" schema_version " : 3 ,
2026-09-13 01:48:05 -05:00
" updated_at " : checked_at ,
2026-08-11 20:22:26 -03:00
" 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. """
2026-09-13 15:04:01 -05:00
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 }
)
2026-08-11 20:22:26 -03:00
_atomic_write ( path , json . dumps ( catalog , indent = 2 , sort_keys = True ) + " \n " , 0o644 )
return catalog
2026-08-08 17:59:45 -03:00
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 :
2026-08-15 17:58:47 -03:00
""" Refresh non-secret managed settings and remove stale credential copies. """
2026-08-08 17:59:45 -03:00
try :
old_lines = path . read_text ( encoding = " utf-8 " ) . splitlines ( )
except OSError :
old_lines = [ ]
kept = [
line
for line in old_lines
2026-08-15 17:58:47 -03:00
if not any (
line . lstrip ( ) . startswith ( f " { key } = " )
for key in MANAGED_ENV_KEYS | RUNTIME_SECRET_ENV_KEYS
)
2026-08-08 17:59:45 -03:00
]
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 )
2026-08-10 23:28:35 -03:00
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
2026-08-11 20:22:26 -03:00
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
2026-08-08 17:59:45 -03:00
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 )
2026-08-11 20:22:26 -03:00
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. """
2026-08-08 17:59:45 -03:00
config_path = root / " config.yaml "
base = _read_yaml ( config_path )
2026-08-11 20:22:26 -03:00
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
2026-08-08 17:59:45 -03:00
_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 } "
2026-09-13 01:48:05 -05:00
codex_route = f " atlas/manual/codex/auto/ { effort } "
claude_route = f " atlas/manual/claude/auto/ { effort } "
2026-08-08 17:59:45 -03:00
_write_profile (
root ,
codex_name ,
2026-08-11 20:22:26 -03:00
f " Codex implementation preference at { effort } effort, enforced by Switchyard. " ,
2026-08-08 17:59:45 -03:00
" You are an implementation worker. Make focused, tested changes for the assigned task, preserve unrelated work, and report evidence and blockers to the coordinator. " ,
2026-08-11 20:22:26 -03:00
_switchyard_profile_config ( base , codex_route , effort ) ,
2026-08-08 17:59:45 -03:00
env_values ,
)
_write_profile (
root ,
claude_name ,
2026-08-11 20:22:26 -03:00
f " Claude analysis preference at { effort } effort, enforced by Switchyard. " ,
2026-08-08 17:59:45 -03:00
" 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. " ,
2026-08-11 20:22:26 -03:00
_switchyard_profile_config ( base , claude_route , effort ) ,
2026-08-08 17:59:45 -03:00
env_values ,
)
2026-08-11 20:22:26 -03:00
routes [ codex_name ] = [ codex_route ]
routes [ claude_name ] = [ claude_route ]
2026-08-08 17:59:45 -03:00
_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. " ,
2026-08-11 20:22:26 -03:00
_switchyard_profile_config ( base , SWITCHYARD_AUTO_ROUTE , " xhigh " ) ,
2026-08-08 17:59:45 -03:00
env_values ,
)
2026-08-11 20:22:26 -03:00
routes [ " synthesis-xhigh " ] = [ SWITCHYARD_AUTO_ROUTE ]
2026-08-08 17:59:45 -03:00
_write_yaml (
root / " profile.yaml " ,
{
2026-08-11 20:22:26 -03:00
" description " : " Owner-only project coordinator using Switchyard to route Hermes, Codex, Claude, and local model boundaries. " ,
2026-08-08 17:59:45 -03:00
" description_auto " : False ,
} ,
)
2026-08-11 20:22:26 -03:00
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 ( ) ) ,
2026-08-08 17:59:45 -03:00
]
return routes