fix(model): show MoA preset picker on selection and label MoA in the banner

Selecting 'Mixture of Agents' in the `hermes model` provider picker fell
through silently — select_provider_and_model had no moa branch, so it just
reprinted the current model/provider summary and exited. And the CLI session
banner rendered the bare preset name (e.g. 'opus-gpt · Nous Research'),
which is meaningless out of context.

- Add _model_flow_moa: always lists the available presets (even one), then
  prints the full reference-models + aggregator breakdown for the selection
  and persists model.provider=moa / model.default=<preset> (dropping stale
  base_url + endpoint creds, since moa is a virtual local provider).
- Wire the branch into select_provider_and_model.
- build_welcome_banner takes provider; when 'moa' it renders
  'MoA: <preset> · agg <aggregator>' instead of a bare slug. Both CLI call
  sites pass self.provider.

Tests: 2 new banner tests (moa + non-moa unchanged); E2E verified the picker
persists the preset and clears stale base_url/api_key.
This commit is contained in:
teknium1 2026-06-27 11:42:11 -07:00 committed by Teknium
parent 1b6ebb24c0
commit 1ef19bad90
5 changed files with 210 additions and 8 deletions

2
cli.py
View file

@ -5880,6 +5880,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)
# Tool discovery is intentionally deferred on the Termux bare prompt
@ -8148,6 +8149,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
provider=self.provider,
)
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
# Show a random tip on new session

View file

@ -583,7 +583,8 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
enabled_toolsets: List[str] = None,
session_id: str = None,
get_toolset_for_tool=None,
context_length: int = None):
context_length: int = None,
provider: str = None):
"""Build and print a welcome banner with caduceus on left and info on right.
Args:
@ -595,6 +596,9 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
session_id: Session identifier.
get_toolset_for_tool: Callable to map tool name -> toolset name.
context_length: Model's context window size in tokens.
provider: Active provider id. When ``"moa"``, ``model`` is a MoA
preset name and the banner renders the aggregator instead of a
bare model slug.
"""
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
from rich.panel import Panel
@ -651,13 +655,36 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
_bskin = None
_hero = HERMES_CADUCEUS
left_lines = ["", _hero, ""]
model_short = model.split("/")[-1] if "/" in model else model
if model_short.endswith(".gguf"):
model_short = model_short[:-5]
if len(model_short) > 28:
model_short = model_short[:25] + "..."
ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]")
if (provider or "").strip().lower() == "moa":
# MoA virtual provider: ``model`` is a preset name. Show the preset and
# its aggregator so the banner is meaningful instead of a bare slug.
preset_name = model
agg_label = ""
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import normalize_moa_config
_moa = normalize_moa_config(load_config().get("moa") or {})
_preset = _moa.get("presets", {}).get(preset_name)
if _preset:
_agg = _preset.get("aggregator") or {}
_am = str(_agg.get("model") or "")
agg_label = _am.split("/")[-1] if "/" in _am else _am
except Exception:
agg_label = ""
if len(preset_name) > 28:
preset_name = preset_name[:25] + "..."
agg_str = f" [dim {dim}]·[/] [dim {dim}]agg {agg_label}[/]" if agg_label else ""
ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
left_lines.append(f"[{accent}]MoA: {preset_name}[/]{agg_str}{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]")
else:
model_short = model.split("/")[-1] if "/" in model else model
if model_short.endswith(".gguf"):
model_short = model_short[:-5]
if len(model_short) > 28:
model_short = model_short[:25] + "..."
ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else ""
left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]")
if os.getenv("HERMES_YOLO_MODE"):
left_lines.append(f"[bold red]⚠ YOLO mode[/] [dim {dim}]— all approval prompts bypassed[/]")

View file

@ -613,6 +613,7 @@ from hermes_cli.model_setup_flows import (
_model_flow_bedrock,
_model_flow_api_key_provider,
_model_flow_anthropic,
_model_flow_moa,
)
logger = logging.getLogger(__name__)
@ -3061,6 +3062,8 @@ def select_provider_and_model(args=None):
# Step 2: Provider-specific setup + model selection
if selected_provider == "openrouter":
_model_flow_openrouter(config, current_model)
elif selected_provider == "moa":
_model_flow_moa(config, current_model)
elif selected_provider == "nous":
_model_flow_nous(config, current_model, args=args)
elif selected_provider == "openai-codex":

View file

