feat(cli): make reasoning effort session-scoped

This commit is contained in:
YAMAGUCHI Seiji 2026-06-22 09:27:59 +09:00 committed by Teknium
parent 8b6fde3a35
commit 8590c2d0d9
9 changed files with 269 additions and 50 deletions

4
cli.py
View file

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

View file

@ -2516,12 +2516,13 @@ class CLICommandsMixin:
Usage:
/reasoning Show current effort level and display state
/reasoning <level> Set effort (none, minimal, low, medium, high, xhigh, max, ultra)
/reasoning <level> --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 <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp>{_RST}")
_cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp> [--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}")

View file

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

View file

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

View file

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

View file

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

View file

@ -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
# <level>` (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

View file

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

View file

@ -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<ConfigGetValueResponse>('config.get', { key: 'reasoning' })
.rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning', session_id: ctx.sid })
.then(
ctx.guarded<ConfigGetValueResponse>(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
@ -453,29 +482,31 @@ export const sessionCommands: SlashCommand[] = [
)
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }).then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', reasoningConfigPayload(arg, ctx.sid ?? ''))
.then(
ctx.guarded<ConfigSetResponse>(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}`)
})
)
}
},