From dbfa33a9990a5684fe9d7fbd75e59043a680f5ea Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 28 Jun 2026 00:23:51 +0000 Subject: [PATCH] feat(moa): route /moa through WebUI via server-resolved MoA config (#5070, #5057) Adds a dedicated GET /api/commands/moa/resolve endpoint + resolve_moa_config() that reads the MoA preset/usage server-side. The /moa send-path passes only a moa_config:true boolean to /api/chat/start; the server re-resolves the real config itself (never trusts a browser-supplied dict) and threads it per-turn into the live agent run. Fails closed (409) on gateway-backed sessions. Replaces the originally-proposed broad 43-command subprocess approach (bounced) with this narrow, security-reviewed /moa route. Maintainer fixes on top of the contributor's converged design: moa_config is added to run_conversation only when not None (older-agent TypeError safety) at the main call AND both auth self-heal retry sites, and forwarded through the legacy-journal runtime adapter. Gate: Codex SAFE (after the maintainer fixes) + full suite 10784 pass + the activity-stream regression gate clean. Co-authored-by: rodboev --- CHANGELOG.md | 2 + api/commands.py | 16 +++ api/routes.py | 24 ++++ api/streaming.py | 19 ++- static/messages.js | 26 +++- .../test_issue4729_reasoning_sse_coalesce.py | 8 +- tests/test_issue5057_moa_webui_route.py | 129 ++++++++++++++++++ 7 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue5057_moa_webui_route.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6999ca97a..ad7f49cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +- **`/moa` now works in the WebUI, routing the turn through your configured Mixture-of-Agents preset.** Typing `/moa ` runs that turn through the agent's MoA config (the same preset/usage you'd get from the CLI) instead of a plain single-model turn; `/moa` with no prompt shows the available usage. The MoA config is resolved **server-side** from your profile config — the browser only signals that an override is wanted, it can't supply the config — and it's applied per-turn (not persisted). It fails closed on gateway-backed sessions. Thanks @rodboev. (#5070, #5057) + - **Three new opt-in appearance skins: GitHub, Codex, and Terracotta.** All are CSS-only, namespaced under `[data-skin]`, and selectable from Settings → Appearance — they change nothing unless you pick them. GitHub uses a restrained graphite + Primer-blue palette; Codex a minimal editor look with a muted sage accent; Terracotta a warm clay accent on a soft neutral background. Thanks @gottipx (GitHub #4634, Codex #4636, Terracotta #4635 — renamed from the originally-proposed name to a descriptive material name). ### Fixed diff --git a/api/commands.py b/api/commands.py index e50eae385..d81a2cfdf 100644 --- a/api/commands.py +++ b/api/commands.py @@ -385,6 +385,22 @@ def _run_credits_command() -> str: return "\n".join(lines) +def resolve_moa_config() -> dict: + try: + from hermes_cli.moa_config import moa_usage, normalize_moa_config + except ImportError as exc: + raise RuntimeError("MoA runtime unavailable (hermes-agent not installed or too old)") from exc + try: + from hermes_cli.config import load_config + cfg = load_config() + moa_cfg = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {}) + except Exception: + moa_cfg = normalize_moa_config({}) + moa_cfg["preset"] = moa_cfg["default_preset"] + moa_cfg["usage"] = moa_usage() + return moa_cfg + + def execute_plugin_command(command: str) -> str: """Execute a plugin-registered slash command and return printable output. diff --git a/api/routes.py b/api/routes.py index 00b9f6e27..a38d530e3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10512,6 +10512,13 @@ def handle_get(handler, parsed) -> bool: from api.commands import list_command_bundles return j(handler, {"bundles": list_command_bundles()}) + if parsed.path == "/api/commands/moa/resolve": + from api.commands import resolve_moa_config + try: + return j(handler, resolve_moa_config()) + except RuntimeError as e: + return bad(handler, str(e), 503) + if parsed.path == "/api/updates/check": settings = load_settings() if not settings.get("check_for_updates", True): @@ -16886,6 +16893,7 @@ def _start_chat_stream_for_session( diag=None, goal_related: bool = False, source: str = "webui", + moa_config=None, ): """Persist pending state, register an SSE channel, and start an agent turn.""" attachments = attachments or [] @@ -17010,6 +17018,8 @@ def _start_chat_stream_for_session( worker_kwargs = {"model_provider": model_provider} if not backend_is_gateway: worker_kwargs["goal_related"] = goal_related + if moa_config and not backend_is_gateway: + worker_kwargs["moa_config"] = moa_config thr = threading.Thread( target=worker_target, args=(s.session_id, msg, model, workspace, stream_id, attachments), @@ -17095,6 +17105,7 @@ def _start_run( source: str, route: str, diag=None, + moa_config=None, ): """Shared start-run helper for /api/chat/start and start_session_turn. @@ -17134,6 +17145,7 @@ def _start_run( normalized_model=normalized_model, diag=diag, source=request.source or source, + moa_config=moa_config, ) def _legacy_adapter_factory(): @@ -17173,6 +17185,7 @@ def _start_run( normalized_model=normalized_model, diag=diag, source=source, + moa_config=moa_config, ) @@ -17542,6 +17555,16 @@ def _handle_chat_start(handler, body, diag=None): ) _pp_provider, _pp_default = _read_profile_model_config(s, requested_provider) explicit_model_pick = bool(body.get("explicit_model_pick")) + moa_config = None + if body.get("moa_config"): + if webui_gateway_chat_enabled(get_config()): + return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409) + from api.commands import resolve_moa_config + + try: + moa_config = resolve_moa_config() + except RuntimeError as e: + return bad(handler, str(e), 503) diag.stage("resolve_model_provider") if diag else None model, model_provider, normalized_model = _resolve_compatible_session_model_state( requested_model, @@ -17565,6 +17588,7 @@ def _handle_chat_start(handler, body, diag=None): source="webui", route="/api/chat/start", diag=diag, + moa_config=moa_config, ) # Map adapter-selection NotImplementedError (501) onto the legacy # bad-request response shape that this route exposed historically diff --git a/api/streaming.py b/api/streaming.py index 77eec9d4d..683d48871 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5864,6 +5864,7 @@ def _run_agent_streaming( ephemeral=False, model_provider=None, goal_related=False, + moa_config=None, ): """Run agent in background thread, writing SSE events to STREAMS[stream_id]. @@ -7613,13 +7614,19 @@ def _run_agent_streaming( _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg) _persistent_state_before = _persistent_state_snapshot(_profile_home) - result = agent.run_conversation( + _run_conversation_kwargs = dict( user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) + # Only pass moa_config when a /moa override is actually active, so a + # normal send never trips a TypeError on an older hermes-agent whose + # run_conversation() predates the moa_config kwarg. + if moa_config is not None: + _run_conversation_kwargs["moa_config"] = moa_config + result = agent.run_conversation(**_run_conversation_kwargs) # #4729: the run is done — flush any reasoning tail still in the coalescing # buffer (the agent never calls reasoning_callback(None), and a turn can end on # reasoning with no trailing token/tool boundary to trigger a flush) so the last @@ -7993,13 +8000,16 @@ def _run_agent_streaming( _self_healed = True _token_sent = False try: - _heal_result = agent.run_conversation( + _heal_kwargs = dict( user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) + if moa_config is not None: + _heal_kwargs["moa_config"] = moa_config + _heal_result = agent.run_conversation(**_heal_kwargs) _heal_all_msgs = _heal_result.get('messages') or [] _heal_ok = _has_new_assistant_reply(_heal_all_msgs, _prev_len) or _token_sent except Exception as _retry_exc: @@ -9049,13 +9059,16 @@ def _run_agent_streaming( # Retry the conversation _token_sent = False try: - _heal_result = _heal_agent.run_conversation( + _heal_kwargs2 = dict( user_message=user_message, system_message=workspace_system_msg, conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) + if moa_config is not None: + _heal_kwargs2["moa_config"] = moa_config + _heal_result = _heal_agent.run_conversation(**_heal_kwargs2) # Retry succeeded — persist the result normally if s is not None: if _checkpoint_stop is not None: diff --git a/static/messages.js b/static/messages.js index ce1c03823..d86cebfe3 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1321,6 +1321,7 @@ async function send(){ return; } let _slashDisplayTextOverride=null; + let _pendingMoaConfig=null; // Slash command intercept -- local commands handled without agent round-trip. // We push the user message BEFORE running the handler for echo-worthy // commands so chat order is correct: some handlers (e.g. cmdHelp) push @@ -1391,6 +1392,27 @@ async function send(){ renderMessages(); $('msg').value='';autoResize();hideCmdDropdown();return; } + if(_agentCmdName==='moa'){ + const _moaArgs=(text.split(/\s+/).slice(1).join(' ')||'').trim(); + if(!S.session){await newSession();await renderSessionList();} + if(!_moaArgs){ + let _moaUsage='/moa '; + try{const _moaCfgU=await api('/api/commands/moa/resolve');_moaUsage=_moaCfgU.usage||_moaUsage;}catch(_eu){} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + S.messages.push({role:'assistant',content:_moaUsage,_ts:Date.now()/1000}); + renderMessages();$('msg').value='';autoResize();hideCmdDropdown();return; + } + try{ + await api('/api/commands/moa/resolve'); + _slashDisplayTextOverride=text; + text=_moaArgs; + _pendingMoaConfig=true; + }catch(_e){ + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + S.messages.push({role:'assistant',content:'MoA unavailable: '+(_e&&_e.message||_e),_ts:Date.now()/1000}); + renderMessages();$('msg').value='';autoResize();hideCmdDropdown();return; + } + } const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function' ? await getBundleCommandMetadata(_parsedCmd.name) : null; @@ -1576,8 +1598,10 @@ async function send(){ model_provider:_modelState.model_provider, profile:S.activeProfile||S.session.profile||'default', explicit_model_pick:_explicitPick||undefined, - attachments:uploaded.length?uploaded:undefined + attachments:uploaded.length?uploaded:undefined, + moa_config:_pendingMoaConfig?true:undefined })}); + _pendingMoaConfig=null; postStartData = startData; }catch(e){ const errMsg=String((e&&e.message)||''); diff --git a/tests/test_issue4729_reasoning_sse_coalesce.py b/tests/test_issue4729_reasoning_sse_coalesce.py index 13d8b7aae..87cabbb62 100644 --- a/tests/test_issue4729_reasoning_sse_coalesce.py +++ b/tests/test_issue4729_reasoning_sse_coalesce.py @@ -16,6 +16,7 @@ These are source-structure assertions on the on_reasoning closure in api/streami coalescing contract can't silently regress to the drop-based version. """ import pathlib +import re REPO = pathlib.Path(__file__).parent.parent STREAMING = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") @@ -96,9 +97,10 @@ def test_reasoning_buffer_flushed_at_every_boundary(): # terminal catch-all: flush right after the agent run returns, AND a guaranteed-exit # flush in the inner finally so exception / retry paths (which bypass the post-run # flush) still emit the tail before the outer apperror. - assert "persist_user_message=msg_text,\n )\n # #4729" in STREAMING, ( - "must flush after agent.run_conversation() returns (success path, before done)" - ) + assert re.search( + r"agent\.run_conversation\(\*\*_run_conversation_kwargs\)\n\s*# #4729", + STREAMING, + ), "must flush after agent.run_conversation() returns (success path, before done)" # the inner finally must also flush (covers exception/retry exits) fin = STREAMING[STREAMING.index(" finally:\n # #4729"):] fin = fin[:700] diff --git a/tests/test_issue5057_moa_webui_route.py b/tests/test_issue5057_moa_webui_route.py new file mode 100644 index 000000000..d516de8d3 --- /dev/null +++ b/tests/test_issue5057_moa_webui_route.py @@ -0,0 +1,129 @@ +"""Tests for /moa WebUI route: resolve_moa_config and GET /api/commands/moa/resolve.""" +import json +import re +import sys +import urllib.request +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest + +from tests.conftest import TEST_BASE, requires_agent_modules + + +def _install_fake_moa_config(monkeypatch, *, default_preset="moa-default", usage_text="Usage: /moa "): + hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli") + hermes_cli_pkg.__path__ = [] + moa_config = ModuleType("hermes_cli.moa_config") + + def normalize_moa_config(cfg): + return {"default_preset": default_preset} + + def moa_usage(): + return usage_text + + moa_config_any = cast(Any, moa_config) + moa_config_any.normalize_moa_config = normalize_moa_config + moa_config_any.moa_usage = moa_usage + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.moa_config", moa_config) + + +def _install_fake_hermes_config(monkeypatch, cfg_data=None): + hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli") + hermes_cli_pkg.__path__ = [] + config_mod = ModuleType("hermes_cli.config") + + def load_config(): + if cfg_data is None: + raise RuntimeError("no config") + return cfg_data + + config_mod_any = cast(Any, config_mod) + config_mod_any.load_config = load_config + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.config", config_mod) + + +def test_resolve_moa_config_returns_expected_shape(monkeypatch): + _install_fake_moa_config(monkeypatch, default_preset="moa-fast", usage_text="/moa -- run with MoA") + _install_fake_hermes_config(monkeypatch, cfg_data={"moa": {}}) + from api.commands import resolve_moa_config + result = resolve_moa_config() + assert result["default_preset"] == "moa-fast" + assert result["preset"] == "moa-fast" + assert result["usage"] == "/moa -- run with MoA" + assert "model" not in result + assert "model_provider" not in result + + +def test_resolve_moa_config_degrades_without_config(monkeypatch): + _install_fake_moa_config(monkeypatch, default_preset="moa-default-cfg") + _install_fake_hermes_config(monkeypatch, cfg_data=None) + from api.commands import resolve_moa_config + result = resolve_moa_config() + assert result["default_preset"] == "moa-default-cfg" + assert result["preset"] == "moa-default-cfg" + + +def test_resolve_moa_config_raises_when_moa_unavailable(monkeypatch): + monkeypatch.setitem(sys.modules, "hermes_cli.moa_config", None) + from api.commands import resolve_moa_config + with pytest.raises(RuntimeError, match="MoA runtime unavailable"): + resolve_moa_config() + + +@requires_agent_modules +def test_moa_resolve_endpoint_returns_200(): + with urllib.request.urlopen(TEST_BASE + "/api/commands/moa/resolve", timeout=10) as r: + assert r.status == 200 + body = json.loads(r.read()) + assert "default_preset" in body + assert "preset" in body + assert isinstance(body.get("usage"), str) + assert isinstance(body.get("reference_models"), list) + + +def test_moa_not_in_agent_commands_webui(): + js_path = Path(__file__).resolve().parent.parent / "static" / "messages.js" + source = js_path.read_text(encoding="utf-8") + match = re.search(r"_AGENT_COMMANDS_RUN_ON_WEBUI\s*=\s*new\s+Set\(\[([^\]]+)\]\)", source) + assert match, "_AGENT_COMMANDS_RUN_ON_WEBUI not found in messages.js" + entries = match.group(1) + assert "'moa'" not in entries and '"moa"' not in entries + + +def test_no_subprocess_in_moa_code_paths(): + commands_path = Path(__file__).resolve().parent.parent / "api" / "commands.py" + source = commands_path.read_text(encoding="utf-8") + match = re.search(r"def resolve_moa_config\b.*?(?=\ndef |\Z)", source, re.DOTALL) + assert match, "resolve_moa_config not found in commands.py" + func_body = match.group(0) + assert "process_command" not in func_body + assert "HermesCLI" not in func_body + assert "subprocess" not in func_body + + +def test_moa_config_is_per_turn_not_persisted(): + """moa_config stays per-turn, but the server re-resolves it instead of + trusting a client-echoed dict.""" + streaming_path = Path(__file__).resolve().parent.parent / "api" / "streaming.py" + source = streaming_path.read_text(encoding="utf-8") + # moa_config is threaded into the live agent turn as a per-turn kwarg. It is + # added CONDITIONALLY (only when not None) so a normal send never trips a + # TypeError on an older hermes-agent whose run_conversation() predates the + # kwarg — so accept either the direct kwarg form or the conditional-dict form. + assert ( + re.search(r"run_conversation\([\s\S]*?moa_config=moa_config", source) + or re.search(r'if moa_config is not None:[\s\S]*?\["moa_config"\]\s*=\s*moa_config', source) + ), "run_conversation must receive moa_config as a per-turn kwarg (directly or conditionally)" + routes_path = Path(__file__).resolve().parent.parent / "api" / "routes.py" + routes_source = routes_path.read_text(encoding="utf-8") + assert re.search(r"if body\.get\(\"moa_config\"\):[\s\S]*?moa_config = resolve_moa_config\(\)", routes_source), \ + "chat-start must re-resolve MoA config server-side instead of trusting the browser payload" + assert "MoA override is unavailable on gateway-backed sessions" in routes_source + js_path = Path(__file__).resolve().parent.parent / "static" / "messages.js" + js_source = js_path.read_text(encoding="utf-8") + assert "moa_config:_pendingMoaConfig?true:undefined" in js_source + assert "_pendingMoaConfig=null" in js_source