NousResearch/hermes-agent

Fix /model command scope in messaging gateways

Cluster 61458 · gateway. Data as of

Nothing open on GitHub. Fix merged in #67946 on 20 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #67946. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #67946. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address a bug where the /model command defaults to global configuration instead of session scope when used in messaging gateway contexts. The work involves correcting scoping behavior for /model and related commands across different surfaces.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #67946 fix: session-scoped /model and /reasoning defaults across all surfaces

    merged to main

    files: cli.py, gateway/slash_commands.py, hermes_cli/cli_commands_mixin.py, hermes_cli/commands.py, hermes_cli/model_switch.py, tests/cli/test_reasoning_command.py, tests/gateway/test_25107_stale_base_url_api_mode.py, tests/gateway/test_model_command_flat_string_config.py, tests/gateway/test_model_picker_persist.py, tests/hermes_cli/test_model_switch_persist_default.py, tests/hermes_cli/test_reasoning_full_command.py, tests/test_tui_gateway_server.py, tui_gateway/server.py, ui-tui/src/__tests__/createSlashHandler.test.ts, ui-tui/src/app/slash/commands/session.ts, website/docs/reference/cli-commands.md, website/docs/user-guide/configuration.md

    Show the diff
    • cli.py
      @@ -7151,11 +7151,15 @@ def new_session(self, silent=False, title=None):
               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
      @@ -8189,16 +8193,16 @@ def _handle_model_switch(self, cmd_original: str):
       
               Supports:
                 /model                              — show current model + usage hints
      -          /model <name>                       — switch model (persists by default)
      +          /model <name>                       — switch model (this session only)
                 /model <name> --once                — switch for the next turn only
      -          /model <name> --session             — switch for this session only
      -          /model <name> --global              — switch and persist (explicit)
      +          /model <name> --session             — switch for this session only (explicit)
      +          /model <name> --global              — switch and persist to config.yaml
                 /model <name> --provider <provider> — switch provider + model
                 /model --provider <provider>        — switch to provider, auto-detect model
       
      -        Persistence defaults to on (``model.persist_switch_by_default`` in
      -        config.yaml, default True). Use ``--session`` for this CLI session or
      -        ``--once`` for the next turn only.
      +        Persistence defaults to off (``model.persist_switch_by_default`` in
      +        config.yaml, default False — switches are session-scoped). Use
      +        ``--global`` to persist, or ``--once`` for the next turn only.
               """
               from hermes_cli.model_switch import (
                   switch_model,
      @@ -8225,11 +8229,14 @@ def _handle_model_switch(self, cmd_original: str):
               if one_turn and not model_input and not explicit_provider:
                   _cprint("  ✗ /model --once requires a model or provider.")
                   return
      -        # Resolve the effective persistence once: --session overrides the
      -        # config-gated default, --global forces persist, otherwise defer to
      -        # model.persist_switch_by_default (defaults to True so /model survives
      -        # across sessions).
      -        persist_global = resolve_persist_behavior(is_global_flag, is_session, is_once=one_turn)
      +        # Resolve the effective persistence once: --global forces persist,
      +        # --session/--once force session-scope, otherwise defer to
      +        # model.persist_switch_by_default (defaults to False so /model is
      +        # session-scoped unless the user opts in).
      +        persist_global = resolve_persist_behavior(
      +            is_global_flag, is_session, is_once=one_turn,
      +            explicit_provider=explicit_provider,
      +        )
       
               # --refresh: wipe the on-disk picker cache before building the
               # provider list. Forces a live re-fetch of every authed provider's
      @@ -13157,8 +13164,8 @@ def handle_enter(event):
                   # --- /model picker modal ---
                   if self._model_picker_state:
                       try:
      -                    # Picker selections persist by default (same default as
      -                    # /model <name>); honour model.persist_switch_by_default.
      +                    # Picker selections follow the same session-scoped default
      +                    # as /model <name>; honour model.persist_switch_by_default.
                           from hermes_cli.model_switch import resolve_persist_behavior
       
                           self._handle_model_picker_selection(
    • gateway/slash_commands.py
      @@ -1437,10 +1437,10 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
       
               Supports:
                 /model                              — interactive picker (Telegram/Discord) or text list
      -          /model <name>                       — switch model (persists by default)
      +          /model <name>                       — switch model (this session only)
                 /model <name> --once                — switch for the next turn only
      -          /model <name> --session             — switch for this session only
      -          /model <name> --global              — switch and persist (explicit)
      +          /model <name> --session             — switch for this session only (explicit)
      +          /model <name> --global              — switch and persist to config.yaml
                 /model <name> --provider <provider> — switch provider + model
                 /model --provider <provider>        — switch to provider, auto-detect model
               """
      @@ -1478,6 +1478,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
                   is_global_flag,
                   is_session,
                   is_once=one_turn,
      +            explicit_provider=explicit_provider,
               )
       
               # --refresh: bust the disk cache so the picker shows live data.
    • hermes_cli/cli_commands_mixin.py
      @@ -2515,13 +2515,14 @@ def _handle_reasoning_command(self, cmd: str):
       
               Usage:
                   /reasoning              Show current effort level and display state
      -            /reasoning <level>      Set effort (none, minimal, low, medium, high, xhigh, max, ultra)
      +            /reasoning <level>      Set effort for this session only (none, minimal, low, medium, high, xhigh, max, ultra)
      +            /reasoning <level> --global  Persist reasoning effort to config.yaml
                   /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,20 @@ def _handle_reasoning_command(self, cmd: str):
                   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> [--global]{_RST}")
                   return
       
               arg = parts[1].strip().lower()
      +        arg_tokens = arg.split()
      +        # Session scope is the default; --global opts into persisting to
      +        # config.yaml. --session is accepted as an explicit no-op for parity
      +        # with /model and the gateway /reasoning handler.
      +        explicit_global = "--global" in arg_tokens
      +        if explicit_global or "--session" in arg_tokens:
      +            arg = " ".join(
      +                token for token in arg_tokens
      +                if token not in ("--global", "--session")
      +            )
       
               # Display toggle
               if arg in {"show", "on"}:
      @@ -2580,15 +2591,23 @@ def _handle_reasoning_command(self, cmd: str):
                   _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:        session-scoped by default, --global to persist{_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 explicit_global 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 explicit_global:
      +            _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}")
      +            _cprint(f"  {_ACCENT}✓ Reasoning effort set to '{arg}' (this session — use --global to persist){_RST}")
       
           def _handle_busy_command(self, cmd: str):
               """Handle /busy — control what Enter does while Hermes is working.
    • hermes_cli/commands.py
      @@ -131,7 +131,7 @@ class CommandDef:
           # Configuration
           CommandDef("config", "Show current configuration", "Configuration",
                      cli_only=True),
      -    CommandDef("model", "Switch model (persists by default)", "Configuration",
      +    CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
                      args_hint="[model] [--provider name] [--global|--session] [--refresh]"),
           CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
                      "Configuration", aliases=("codex_runtime",),
      @@ -153,8 +153,8 @@ class 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] [--global]",
      +               subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")),
           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")),
    • hermes_cli/model_switch.py
      @@ -552,7 +552,12 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:
           )
       
       
      -def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool = False) -> bool:
      +def resolve_persist_behavior(
      +    is_global: bool,
      +    is_session: bool,
      +    is_once: bool = False,
      +    explicit_provider: str = "",
      +) -> bool:
           """Decide whether a ``/model`` switch should persist to ``config.yaml``.
       
           Resolution order:
      @@ -560,13 +565,19 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =
           1. ``--once`` explicitly opts out → ``False`` (next turn only).
           2. ``--session`` explicitly opts out → ``False`` (this session only).
           3. ``--global`` explicitly opts in → ``True``.
      -    4. Otherwise defer to ``model.persist_switch_by_default`` in
      -       ``config.yaml`` (defaults to ``True``, so a plain ``/model <name>``
      -       survives across sessions — the behavior users expect).
      +    4. ``--provider`` given without an explicit persist flag → ``False``
      +       (session only).  Provider switches are typically exploratory — the
      +       user is trying a different backend for this conversation, not
      +       reconfiguring the default.  ``--global`` can still force persist.
      +    5. Otherwise defer to ``model.persist_switch_by_default`` in
      +       ``config.yaml`` (defaults to ``False``: a plain ``/model <name>``
      +       affects only the current session).  Users who want the old
      +       persist-by-default behavior can set the key to ``true``; a one-off
      +       ``--global`` always persists.
       
           The config read is defensive: on a fresh install ``model`` may be a
           flat string rather than a dict, in which case the built-in default
      -    (``True``) applies.
      +    (``False``) applies.
           """
           if is_once:
               return False
      @@ -574,15 +585,17 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =
               return False
           if is_global:
               return True
      +    if explicit_provider:
      +        return False
           try:
               from hermes_cli.config import load_config
       
               model_cfg = load_config().get("model")
               if isinstance(model_cfg, dict):
      -            return bool(model_cfg.get("persist_switch_by_default", True))
      +            return bool(model_cfg.get("persist_switch_by_default", False))
           except Exception:
               pass
      -    return True
      +    return False
       
       
       # ---------------------------------------------------------------------------
    • tests/cli/test_reasoning_command.py
      @@ -154,6 +154,72 @@ def test_status_with_explicit_level(self):
               level = rc.get("effort", "medium")
               self.assertEqual(level, "xhigh")
       
      +    def test_effort_defaults_to_session_only(self):
      +        """Plain /reasoning <level> is session-scoped — no config write."""
      +        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")
      +
      +        save_config.assert_not_called()
      +        self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
      +        self.assertIsNone(stub.agent)
      +
      +    def test_effort_global_flag_persists_config(self):
      +        """--global opts into persisting the effort to config.yaml."""
      +        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 --global")
      +            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 (explicit no-op alias for the default) stays session-only."""
      +        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
    • tests/gateway/test_25107_stale_base_url_api_mode.py
      @@ -186,7 +186,7 @@ async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path,
           adapter = _FakePickerAdapter()
           cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
       
      -    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
      +    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model --global"))
       
           assert confirmation is not None
           written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
    • tests/gateway/test_model_command_flat_string_config.py
      @@ -159,11 +159,11 @@ async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path,
       
       
       @pytest.mark.asyncio
      -async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):
      -    """A plain ``/model X`` (no --global) now persists to config.yaml.
      +async def test_model_no_flag_is_session_scoped_by_default(tmp_path, monkeypatch):
      +    """A plain ``/model X`` (no --global) does NOT persist to config.yaml.
       
      -    This is the user-facing fix: switching models in one session survives
      -    into the next without re-typing the switch every time.
      +    This is the user-facing fix: switches are session-scoped unless the user
      +    opts in with ``--global`` or sets ``model.persist_switch_by_default: true``.
           """
           cfg_path = _setup_isolated_home(
               tmp_path,
      @@ -178,7 +178,7 @@ async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):
           assert result is not None
           assert "gpt-5.5" in result
           written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
      -    assert written["model"]["default"] == "gpt-5.5"
      +    assert written["model"]["default"] == "old-model"
       
       
       @pytest.mark.asyncio
    • tests/gateway/test_model_picker_persist.py
      @@ -8,9 +8,10 @@
       *typing* the same model persisted — a contradiction the same PR introduced.
       
       After the fix (#49176), the picker callback honors the resolved
      -``persist_global`` (defaults to ``True``, still respects ``--session``) and runs
      -the same read-modify-write block the text path uses, so a tapped model survives
      -across sessions like a typed one.
      +``persist_global`` and runs the same read-modify-write block the text path
      +uses, so a tapped model behaves exactly like a typed one.  Since the
      +session-scope-by-default change, both default to session-only and persist
      +only with ``--global`` (or ``model.persist_switch_by_default: true``).
       
       These tests drive the real ``_handle_model_command`` with a fake picker-capable
       adapter that captures the ``on_model_selected`` callback, then invoke that
      @@ -176,14 +177,14 @@ async def _drive_picker(runner, event):
           ],
           ids=["nested-dict", "flat-string"],
       )
      -async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model):
      -    """Tapping a model in the picker (bare /model) persists to config.yaml,
      -    matching the typed ``/model`` default — this is the #49176 fix. The written
      -    ``model:`` must always end up a nested dict regardless of the seed shape."""
      +async def test_picker_tap_global_flag_persists(tmp_path, monkeypatch, seed_model):
      +    """Tapping a model in a ``/model --global`` picker persists to config.yaml,
      +    matching the typed ``/model --global`` path. The written ``model:`` must
      +    always end up a nested dict regardless of the seed shape."""
           adapter = _FakePickerAdapter()
           cfg_path = _setup_isolated_home(tmp_path, monkeypatch, seed_model)
       
      -    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
      +    confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model --global"))
       
           assert confirmation is not None
           assert "gpt-5.5" in confirmation
      @@ -198,6 +199,34 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)
           assert "api_mode" not in written["model"]
       
       
      [redacted:email]
      +async def test_picker_tap_is_session_scoped_by_default(tmp_path, monkeypatch):
      +    """Tapping a model in a bare ``/model`` picker applies an in-memory session
      +    override and does NOT touch config.yaml — switches are session-scoped
      +    unless the user opts in with ``--global`` (or sets
      +    ``model.persist_switch_by_default: true``)."""
      +    adapter = _FakePickerAdapter()
      +    cfg_path = _setup_isolated_home(
      +        tmp_path, monkeypatch, {"default": "old-model", "provider": "openrouter"}
      +    )
      +    runner = _make_runner(adapter)
      +
      +    confirmation = await _drive_picker(runner, _make_event("/model"))
      +
      +    assert confirmation is not None
      +    assert "gpt-5.5" in confirmation
      +    # The session override IS applied in-memory (the switch worked).
      +    assert runner._session_model_overrides, "session override should be set"
      +    assert any(
      +        ov.get("model") == "gpt-5.5"
      +        for ov in runner._session_model_overrides.values()
      +    )
      +    # But config.yaml is untouched — session-scoped by default.
      +    written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
      +    assert written["model"]["default"] == "old-model"
      +    assert written["model"]["provider"] == "openrouter"
      +
      +
       @pytest.mark.asyncio
       async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch):
           """``/model --session`` then a picker tap stays in-memory only — config
    • tests/hermes_cli/test_model_switch_persist_default.py
      @@ -1,11 +1,12 @@
      -"""Tests for persist-by-default model switching.
      +"""Tests for session-scoped-by-default model switching.
       
       Covers:
       - ``parse_model_flags`` recognises ``--session`` (and keeps ``--global``).
       - ``resolve_persist_behavior`` applies the config-gated default and the
         ``--session`` / ``--global`` overrides.
      -- The default (no flags) persists, which is the user-facing fix: a plain
      -  ``/model <name>`` survives across sessions.
      +- The default (no flags) is session-only, which is the user-facing fix: a
      +  plain ``/model <name>`` affects only the current session unless the user
      +  passes ``--global`` or sets ``model.persist_switch_by_default: true``.
       """
       
       from unittest.mock import patch
      @@ -73,10 +74,10 @@ def test_global_flag_always_persists(self):
               with _config({"model": {"persist_switch_by_default": False}}):
                   assert resolve_persist_behavior(True, False) is True
       
      -    def test_default_persists_when_config_missing(self):
      -        # No model section at all → built-in default (True).
      +    def test_default_session_only_when_config_missing(self):
      +        # No model section at all → built-in default (False, session-only).
               with _config({}):
      -            assert resolve_persist_behavior(False, False) is True
      +            assert resolve_persist_behavior(False, False) is False
       
           def test_default_persists_when_key_true(self):
               with _config({"model": {"persist_switch_by_default": True}}):
      @@ -87,15 +88,35 @@ def test_default_session_only_when_key_false(self):
                   assert resolve_persist_behavior(False, False) is False
       
           def test_default_when_model_is_flat_string(self):
      -        # Fresh install: ``model: ""`` (not a dict) → built-in default True.
      +        # Fresh install: ``model: ""`` (not a dict) → built-in default False.
               with _config({"model": ""}):
      -            assert resolve_persist_behavior(False, False) is True
      +            assert resolve_persist_behavior(False, False) is False
       
           def test_session_overrides_global_when_both_set(self):
               # --session is the explicit opt-out and wins over --global.
               with _config({"model": {"persist_switch_by_default": True}}):
                   assert resolve_persist_behavior(True, True) is False
       
      +    def test_provider_flag_defaults_to_session_only(self):
      +        # --provider without --global/--session → session only.
      +        with _config({"model": {"persist_switch_by_default": True}}):
      +            assert resolve_persist_behavior(False, False, explicit_provider="anthropic") is False
      +
      +    def test_provider_with_global_still_persists(self):
      +        # --provider + --global → persists.
      +        with _config({"model": {"persist_switch_by_default": False}}):
      +            assert resolve_persist_behavior(True, False, explicit_provider="anthropic") is True
      +
      +    def test_provider_with_session_still_session_only(self):
      +        # --provider + --session → session only.
      +        with _config({"model": {"persist_switch_by_default": True}}):
      +            assert resolve_persist_behavior(False, True, explicit_provider="anthropic") is False
      +
      +    def test_no_provider_uses_config_default(self):
      +        # No --provider → respects config default (True).
      +        with _config({"model": {"persist_switch_by_default": True}}):
      +            assert resolve_persist_behavior(False, False, explicit_provider="") is True
      +
       
       # ---------------------------------------------------------------------------
       # helper
    • 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):
    • 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_global_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", "--global"} <= 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)
    • tui_gateway/server.py
      @@ -3221,6 +3221,7 @@ def _apply_model_switch(
                   is_global_flag,
                   is_session,
                   is_once=one_turn,
      +            explicit_provider=explicit_provider,
               )
           )
           if not model_input:
      @@ -11554,6 +11555,8 @@ def _resolve_toggle(current: bool) -> bool:
                   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 = (
      @@ -11633,23 +11636,25 @@ def _resolve_toggle(current: bool) -> bool:
                   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))
      @@ -12344,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
    • ui-tui/src/__tests__/createSlashHandler.test.ts
      @@ -267,6 +267,55 @@ 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 without a scope (session default)', effort => {
      +    patchUiState({ sid: 'sid-abc' })
      +    const ctx = buildCtx()
      +
      +    expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true)
      +    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      +      key: 'reasoning',
      +      session_id: 'sid-abc',
      +      value: effort
      +    })
      +  })
      +
      +  it('sends /reasoning <level> --global as global config.set', () => {
      +    patchUiState({ sid: 'sid-abc' })
      +    const ctx = buildCtx()
      +
      +    expect(createSlashHandler(ctx)('/reasoning high --global')).toBe(true)
      +    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      +      key: 'reasoning',
      +      scope: 'global',
      +      session_id: 'sid-abc',
      +      value: 'high'
      +    })
      +  })
      +
      +  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()
       
    • 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_GLOBAL_FLAGS = new Set(['--global'])
       
       const modelValueForConfigSet = (arg: string) => {
         const trimmed = arg.trim()
      @@ -38,6 +40,37 @@ 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_GLOBAL_FLAGS.has(flag)) {
      +      scope = 'global'
      +      continue
      +    }
      +    if (REASONING_SESSION_FLAGS.has(flag)) {
      +      // Session scope is the default; accept the flag for parity with /model.
      +      if (!scope) {
      +        scope = 'session'
      +      }
      +      continue
      +    }
      +    valueParts.push(part)
      +  }
      +
      +  const value = valueParts.join(' ')
      +
      +  return {
      +    key: 'reasoning',
      +    session_id: sid,
      +    value,
      +    ...(scope ? { scope } : {})
      +  }
      +}
      +
       export const sessionCommands: SlashCommand[] = [
         {
           aliases: ['bg', 'btw'],
      @@ -445,7 +478,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 +486,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}`)
      +          })
      +        )
           }
         },
       
    • website/docs/reference/cli-commands.md
      @@ -199,7 +199,7 @@ Switch between already-configured models without leaving a session:
       /model openrouter:anthropic/claude-sonnet-4  # Switch back to cloud
       ```
       
      -By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml`:
      +By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml` (or set `model.persist_switch_by_default: true` to make every switch persist):
       
       ```
       /model claude-sonnet-4 --global     # Switch and save as new default
      @@ -209,7 +209,7 @@ By default, `/model` changes apply **to the current session only**. Add `--globa
       If you've only configured OpenRouter, `/model` will only show OpenRouter models. To add another provider (Anthropic, DeepSeek, Copilot, etc.), exit your session and run `hermes model` from the terminal.
       :::
       
      -Provider and base URL changes are persisted to `config.yaml` automatically. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.
      +On a `--global` switch, provider and base URL changes are persisted to `config.yaml` alongside the model. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.
       
       ## `hermes gateway`
       
    • website/docs/user-guide/configuration.md
      @@ -1323,13 +1323,17 @@ native Anthropic provider already controls effort directly and is unaffected.
       You can also change the reasoning effort at runtime with the `/reasoning` command:
       
       ```
      -/reasoning           # Show current effort level and display state
      -/reasoning high      # Set reasoning effort to high
      -/reasoning none      # Disable reasoning
      -/reasoning show      # Show model thinking above each response
      -/reasoning hide      # Hide model thinking
      +/reasoning                # Show current effort level and display state
      +/reasoning high           # Set reasoning effort to high (this session only)
      +/reasoning high --global  # Set effort and persist to config.yaml
      +/reasoning none           # Disable reasoning (this session only)
      +/reasoning show           # Show model thinking above each response
      +/reasoning hide           # Hide model thinking
       ```
       
      +Effort changes are session-scoped by default; add `--global` to save the
      +new level as your `agent.reasoning_effort` default.
      +
       #### Per-Model Reasoning Overrides
       
       You can set different reasoning effort levels for different models. This is useful when you want high reasoning for complex models but medium for faster ones:

1 PR closed without merging

From the close comments: merged via another change 1

  • #61488 fix(gateway): default /model to session scope on messaging platforms · merged via another change → #67946, #58371
Closed issues · 1
  • Closed#61458[Bug]: /model command defaults to global config in messaging gateways
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#67946
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
20 Jul 2026

For your agent

The same facts, typed.