hermes-agent/hermes_cli/moa_cmd.py
Teknium c6575df927
feat(moa): expose MoA presets as selectable virtual models (#46081)
* 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.
2026-06-25 13:52:06 -07:00

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}")