mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
* feat(moa): expose MoA presets as selectable virtual models Reconstructed onto current main (PR #46081's base had diverged with no common ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual provider: each named preset is a selectable model under provider 'moa', and the preset's aggregator is the acting model that answers and calls tools. Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same batch pattern delegate_task uses) — all references dispatched at once, collected when every one finishes, then handed to the aggregator. Output order is preserved, failures and the MoA-recursion guard stay isolated per reference. - Removed the old mixture_of_agents model tool and moa toolset. - Added moa as a virtual provider in the provider/model inventory. - /moa is shortcut behavior over model selection (default preset / named preset / one-shot prompt). - Dashboard + Desktop manage named presets; presets appear in model pickers. - Parallel reference fan-out in agent/moa_loop.py with regression test. * fix(moa): thread moa_config through _run_agent to _run_agent_inner The reconstructed gateway MoA wiring declared moa_config on _run_agent (the profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper never forwarded it — _run_agent_inner had no such parameter, so the runtime hit NameError: name 'moa_config' is not defined on the compression-failure session sync path. Add moa_config to _run_agent_inner's signature and forward it from both wrapper call sites (multiplex and non-multiplex). Caught by tests/gateway/test_compression_failure_session_sync.py on CI shard test(4). * fix(moa): classify moa as a virtual provider in the catalog The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so provider_catalog() fell through to the default auth_type="api_key" with no env vars — tripping two catalog invariants: - test_provider_catalog: api_key providers must expose a credential env var - test_provider_parity: every hermes-model provider must be desktop-configurable moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that overlay as an auth_type fallback so the catalog reports moa as virtual (no real credential, no network endpoint). Exempt virtual providers from the desktop parity union check the same way 'custom' is exempt — derived from the catalog, not a hardcoded slug, so future virtual providers are covered too.
135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""CLI helpers for configuring Mixture of Agents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from hermes_cli.config import load_config, save_config
|
|
from hermes_cli.inventory import build_models_payload, load_picker_context
|
|
from hermes_cli.moa_config import DEFAULT_MOA_PRESET_NAME, normalize_moa_config
|
|
|
|
|
|
def _prompt_choice(title: str, rows: list[str], default: int = 0) -> int:
|
|
try:
|
|
from hermes_cli.curses_ui import curses_radiolist
|
|
|
|
return curses_radiolist(title, rows, selected=default, cancel_returns=default)
|
|
except Exception:
|
|
for idx, row in enumerate(rows, start=1):
|
|
print(f"{idx}. {row}")
|
|
raw = input(f"{title} [{default + 1}]: ").strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
return max(0, min(len(rows) - 1, int(raw) - 1))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def _model_options() -> list[dict[str, Any]]:
|
|
payload = build_models_payload(
|
|
load_picker_context(),
|
|
include_unconfigured=True,
|
|
picker_hints=True,
|
|
canonical_order=True,
|
|
pricing=True,
|
|
capabilities=True,
|
|
max_models=200,
|
|
)
|
|
providers = payload.get("providers") or []
|
|
return [p for p in providers if p.get("slug") and p.get("models")]
|
|
|
|
|
|
def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]:
|
|
providers = _model_options()
|
|
if not providers:
|
|
raise RuntimeError("No configured model providers found. Run `hermes model` first.")
|
|
current_provider = (current or {}).get("provider", "")
|
|
provider_default = next(
|
|
(idx for idx, p in enumerate(providers) if p.get("slug") == current_provider),
|
|
0,
|
|
)
|
|
provider_rows = [f"{p.get('name') or p.get('slug')} ({p.get('slug')})" for p in providers]
|
|
provider = providers[_prompt_choice("Select provider", provider_rows, provider_default)]
|
|
models = list(provider.get("models") or [])
|
|
if not models:
|
|
raise RuntimeError(f"Provider {provider.get('slug')} has no selectable models")
|
|
current_model = (current or {}).get("model", "")
|
|
model_default = models.index(current_model) if current_model in models else 0
|
|
model = models[_prompt_choice(f"Select model for {provider.get('slug')}", models, model_default)]
|
|
return {"provider": str(provider.get("slug") or ""), "model": str(model)}
|
|
|
|
|
|
def _print_config(config: dict[str, Any]) -> None:
|
|
cfg = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {})
|
|
print("Mixture of Agents presets")
|
|
print(f"Default: {cfg['default_preset']}")
|
|
active = cfg.get("active_preset") or "(off)"
|
|
print(f"Active in config: {active}")
|
|
for name, preset in cfg["presets"].items():
|
|
marker = "*" if name == cfg["default_preset"] else " "
|
|
print(f"\n{marker} {name}")
|
|
print(" Reference models:")
|
|
for idx, slot in enumerate(preset["reference_models"], start=1):
|
|
print(f" {idx}. {slot['provider']}:{slot['model']}")
|
|
agg = preset["aggregator"]
|
|
print(f" Aggregator: {agg['provider']}:{agg['model']}")
|
|
|
|
|
|
def cmd_moa(args) -> None:
|
|
"""Manage Mixture of Agents model presets."""
|
|
cfg = load_config()
|
|
sub = getattr(args, "moa_command", None) or "list"
|
|
|
|
if sub in {"list", "ls"}:
|
|
_print_config(cfg)
|
|
return
|
|
|
|
if sub in {"config", "configure"}:
|
|
moa = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {})
|
|
preset_name = (getattr(args, "name", None) or moa.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip()
|
|
current = moa["presets"].get(preset_name, moa["presets"][moa["default_preset"]])
|
|
print(f"Configure MoA preset: {preset_name}")
|
|
print("Pick at least one reference model; choose Done when finished.")
|
|
refs: list[dict[str, str]] = []
|
|
existing = list(current.get("reference_models") or [])
|
|
idx = 0
|
|
while True:
|
|
base = existing[idx] if idx < len(existing) else None
|
|
refs.append(_pick_slot(base))
|
|
idx += 1
|
|
choice = _prompt_choice("Add another reference model?", ["Add another", "Done"], 1)
|
|
if choice == 1:
|
|
break
|
|
print("Configure aggregator model.")
|
|
current = dict(current)
|
|
current["reference_models"] = refs
|
|
current["aggregator"] = _pick_slot(current.get("aggregator"))
|
|
moa["presets"][preset_name] = current
|
|
moa.setdefault("default_preset", preset_name)
|
|
cfg["moa"] = normalize_moa_config(moa)
|
|
save_config(cfg)
|
|
print(f"Saved MoA preset: {preset_name}")
|
|
_print_config(cfg)
|
|
return
|
|
|
|
if sub == "delete":
|
|
moa = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {})
|
|
preset_name = (getattr(args, "name", None) or "").strip()
|
|
if not preset_name:
|
|
raise SystemExit("Usage: hermes moa delete <name>")
|
|
if preset_name not in moa["presets"]:
|
|
raise SystemExit(f"Unknown MoA preset: {preset_name}")
|
|
if len(moa["presets"]) <= 1:
|
|
raise SystemExit("Cannot delete the only MoA preset")
|
|
del moa["presets"][preset_name]
|
|
if moa["default_preset"] == preset_name:
|
|
moa["default_preset"] = next(iter(moa["presets"]))
|
|
if moa.get("active_preset") == preset_name:
|
|
moa["active_preset"] = ""
|
|
cfg["moa"] = normalize_moa_config(moa)
|
|
save_config(cfg)
|
|
print(f"Deleted MoA preset: {preset_name}")
|
|
return
|
|
|
|
raise SystemExit(f"Unknown moa subcommand: {sub}")
|