Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 26 additions & 35 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8419,50 +8419,41 @@ def process_command(self, command: str) -> bool:
elif canonical == "goal":
self._handle_goal_command(cmd_original)
elif canonical == "moa":
# /moa is one-shot sugar only: run a single prompt through the
# default MoA preset, then restore the prior model. To *switch* to a
# MoA preset for the session, pick it from the model picker (MoA
# presets surface as a virtual "Mixture of Agents" provider).
from hermes_cli.moa_config import (
exact_moa_preset_name,
moa_usage,
normalize_moa_config,
resolve_moa_preset,
)

parts = cmd_original.split(None, 1)
payload = parts[1].strip() if len(parts) > 1 else ""
if not payload:
_cprint(f" {moa_usage()}")
return True
moa_cfg = self.config.get("moa") if isinstance(self.config, dict) else {}
normalized = normalize_moa_config(moa_cfg)
matched_preset = exact_moa_preset_name(normalized, payload) if payload else normalized["default_preset"]
if matched_preset:
self.requested_provider = "moa"
self.provider = "moa"
self.model = matched_preset
self.api_key = "moa-virtual-provider"
self.base_url = "moa://local"
self.api_mode = "chat_completions"
self.agent = None
_cprint(f" Model switched to MoA preset: {matched_preset}.")
else:
if not payload:
_cprint(f" {moa_usage()}")
return True
preset = normalized["default_preset"]
self._pending_moa_restore_model = {
"requested_provider": getattr(self, "requested_provider", None),
"provider": getattr(self, "provider", None),
"model": getattr(self, "model", None),
"api_key": getattr(self, "api_key", None),
"base_url": getattr(self, "base_url", None),
"api_mode": getattr(self, "api_mode", None),
}
self.requested_provider = "moa"
self.provider = "moa"
self.model = preset
self.api_key = "moa-virtual-provider"
self.base_url = "moa://local"
self.api_mode = "chat_completions"
self.agent = None
self._pending_moa_disable_after_turn = True
self._pending_agent_seed = payload
_cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.")
preset = normalized["default_preset"]
self._pending_moa_restore_model = {
"requested_provider": getattr(self, "requested_provider", None),
"provider": getattr(self, "provider", None),
"model": getattr(self, "model", None),
"api_key": getattr(self, "api_key", None),
"base_url": getattr(self, "base_url", None),
"api_mode": getattr(self, "api_mode", None),
}
self.requested_provider = "moa"
self.provider = "moa"
self.model = preset
self.api_key = "moa-virtual-provider"
self.base_url = "moa://local"
self.api_mode = "chat_completions"
self.agent = None
self._pending_moa_disable_after_turn = True
self._pending_agent_seed = payload
_cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.")
elif canonical == "subgoal":
self._handle_subgoal_command(cmd_original)
elif canonical == "skin":
Expand Down
21 changes: 6 additions & 15 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8756,33 +8756,24 @@ async def _do_undo():
return await self._handle_goal_command(event)

if canonical == "moa":
# /moa is one-shot sugar only: run a single prompt through the
# default MoA preset, then restore the prior model. To *switch* to a
# MoA preset for the session, pick it from the model picker (MoA
# presets surface as a virtual "Mixture of Agents" provider).
from hermes_cli.moa_config import (
exact_moa_preset_name,
moa_usage,
normalize_moa_config,
resolve_moa_preset,
)
from hermes_cli.config import load_config

