mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
Update model correctly when updating from dashboard
This commit is contained in:
parent
1e25358a8f
commit
c61815232a
3 changed files with 179 additions and 16 deletions
|
|
@ -1051,6 +1051,94 @@ def test_resolve_model_strips_config_model(monkeypatch):
|
|||
assert server._resolve_model() == "nous/hermes-test"
|
||||
|
||||
|
||||
def _sync_test_session(**extra):
|
||||
session = {
|
||||
"agent": types.SimpleNamespace(model="old/model"),
|
||||
"session_key": "session-key",
|
||||
}
|
||||
session.update(extra)
|
||||
return session
|
||||
|
||||
|
||||
def _patch_config_model(monkeypatch, model, provider=""):
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
|
||||
cfg_model = {"default": model}
|
||||
if provider:
|
||||
cfg_model["provider"] = provider
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"model": cfg_model})
|
||||
|
||||
|
||||
def test_config_sync_switches_unpinned_session(monkeypatch):
|
||||
_patch_config_model(monkeypatch, "new/model", provider="nous")
|
||||
session = _sync_test_session(config_model_seen=("old/model", "nous"))
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_apply_model_switch",
|
||||
lambda sid, sess, raw, **kw: calls.append((sid, raw, kw)),
|
||||
)
|
||||
|
||||
server._sync_agent_model_with_config("sid", session)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
"sid",
|
||||
"new/model --provider nous",
|
||||
{"confirm_expensive_model": True, "pin_session_override": False},
|
||||
)
|
||||
]
|
||||
assert session["config_model_seen"] == ("new/model", "nous")
|
||||
|
||||
|
||||
def test_config_sync_skips_session_pinned_by_model_command(monkeypatch):
|
||||
_patch_config_model(monkeypatch, "new/model")
|
||||
session = _sync_test_session(
|
||||
config_model_seen=("old/model", ""),
|
||||
model_override={"model": "pinned/model"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_apply_model_switch",
|
||||
lambda *a, **k: pytest.fail("pinned session must not be switched"),
|
||||
)
|
||||
|
||||
server._sync_agent_model_with_config("sid", session)
|
||||
|
||||
|
||||
def test_config_sync_noop_when_config_unchanged(monkeypatch):
|
||||
_patch_config_model(monkeypatch, "old/model")
|
||||
session = _sync_test_session(config_model_seen=("old/model", ""))
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_apply_model_switch",
|
||||
lambda *a, **k: pytest.fail("unchanged config must not switch"),
|
||||
)
|
||||
|
||||
server._sync_agent_model_with_config("sid", session)
|
||||
|
||||
|
||||
def test_config_sync_failure_emits_error_once_per_edit(monkeypatch):
|
||||
_patch_config_model(monkeypatch, "broken/model")
|
||||
session = _sync_test_session(config_model_seen=("old/model", ""))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise ValueError("no such model")
|
||||
|
||||
monkeypatch.setattr(server, "_apply_model_switch", boom)
|
||||
emits = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda ev, sid, payload: emits.append((ev, payload))
|
||||
)
|
||||
|
||||
server._sync_agent_model_with_config("sid", session)
|
||||
server._sync_agent_model_with_config("sid", session)
|
||||
|
||||
assert len(emits) == 1
|
||||
assert emits[0][0] == "error"
|
||||
assert "broken/model" in emits[0][1]["message"]
|
||||
|
||||
|
||||
def test_startup_runtime_uses_tui_provider_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MODEL", "nous/hermes-test")
|
||||
monkeypatch.setenv("HERMES_TUI_PROVIDER", "nous")
|
||||
|
|
|
|||
|
|
@ -948,6 +948,9 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
# Session DB row deferred to first run_conversation() call.
|
||||
# pending_title applied post-first-message (see cli.exec handler).
|
||||
current["agent"] = agent
|
||||
# Baseline for the per-turn config sync; the profile home
|
||||
# override is still active here.
|
||||
current["config_model_seen"] = _config_model_target()
|
||||
|
||||
try:
|
||||
worker = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
|
||||
|
|
@ -1414,6 +1417,16 @@ def _resolve_model() -> str:
|
|||
return "anthropic/claude-sonnet-4"
|
||||
|
||||
|
||||
def _config_model_target() -> tuple[str, str]:
|
||||
"""(model, provider) currently selected by env/config."""
|
||||
model = _resolve_model()
|
||||
cfg_model = _load_cfg().get("model")
|
||||
provider = ""
|
||||
if isinstance(cfg_model, dict):
|
||||
provider = str(cfg_model.get("provider") or "").strip()
|
||||
return model, provider
|
||||
|
||||
|
||||
def _resolve_startup_runtime() -> tuple[str, str | None]:
|
||||
model = _resolve_model()
|
||||
explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
|
||||
|
|
@ -1883,6 +1896,7 @@ def _apply_model_switch(
|
|||
raw_input: str,
|
||||
*,
|
||||
confirm_expensive_model: bool = False,
|
||||
pin_session_override: bool = True,
|
||||
) -> dict:
|
||||
from hermes_cli.model_switch import parse_model_flags, switch_model
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
|
@ -1986,7 +2000,7 @@ def _apply_model_switch(
|
|||
# contamination bug). agent.switch_model() above already mutated the right
|
||||
# agent in place; the override dict makes that choice survive a rebuild
|
||||
# without touching shared process state.
|
||||
if isinstance(session, dict):
|
||||
if pin_session_override and isinstance(session, dict):
|
||||
session["model_override"] = {
|
||||
"model": result.new_model,
|
||||
"provider": result.target_provider,
|
||||
|
|
@ -2003,6 +2017,42 @@ def _apply_model_switch(
|
|||
}
|
||||
|
||||
|
||||
def _sync_agent_model_with_config(sid: str, session: dict) -> None:
|
||||
"""Adopt a config.yaml model change at turn start, like gateways do per
|
||||
message. Sessions pinned with /model keep their choice; a failed switch
|
||||
keeps the current model and never blocks the turn.
|
||||
"""
|
||||
agent = session.get("agent")
|
||||
if agent is None or session.get("model_override"):
|
||||
return
|
||||
target = _config_model_target()
|
||||
if not target[0]:
|
||||
return
|
||||
seen = session.get("config_model_seen")
|
||||
# Record first so a broken config gets one attempt per edit, not per turn.
|
||||
session["config_model_seen"] = target
|
||||
if target == seen:
|
||||
return
|
||||
if seen is None and target[0] == (getattr(agent, "model", "") or ""):
|
||||
return
|
||||
model, provider = target
|
||||
raw = f"{model} --provider {provider}" if provider else model
|
||||
try:
|
||||
_apply_model_switch(
|
||||
sid,
|
||||
session,
|
||||
raw,
|
||||
confirm_expensive_model=True,
|
||||
pin_session_override=False,
|
||||
)
|
||||
except Exception as e:
|
||||
_emit(
|
||||
"error",
|
||||
sid,
|
||||
{"message": f"Could not switch to configured model {model}: {e}"},
|
||||
)
|
||||
|
||||
|
||||
def _compress_session_history(
|
||||
session: dict,
|
||||
focus_topic: str | None = None,
|
||||
|
|
@ -3140,6 +3190,7 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
|
|||
finally:
|
||||
_clear_session_context(tokens)
|
||||
session["agent"] = new_agent
|
||||
session["config_model_seen"] = _config_model_target()
|
||||
session["attached_images"] = []
|
||||
session["edit_snapshots"] = {}
|
||||
session["image_counter"] = 0
|
||||
|
|
@ -5563,6 +5614,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
# the sudo.request overlay. (secret capture is a module global, so
|
||||
# re-running is a harmless no-op.)
|
||||
_wire_callbacks(sid)
|
||||
_sync_agent_model_with_config(sid, session)
|
||||
cwd = _session_cwd(session)
|
||||
_register_session_cwd(session)
|
||||
cols = session.get("cols", 80)
|
||||
|
|
|
|||
|
|
@ -855,20 +855,29 @@ export default function ModelsPage() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
api.getModelsAnalytics(days),
|
||||
api.getAuxiliaryModels().catch(() => null),
|
||||
])
|
||||
.then(([models, auxData]) => {
|
||||
setData(models);
|
||||
setAux(auxData);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [days]);
|
||||
const load = useCallback(
|
||||
(opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
Promise.all([
|
||||
api.getModelsAnalytics(days),
|
||||
api.getAuxiliaryModels().catch(() => null),
|
||||
])
|
||||
.then(([models, auxData]) => {
|
||||
setData(models);
|
||||
setAux(auxData);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!opts?.silent) setError(String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!opts?.silent) setLoading(false);
|
||||
});
|
||||
},
|
||||
[days],
|
||||
);
|
||||
|
||||
const onAssigned = useCallback(() => {
|
||||
// Reload aux state after any assignment change.
|
||||
|
|
@ -903,7 +912,7 @@ export default function ModelsPage() {
|
|||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={load}
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
|
|
@ -922,6 +931,20 @@ export default function ModelsPage() {
|
|||
load();
|
||||
}, [load]);
|
||||
|
||||
// Model assignments can change outside this page (config editor, chat
|
||||
// /model --global, CLI) — refetch silently when the page regains focus.
|
||||
useEffect(() => {
|
||||
const refetch = () => {
|
||||
if (document.visibilityState === "visible") load({ silent: true });
|
||||
};
|
||||
window.addEventListener("focus", refetch);
|
||||
document.addEventListener("visibilitychange", refetch);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refetch);
|
||||
document.removeEventListener("visibilitychange", refetch);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||
<PluginSlot name="models:top" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue