mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user