diff --git a/cli.py b/cli.py index 600aff62aa35..968e88e0f78c 100644 --- a/cli.py +++ b/cli.py @@ -7151,11 +7151,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.conversation_history = [] self._pending_title = None self._resumed = False + self.reasoning_config = _parse_reasoning_config( + CLI_CONFIG["agent"].get("reasoning_effort", "") + ) _sync_process_session_id(self.session_id) if self.agent: self.agent.session_id = self.session_id self.agent.session_start = self.session_start + self.agent.reasoning_config = self.reasoning_config self.agent.reset_session_state() if hasattr(self.agent, "_last_flushed_db_idx"): self.agent._last_flushed_db_idx = 0 diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 8f9e6b8e886e..cd72f9842e2a 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2516,12 +2516,13 @@ class CLICommandsMixin: Usage: /reasoning Show current effort level and display state /reasoning Set effort (none, minimal, low, medium, high, xhigh, max, ultra) + /reasoning --session Set reasoning effort for this session only /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output /reasoning full Show complete thinking (no 10-line clamp) /reasoning clamp Collapse long thinking to the first 10 lines """ - from cli import _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value + from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value parts = cmd.strip().split(maxsplit=1) if len(parts) < 2: @@ -2537,10 +2538,14 @@ class CLICommandsMixin: full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") - _cprint(f" {_DIM}Usage: /reasoning {_RST}") + _cprint(f" {_DIM}Usage: /reasoning [--session]{_RST}") return arg = parts[1].strip().lower() + arg_tokens = arg.split() + explicit_session = "--session" in arg_tokens + if explicit_session: + arg = " ".join(token for token in arg_tokens if token != "--session") # Display toggle if arg in {"show", "on"}: @@ -2580,13 +2585,21 @@ class CLICommandsMixin: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}") _cprint(f" {_DIM}Display: show, hide{_RST}") + _cprint(f" {_DIM}Scope: saved by default, --session for a temporary override{_RST}") return self.reasoning_config = parsed self.agent = None # Force agent re-init with new reasoning config - if save_config_value("agent.reasoning_effort", arg): + if not explicit_session and save_config_value("agent.reasoning_effort", arg): + agent_cfg = CLI_CONFIG.get("agent") + if not isinstance(agent_cfg, dict): + agent_cfg = {} + CLI_CONFIG["agent"] = agent_cfg + agent_cfg["reasoning_effort"] = arg _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}") + elif not explicit_session: + _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only; config save failed){_RST}") else: _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index aab738c3ea55..073f56dd6d52 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -153,8 +153,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", - args_hint="[level|show|hide|full|clamp]", - subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp")), + args_hint="[level|show|hide|full|clamp] [--session]", + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--session")), CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py index 5bd0c4cc7c92..1aff1af5908f 100644 --- a/tests/cli/test_reasoning_command.py +++ b/tests/cli/test_reasoning_command.py @@ -154,6 +154,60 @@ class TestHandleReasoningCommand(unittest.TestCase): level = rc.get("effort", "medium") self.assertEqual(level, "xhigh") + def test_effort_defaults_to_global_save(self): + """Plain /reasoning keeps the existing config-writing behavior.""" + from cli import CLI_CONFIG + from hermes_cli.cli_commands_mixin import CLICommandsMixin + + stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"}) + with patch.dict(CLI_CONFIG.setdefault("agent", {}), {"reasoning_effort": "medium"}), \ + patch("cli.save_config_value", return_value=True) as save_config, \ + patch("cli._cprint"): + CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high") + self.assertEqual(CLI_CONFIG["agent"]["reasoning_effort"], "high") + + save_config.assert_called_once_with("agent.reasoning_effort", "high") + self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"}) + self.assertIsNone(stub.agent) + + def test_effort_session_flag_does_not_persist_config(self): + """--session opts into a temporary session-only effort override.""" + from hermes_cli.cli_commands_mixin import CLICommandsMixin + + stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"}) + with patch("cli.save_config_value") as save_config, patch("cli._cprint"): + CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high --session") + + save_config.assert_not_called() + self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"}) + self.assertIsNone(stub.agent) + + def test_new_session_clears_session_reasoning_override(self): + """/new and /clear must not carry a session-only effort override forward.""" + from cli import CLI_CONFIG, HermesCLI + + agent = SimpleNamespace( + reasoning_config={"enabled": True, "effort": "high"}, + reset_session_state=MagicMock(), + ) + stub = SimpleNamespace( + agent=agent, + conversation_history=[], + session_id="old-session", + _session_db=None, + _pending_title=None, + _resumed=False, + reasoning_config={"enabled": True, "effort": "high"}, + _notify_session_boundary=MagicMock(), + ) + + with patch.dict(CLI_CONFIG.setdefault("agent", {}), {"reasoning_effort": "medium"}): + HermesCLI.new_session(stub, silent=True) + + self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "medium"}) + self.assertEqual(agent.reasoning_config, {"enabled": True, "effort": "medium"}) + agent.reset_session_state.assert_called_once() + # --------------------------------------------------------------------------- # Reasoning extraction and result dict diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py index afea65771c36..dfa793065f2b 100644 --- a/tests/hermes_cli/test_reasoning_full_command.py +++ b/tests/hermes_cli/test_reasoning_full_command.py @@ -55,7 +55,7 @@ def test_reasoning_full_sets_and_persists(tmp_path, monkeypatch): assert saved["display"]["reasoning_full"] is True -def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch): +def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch, capsys): hh = _seed_config(tmp_path, monkeypatch) s = _Stub() s.reasoning_full = True @@ -64,6 +64,7 @@ def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch): assert s.reasoning_full is False saved = yaml.safe_load((hh / "config.yaml").read_text()) assert saved["display"]["reasoning_full"] is False + assert "Unknown argument" not in capsys.readouterr().out def test_reasoning_all_is_alias_for_full(tmp_path, monkeypatch): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 786587b07905..beaf45d9cd14 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4649,8 +4649,18 @@ def test_complete_slash_details_args(): assert any(item["text"] == "expanded" for item in resp_mode["result"]["items"]) +def test_complete_slash_reasoning_includes_current_efforts_and_session_scope(): + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/reasoning "}} + ) + + values = {item["text"] for item in resp["result"]["items"]} + assert {"max", "ultra", "--session"} <= values + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text("agent:\n reasoning_effort: medium\n", encoding="utf-8") agent = types.SimpleNamespace(reasoning_config=None) server._sessions["sid"] = _session(agent=agent) @@ -4658,11 +4668,42 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat { "id": "1", "method": "config.set", - "params": {"session_id": "sid", "key": "reasoning", "value": "low"}, + "params": { + "session_id": "sid", + "key": "reasoning", + "value": "low", + }, } ) assert resp_effort["result"]["value"] == "low" assert agent.reasoning_config == {"enabled": True, "effort": "low"} + assert server._sessions["sid"]["create_reasoning_override"] == {"enabled": True, "effort": "low"} + assert server._load_cfg()["agent"]["reasoning_effort"] == "medium" + + resp_status = server.handle_request( + { + "id": "5", + "method": "config.get", + "params": {"session_id": "sid", "key": "reasoning"}, + } + ) + assert resp_status["result"]["value"] == "low" + + resp_global_status = server.handle_request( + {"id": "6", "method": "config.get", "params": {"key": "reasoning"}} + ) + assert resp_global_status["result"]["value"] == "medium" + + del server._sessions["sid"]["create_reasoning_override"] + agent.reasoning_config = {"enabled": True, "effort": "high"} + resp_agent_status = server.handle_request( + { + "id": "7", + "method": "config.get", + "params": {"session_id": "sid", "key": "reasoning"}, + } + ) + assert resp_agent_status["result"]["value"] == "high" resp_show = server.handle_request( { @@ -4714,6 +4755,36 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat assert cfg_clamp["display"]["sections"]["thinking"] == "collapsed" +def test_config_set_reasoning_global_scope_clears_session_override(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text("agent:\n reasoning_effort: medium\n", encoding="utf-8") + agent = types.SimpleNamespace(reasoning_config=None) + server._sessions["sid"] = _session(agent=agent) + server._sessions["sid"]["create_reasoning_override"] = {"enabled": True, "effort": "low"} + + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "reasoning", + "value": "high", + "scope": "global", + }, + } + ) + + assert resp["result"]["value"] == "high" + assert server._load_cfg()["agent"]["reasoning_effort"] == "high" + assert "create_reasoning_override" not in server._sessions["sid"] + + status = server.handle_request( + {"id": "2", "method": "config.get", "params": {"session_id": "sid", "key": "reasoning"}} + ) + assert status["result"]["value"] == "high" + + def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(verbose_logging=False) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7489394c990b..d461898e19d2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -11555,6 +11555,8 @@ def _(rid, params: dict) -> dict: from hermes_constants import parse_reasoning_effort arg = str(value or "").strip().lower() + scope = str(params.get("scope") or "").strip().lower() + global_scope = scope == "global" if arg in {"show", "on"}: cfg = _load_cfg() display = ( @@ -11634,23 +11636,25 @@ def _(rid, params: dict) -> dict: parsed = parse_reasoning_effort(arg) if parsed is None: return _err(rid, 4002, f"unknown reasoning value: {value}") - if session is not None: + if global_scope or session is None: + _write_config_key("agent.reasoning_effort", arg) + if session is not None: + session.pop("create_reasoning_override", None) + else: # Session-scoped, like the messaging gateway's `/reasoning # ` (global persistence is `--global` / Settings → # Model territory). Writing config.yaml here let every # desktop model-menu selection rewrite the user's global # agent.reasoning_effort to the preset default. session["create_reasoning_override"] = parsed - if session.get("agent") is not None: - session["agent"].reasoning_config = parsed - _persist_live_session_runtime(session) - _emit( - "session.info", - params.get("session_id", ""), - _session_info(session["agent"], session), - ) - else: - _write_config_key("agent.reasoning_effort", arg) + if session and session.get("agent") is not None: + session["agent"].reasoning_config = parsed + _persist_live_session_runtime(session) + _emit( + "session.info", + params.get("session_id", ""), + _session_info(session["agent"], session), + ) return _ok(rid, {"key": key, "value": arg}) except Exception as e: return _err(rid, 5001, str(e)) @@ -12345,19 +12349,23 @@ def _(rid, params: dict) -> dict: ) if key == "reasoning": cfg = _load_cfg() - effort = "" - # Prefer the session's live value — `config.set reasoning` is - # session-scoped, so the global key may not reflect this chat. session = _sessions.get(params.get("session_id", "")) - live = getattr((session or {}).get("agent"), "reasoning_config", None) - if live is None and session is not None: - live = session.get("create_reasoning_override") - if isinstance(live, dict): - if live.get("enabled") is False: + reasoning_config = None + if session is not None: + if isinstance(session.get("create_reasoning_override"), dict): + reasoning_config = session.get("create_reasoning_override") + else: + agent = session.get("agent") + agent_reasoning = getattr(agent, "reasoning_config", None) + if isinstance(agent_reasoning, dict): + reasoning_config = agent_reasoning + + if isinstance(reasoning_config, dict): + if reasoning_config.get("enabled") is False: effort = "none" else: - effort = str(live.get("effort", "") or "") - if not effort: + effort = str(reasoning_config.get("effort") or "medium") + else: raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "") if raw_effort is False: # YAML `reasoning_effort: false`/`off`/`no` — thinking diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b4dc5f7b6761..2f0c0c7e4dd3 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -267,6 +267,43 @@ describe('createSlashHandler', () => { }) }) + it('reads /reasoning status for the active session', () => { + patchUiState({ sid: 'sid-abc' }) + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/reasoning')).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', { + key: 'reasoning', + session_id: 'sid-abc' + }) + }) + + it.each(['low', 'max', 'ultra'])('sends plain /reasoning %s as global config.set', effort => { + patchUiState({ sid: 'sid-abc' }) + const ctx = buildCtx() + + expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'reasoning', + scope: 'global', + session_id: 'sid-abc', + value: effort + }) + }) + + it('strips /reasoning session flags before config.set', () => { + patchUiState({ sid: 'sid-abc' }) + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/reasoning low --session')).toBe(true) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'reasoning', + scope: 'session', + session_id: 'sid-abc', + value: 'low' + }) + }) + it('opens the skills hub locally for bare /skills', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 47922c5f7add..caabea9e8158 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -23,6 +23,8 @@ import type { SlashCommand } from '../types.js' const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance' const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) +const REASONING_SESSION_FLAGS = new Set(['--session']) +const REASONING_EFFORT_VALUES = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']) const modelValueForConfigSet = (arg: string) => { const trimmed = arg.trim() @@ -38,6 +40,33 @@ const modelValueForConfigSet = (arg: string) => { return trimmed } +const reasoningConfigPayload = (arg: string, sid: string) => { + const parts = arg.trim().split(/\s+/).filter(Boolean) + let scope = '' + const valueParts: string[] = [] + + for (const part of parts) { + const flag = part.toLowerCase() + if (REASONING_SESSION_FLAGS.has(flag)) { + scope = 'session' + continue + } + valueParts.push(part) + } + + const value = valueParts.join(' ') + if (!scope && REASONING_EFFORT_VALUES.has(value.toLowerCase())) { + scope = 'global' + } + + return { + key: 'reasoning', + session_id: sid, + value, + ...(scope ? { scope } : {}) + } +} + export const sessionCommands: SlashCommand[] = [ { aliases: ['bg', 'btw'], @@ -445,7 +474,7 @@ export const sessionCommands: SlashCommand[] = [ run: (arg, ctx) => { if (!arg) { return ctx.gateway - .rpc('config.get', { key: 'reasoning' }) + .rpc('config.get', { key: 'reasoning', session_id: ctx.sid }) .then( ctx.guarded( r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) @@ -453,29 +482,31 @@ export const sessionCommands: SlashCommand[] = [ ) } - ctx.gateway.rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }).then( - ctx.guarded(r => { - if (!r.value) { - return - } + ctx.gateway + .rpc('config.set', reasoningConfigPayload(arg, ctx.sid ?? '')) + .then( + ctx.guarded(r => { + if (!r.value) { + return + } - if (r.value === 'hide') { - patchUiState(state => ({ - ...state, - sections: { ...state.sections, thinking: 'hidden' }, - showReasoning: false - })) - } else if (r.value === 'show') { - patchUiState(state => ({ - ...state, - sections: { ...state.sections, thinking: 'expanded' }, - showReasoning: true - })) - } + if (r.value === 'hide') { + patchUiState(state => ({ + ...state, + sections: { ...state.sections, thinking: 'hidden' }, + showReasoning: false + })) + } else if (r.value === 'show') { + patchUiState(state => ({ + ...state, + sections: { ...state.sections, thinking: 'expanded' }, + showReasoning: true + })) + } - ctx.transcript.sys(`reasoning: ${r.value}`) - }) - ) + ctx.transcript.sys(`reasoning: ${r.value}`) + }) + ) } },