feat(reasoning): full /reasoning CLI parity — show|hide + effort levels via config.yaml (#812)
Closes #461 Adds full /reasoning CLI parity to the WebUI slash command system: - /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js - /reasoning hide|off → same in reverse; re-renders immediately - /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics) - /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml - Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh - Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across - Boot hydration: window._showThinking initialised from settings.json show_thinking on page load - Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError 28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
This commit is contained in:
@@ -761,6 +761,99 @@ def get_effective_default_model(config_data: dict | None = None) -> str:
|
||||
return default_model
|
||||
|
||||
|
||||
# ── Reasoning config (CLI parity for /reasoning) ─────────────────────────────
|
||||
# Mirrors hermes_constants.parse_reasoning_effort so WebUI can validate without
|
||||
# importing from the agent tree (which may not be installed). Any drift here
|
||||
# will show up in the shared test suite since both sides accept the same set.
|
||||
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
|
||||
|
||||
|
||||
def parse_reasoning_effort(effort):
|
||||
"""Parse an effort level into the dict the agent expects.
|
||||
|
||||
Returns None when *effort* is empty or unrecognised (caller interprets as
|
||||
"use default"), ``{"enabled": False}`` for ``"none"``, and
|
||||
``{"enabled": True, "effort": <level>}`` for any of
|
||||
``VALID_REASONING_EFFORTS``.
|
||||
"""
|
||||
if not effort or not str(effort).strip():
|
||||
return None
|
||||
eff = str(effort).strip().lower()
|
||||
if eff == "none":
|
||||
return {"enabled": False}
|
||||
if eff in VALID_REASONING_EFFORTS:
|
||||
return {"enabled": True, "effort": eff}
|
||||
return None
|
||||
|
||||
|
||||
def get_reasoning_status() -> dict:
|
||||
"""Return current reasoning configuration from the active profile's
|
||||
config.yaml — the same source of truth the CLI reads from.
|
||||
|
||||
Keys:
|
||||
- show_reasoning: bool — from ``display.show_reasoning`` (default True)
|
||||
- reasoning_effort: str — from ``agent.reasoning_effort`` ('' = default)
|
||||
"""
|
||||
config_data = _load_yaml_config_file(_get_config_path())
|
||||
display_cfg = config_data.get("display") or {}
|
||||
agent_cfg = config_data.get("agent") or {}
|
||||
show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None
|
||||
effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
|
||||
return {
|
||||
# Match CLI default (True if unset in config.yaml)
|
||||
"show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True,
|
||||
"reasoning_effort": str(effort_raw or "").strip().lower(),
|
||||
}
|
||||
|
||||
|
||||
def set_reasoning_display(show: bool) -> dict:
|
||||
"""Persist ``display.show_reasoning`` to the active profile's config.yaml.
|
||||
|
||||
Mirrors CLI ``/reasoning show|hide``: writes the same key that the CLI
|
||||
writes, so the preference is shared across the WebUI and the terminal
|
||||
REPL for the same profile.
|
||||
"""
|
||||
config_path = _get_config_path()
|
||||
with _cfg_lock:
|
||||
config_data = _load_yaml_config_file(config_path)
|
||||
display_cfg = config_data.get("display")
|
||||
if not isinstance(display_cfg, dict):
|
||||
display_cfg = {}
|
||||
display_cfg["show_reasoning"] = bool(show)
|
||||
config_data["display"] = display_cfg
|
||||
_save_yaml_config_file(config_path, config_data)
|
||||
reload_config()
|
||||
return get_reasoning_status()
|
||||
|
||||
|
||||
def set_reasoning_effort(effort: str) -> dict:
|
||||
"""Persist ``agent.reasoning_effort`` to the active profile's config.yaml.
|
||||
|
||||
Mirrors CLI ``/reasoning <level>``: same key, same valid values
|
||||
(``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh``).
|
||||
Raises ``ValueError`` on an unrecognised level so callers can return 400.
|
||||
"""
|
||||
raw = str(effort or "").strip().lower()
|
||||
if not raw:
|
||||
raise ValueError("effort is required")
|
||||
if raw != "none" and raw not in VALID_REASONING_EFFORTS:
|
||||
raise ValueError(
|
||||
f"Unknown reasoning effort '{effort}'. "
|
||||
f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}."
|
||||
)
|
||||
config_path = _get_config_path()
|
||||
with _cfg_lock:
|
||||
config_data = _load_yaml_config_file(config_path)
|
||||
agent_cfg = config_data.get("agent")
|
||||
if not isinstance(agent_cfg, dict):
|
||||
agent_cfg = {}
|
||||
agent_cfg["reasoning_effort"] = raw
|
||||
config_data["agent"] = agent_cfg
|
||||
_save_yaml_config_file(config_path, config_data)
|
||||
reload_config()
|
||||
return get_reasoning_status()
|
||||
|
||||
|
||||
def set_hermes_default_model(model_id: str) -> dict:
|
||||
"""Persist the Hermes default model in config.yaml and reload runtime config."""
|
||||
selected_model = str(model_id or "").strip()
|
||||
@@ -1381,6 +1474,7 @@ _SETTINGS_DEFAULTS = {
|
||||
), # display name for the assistant
|
||||
"sound_enabled": False, # play notification sound when assistant finishes
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
|
||||
"sidebar_density": "compact", # compact | detailed
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
@@ -1491,6 +1585,7 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"check_for_updates",
|
||||
"sound_enabled",
|
||||
"notifications_enabled",
|
||||
"show_thinking",
|
||||
}
|
||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||
|
||||
@@ -48,6 +48,9 @@ from api.config import (
|
||||
load_settings,
|
||||
save_settings,
|
||||
set_hermes_default_model,
|
||||
get_reasoning_status,
|
||||
set_reasoning_display,
|
||||
set_reasoning_effort,
|
||||
)
|
||||
from api.helpers import (
|
||||
require,
|
||||
@@ -557,6 +560,12 @@ def handle_get(handler, parsed) -> bool:
|
||||
pass
|
||||
return j(handler, settings)
|
||||
|
||||
if parsed.path == "/api/reasoning":
|
||||
# Current reasoning config (shared source of truth with the CLI —
|
||||
# reads display.show_reasoning and agent.reasoning_effort from
|
||||
# the active profile's config.yaml).
|
||||
return j(handler, get_reasoning_status())
|
||||
|
||||
if parsed.path == "/api/onboarding/status":
|
||||
return j(handler, get_onboarding_status())
|
||||
|
||||
@@ -899,6 +908,32 @@ def handle_post(handler, parsed) -> bool:
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 500)
|
||||
|
||||
if parsed.path == "/api/reasoning":
|
||||
# CLI-parity /reasoning handler — writes to the same config.yaml keys
|
||||
# the CLI uses (display.show_reasoning, agent.reasoning_effort) so a
|
||||
# preference set via WebUI is honoured in the terminal REPL and vice
|
||||
# versa. Body is one of:
|
||||
# {"display": "show"|"hide"|"on"|"off"} → display.show_reasoning
|
||||
# {"effort": "none"|"minimal"|"low"|"medium"|"high"|"xhigh"}
|
||||
# → agent.reasoning_effort
|
||||
try:
|
||||
display = body.get("display")
|
||||
effort = body.get("effort")
|
||||
if display is not None:
|
||||
flag = str(display).strip().lower()
|
||||
if flag in ("show", "on", "true", "1"):
|
||||
return j(handler, set_reasoning_display(True))
|
||||
if flag in ("hide", "off", "false", "0"):
|
||||
return j(handler, set_reasoning_display(False))
|
||||
return bad(handler, f"display must be show|hide|on|off (got '{display}')")
|
||||
if effort is not None:
|
||||
return j(handler, set_reasoning_effort(effort))
|
||||
return bad(handler, "reasoning: must supply 'display' or 'effort'")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 500)
|
||||
|
||||
if parsed.path == "/api/sessions/cleanup":
|
||||
return _handle_sessions_cleanup(handler, body, zero_only=False)
|
||||
|
||||
|
||||
@@ -1100,6 +1100,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
import inspect as _inspect
|
||||
_agent_params = set(_inspect.signature(_AIAgent.__init__).parameters)
|
||||
|
||||
# CLI-parity reasoning effort: read agent.reasoning_effort from the
|
||||
# active profile's config.yaml (the same key the CLI writes via
|
||||
# `/reasoning <level>`) and hand the parsed dict to AIAgent. When
|
||||
# the key is absent or invalid, pass None → agent uses its default.
|
||||
try:
|
||||
from api.config import parse_reasoning_effort as _parse_reff
|
||||
_effort_cfg = _cfg.cfg.get('agent', {}) if isinstance(_cfg.cfg, dict) else {}
|
||||
_effort_raw = _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None
|
||||
_reasoning_config = _parse_reff(_effort_raw)
|
||||
except Exception:
|
||||
_reasoning_config = None
|
||||
|
||||
_agent_kwargs = dict(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
@@ -1120,6 +1132,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
)
|
||||
),
|
||||
)
|
||||
# reasoning_config has been an AIAgent param for several releases,
|
||||
# but guard defensively to avoid TypeError on an older agent build.
|
||||
if 'reasoning_config' in _agent_params and _reasoning_config is not None:
|
||||
_agent_kwargs['reasoning_config'] = _reasoning_config
|
||||
# Params added in newer hermes-agent — skip if not supported
|
||||
if 'api_mode' in _agent_params:
|
||||
_agent_kwargs['api_mode'] = _rt.get('api_mode')
|
||||
|
||||
Reference in New Issue
Block a user