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 <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-28 00:23:51 +00:00
parent 9b80e9b06f
commit dbfa33a999
7 changed files with 217 additions and 7 deletions
+2
View File
@@ -5,6 +5,8 @@
### Added
- **`/moa` now works in the WebUI, routing the turn through your configured Mixture-of-Agents preset.** Typing `/moa <prompt>` 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
+16
View File
@@ -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.
+24
View File
@@ -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
+16 -3
View File
@@ -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:
+25 -1
View File
@@ -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 <prompt>';
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)||'');
@@ -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]
+129
View File
@@ -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 <prompt>"):
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 <prompt> -- 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 <prompt> -- 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