diff --git a/cli.py b/cli.py index 968e88e0f78cd..20929befe882a 100644 --- a/cli.py +++ b/cli.py @@ -7154,6 +7154,68 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.reasoning_config = _parse_reasoning_config( CLI_CONFIG["agent"].get("reasoning_effort", "") ) + # /new is a full conversation boundary: session-scoped runtime + # overrides (/model --session, /fast, one-turn restores) do not carry + # forward. Re-derive model/provider and service tier from config.yaml + # so a session-only switch never leaks into the next session (#48055, + # #23131). + self._pending_one_turn_model_restore = None + self.service_tier = _parse_service_tier_config( + CLI_CONFIG["agent"].get("service_tier", "") + ) + _model_config = CLI_CONFIG.get("model", {}) + _config_model = ( + (_model_config.get("default") or _model_config.get("model") or "") + if isinstance(_model_config, dict) + else (_model_config or "") + ) + if _config_model and _config_model != getattr(self, "model", None): + _config_provider = ( + _model_config.get("provider", "") + if isinstance(_model_config, dict) + else "" + ) + try: + from hermes_cli.model_switch import switch_model as _switch_model + + _reset_result = _switch_model( + raw_input=_config_model, + current_provider=self.provider or "", + current_model=self.model or "", + current_base_url=self.base_url or "", + current_api_key=self.api_key or "", + is_global=False, + explicit_provider=_config_provider or "", + ) + if _reset_result.success: + if self.agent: + self.agent.switch_model( + new_model=_reset_result.new_model, + new_provider=_reset_result.target_provider, + api_key=_reset_result.api_key, + base_url=_reset_result.base_url, + api_mode=_reset_result.api_mode, + ) + self.model = _reset_result.new_model + self.provider = _reset_result.target_provider + self.requested_provider = _reset_result.target_provider + self._explicit_api_key = _reset_result.api_key + self._explicit_base_url = _reset_result.base_url + if _reset_result.api_key: + self.api_key = _reset_result.api_key + if _reset_result.base_url: + self.base_url = _reset_result.base_url + if _reset_result.api_mode: + self.api_mode = _reset_result.api_mode + if not silent: + _cprint( + f" (model reset to config default: " + f"{_reset_result.new_model})" + ) + except Exception: + # Best-effort: an unreachable config default must never block + # /new. The session keeps the current working model. + logger.debug("/new model reset to config default failed", exc_info=True) _sync_process_session_id(self.session_id) if self.agent: diff --git a/gateway/run.py b/gateway/run.py index 1f9ffe3e22300..f697992d12561 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2031,6 +2031,7 @@ _CONVERSATION_SCOPED_STATE: tuple = ( "_session_model_overrides", "_pending_one_turn_model_restores", "_session_reasoning_overrides", + "_session_service_tier_overrides", "_pending_model_notes", "_last_resolved_model", "_queued_events", @@ -3055,6 +3056,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _session_model_overrides: Dict[str, Dict[str, str]] = {} _pending_one_turn_model_restores: Dict[str, Dict[str, Any]] = {} _session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} + _session_service_tier_overrides: Dict[str, Optional[str]] = {} _pending_turn_sidecar_notes: Dict[str, List[str]] = {} _session_ephemeral_pin: Dict[str, tuple] = {} _session_vc_last: Dict[str, str] = {} @@ -3264,6 +3266,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Per-session reasoning effort overrides from /reasoning. # Key: session_key, Value: parsed reasoning config dict. self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} + # Per-session fast-mode overrides from /fast. + # Key: session_key, Value: "priority" or None (explicit normal). + self._session_service_tier_overrides: Dict[str, Optional[str]] = {} # Per-turn must-deliver notes relocated out of the ephemeral system # prompt (auto-reset note, first-contact intro, voice-channel change). # Staged by _handle_message_with_agent, consumed once by run_sync and @@ -5238,6 +5243,52 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew else: self._session_reasoning_overrides[session_key] = dict(reasoning_config) + def _resolve_session_service_tier( + self, + source=None, + session_key: Optional[str] = None, + ) -> Optional[str]: + """Resolve the effective service tier for a session. + + A session-scoped /fast override wins over the config default. The + override dict stores "priority" or None (explicit normal), so key + presence — not value truthiness — decides whether it applies. + """ + resolved_session_key = session_key + if not resolved_session_key and source is not None: + try: + resolved_session_key = self._session_key_for_source(source) + except Exception: + resolved_session_key = None + + overrides = getattr(self, "_session_service_tier_overrides", {}) or {} + if resolved_session_key and resolved_session_key in overrides: + return overrides[resolved_session_key] + return self._load_service_tier() + + def _set_session_service_tier_override( + self, + session_key: str, + service_tier, + clear: bool = False, + ) -> None: + """Set or clear the session-scoped /fast override. + + ``service_tier`` is "priority" or None (explicit normal). Pass + ``clear=True`` to remove the override entirely (fall back to config). + """ + if not session_key: + return + if "_session_service_tier_overrides" not in self.__dict__: + # Force an instance-level dict: the class attribute is a shared + # default for partially-constructed test runners, and mutating it + # would leak overrides across runner instances. + self._session_service_tier_overrides = {} + if clear: + self._session_service_tier_overrides.pop(session_key, None) + else: + self._session_service_tier_overrides[session_key] = service_tier + @staticmethod def _load_service_tier() -> str | None: """Load Priority Processing setting from config.yaml. @@ -14702,7 +14753,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew source=source, model=model ) self._reasoning_config = reasoning_config - self._service_tier = self._load_service_tier() + self._service_tier = self._resolve_session_service_tier(source=source) turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) # Enrich the prompt with image descriptions so the background @@ -19917,7 +19968,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew model=model, ) self._reasoning_config = reasoning_config - self._service_tier = self._load_service_tier() + self._service_tier = self._resolve_session_service_tier( + source=source, session_key=session_key + ) # Set up stream consumer for token streaming or interim commentary. _stream_consumer = None _stream_delta_cb = None diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 9411515f4dcc4..4aa75fa7985aa 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3108,43 +3108,64 @@ class GatewaySlashCommandsMixin: return out async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]: - """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" + """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats. + + Session-scoped by default; ``--global`` persists agent.service_tier + to config.yaml (parity with /model and /reasoning). + """ from gateway.run import _load_gateway_config, _resolve_gateway_model from hermes_cli.models import model_supports_fast_mode - args = event.get_command_args().strip().lower() - self._service_tier = self._load_service_tier() + raw_args = event.get_command_args().strip().lower() + # Reuse the /reasoning arg parser: strips --global (any position), + # normalizes unicode dashes. + args, persist_global = self._parse_reasoning_command_args(raw_args) + session_key = self._session_key_for_source(event.source) + self._service_tier = self._resolve_session_service_tier( + session_key=session_key + ) user_config = _load_gateway_config() model = _resolve_gateway_model(user_config) if not model_supports_fast_mode(model): return t("gateway.fast.not_supported") - def _apply_fast_selection(value: str) -> str: + def _apply_fast_selection(value: str, persist: bool = False) -> str: """Apply a /fast argument (typed or picked) and return the reply.""" if value in {"fast", "on"}: - self._service_tier = "priority" + tier = "priority" saved_value = "fast" label = t("gateway.fast.label_fast") elif value in {"normal", "off"}: - self._service_tier = None + tier = None saved_value = "normal" label = t("gateway.fast.label_normal") else: return t("gateway.fast.unknown_arg", arg=value) - if self._save_gateway_config_key("agent.service_tier", saved_value): - return t("gateway.fast.saved", label=label) + self._service_tier = tier + if persist: + if self._save_gateway_config_key("agent.service_tier", saved_value): + # Global write supersedes any session override. + self._set_session_service_tier_override( + session_key, None, clear=True + ) + self._evict_cached_agent(session_key) + return t("gateway.fast.saved", label=label) + # Config write failed — fall back to a session override so the + # user's choice still applies (mirrors /reasoning --global). + self._set_session_service_tier_override(session_key, tier) + self._evict_cached_agent(session_key) + return t("gateway.fast.session_only", label=label) + self._set_session_service_tier_override(session_key, tier) + self._evict_cached_agent(session_key) return t("gateway.fast.session_only", label=label) if not args or args == "status": is_fast = self._service_tier == "priority" status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal") - # Interactive picker on platforms that support it. - session_key = self._session_key_for_source(event.source) - async def _on_fast_choice(_chat_id: str, value: str) -> str: - return _apply_fast_selection(value) + return _apply_fast_selection(value, persist=persist_global) picker_sent = await self._try_send_choice_picker( event, @@ -3169,7 +3190,7 @@ class GatewaySlashCommandsMixin: return t("gateway.fast.status", mode=status) - return _apply_fast_selection(args) + return _apply_fast_selection(args, persist=persist_global) async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index c199c5ce6497e..e108243840011 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2653,7 +2653,11 @@ class CLICommandsMixin: _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}") def _handle_fast_command(self, cmd: str): - """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).""" + """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode). + + Session-scoped by default; ``--global`` persists agent.service_tier + to config.yaml (parity with /model and /reasoning). + """ from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value if not self._fast_command_available(): _cprint(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).") @@ -2672,10 +2676,15 @@ class CLICommandsMixin: if len(parts) < 2 or parts[1].strip().lower() == "status": status = "fast" if self.service_tier == "priority" else "normal" _cprint(f" {_ACCENT}{feature_name}: {status}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}") + _cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}") return - arg = parts[1].strip().lower() + arg_tokens = parts[1].strip().lower().split() + explicit_global = "--global" in arg_tokens + arg = " ".join( + token for token in arg_tokens + if token not in ("--global", "--session") + ) if arg in {"fast", "on"}: self.service_tier = "priority" @@ -2687,14 +2696,16 @@ class CLICommandsMixin: label = "NORMAL" else: _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}") + _cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}") return self.agent = None # Force agent re-init with new service-tier config - if save_config_value("agent.service_tier", saved_value): + if explicit_global and save_config_value("agent.service_tier", saved_value): _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (saved to config){_RST}") + elif explicit_global: + _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only; config save failed){_RST}") else: - _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only){_RST}") + _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (this session — use --global to persist){_RST}") def _handle_debug_command(self, cmd_original: str = ""): """Handle /debug — upload debug report + logs and print share URLs. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index aa633f7709912..76642b7b29bb1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -156,8 +156,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ 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")), + args_hint="[normal|fast|status] [--global]", + subcommands=("normal", "fast", "status", "on", "off", "--global")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration", diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py index 7745737c45416..5cd0cfc71c684 100644 --- a/tests/cli/test_fast_command.py +++ b/tests/cli/test_fast_command.py @@ -83,6 +83,20 @@ class TestHandleFastCommand(unittest.TestCase): ): cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal") + # Session-scoped by default: no config write. + mock_save.assert_not_called() + self.assertIsNone(stub.service_tier) + self.assertIsNone(stub.agent) + + def test_global_flag_persists_service_tier(self): + cli_mod = _import_cli() + stub = self._make_cli(service_tier="priority") + with ( + patch.object(cli_mod, "_cprint"), + patch.object(cli_mod, "save_config_value", return_value=True) as mock_save, + ): + cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal --global") + mock_save.assert_called_once_with("agent.service_tier", "normal") self.assertIsNone(stub.service_tier) self.assertIsNone(stub.agent) diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py index 868d8ff4f5056..3b66680b54034 100644 --- a/tests/cli/test_reasoning_command.py +++ b/tests/cli/test_reasoning_command.py @@ -220,6 +220,63 @@ class TestHandleReasoningCommand(unittest.TestCase): self.assertEqual(agent.reasoning_config, {"enabled": True, "effort": "medium"}) agent.reset_session_state.assert_called_once() + def test_new_session_resets_service_tier_and_model_from_config(self): + """/new re-derives service tier and model from config.yaml — session + /fast and /model switches do not carry forward (#48055, #23131).""" + from cli import CLI_CONFIG, HermesCLI + + agent = SimpleNamespace( + reasoning_config=None, + reset_session_state=MagicMock(), + switch_model=MagicMock(), + ) + stub = SimpleNamespace( + agent=agent, + conversation_history=[], + session_id="old-session", + _session_db=None, + _pending_title=None, + _resumed=False, + reasoning_config=None, + _notify_session_boundary=MagicMock(), + # Session had switched to fast + a session-only model. + service_tier="priority", + _pending_one_turn_model_restore={"model": "stale"}, + model="session-switched-model", + provider="openrouter", + requested_provider="openrouter", + api_key="k", + base_url="", + api_mode="", + ) + + fake_result = SimpleNamespace( + success=True, + new_model="config-default-model", + target_provider="openrouter", + api_key="k2", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + ) + with patch.dict( + CLI_CONFIG.setdefault("agent", {}), + {"reasoning_effort": "medium", "service_tier": "normal"}, + ), patch.dict( + CLI_CONFIG, + {"model": {"default": "config-default-model", "provider": "openrouter"}}, + ), patch( + "hermes_cli.model_switch.switch_model", return_value=fake_result + ): + HermesCLI.new_session(stub, silent=True) + + # Fast override cleared back to config default (normal → None). + self.assertIsNone(stub.service_tier) + # One-turn restore snapshot cleared. + self.assertIsNone(stub._pending_one_turn_model_restore) + # Model reset to the config default via the live agent swap. + self.assertEqual(stub.model, "config-default-model") + agent.switch_model.assert_called_once() + # --------------------------------------------------------------------------- # Reasoning extraction and result dict diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py index eb48782733661..eb68b9eeeeffe 100644 --- a/tests/gateway/test_choice_picker.py +++ b/tests/gateway/test_choice_picker.py @@ -189,7 +189,8 @@ class TestFastChoicePicker: assert values == ["fast", "normal"] @pytest.mark.asyncio - async def test_fast_picker_selection_persists_service_tier(self, tmp_path, monkeypatch): + async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypatch): + """A bare /fast picker tap applies a session override, not a config write.""" self._patch_fast_support(monkeypatch, tmp_path) adapter = _PickerAdapter() runner = _make_runner(adapter) @@ -199,6 +200,22 @@ class TestFastChoicePicker: on_choice = adapter.calls[0]["on_choice_selected"] await on_choice(event.source.chat_id, "fast") + assert runner._service_tier == "priority" + assert runner._session_service_tier_overrides + assert not (tmp_path / "config.yaml").exists() + + @pytest.mark.asyncio + async def test_fast_picker_global_flag_persists_service_tier(self, tmp_path, monkeypatch): + """A /fast --global picker tap persists agent.service_tier to config.""" + self._patch_fast_support(monkeypatch, tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + event = _make_event("/fast --global") + + await runner._handle_fast_command(event) + on_choice = adapter.calls[0]["on_choice_selected"] + await on_choice(event.source.chat_id, "fast") + assert runner._service_tier == "priority" saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) assert saved["agent"]["service_tier"] == "fast" diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index a5b07c89880d1..3e76b42e3332b 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -143,7 +143,8 @@ def test_turn_route_skips_priority_processing_for_unsupported_models(): @pytest.mark.asyncio -async def test_handle_fast_command_persists_config(monkeypatch, tmp_path): +async def test_handle_fast_command_session_scoped_by_default(monkeypatch, tmp_path): + """Bare /fast fast applies a session override — config.yaml untouched.""" runner = _make_runner() monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) @@ -152,11 +153,57 @@ async def test_handle_fast_command_persists_config(monkeypatch, tmp_path): response = await runner._handle_fast_command(_make_event("/fast fast")) + assert "FAST" in response + assert runner._service_tier == "priority" + # Session override recorded; config.yaml NOT written. + assert runner._session_service_tier_overrides + assert not (tmp_path / "config.yaml").exists() + + +@pytest.mark.asyncio +async def test_handle_fast_command_global_flag_persists_config(monkeypatch, tmp_path): + runner = _make_runner() + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + + response = await runner._handle_fast_command(_make_event("/fast fast --global")) + assert "FAST" in response assert runner._service_tier == "priority" saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) assert saved["agent"]["service_tier"] == "fast" + # Global write supersedes the session override. + assert not runner._session_service_tier_overrides + + +@pytest.mark.asyncio +async def test_session_fast_override_beats_config_default(monkeypatch, tmp_path): + """A session /fast normal wins over agent.service_tier: fast in config.""" + runner = _make_runner() + + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr( + gateway_run, + "_load_gateway_runtime_config", + lambda: {"agent": {"service_tier": "fast"}}, + ) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") + + event = _make_event("/fast normal") + session_key = runner._session_key_for_source(event.source) + + response = await runner._handle_fast_command(event) + + assert "NORMAL" in response + # Override stores explicit None (normal) and wins over config "fast". + assert session_key in runner._session_service_tier_overrides + assert runner._resolve_session_service_tier(session_key=session_key) is None + # A different session still gets the config default. + assert runner._resolve_session_service_tier(session_key="other-session") == "priority" @pytest.mark.asyncio diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fdffa98e1f7b8..6b9cd1b140ea5 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -10459,16 +10459,20 @@ def test_start_agent_build_passes_session_model_override( # ── billing/subscription state + error serialization ───────────────── -def test_reset_session_agent_preserves_explicit_normal_fast(monkeypatch): +def test_reset_session_agent_clears_session_overrides(monkeypatch): + """/new is a full conversation boundary: session-scoped /model, /reasoning, + and /fast overrides do NOT carry into the fresh agent — it re-derives + everything from config.yaml (#48055, #23131).""" captured = {} new_agent = types.SimpleNamespace(model="openai/gpt-5.4", service_tier="") session = _session( agent=types.SimpleNamespace( model="openai/gpt-5.4", - reasoning_config=None, + reasoning_config={"enabled": True, "effort": "high"}, service_tier="", ), model_override={"model": "openai/gpt-5.4"}, + create_reasoning_override={"enabled": True, "effort": "high"}, create_service_tier_override="", ) @@ -10488,7 +10492,14 @@ def test_reset_session_agent_preserves_explicit_normal_fast(monkeypatch): server._reset_session_agent("sid", session) - assert captured["service_tier_override"] == "" + # No session overrides forwarded — fresh agent builds from config. + assert "model_override" not in captured + assert "reasoning_config_override" not in captured + assert "service_tier_override" not in captured + # And the session pins are gone so a later rebuild can't resurrect them. + assert "model_override" not in session + assert "create_reasoning_override" not in session + assert "create_service_tier_override" not in session assert session["agent"] is new_agent diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d461898e19d24..e6f32e078baa3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4765,25 +4765,22 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict: def _reset_session_agent(sid: str, session: dict) -> dict: tokens = _set_session_context(session["session_key"]) try: - # Preserve this session's chosen model AND reasoning across /new so a - # reset doesn't silently revert to global config (or to a model - # another session set). See the cross-session-contamination note in - # _apply_model_switch. - reset_kw = {"model_override": session.get("model_override")} - old_reasoning = getattr(session.get("agent"), "reasoning_config", None) - if old_reasoning is None: - old_reasoning = session.get("create_reasoning_override") - if isinstance(old_reasoning, dict): - reset_kw["reasoning_config_override"] = old_reasoning - create_service_tier_override = session.get("create_service_tier_override") - if create_service_tier_override is not None: - reset_kw["service_tier_override"] = create_service_tier_override + # /new is a full conversation boundary: session-scoped runtime + # overrides (/model, /reasoning, /fast) do NOT carry forward — the + # fresh agent re-derives model/provider, reasoning, and service tier + # from config.yaml (#48055, #23131). Session pins are cleared below so + # a rebuild can't resurrect them. (Global process state is still never + # touched — see the cross-session-contamination note in + # _apply_model_switch.) + session.pop("model_override", None) + session.pop("create_reasoning_override", None) + session.pop("create_service_tier_override", None) + session.pop("one_turn_model_restore", None) new_agent = _make_agent( sid, session["session_key"], session_id=session["session_key"], platform_override=_session_source(session), - **reset_kw, ) finally: _clear_session_context(tokens)