Refresh session context metadata on model changes

This commit is contained in:
Frank Song committed 2026-05-17 13:27:40 +08:00
1 parent 603183a301
commit fe55cf5b9e
7 files changed
+251 -59

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Refresh context-window metadata when a session's resolved model changes during load or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Fixes #2442.
## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)
### Added
+78 -41
View File
@@ -1630,6 +1630,57 @@ def _resolve_effective_session_model_provider_for_display(session) -> str | None
return provider
def _resolve_context_length_for_session_model(
model: str | None,
provider: str | None = None,
) -> int:
"""Best-effort current context window for a session model.
Persisted session context metadata is a snapshot from a prior model call.
During session hydration/model switching, the current model metadata should
be allowed to replace that stale snapshot.
"""
model_for_lookup = str(model or "").strip()
if not model_for_lookup:
return 0
try:
from agent.model_metadata import get_model_context_length as _get_cl
from api.config import get_config as _get_config_for_cl
_cfg_for_cl = _get_config_for_cl()
_cfg_ctx_len_load = None
_cfg_custom_providers_load = None
try:
_model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {}
if isinstance(_model_cfg_load, dict):
_raw_cfg_ctx_load = _model_cfg_load.get('context_length')
if _raw_cfg_ctx_load is not None:
try:
_parsed_load = int(_raw_cfg_ctx_load)
if _parsed_load > 0:
_cfg_ctx_len_load = _parsed_load
except (TypeError, ValueError):
pass
_raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None
if isinstance(_raw_cp_load, list):
_cfg_custom_providers_load = _raw_cp_load
except Exception:
pass
try:
return _get_cl(
model_for_lookup,
"",
config_context_length=_cfg_ctx_len_load,
provider=provider or "",
custom_providers=_cfg_custom_providers_load,
) or 0
except TypeError:
# Older hermes-agent builds: legacy 2-arg form.
return _get_cl(model_for_lookup, "") or 0
except Exception:
return 0
def _session_model_state_from_request(
model: str | None,
requested_provider: str | None,
@@ -3553,48 +3604,22 @@ def handle_get(handler, parsed) -> bool:
# /api/session/get response — the same wrong-window display this
# fix addresses on the streaming side.
_persisted_cl = getattr(s, "context_length", 0) or 0
if not _persisted_cl:
_threshold_tokens = getattr(s, "threshold_tokens", 0) or 0
if (not _persisted_cl) or resolve_model:
_model_for_lookup = (
getattr(s, "model", "") or effective_model or ""
effective_model or getattr(s, "model", "") or ""
).strip()
if _model_for_lookup:
try:
from agent.model_metadata import get_model_context_length as _get_cl
from api.config import get_config as _get_config_for_cl
_cfg_for_cl = _get_config_for_cl()
_cfg_ctx_len_load = None
_cfg_custom_providers_load = None
try:
_model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {}
if isinstance(_model_cfg_load, dict):
_raw_cfg_ctx_load = _model_cfg_load.get('context_length')
if _raw_cfg_ctx_load is not None:
try:
_parsed_load = int(_raw_cfg_ctx_load)
if _parsed_load > 0:
_cfg_ctx_len_load = _parsed_load
except (TypeError, ValueError):
pass
_raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None
if isinstance(_raw_cp_load, list):
_cfg_custom_providers_load = _raw_cp_load
except Exception:
pass
try:
_fb_cl = _get_cl(
_model_for_lookup,
"",
config_context_length=_cfg_ctx_len_load,
provider=effective_provider or "",
custom_providers=_cfg_custom_providers_load,
) or 0
except TypeError:
# Older hermes-agent builds: legacy 2-arg form.
_fb_cl = _get_cl(_model_for_lookup, "") or 0
if _fb_cl:
_persisted_cl = _fb_cl
except Exception:
pass
_fb_cl = _resolve_context_length_for_session_model(
_model_for_lookup,
effective_provider or getattr(s, "model_provider", None) or "",
)
if _fb_cl:
if _persisted_cl and _fb_cl != _persisted_cl:
# The old threshold belongs to the old window. Hiding it
# is less misleading than rendering a stale compression
# threshold against a freshly resolved context length.
_threshold_tokens = 0
_persisted_cl = _fb_cl
_session_tool_calls = getattr(s, "tool_calls", []) if load_messages else []
if (
load_messages
@@ -3613,7 +3638,7 @@ def handle_get(handler, parsed) -> bool:
"pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [],
"pending_started_at": getattr(s, "pending_started_at", None),
"context_length": _persisted_cl,
"threshold_tokens": getattr(s, "threshold_tokens", 0) or 0,
"threshold_tokens": _threshold_tokens,
"last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0,
}
if original_stream_id:
@@ -4638,6 +4663,8 @@ def handle_post(handler, parsed) -> bool:
except KeyError:
return bad(handler, "Session not found", 404)
old_ws = getattr(s, "workspace", "")
old_model = getattr(s, "model", None)
old_provider = getattr(s, "model_provider", None)
try:
new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace)))
except ValueError as e:
@@ -4653,6 +4680,16 @@ def handle_post(handler, parsed) -> bool:
if model is not None:
s.model = model
s.model_provider = provider
if (
str(old_model or "") != str(getattr(s, "model", "") or "")
or str(old_provider or "") != str(getattr(s, "model_provider", "") or "")
):
s.context_length = _resolve_context_length_for_session_model(
getattr(s, "model", None),
getattr(s, "model_provider", None),
)
s.threshold_tokens = 0
s.last_prompt_tokens = 0
s.save()
if str(old_ws or "") != str(new_ws or ""):
try:
+25 -5
View File
@@ -907,6 +907,25 @@ function clearPreview(opts={}){
}
$('btnClearPreview').onclick=handleWorkspaceClose;
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
function _applySessionContextMetadataUpdate(data){
if(!S.session||!data||!data.session)return;
S.session.context_length=data.session.context_length||0;
S.session.threshold_tokens=data.session.threshold_tokens||0;
S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
if(typeof _syncCtxIndicator==='function'){
const u=S.lastUsage||{};
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
_syncCtxIndicator({
input_tokens:_pick(u.input_tokens,S.session.input_tokens),
output_tokens:_pick(u.output_tokens,S.session.output_tokens),
estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
context_length:S.session.context_length||0,
last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
threshold_tokens:S.session.threshold_tokens||0,
});
}
}
$('modelSelect').onchange=async()=>{
if(!S.session)return;
const selectedModel=$('modelSelect').value;
@@ -916,7 +935,11 @@ $('modelSelect').onchange=async()=>{
if(typeof closeModelDropdown==='function') closeModelDropdown();
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
await api('/api/session/update',{method:'POST',body:JSON.stringify({
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
}
const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,
workspace:S.session.workspace,
model:modelState.model,
@@ -926,10 +949,7 @@ $('modelSelect').onchange=async()=>{
S.session.model_provider=modelState.model_provider||null;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
}
_applySessionContextMetadataUpdate(data);
// Warn if selected model belongs to a different provider than what Hermes is configured for
if(typeof _checkProviderMismatch==='function'){
const warn=_checkProviderMismatch(selectedModel);
+17 -2
View File
@@ -707,9 +707,9 @@ async function loadSession(sid){
input_tokens: _pick(u.input_tokens, _s.input_tokens),
output_tokens: _pick(u.output_tokens, _s.output_tokens),
estimated_cost: _pick(u.estimated_cost, _s.estimated_cost),
context_length: _pick(u.context_length, _s.context_length),
context_length: _pick(_s.context_length, u.context_length),
last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens),
threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens),
threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens),
});
}
if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession();
@@ -1102,8 +1102,23 @@ function _resolveSessionModelForDisplaySoon(sid){
if(!model||!S.session||S.session.session_id!==sid) return;
S.session.model=model;
S.session.model_provider=provider||null;
S.session.context_length=data.session.context_length||0;
S.session.threshold_tokens=data.session.threshold_tokens||0;
S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
S.session._modelResolutionDeferred=false;
syncTopbar();
if(typeof _syncCtxIndicator==='function'){
const u=S.lastUsage||{};
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
_syncCtxIndicator({
input_tokens:_pick(u.input_tokens,S.session.input_tokens),
output_tokens:_pick(u.output_tokens,S.session.output_tokens),
estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
context_length:data.session.context_length||0,
last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
threshold_tokens:data.session.threshold_tokens||0,
});
}
}catch(_){
// Keep session switching non-blocking; the next load can try again.
}
@@ -103,7 +103,7 @@ class TestIssue1436BackendFallback:
return captured
def test_persisted_context_length_passed_through_unchanged(self):
"""When Session.context_length is non-zero, return it as-is (no fallback)."""
"""Fast metadata loads keep the persisted value to avoid catalog work."""
s = self._stub_session(context_length=200_000, model="claude-sonnet-4.6")
result = self._invoke_get_session(s, fallback_returns=999_999)
body = result["data"]["session"]
@@ -112,6 +112,94 @@ class TestIssue1436BackendFallback:
f"got {body['context_length']}"
)
def test_resolved_model_load_refreshes_stale_persisted_context_length(self):
"""The deferred resolve_model=1 load must refresh stale context metadata.
Session switching first asks for messages=0&resolve_model=0 for speed,
then follows with messages=0&resolve_model=1 to hydrate the final
model/provider display. That second path is also where a stale
context_length from a prior model must be corrected; otherwise a
resumed DeepSeek 1M session can stay stuck on an old 200k window until
the user manually toggles models.
"""
import api.routes as routes
captured = {}
def fake_j(h, data, status=200):
captured["data"] = data
fake_module = MagicMock()
fake_module.get_model_context_length = MagicMock(return_value=1_000_000)
s = self._stub_session(context_length=200_000, model="deepseek-v4-pro")
handler = MagicMock()
parsed = urlparse(
"/api/session?session_id=test-1436&messages=0&resolve_model=1"
)
with patch("api.routes.get_session", return_value=s), \
patch("api.routes.j", side_effect=fake_j), \
patch.dict("sys.modules", {"agent.model_metadata": fake_module}):
routes.handle_get(handler, parsed)
body = captured["data"]["session"]
assert body["context_length"] == 1_000_000, (
"resolve_model=1 must refresh stale persisted context_length from "
"current model metadata"
)
def test_session_model_update_refreshes_context_metadata(self):
"""Changing the session model must not keep the prior model's window."""
import api.routes as routes
captured = {}
def fake_j(h, data, status=200):
captured["data"] = data
fake_module = MagicMock()
fake_module.get_model_context_length = MagicMock(return_value=1_000_000)
s = self._stub_session(context_length=200_000, model="old-model")
s.model_provider = "old-provider"
s.workspace = "/tmp"
s.threshold_tokens = 100_000
s.last_prompt_tokens = 80_000
s.save = MagicMock()
s.compact.return_value = {
**s.compact.return_value,
"model": "deepseek-v4-pro",
"model_provider": "deepseek",
"context_length": 1_000_000,
"threshold_tokens": 0,
"last_prompt_tokens": 0,
}
handler = MagicMock()
parsed = urlparse("/api/session/update")
body = {
"session_id": "test-1436",
"workspace": "/tmp",
"model": "deepseek-v4-pro",
"model_provider": "deepseek",
}
with patch("api.routes._check_csrf", return_value=True), \
patch("api.routes.read_body", return_value=body), \
patch("api.routes.get_session", return_value=s), \
patch("api.routes.resolve_trusted_workspace", return_value="/tmp"), \
patch("api.routes.j", side_effect=fake_j), \
patch.dict("sys.modules", {"agent.model_metadata": fake_module}):
routes.handle_post(handler, parsed)
assert s.model == "deepseek-v4-pro"
assert s.model_provider == "deepseek"
assert s.context_length == 1_000_000
assert s.threshold_tokens == 0
assert s.last_prompt_tokens == 0
s.save.assert_called_once()
assert captured["data"]["session"]["context_length"] == 1_000_000
def test_zero_context_length_falls_back_to_model_metadata(self):
"""Pre-#1318 sessions with context_length=0 must resolve via model_metadata."""
s = self._stub_session(context_length=0, model="claude-opus-4-7",
@@ -276,13 +364,19 @@ class TestIssue1436SourceMarkers:
def test_routes_load_path_imports_get_model_context_length(self):
src = ROUTES.read_text(encoding="utf-8")
# The import must appear inside the GET /api/session load-path block.
# The session load path can call a helper, but the lazy import must
# remain in routes.py so WebUI still works with older/missing agent
# bundles by swallowing metadata-resolution failures.
start = src.find('if parsed.path == "/api/session":')
end = src.find('if parsed.path == "/api/projects":', start)
block = src[start:end]
assert "from agent.model_metadata import get_model_context_length" in block, (
"GET /api/session load-path block must lazy-import "
"get_model_context_length for the context_length=0 fallback (#1436)"
assert "_resolve_context_length_for_session_model" in block, (
"GET /api/session load-path block must resolve model context "
"metadata for the context_length fallback (#1436)"
)
assert "from agent.model_metadata import get_model_context_length" in src, (
"routes.py must lazy-import get_model_context_length for the "
"context_length fallback (#1436)"
)
def test_routes_load_path_marks_fix_with_issue_number(self):
@@ -202,25 +202,32 @@ def test_routes_session_load_fallback_passes_config_overrides():
anchor = "older sessions (pre-#1318) that have context_length=0 persisted"
idx = ROUTES_PY.find(anchor)
assert idx != -1, "session-load fallback comment moved/removed"
# Find the resolver callsite that follows.
# The route block may delegate the resolver details to a helper, but the
# session-load path must still call the helper and that helper must preserve
# the same kwargs as the streaming.py fix.
block_end = ROUTES_PY.find("if _fb_cl:", idx)
assert block_end != -1, "_fb_cl assignment not found after fallback comment"
block = ROUTES_PY[idx:block_end]
helper_start = ROUTES_PY.find("def _resolve_context_length_for_session_model")
assert helper_start != -1, "context-length resolver helper not found"
helper_end = ROUTES_PY.find("\ndef ", helper_start + 1)
helper = ROUTES_PY[helper_start:helper_end if helper_end != -1 else len(ROUTES_PY)]
assert "_resolve_context_length_for_session_model" in block
# Same kwargs as the streaming.py fix.
assert "config_context_length=" in block, (
assert "config_context_length=" in helper, (
"session-load fallback in api/routes.py must pass config_context_length= "
"so user-set model.context_length wins over the 256K default. See #1896."
)
assert "provider=effective_provider" in block, (
"session-load fallback in api/routes.py must pass provider=effective_provider "
assert "provider=provider or" in helper, (
"session-load fallback in api/routes.py must pass provider= "
"so the registry lookup is provider-aware. See #1896."
)
assert "custom_providers=" in block, (
assert "custom_providers=" in helper, (
"session-load fallback in api/routes.py must pass custom_providers= "
"so the per-model override path applies. See #1896."
)
# Legacy fallback for older hermes-agent builds that pre-date the kwargs.
assert "except TypeError:" in block, (
assert "except TypeError:" in helper, (
"session-load fallback must catch TypeError to support older "
"hermes-agent builds without the new kwargs."
)
+15
View File
@@ -42,6 +42,21 @@ def test_session_switch_defers_model_resolution_without_blocking():
assert "if(fallback&&!deferModelCorrection)" in ui
def test_deferred_model_resolution_refreshes_context_metadata():
src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
start = src.index("function _resolveSessionModelForDisplaySoon")
end = src.index("const _INITIAL_MSG_LIMIT", start)
block = src[start:end]
assert "S.session.context_length" in block, (
"deferred model resolution must also hydrate context_length so a "
"resumed high-context session does not keep the old model's limit"
)
assert "S.session.threshold_tokens" in block
assert "_syncCtxIndicator" in block
assert "context_length:data.session.context_length||0" in block.replace(" ", "")
def test_boot_does_not_block_session_restore_on_model_catalog():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")