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:
nesquena-hermes
2026-04-21 15:26:52 -07:00
committed by GitHub
parent f6e1612c7e
commit 811424a87b
12 changed files with 628 additions and 10 deletions

View File

@@ -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})?$")

View File

@@ -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)

View File

@@ -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')