Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,44 @@ def rewrite_prompt_model_identity(agent, model: str, provider: str) -> None:
agent._cached_system_prompt = sp


def sync_lmstudio_active_model(agent, response) -> None:
"""Re-point the cached identity when LM Studio serves a different model.

LM Studio (and other local OpenAI-compatible servers) let the user swap the
loaded model from the app while a Hermes session stays open. ``agent.model``
and the ``Model:`` line in the cached system prompt are resolved once at
session start, so after a swap the agent keeps reporting the original model
when asked about its inference engine — only a brand-new session picks up the
change (#54454).

Every chat-completion response echoes the model that actually served it, so
when that live name diverges from ``agent.model`` we adopt it and rewrite the
cached identity via :func:`rewrite_prompt_model_identity` (the same in-place,
non-persisted rewrite already used for provider failover). Scoped to the
``lmstudio`` provider — the only path where the server-side model changes
underneath a live session — so no other provider's identity is touched.
"""
if (getattr(agent, "provider", "") or "").strip().lower() != "lmstudio":
return
live_model = getattr(response, "model", None)
if not isinstance(live_model, str) or not live_model.strip():
return
live_model = live_model.strip()
current = (getattr(agent, "model", "") or "").strip()
if not current:
return
try:
from agent.model_metadata import _model_id_matches

if _model_id_matches(live_model, current) or _model_id_matches(current, live_model):
return
except Exception:
if live_model == current:
return
agent.model = live_model
rewrite_prompt_model_identity(agent, live_model, agent.provider)


def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
return (
str(fb.get("provider") or "").strip().lower(),
Expand Down
9 changes: 9 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2263,6 +2263,15 @@ def _perform_api_call(next_api_kwargs):
except Exception:
pass
agent._touch_activity(f"API call #{api_call_count} completed")
# LM Studio users can swap the loaded model from the app while a
# session is open; the response echoes the live model, so re-sync
# the cached identity when it drifts (#54454).
if agent.provider == "lmstudio":
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs after the response has already been generated, so it cannot correct the first “what model are you?” answer after an LM Studio swap. It also follows the token-persistence block, which has already recorded the completed response under the previous agent.model. Please move discovery/synchronization to a pre-request path (or narrow the behavior contract) and cover that ordering with a loop-level test.

from agent.chat_completion_helpers import sync_lmstudio_active_model
sync_lmstudio_active_model(agent, response)
except Exception:
pass
break # Success, exit retry loop

except InterruptedError:
Expand Down
61 changes: 60 additions & 1 deletion tests/agent/test_failover_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@

from types import SimpleNamespace

from agent.chat_completion_helpers import rewrite_prompt_model_identity
from agent.chat_completion_helpers import (
rewrite_prompt_model_identity,
sync_lmstudio_active_model,
)
from agent.conversation_loop import _sync_failover_system_message


Expand Down Expand Up @@ -70,6 +73,62 @@ def test_empty_values_leave_lines_unchanged(self):
assert agent._cached_system_prompt == _PROMPT


_LMSTUDIO_PROMPT = (
"You are a helpful assistant.\n"
"\n"
"Conversation started: Wednesday, June 10, 2026\n"
"Model: gemma-3-4b\n"
"Provider: lmstudio"
)


def _lmstudio_agent(model="gemma-3-4b", prompt=_LMSTUDIO_PROMPT):
return SimpleNamespace(
provider="lmstudio",
model=model,
_cached_system_prompt=prompt,
ephemeral_system_prompt=None,
)


class TestSyncLmstudioActiveModel:
def test_adopts_swapped_model_and_rewrites_identity(self):
# User swapped Gemma -> Qwen in the LM Studio app mid-session (#54454).
agent = _lmstudio_agent()
sync_lmstudio_active_model(agent, SimpleNamespace(model="qwen3-6b"))
assert agent.model == "qwen3-6b"
assert "Model: qwen3-6b" in agent._cached_system_prompt
assert "Model: gemma-3-4b" not in agent._cached_system_prompt

def test_noop_when_model_unchanged(self):
agent = _lmstudio_agent()
sync_lmstudio_active_model(agent, SimpleNamespace(model="gemma-3-4b"))
assert agent.model == "gemma-3-4b"
assert agent._cached_system_prompt == _LMSTUDIO_PROMPT

def test_noop_on_slug_vs_basename_match(self):
# LM Studio native API returns "publisher/slug"; the configured value is
# the bare slug — these are the same model, not a drift.
agent = _lmstudio_agent(model="gemma-3-4b")
sync_lmstudio_active_model(agent, SimpleNamespace(model="google/gemma-3-4b"))
assert agent.model == "gemma-3-4b"
assert agent._cached_system_prompt == _LMSTUDIO_PROMPT

def test_noop_for_non_lmstudio_provider(self):
agent = _lmstudio_agent()
agent.provider = "openai"
sync_lmstudio_active_model(agent, SimpleNamespace(model="qwen3-6b"))
assert agent.model == "gemma-3-4b"
assert agent._cached_system_prompt == _LMSTUDIO_PROMPT

def test_noop_when_response_has_no_model(self):
agent = _lmstudio_agent()
sync_lmstudio_active_model(agent, SimpleNamespace(model=None))
assert agent.model == "gemma-3-4b"
sync_lmstudio_active_model(agent, SimpleNamespace(model=" "))
assert agent.model == "gemma-3-4b"


class TestSyncFailoverSystemMessage:
def test_patches_in_flight_system_message(self):
agent = _agent()
Expand Down