{"repo":"NousResearch/hermes-agent","public_id":"53444","display_label":"MoA architecture simplification and routing changes","summary":"These items concern rethinking and refining the Mixture-of-Agents (MoA) implementation, including making it one-shot, replacing a virtual provider approach with subagent delegation, and applying MoA switches to the live TUI agent.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53548,"merged_at":"2026-06-27T10:09:09+00:00"},"resolution":{"state":"resolved","by":"pr-53548"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T10:09:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":53444,"pr_number":53548,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-27T10:09:09+00:00"}],"members":[{"kind":"issue","number":53444,"title":"feat: Rethink MoA architecture — virtual provider is overkill, subagent delegation is simpler and more robust","url":null,"state":"closed"},{"kind":"pr","number":53502,"title":"fix: apply moa switches to live tui agent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53548","number":53548},{"kind":"pr","value":"#53444","number":53444}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":53548,"title":"feat(moa): make /moa one-shot only; route preset switching through the model picker","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","hermes_cli/commands.py","hermes_cli/moa_config.py","tests/cli/test_moa_command.py","tests/tui_gateway/test_goal_command.py","tui_gateway/server.py","website/docs/reference/cli-commands.md","website/docs/user-guide/features/mixture-of-agents.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -8419,50 +8419,41 @@ def process_command(self, command: str) -> bool:\n         elif canonical == \"goal\":\n             self._handle_goal_command(cmd_original)\n         elif canonical == \"moa\":\n+            # /moa is one-shot sugar only: run a single prompt through the\n+            # default MoA preset, then restore the prior model. To *switch* to a\n+            # MoA preset for the session, pick it from the model picker (MoA\n+            # presets surface as a virtual \"Mixture of Agents\" provider).\n             from hermes_cli.moa_config import (\n-                exact_moa_preset_name,\n                 moa_usage,\n                 normalize_moa_config,\n-                resolve_moa_preset,\n             )\n \n             parts = cmd_original.split(None, 1)\n             payload = parts[1].strip() if len(parts) > 1 else \"\"\n+            if not payload:\n+                _cprint(f\"  {moa_usage()}\")\n+                return True\n             moa_cfg = self.config.get(\"moa\") if isinstance(self.config, dict) else {}\n             normalized = normalize_moa_config(moa_cfg)\n-            matched_preset = exact_moa_preset_name(normalized, payload) if payload else normalized[\"default_preset\"]\n-            if matched_preset:\n-                self.requested_provider = \"moa\"\n-                self.provider = \"moa\"\n-                self.model = matched_preset\n-                self.api_key = \"[redacted:secret-assignment]\"\n-                self.base_url = \"moa://local\"\n-                self.api_mode = \"chat_completions\"\n-                self.agent = None\n-                _cprint(f\"  Model switched to MoA preset: {matched_preset}.\")\n-            else:\n-                if not payload:\n-                    _cprint(f\"  {moa_usage()}\")\n-                    return True\n-                preset = normalized[\"default_preset\"]\n-                self._pending_moa_restore_model = {\n-                    \"requested_provider\": getattr(self, \"requested_provider\", None),\n-                    \"provider\": getattr(self, \"provider\", None),\n-                    \"model\": getattr(self, \"model\", None),\n-                    \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n-                    \"base_url\": getattr(self, \"base_url\", None),\n-                    \"api_mode\": getattr(self, \"api_mode\", None),\n-                }\n-                self.requested_provider = \"moa\"\n-                self.provider = \"moa\"\n-                self.model = preset\n-                self.api_key = \"[redacted:secret-assignment]\"\n-                self.base_url = \"moa://local\"\n-                self.api_mode = \"chat_completions\"\n-                self.agent = None\n-                self._pending_moa_disable_after_turn = True\n-                self._pending_agent_seed = payload\n-                _cprint(f\"  MoA one-shot queued with preset {preset}; previous model will be restored after this turn.\")\n+            preset = normalized[\"default_preset\"]\n+            self._pending_moa_restore_model = {\n+                \"requested_provider\": getattr(self, \"requested_provider\", None),\n+                \"provider\": getattr(self, \"provider\", None),\n+                \"model\": getattr(self, \"model\", None),\n+                \"api_key\": [redacted:secret-assignment] \"api_key\", None),\n+                \"base_url\": getattr(self, \"base_url\", None),\n+                \"api_mode\": getattr(self, \"api_mode\", None),\n+            }\n+            self.requested_provider = \"moa\"\n+            self.provider = \"moa\"\n+            self.model = preset\n+            self.api_key = \"[redacted:secret-assignment]\"\n+            self.base_url = \"moa://local\"\n+            self.api_mode = \"chat_completions\"\n+            self.agent = None\n+            self._pending_moa_disable_after_turn = True\n+            self._pending_agent_seed = payload\n+            _cprint(f\"  MoA one-shot queued with preset {preset}; previous model will be restored after this turn.\")\n         elif canonical == \"subgoal\":\n             self._handle_subgoal_command(cmd_original)\n         elif canonical == \"skin\":"]},{"file":"gateway/run.py","hunks":["@@ -8756,33 +8756,24 @@ async def _do_undo():\n             return await self._handle_goal_command(event)\n \n         if canonical == \"moa\":\n+            # /moa is one-shot sugar only: run a single prompt through the\n+            # default MoA preset, then restore the prior model. To *switch* to a\n+            # MoA preset for the session, pick it from the model picker (MoA\n+            # presets surface as a virtual \"Mixture of Agents\" provider).\n             from hermes_cli.moa_config import (\n-                exact_moa_preset_name,\n                 moa_usage,\n                 normalize_moa_config,\n-                resolve_moa_preset,\n             )\n             from hermes_cli.config import load_config\n \n             moa_payload = event.get_command_args().strip()\n+            if not moa_payload:\n+                return moa_usage()\n             try:\n                 cfg = load_config()\n                 moa_cfg = normalize_moa_config(cfg.get(\"moa\") if isinstance(cfg, dict) else {})\n             except Exception:\n                 moa_cfg = normalize_moa_config({})\n-            matched_preset = exact_moa_preset_name(moa_cfg, moa_payload) if moa_payload else moa_cfg[\"default_preset\"]\n-            if matched_preset:\n-                self._session_model_overrides[_quick_key] = {\n-                    \"provider\": \"moa\",\n-                    \"model\": matched_preset,\n-                    \"base_url\": \"moa://local\",\n-                    \"api_key\": \"[redacted:secret-assignment]\",\n-                    \"api_mode\": \"chat_completions\",\n-                }\n-                self._evict_cached_agent(_quick_key)\n-                return f\"Model switched to MoA preset: {matched_preset}.\"\n-            if not moa_payload:\n-                return moa_usage()\n             preset = moa_cfg[\"default_preset\"]\n             try:\n                 event.text = moa_payload"]},{"file":"hermes_cli/commands.py","hunks":["@@ -109,7 +109,7 @@ class CommandDef:\n                args_hint=\"<prompt>\"),\n     CommandDef(\"goal\", \"Set a standing goal Hermes works on across turns until achieved\", \"Session\",\n                args_hint=\"[text | draft <text> | show | pause | resume | clear | status | wait <pid> | unwait]\"),\n-    CommandDef(\"moa\", \"Run one prompt through configured Mixture of Agents models\", \"Session\",\n+    CommandDef(\"moa\", \"Run one prompt through the default Mixture of Agents preset, then restore your model\", \"Session\",\n                args_hint=\"<prompt>\"),\n     CommandDef(\"subgoal\", \"Add or manage extra criteria on the active goal\", \"Session\",\n                args_hint=\"[text | remove N | clear]\"),"]},{"file":"hermes_cli/moa_config.py","hunks":["@@ -199,4 +199,4 @@ def build_moa_turn_prompt(user_prompt: str, config: Any = None, preset: str | No\n \n \n def moa_usage() -> str:\n-    return \"Usage: /moa [preset-name | prompt]  (bare /moa toggles the default preset)\"\n+    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)\""]},{"file":"tests/cli/test_moa_command.py","hunks":["@@ -25,28 +25,41 @@ def _make_cli():\n     cli._pending_input = queue.Queue()\n     cli._pending_agent_seed = None\n     cli._pending_moa_config = None\n+    cli._pending_moa_disable_after_turn = False\n+    cli._pending_moa_restore_model = None\n     cli._agent_running = False\n     cli.agent = None\n+    cli.provider = \"openrouter\"\n+    cli.requested_provider = \"openrouter\"\n+    cli.model = \"anthropic/claude-opus-4.8\"\n+    cli.api_key = \"[redacted:secret-assignment]\"\n+    cli.base_url = \"https://openrouter.ai/api/v1\"\n+    cli.api_mode = \"chat_completions\"\n     return cli\n \n \n-def test_moa_bare_switches_to_default_preset_model():\n+def test_moa_bare_shows_usage_no_switch():\n+    # /moa with no prompt is usage-only now; switching to a preset for the\n+    # session is done via the model picker, not /moa.\n     cli = _make_cli()\n+    cli._pending_moa_disable_after_turn = False\n     with patch(\"cli._cprint\"):\n         assert cli.process_command(\"/moa\") is True\n-    assert cli.provider == \"moa\"\n-    assert cli.requested_provider == \"moa\"\n-    assert cli.model == \"default\"\n-    assert cli.agent is None\n+    assert cli.provider != \"moa\"\n+    assert cli._pending_agent_seed is None\n+    assert cli._pending_moa_disable_after_turn is False\n \n \n-def test_moa_exact_preset_switches_to_named_preset_model():\n+def test_moa_arg_is_always_one_shot_prompt():\n+    # Any argument (even a string that matches a preset name) is treated as a\n+    # one-shot prompt through the DEFAULT preset, then the model is restored.\n     cli = _make_cli()\n     with patch(\"cli._cprint\"):\n         cli.process_command(\"/moa review\")\n+    assert cli._pending_agent_seed == \"review\"\n+    assert cli._pending_moa_disable_after_turn is True\n     assert cli.provider == \"moa\"\n-    assert cli.model == \"review\"\n-    assert cli.agent is None\n+    assert cli.model == \"default\"\n \n \n def test_moa_non_preset_is_one_shot_prompt():"]},{"file":"tests/tui_gateway/test_goal_command.py","hunks":["@@ -211,7 +211,7 @@ def _write_moa_config(home, text):\n     cfg_path.write_text(text)\n \n \n-def test_moa_bare_switches_to_default_preset_model(server, session, hermes_home):\n+def test_moa_bare_returns_usage(server, session, hermes_home):\n     _write_moa_config(hermes_home, \"\"\"\n moa:\n   default_preset: default","@@ -226,13 +226,14 @@ def test_moa_bare_switches_to_default_preset_model(server, session, hermes_home)\n \"\"\")\n     sid, _, s = session\n     r = _call(server, \"command.dispatch\", name=\"moa\", arg=\"\", session_id=sid)\n-    assert r[\"result\"][\"type\"] == \"exec\"\n-    assert \"Model switched to MoA preset: default\" in r[\"result\"][\"output\"]\n-    assert s[\"model_override\"][\"provider\"] == \"moa\"\n-    assert s[\"model_override\"][\"model\"] == \"default\"\n+    # Bare /moa is usage-only now; switching to a preset is via the model picker.\n+    assert \"error\" in r\n+    assert \"model_override\" not in s\n \n \n-def test_moa_exact_preset_switches_to_named_preset_model(server, session, hermes_home):\n+def test_moa_arg_is_always_one_shot(server, session, hermes_home):\n+    # Any arg (even a preset name) is a one-shot prompt through the DEFAULT\n+    # preset; /moa never does a sticky switch anymore.\n     _write_moa_config(hermes_home, \"\"\"\n moa:\n   default_preset: default","@@ -248,9 +249,14 @@ def test_moa_exact_preset_switches_to_named_preset_model(server, session, hermes\n \"\"\")\n     sid, _, s = session\n     r = _call(server, \"command.dispatch\", name=\"moa\", arg=\"review\", session_id=sid)\n-    assert r[\"result\"][\"type\"] == \"exec\"\n+    result = r[\"result\"]\n+    assert result[\"type\"] == \"send\"\n+    assert result[\"message\"] == \"review\"\n+    assert \"one-shot\" in result[\"notice\"]\n+    # Lazy session (no live agent) → MoA preset pinned via model_override for\n+    # the build, and it is the DEFAULT preset, not the \"review\" arg.\n     assert s[\"model_override\"][\"provider\"] == \"moa\"\n-    assert s[\"model_override\"][\"model\"] == \"review\"\n+    assert s[\"model_override\"][\"model\"] == \"default\"\n \n \n def test_moa_non_preset_returns_one_shot_send(server, session, hermes_home):"]},{"file":"tui_gateway/server.py","hunks":["@@ -8426,7 +8426,39 @@ def _stream(delta):\n             result = agent.run_conversation(run_message, **run_kwargs)\n             if \"moa_one_shot_restore\" in session:\n                 _restore = session.pop(\"moa_one_shot_restore\", None)\n-                if _restore is None:\n+                # Restore the model the user was on before the /moa one-shot.\n+                # The one-shot did a real in-place agent.switch_model() to MoA\n+                # (#53444), so undoing it must go back through the switch path —\n+                # resetting session[\"model_override\"] alone would leave the live\n+                # agent's client pinned to MoA for the next turn.\n+                if isinstance(_restore, dict):\n+                    _prev_override = _restore.get(\"override\")\n+                    _prev_model = _restore.get(\"model\")\n+                    _prev_provider = _restore.get(\"provider\")\n+                    if _prev_override is None:\n+                        session.pop(\"model_override\", None)\n+                    else:\n+                        session[\"model_override\"] = _prev_override\n+                    if _prev_model:\n+                        _raw = (\n+                            f\"{_prev_model} --provider {_prev_provider}\"\n+                            if _prev_provider\n+                            else _prev_model\n+                        )\n+                        try:\n+                            _apply_model_switch(\n+                                sid,\n+                                session,\n+                                _raw,\n+                                confirm_expensive_model=False,\n+                                pin_session_override=bool(_prev_override),\n+                            )\n+                        except Exception as _moa_restore_exc:\n+                            logger.warning(\n+                                \"MoA one-shot model restore failed: %s\",\n+                                _moa_restore_exc,\n+                            )\n+                elif _restore is None:\n                     session.pop(\"model_override\", None)\n                 else:\n                     session[\"model_override\"] = _restore","@@ -11205,38 +11237,54 @@ def _(rid, params: dict) -> dict:\n \n         return _ok(rid, {\"type\": \"send\", \"message\": build_learn_prompt(arg)})\n     if name == \"moa\":\n+        # /moa is one-shot sugar only: run a single prompt through the default\n+        # MoA preset, then restore the prior model. To *switch* to a MoA preset\n+        # for the rest of the session, pick it from the model picker (MoA\n+        # presets surface as a virtual \"Mixture of Agents\" provider).\n         try:\n-            from hermes_cli.moa_config import (\n-                build_moa_turn_prompt, exact_moa_preset_name, moa_usage, normalize_moa_config\n-            )\n+            from hermes_cli.moa_config import moa_usage, normalize_moa_config\n \n+            if not arg:\n+                return _err(rid, 4004, moa_usage())\n+            if not session:\n+                return _err(rid, 4001, \"no active session\")\n+            sid = params.get(\"session_id\", \"\")\n             moa_cfg = normalize_moa_config(_load_cfg().get(\"moa\") or {})\n-            matched = exact_moa_preset_name(moa_cfg, arg) if arg else moa_cfg[\"default_preset\"]\n-            if matched:\n-                if not session:\n-                    return _err(rid, 4001, \"no active session\")\n+            preset = moa_cfg[\"default_preset\"]\n+            # Record the live model identity so it can be restored after the\n+            # one-shot turn, then swap the agent's client in place (#53444:\n+            # setting session[\"model_override\"] alone never switched the\n+            # already-built agent, so the turn silently ran on the old model).\n+            agent = session.get(\"agent\")\n+            session[\"moa_one_shot_restore\"] = {\n+                \"override\": session.get(\"model_override\"),\n+                \"model\": getattr(agent, \"model\", None) if agent else None,\n+                \"provider\": getattr(agent, \"provider\", None) if agent else None,\n+            }\n+            if agent is not None:\n+                # Live agent: swap its client in place so THIS turn runs MoA.\n+                try:\n+                    _apply_model_switch(\n+                        sid,\n+                        session,\n+                        f\"{preset} --provider moa\",\n+                        confirm_expensive_model=False,\n+                        pin_session_override=True,\n+                    )\n+                except Exception as exc:\n+                    session.pop(\"moa_one_shot_restore\", None)\n+                    return _err(rid, 5030, f\"moa unavailable: {exc}\")\n+            else:\n+                # No agent built yet (lazy/fresh session): the override is\n+                # consumed by the first build, so the turn runs MoA without an\n+                # in-place switch.\n                 session[\"model_override\"] = {\n-                    \"model\": matched,\n                     \"provider\": \"moa\",\n+                    \"model\": preset,\n                     \"base_url\": \"moa://local\",\n                     \"api_key\": \"[redacted:secret-assignment]\",\n                     \"api_mode\": \"chat_completions\",\n                 }\n-                session[\"moa_active_preset\"] = matched\n-                return _ok(rid, {\"type\": \"exec\", \"output\": f\"Model switched to MoA preset: {matched}.\"})\n-            if not arg:\n-                return _err(rid, 4004, moa_usage())\n-            if not session:\n-                return _err(rid, 4001, \"no active session\")\n-            preset = moa_cfg[\"default_preset\"]\n-            session[\"moa_one_shot_restore\"] = session.get(\"model_override\")\n-            session[\"model_override\"] = {\n-                \"model\": preset,\n-                \"provider\": \"moa\",\n-                \"base_url\": \"moa://local\",\n-                \"api_key\": \"[redacted:secret-assignment]\",\n-                \"api_mode\": \"chat_completions\",\n-            }\n             return _ok(\n                 rid,\n                 {"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -39,7 +39,7 @@ hermes [global-options] <command> [subcommand/options]\n |---------|---------|\n | `hermes chat` | Interactive or one-shot chat with the agent. |\n | `hermes model` | Interactively choose the default provider and model. |\n-| `hermes moa` | Configure named Mixture of Agents presets used by `/moa`. |\n+| `hermes moa` | Configure named Mixture of Agents presets selectable from the model picker. |\n | `hermes fallback` | Manage fallback providers tried when the primary model errors. |\n | `hermes gateway` | Run or manage the messaging gateway service. |\n | `hermes proxy` | Local OpenAI-compatible proxy that attaches OAuth provider credentials. See [Subscription Proxy](../user-guide/features/subscription-proxy.md). |","@@ -1122,7 +1122,7 @@ See [Curator](../user-guide/features/curator.md) for behavior and config.\n \n ## `hermes moa`\n \n-Configure named Mixture of Agents presets used by the `/moa` slash command.\n+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.\n \n ```bash\n hermes moa list"]},{"file":"website/docs/user-guide/features/mixture-of-agents.md","hunks":["@@ -31,27 +31,21 @@ Configured presets therefore show up wherever you would pick any other model.\n \n ## Slash command shortcut\n \n-`/moa` is convenience sugar over model selection:\n+`/moa` is one-shot convenience sugar. It runs a single prompt through the **default** MoA preset, then restores whatever model you were on:\n \n ```bash\n-/moa\n-```\n-\n-Switches the current session to the default MoA preset.\n-\n-```bash\n-/moa review\n+/moa design and implement a migration plan for this flaky test cluster\n ```\n \n-If `review` exactly matches a preset name, switches the current session to provider `moa`, model `review`.\n+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.\n \n ```bash\n-/moa design and implement a migration plan for this flaky test cluster\n+/moa\n ```\n \n-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.\n+Bare `/moa` (no prompt) just prints usage.\n \n-Preset matching is exact on purpose. Hermes does not fuzzy-match preset names, so normal prompts cannot accidentally become model switches.\n+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.\n \n ## How it works in the agent loop\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}