moa_payload = event.get_command_args().strip()
if not moa_payload:
return moa_usage()
try:
cfg = load_config()
moa_cfg = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {})
except Exception:
moa_cfg = normalize_moa_config({})
matched_preset = exact_moa_preset_name(moa_cfg, moa_payload) if moa_payload else moa_cfg["default_preset"]
if matched_preset:
self._session_model_overrides[_quick_key] = {
"provider": "moa",
"model": matched_preset,
"base_url": "moa://local",
"api_key": "moa-virtual-provider",
"api_mode": "chat_completions",
}
self._evict_cached_agent(_quick_key)
return f"Model switched to MoA preset: {matched_preset}."
if not moa_payload:
return moa_usage()
preset = moa_cfg["default_preset"]
try:
event.text = moa_payload
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ class CommandDef:
args_hint="<prompt>"),
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
args_hint="[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]"),
CommandDef("moa", "Run one prompt through configured Mixture of Agents models", "Session",
CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session",
args_hint="<prompt>"),
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
args_hint="[text | remove N | clear]"),
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/moa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,4 @@ def build_moa_turn_prompt(user_prompt: str, config: Any = None, preset: str | No


def moa_usage() -> str:
return "Usage: /moa [preset-name | prompt] (bare /moa toggles the default preset)"
return "Usage: /moa <prompt> (runs one prompt through the default MoA preset, then restores your model; pick a preset from the model picker to switch for the session)"
29 changes: 21 additions & 8 deletions tests/cli/test_moa_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,41 @@ def _make_cli():
cli._pending_input = queue.Queue()
cli._pending_agent_seed = None
cli._pending_moa_config = None
cli._pending_moa_disable_after_turn = False
cli._pending_moa_restore_model = None
cli._agent_running = False
cli.agent = None
cli.provider = "openrouter"
cli.requested_provider = "openrouter"
cli.model = "anthropic/claude-opus-4.8"
cli.api_key = "test-key"
cli.base_url = "https://openrouter.ai/api/v1"
cli.api_mode = "chat_completions"
return cli


def test_moa_bare_switches_to_default_preset_model():
def test_moa_bare_shows_usage_no_switch():
# /moa with no prompt is usage-only now; switching to a preset for the
# session is done via the model picker, not /moa.
cli = _make_cli()
cli._pending_moa_disable_after_turn = False
with patch("cli._cprint"):
assert cli.process_command("/moa") is True
assert cli.provider == "moa"
assert cli.requested_provider == "moa"
assert cli.model == "default"
assert cli.agent is None
assert cli.provider != "moa"
assert cli._pending_agent_seed is None
assert cli._pending_moa_disable_after_turn is False


def test_moa_exact_preset_switches_to_named_preset_model():
def test_moa_arg_is_always_one_shot_prompt():
# Any argument (even a string that matches a preset name) is treated as a
# one-shot prompt through the DEFAULT preset, then the model is restored.
cli = _make_cli()
with patch("cli._cprint"):
cli.process_command("/moa review")
assert cli._pending_agent_seed == "review"
assert cli._pending_moa_disable_after_turn is True
assert cli.provider == "moa"
assert cli.model == "review"
assert cli.agent is None
assert cli.model == "default"


def test_moa_non_preset_is_one_shot_prompt():
Expand Down
22 changes: 14 additions & 8 deletions tests/tui_gateway/test_goal_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def _write_moa_config(home, text):
cfg_path.write_text(text)


def test_moa_bare_switches_to_default_preset_model(server, session, hermes_home):
def test_moa_bare_returns_usage(server, session, hermes_home):
_write_moa_config(hermes_home, """
moa:
default_preset: default
Expand All @@ -226,13 +226,14 @@ def test_moa_bare_switches_to_default_preset_model(server, session, hermes_home)
""")
sid, _, s = session
r = _call(server, "command.dispatch", name="moa", arg="", session_id=sid)
assert r["result"]["type"] == "exec"
assert "Model switched to MoA preset: default" in r["result"]["output"]
assert s["model_override"]["provider"] == "moa"
assert s["model_override"]["model"] == "default"
# Bare /moa is usage-only now; switching to a preset is via the model picker.
assert "error" in r
assert "model_override" not in s


def test_moa_exact_preset_switches_to_named_preset_model(server, session, hermes_home):
def test_moa_arg_is_always_one_shot(server, session, hermes_home):
# Any arg (even a preset name) is a one-shot prompt through the DEFAULT
# preset; /moa never does a sticky switch anymore.
_write_moa_config(hermes_home, """
moa:
default_preset: default
Expand All @@ -248,9 +249,14 @@ def test_moa_exact_preset_switches_to_named_preset_model(server, session, hermes
""")
sid, _, s = session
r = _call(server, "command.dispatch", name="moa", arg="review", session_id=sid)
assert r["result"]["type"] == "exec"
result = r["result"]
assert result["type"] == "send"
assert result["message"] == "review"
assert "one-shot" in result["notice"]
# Lazy session (no live agent) → MoA preset pinned via model_override for
# the build, and it is the DEFAULT preset, not the "review" arg.
assert s["model_override"]["provider"] == "moa"
assert s["model_override"]["model"] == "review"
assert s["model_override"]["model"] == "default"


def test_moa_non_preset_returns_one_shot_send(server, session, hermes_home):
Expand Down
96 changes: 72 additions & 24 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8426,7 +8426,39 @@ def _stream(delta):
result = agent.run_conversation(run_message, **run_kwargs)
if "moa_one_shot_restore" in session:
_restore = session.pop("moa_one_shot_restore", None)
if _restore is None:
# Restore the model the user was on before the /moa one-shot.
# The one-shot did a real in-place agent.switch_model() to MoA
# (#53444), so undoing it must go back through the switch path —
# resetting session["model_override"] alone would leave the live
# agent's client pinned to MoA for the next turn.
if isinstance(_restore, dict):
_prev_override = _restore.get("override")
_prev_model = _restore.get("model")
_prev_provider = _restore.get("provider")
if _prev_override is None:
session.pop("model_override", None)
else:
session["model_override"] = _prev_override
if _prev_model:
_raw = (
f"{_prev_model} --provider {_prev_provider}"
if _prev_provider
else _prev_model
)
try:
_apply_model_switch(
sid,
session,
_raw,
confirm_expensive_model=False,
pin_session_override=bool(_prev_override),
)
except Exception as _moa_restore_exc:
logger.warning(
"MoA one-shot model restore failed: %s",
_moa_restore_exc,
)
elif _restore is None:
session.pop("model_override", None)
else:
session["model_override"] = _restore
Expand Down Expand Up @@ -11205,38 +11237,54 @@ def _(rid, params: dict) -> dict:

return _ok(rid, {"type": "send", "message": build_learn_prompt(arg)})
if name == "moa":
# /moa is one-shot sugar only: run a single prompt through the default
# MoA preset, then restore the prior model. To *switch* to a MoA preset
# for the rest of the session, pick it from the model picker (MoA
# presets surface as a virtual "Mixture of Agents" provider).
try:
from hermes_cli.moa_config import (
build_moa_turn_prompt, exact_moa_preset_name, moa_usage, normalize_moa_config
)
from hermes_cli.moa_config import moa_usage, normalize_moa_config

if not arg:
return _err(rid, 4004, moa_usage())
if not session:
return _err(rid, 4001, "no active session")
sid = params.get("session_id", "")
moa_cfg = normalize_moa_config(_load_cfg().get("moa") or {})
matched = exact_moa_preset_name(moa_cfg, arg) if arg else moa_cfg["default_preset"]
if matched:
if not session:
return _err(rid, 4001, "no active session")
preset = moa_cfg["default_preset"]
# Record the live model identity so it can be restored after the
# one-shot turn, then swap the agent's client in place (#53444:
# setting session["model_override"] alone never switched the
# already-built agent, so the turn silently ran on the old model).
agent = session.get("agent")
session["moa_one_shot_restore"] = {
"override": session.get("model_override"),
"model": getattr(agent, "model", None) if agent else None,
"provider": getattr(agent, "provider", None) if agent else None,
}
if agent is not None:
# Live agent: swap its client in place so THIS turn runs MoA.
try:
_apply_model_switch(
sid,
session,
f"{preset} --provider moa",
confirm_expensive_model=False,
pin_session_override=True,
)
except Exception as exc:
session.pop("moa_one_shot_restore", None)
return _err(rid, 5030, f"moa unavailable: {exc}")
else:
# No agent built yet (lazy/fresh session): the override is
# consumed by the first build, so the turn runs MoA without an
# in-place switch.
session["model_override"] = {
"model": matched,
"provider": "moa",
"model": preset,
"base_url": "moa://local",
"api_key": "moa-virtual-provider",
"api_mode": "chat_completions",
}
session["moa_active_preset"] = matched
return _ok(rid, {"type": "exec", "output": f"Model switched to MoA preset: {matched}."})
if not arg:
return _err(rid, 4004, moa_usage())
if not session:
return _err(rid, 4001, "no active session")
preset = moa_cfg["default_preset"]
session["moa_one_shot_restore"] = session.get("model_override")
session["model_override"] = {
"model": preset,
"provider": "moa",
"base_url": "moa://local",
"api_key": "moa-virtual-provider",
"api_mode": "chat_completions",
}
return _ok(
rid,
{
Expand Down
4 changes: 2 additions & 2 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ hermes [global-options] <command> [subcommand/options]
|---------|---------|
| `hermes chat` | Interactive or one-shot chat with the agent. |
| `hermes model` | Interactively choose the default provider and model. |
| `hermes moa` | Configure named Mixture of Agents presets used by `/moa`. |
| `hermes moa` | Configure named Mixture of Agents presets selectable from the model picker. |
| `hermes fallback` | Manage fallback providers tried when the primary model errors. |
| `hermes gateway` | Run or manage the messaging gateway service. |
| `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). |
Expand Down Expand Up @@ -1122,7 +1122,7 @@ See [Curator](../user-guide/features/curator.md) for behavior and config.

## `hermes moa`

Configure named Mixture of Agents presets used by the `/moa` slash command.
Configure named Mixture of Agents presets. Presets appear as selectable models under a `Mixture of Agents` provider in every model picker; `/moa <prompt>` runs one prompt through the default preset.

```bash
hermes moa list
Expand Down
18 changes: 6 additions & 12 deletions website/docs/user-guide/features/mixture-of-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,21 @@ Configured presets therefore show up wherever you would pick any other model.

## Slash command shortcut

`/moa` is convenience sugar over model selection:
`/moa` is one-shot convenience sugar. It runs a single prompt through the **default** MoA preset, then restores whatever model you were on:

```bash
/moa
```

Switches the current session to the default MoA preset.

```bash
/moa review
/moa design and implement a migration plan for this flaky test cluster
```

If `review` exactly matches a preset name, switches the current session to provider `moa`, model `review`.
Hermes temporarily switches to the default MoA preset for that one turn, sends the prompt, then restores your previous model afterward. The whole argument is the prompt — `/moa` no longer interprets it as a preset name.

```bash
/moa design and implement a migration plan for this flaky test cluster
/moa
```

If the text does not exactly match a preset name, Hermes treats it as a one-shot prompt. It temporarily switches to the default MoA preset for that turn, sends the prompt, then restores the previous model afterward.
Bare `/moa` (no prompt) just prints usage.

Preset matching is exact on purpose. Hermes does not fuzzy-match preset names, so normal prompts cannot accidentally become model switches.
To **switch** to a MoA preset for the rest of the session, select it from the model picker — MoA presets appear under a `Mixture of Agents` provider in every model-selection surface (see above). `/moa` is deliberately not a model switch, so a normal prompt can never accidentally change your model.

## How it works in the agent loop

Expand Down
Loading