@ -132,6 +132,102 @@ def _model_flow_openrouter(config, current_model=""):
else:
print("No change.")
def _print_moa_preset(name: str, preset: dict) -> None:
"""Print the full reference-models + aggregator breakdown for a preset."""
print(f" Preset: {name}")
print(" Reference models:")
for idx, slot in enumerate(preset.get("reference_models") or [], start=1):
print(f" {idx}. {slot.get('provider')}:{slot.get('model')}")
agg = preset.get("aggregator") or {}
print(f" Aggregator: {agg.get('provider')}:{agg.get('model')}")
def _model_flow_moa(config, current_model=""):
"""Mixture of Agents virtual provider: pick a preset, then persist it.
Unlike the other provider flows there is no credential step MoA is a
virtual provider whose presets reference already-configured providers. We
always show the preset list (even when there is only one) so the user sees
what they are selecting, then print the full preset breakdown on selection.
"""
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import load_config, save_config
from hermes_cli.moa_config import normalize_moa_config
moa = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {})
presets = moa.get("presets") or {}
if not presets:
print("No MoA presets configured. Run `hermes moa configure <name>` first.")
return
names = list(presets.keys())
default_name = moa.get("default_preset") or names[0]
# Build labelled rows showing the aggregator so the picker is informative
# even before drilling into the full breakdown.
rows = []
for n in names:
agg = (presets[n].get("aggregator") or {})
agg_label = f"{agg.get('provider')}:{agg.get('model')}" if agg else ""
ref_count = len(presets[n].get("reference_models") or [])
suffix = " ← default" if n == default_name else ""
rows.append(f"{n} (agg {agg_label}, {ref_count} refs){suffix}")
default_idx = names.index(default_name) if default_name in names else 0
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice("Select a Mixture of Agents preset:", rows, default_idx)
except Exception:
print("Select a Mixture of Agents preset:")
for i, row in enumerate(rows, 1):
marker = "" if (i - 1) == default_idx else " "
print(f" {marker} {i}. {row}")
try:
raw = input(f" Choice [1-{len(rows)}]: ").strip()
except (KeyboardInterrupt, EOFError):
print("No change.")
return
if not raw:
idx = default_idx
else:
try:
idx = max(0, min(len(rows) - 1, int(raw) - 1))
except ValueError:
print("No change.")
return
if idx is None or idx < 0:
print("No change.")
return
selected_name = names[idx]
preset = presets[selected_name]
cfg = load_config()
model = cfg.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["default"] = selected_name
model["provider"] = "moa"
# MoA is a virtual local provider — drop any stale endpoint credentials and
# base_url so auto-resolution doesn't keep pointing at the previous real
# provider. (clear_model_endpoint_credentials handles api_key/api_mode but
# intentionally leaves base_url, so pop it here.)
clear_model_endpoint_credentials(model, clear_api_mode=True)
model.pop("base_url", None)
save_config(cfg)
_save_model_choice(selected_name)
deactivate_provider()
print()
print(f"Default model set to: {selected_name} (via Mixture of Agents)")
_print_moa_preset(selected_name, preset)
def _model_flow_nous(config, current_model="", args=None):
"""Nous Portal provider: ensure logged in, then pick model."""
from hermes_cli.auth import (

View file

@ -278,3 +278,77 @@ def test_banner_skills_section_reflects_disabled_skills_toolset():
out_enabled = console.export_text()
assert "Skills toolset disabled" not in out_enabled
assert "ascii-art" in out_enabled
def test_build_welcome_banner_moa_provider_shows_preset_and_aggregator(tmp_path, monkeypatch):
"""With provider='moa', the banner renders the preset + aggregator, not a bare slug."""
import yaml
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
(home / "config.yaml").write_text(
yaml.safe_dump(
{
"moa": {
"default_preset": "opus-gpt",
"presets": {
"opus-gpt": {
"enabled": True,
"reference_models": [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
}
},
}
}
)
)
with (
patch.object(model_tools, "check_tool_availability", return_value=([], [])),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
):
console = Console(record=True, force_terminal=False, color_system=None, width=160)
banner.build_welcome_banner(
console=console,
model="opus-gpt",
cwd="/tmp/project",
tools=[],
enabled_toolsets=[],
provider="moa",
)
out = console.export_text()
assert "MoA: opus-gpt" in out
assert "agg claude-opus-4.8" in out
def test_build_welcome_banner_non_moa_unchanged(tmp_path, monkeypatch):
"""A normal provider still renders the bare model slug, no MoA prefix."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
with (
patch.object(model_tools, "check_tool_availability", return_value=([], [])),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
):
console = Console(record=True, force_terminal=False, color_system=None, width=160)
banner.build_welcome_banner(
console=console,
model="anthropic/claude-opus-4.8",
cwd="/tmp/project",
tools=[],
enabled_toolsets=[],
provider="openrouter",
)
out = console.export_text()
assert "claude-opus-4.8" in out
assert "MoA:" not in out