From 1ef19bad905dbb9acc9325e74eb9512a356d53ec Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:42:11 -0700 Subject: [PATCH] fix(model): show MoA preset picker on selection and label MoA in the banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= (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: · agg ' 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. --- cli.py | 2 + hermes_cli/banner.py | 43 ++++++++++++--- hermes_cli/main.py | 3 ++ hermes_cli/model_setup_flows.py | 96 +++++++++++++++++++++++++++++++++ tests/hermes_cli/test_banner.py | 74 +++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index 2e7d21876e0..6328fe217bf 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index df127c54b8a..217eb2bb965 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -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[/]") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c43e663533b..ac0bd41fdef 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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": diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 458b442c362..ba42fab485c 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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 ` 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 ( diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py index ec179cdb7e4..1e15dc0e8a2 100644 --- a/tests/hermes_cli/test_banner.py +++ b/tests/hermes_cli/test_banner.py @@ -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