mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
1b6ebb24c0
commit
1ef19bad90
5 changed files with 210 additions and 8 deletions
2
cli.py
2
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
|
||||
|
|
|
|||
|
|
@ -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[/]")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue