fix(tui): stop hermes --tui -m from persisting the model globally (#59805)

The -m flag seeds HERMES_MODEL/HERMES_INFERENCE_MODEL for the launched TUI
process only. But the per-turn config sync (_sync_agent_model_with_config)
computed its target via _config_model_target(), which fell back to those
env vars whenever config.yaml had no model.default — the normal state for
custom-provider-only setups. The sync then replayed the -m model as a
/model switch, and with model.persist_switch_by_default (default true)
_persist_model_switch wrote model.default/provider/base_url into
config.yaml. A one-shot CLI flag became the permanent global model,
visible in every new session and every model picker.

Two-sided fix:
- _config_model_target() no longer falls back to the env seed. Empty
  model = config expresses no preference = sync is a no-op. The agent
  keeps the session-scoped -m model; config.yaml edits still sync.
- _apply_model_switch() gains persist_override; all three internal
  callers (config sync, /moa one-shot swap, /moa post-turn restore) pass
  persist_override=False so session-mechanical switches can never write
  config.yaml regardless of the persist-by-default setting. User-typed
  /model keeps its existing flag/config behavior.

E2E-verified against an isolated HERMES_HOME with a custom-provider-only
config + -m env seed: sync no longer fires, config.yaml byte-identical,
_resolve_model() still returns the seed for the session's own agent.
This commit is contained in:
Teknium 2026-07-06 12:46:40 -07:00 committed by GitHub
parent dd7198e71d
commit 70c6ae609e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 111 additions and 11 deletions

View file

@ -1416,7 +1416,11 @@ def test_config_sync_switches_unpinned_session(monkeypatch):
(
"sid",
"new/model --provider nous",
{"confirm_expensive_model": True, "pin_session_override": False},
{
"confirm_expensive_model": True,
"pin_session_override": False,
"persist_override": False,
},
)
]
assert session["config_model_seen"] == ("new/model", "nous")
@ -1536,6 +1540,78 @@ def test_config_sync_config_wins_over_env_seed(monkeypatch):
assert session["config_model_seen"] == ("new/model", "")
def test_config_sync_ignores_env_seed_without_config_model(monkeypatch):
# `hermes --tui -m <model>` sets HERMES_MODEL/HERMES_INFERENCE_MODEL as a
# launch-scoped seed. When config.yaml has NO model.default (typical
# custom-provider-only setup), the sync must NOT adopt the env seed as a
# config target — doing so replayed the -m flag as a /model switch and
# (with persist_switch_by_default=True) wrote it into config.yaml
# permanently.
monkeypatch.setenv("HERMES_MODEL", "one-shot/model")
monkeypatch.setenv("HERMES_INFERENCE_MODEL", "one-shot/model")
monkeypatch.setattr(
server, "_load_cfg", lambda: {"model": {"provider": "custom:mylocal"}}
)
session = _sync_test_session()
monkeypatch.setattr(
server,
"_apply_model_switch",
lambda *a, **k: pytest.fail("env seed must not trigger a config sync switch"),
)
server._sync_agent_model_with_config("sid", session)
def test_config_model_target_never_reads_env(monkeypatch):
monkeypatch.setenv("HERMES_MODEL", "seed/model")
monkeypatch.setenv("HERMES_INFERENCE_MODEL", "seed/model")
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "nous"}})
assert server._config_model_target() == ("", "nous")
def test_apply_model_switch_persist_override_false_never_persists(monkeypatch):
# Internal callers (config sync, /moa one-shot + restore) pass
# persist_override=False; even with persist_switch_by_default=True the
# switch must not write config.yaml.
import types as _types
result = _types.SimpleNamespace(
success=True,
new_model="new/model",
target_provider="nous",
base_url="",
api_key="key",
api_mode="chat_completions",
warning_message="",
model_info=None,
error_message="",
)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model", lambda **kw: result
)
monkeypatch.setattr(
"hermes_cli.model_switch.resolve_persist_behavior",
lambda *a: pytest.fail("persist_override must bypass resolve_persist_behavior"),
)
monkeypatch.setattr(
server, "_persist_model_switch",
lambda _r: pytest.fail("persist_override=False must not persist"),
)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *a, **k: None,
)
session = {"agent": None}
out = server._apply_model_switch(
"sid", session, "new/model --provider nous", persist_override=False
)
assert out["value"] == "new/model"
assert session["model_override"]["model"] == "new/model"
def test_startup_runtime_uses_tui_provider_env(monkeypatch):
monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous")

View file

@ -1994,14 +1994,14 @@ def _resolve_model() -> str:
def _config_model_target() -> tuple[str, str]:
"""(model, provider) currently selected by config (env as fallback).
"""(model, provider) currently selected by config.yaml — and ONLY config.
config.yaml wins over HERMES_MODEL / HERMES_INFERENCE_MODEL here, the
reverse of `_resolve_model()`'s startup order. Those env vars are a
provision-time seed (hosted instances set HERMES_INFERENCE_MODEL in the
container env); if they outranked config.yaml, the per-turn sync would
stay pinned to the seed forever and dashboard/CLI model changes would
never reach an open chat the exact bug this sync exists to fix.
Unlike `_resolve_model()`, this never reads HERMES_MODEL /
HERMES_INFERENCE_MODEL. Those env vars are a launch-scoped seed
(`hermes --tui -m <model>`, hosted-instance provisioning); if they
fed the per-turn sync, the seed would be replayed as a /model switch
and persisted globally, or would pin the session so dashboard/CLI
model changes never reach an open chat.
"""
cfg_model = _load_cfg().get("model")
model = ""
@ -2013,8 +2013,15 @@ def _config_model_target() -> tuple[str, str]:
provider = ""
elif isinstance(cfg_model, str):
model = cfg_model.strip()
if not model:
model = _resolve_model()
# No fallback to _resolve_model() here: that reads HERMES_MODEL /
# HERMES_INFERENCE_MODEL, which `hermes --tui -m <model>` sets as a
# session-scoped seed for THIS launch. When config.yaml has no
# model.default (custom-provider-only setups), falling back to the env
# seed made the per-turn sync treat the -m flag as "the configured
# model" and replay it as a /model switch — which then persisted the
# one-shot flag into config.yaml globally (#-m leak). An empty model
# simply means "config expresses no preference": the sync is a no-op
# and the agent keeps whatever it was built with.
return model, provider
@ -2660,6 +2667,7 @@ def _apply_model_switch(
confirm_expensive_model: bool = False,
pin_session_override: bool = True,
parsed_flags: tuple[str, str, bool, bool, bool] | None = None,
persist_override: bool | None = None,
) -> dict:
from hermes_cli.model_switch import (
parse_model_flags,
@ -2677,7 +2685,11 @@ def _apply_model_switch(
_force_refresh,
is_session,
) = parsed_flags
persist_global = resolve_persist_behavior(is_global_flag, is_session)
persist_global = (
persist_override
if persist_override is not None
else resolve_persist_behavior(is_global_flag, is_session)
)
if not model_input:
raise ValueError("model value required")
@ -2869,6 +2881,12 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
raw,
confirm_expensive_model=True,
pin_session_override=False,
# This sync ADOPTS a config.yaml change into the live session; it
# must never write config back. Without this, the flag/config
# default (persist_switch_by_default=True) re-persisted whatever
# target the sync computed — the path that leaked `hermes --tui -m`
# into config.yaml as the permanent global model.
persist_override=False,
)
except Exception as e:
_emit(
@ -8700,6 +8718,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
_raw,
confirm_expensive_model=False,
pin_session_override=bool(_prev_override),
# Session-internal restore after the /moa
# one-shot — never persist to config.yaml.
persist_override=False,
)
except Exception as _moa_restore_exc:
logger.warning(
@ -11559,6 +11580,9 @@ def _(rid, params: dict) -> dict:
f"{preset} --provider moa",
confirm_expensive_model=False,
pin_session_override=True,
# One-shot turn-scoped swap — never persist the MoA
# virtual provider to config.yaml.
persist_override=False,
)
except Exception as exc:
session.pop("moa_one_shot_restore", None)