diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cfcd02d..90ff4927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Fixed +- **The context-usage indicator is more accurate right after a context compaction.** After the conversation context was compressed, the indicator kept showing the previous provider-reported prompt size (the pre-compaction request) instead of the smaller prepared one, so it briefly overstated how full the window was. It now shows a short-lived, clearly-labeled post-compaction estimate that expires as soon as the next real provider count arrives — provider accounting, token metering, and recovery behavior are unchanged. Thanks @rodboev. (#5872, #4685) + - **A failed turn now shows the real provider error instead of "no response, try again".** When a turn hit a non-retryable provider error (e.g. HTTP 400 "invalid model" or a credentials problem), the WebUI showed a misleading "No response from provider — this often means a usage/rate limit was hit silently, try again in a moment" message and hid the actual, non-retryable cause. The terminal error the agent reports is now surfaced through to the turn-completion classifier, so you see an accurate, actionable message (e.g. "Model not found — check the model ID in Settings" / "Authentication failed") instead of being told to retry a request that can never succeed. The generic no-response message stays reserved for genuinely empty completions. Thanks @b3nw for the report. (#5940) - **Public-share button labels now show in the right language.** A value-level locale swap shipped the public-share strings (Share / Stop sharing / confirmation dialogs) as Russian text in the English locale and English text in the Russian locale — key-parity checks didn't catch it because the keys themselves were balanced. Both locales are restored to their correct language, with a regression test that fails if the values are ever swapped again. Thanks @DrMaks22. (#5955) diff --git a/api/models.py b/api/models.py index 0c5de56f..cd78fa13 100644 --- a/api/models.py +++ b/api/models.py @@ -1108,6 +1108,7 @@ class Session: context_engine_state=None, context_length=None, threshold_tokens=None, last_prompt_tokens=None, + post_compression_context_tokens_estimate=None, compression_recovery=None, recommended_recovery_action=None, compression_recovery_source_session_id=None, @@ -1167,6 +1168,10 @@ class Session: self.context_length = context_length self.threshold_tokens = threshold_tokens self.last_prompt_tokens = last_prompt_tokens + _post_compression_tokens = _parse_nonnegative_int(post_compression_context_tokens_estimate) + self.post_compression_context_tokens_estimate = ( + _post_compression_tokens if _post_compression_tokens and _post_compression_tokens > 0 else None + ) self.compression_recovery = compression_recovery if isinstance(compression_recovery, dict) else {} self.recommended_recovery_action = recommended_recovery_action self.compression_recovery_source_session_id = ( @@ -1261,6 +1266,7 @@ class Session: 'context_engine', 'compression_anchor_engine', 'compression_anchor_mode', 'compression_anchor_details', 'context_engine_state', 'context_length', 'threshold_tokens', 'last_prompt_tokens', + 'post_compression_context_tokens_estimate', 'compression_recovery', 'recommended_recovery_action', 'compression_recovery_source_session_id', 'compression_recovery_action', 'truncation_watermark', @@ -1622,6 +1628,7 @@ class Session: 'context_length': self.context_length, 'threshold_tokens': self.threshold_tokens, 'last_prompt_tokens': self.last_prompt_tokens, + 'post_compression_context_tokens_estimate': self.post_compression_context_tokens_estimate, 'compression_recovery': self.compression_recovery, 'recommended_recovery_action': self.recommended_recovery_action, 'gateway_routing': self.gateway_routing, diff --git a/api/routes.py b/api/routes.py index b004834f..999b8caa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20510,6 +20510,7 @@ def _prepare_chat_start_session_for_stream( s.model = model s.model_provider = model_provider s.active_stream_id = stream_id + s.post_compression_context_tokens_estimate = None s.pending_user_message = msg s.pending_attachments = attachments s.pending_started_at = started_at if started_at is not None else time.time() diff --git a/api/streaming.py b/api/streaming.py index 1507f774..47a17179 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4414,6 +4414,45 @@ def _prune_context_tool_results_after_compression(agent, context_messages): return pruned_messages +def _estimate_post_compression_context_tokens(agent, context_messages, system_message): + """Return a display-only estimate for the prepared post-compression request.""" + try: + from agent import model_metadata + + messages = context_messages or [] + tools = getattr(agent, 'tools', None) or None + request_estimator = getattr(model_metadata, 'estimate_request_tokens_rough', None) + if callable(request_estimator): + estimate = request_estimator(messages, system_prompt=system_message or '', tools=tools) + else: + message_estimator = getattr(model_metadata, 'estimate_messages_tokens_rough', None) + if callable(message_estimator): + estimate = message_estimator(messages) + if system_message: + estimate += message_estimator([{'role': 'system', 'content': system_message}]) + if tools: + estimate += message_estimator([{'role': 'system', 'content': str(tools)}]) + else: + try: + from agent.context_compressor import _estimate_msg_budget_tokens + except ImportError: + return None + + estimate = sum( + _estimate_msg_budget_tokens(message) + for message in messages + if isinstance(message, dict) + ) + if system_message: + estimate += _estimate_msg_budget_tokens({'role': 'system', 'content': system_message}) + if tools: + estimate += _estimate_msg_budget_tokens({'role': 'system', 'content': str(tools)}) + return estimate if isinstance(estimate, int) and estimate > 0 else None + except Exception: + logger.debug("post-compression context estimate failed", exc_info=True) + return None + + def _restore_reasoning_metadata(previous_messages, updated_messages): """Carry forward display-only metadata lost during API-safe history sanitization. @@ -6675,6 +6714,7 @@ def _run_agent_streaming( 'context_length': 0, 'threshold_tokens': 0, 'last_prompt_tokens': 0, + 'post_compression_context_tokens_estimate': None, } _session_obj = _current_live_usage_session() @@ -6847,6 +6887,11 @@ def _run_agent_streaming( _usage[_field] = getattr(_session_obj, _field, 0) or 0 except Exception: pass + _post_compression_estimate = getattr( + _session_obj, 'post_compression_context_tokens_estimate', None, + ) + if isinstance(_post_compression_estimate, int) and _post_compression_estimate > 0: + _usage['post_compression_context_tokens_estimate'] = _post_compression_estimate _real_prompt_tokens = int(_usage.get('last_prompt_tokens') or 0) _usage['cache_hit_percent'] = prompt_cache_hit_percent( @@ -9039,6 +9084,11 @@ def _run_agent_streaming( agent, s.context_messages, ) + s.post_compression_context_tokens_estimate = _estimate_post_compression_context_tokens( + agent, + s.context_messages, + workspace_system_msg, + ) visible_after = visible_messages_for_anchor(s.messages, auto_compression=True) # Find the LAST [CONTEXT COMPACTION] marker in s.messages # and count visible messages before it. This is the correct @@ -9752,6 +9802,12 @@ def _run_agent_streaming( _sess_lpt = getattr(s, 'last_prompt_tokens', 0) or 0 if _sess_lpt: usage['last_prompt_tokens'] = _sess_lpt + _post_compression_estimate = getattr(s, 'post_compression_context_tokens_estimate', None) + usage['post_compression_context_tokens_estimate'] = ( + _post_compression_estimate + if isinstance(_post_compression_estimate, int) and _post_compression_estimate > 0 + else None + ) # (reasoning trace already attached + saved above, before s.save()) # Leftover-steer delivery: if a /steer was queued (via # api/chat/steer) but the agent finished its turn before diff --git a/static/boot.js b/static/boot.js index 6ee0f3c1..36a07e9a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2058,6 +2058,7 @@ function _applySessionContextMetadataUpdate(data){ 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.post_compression_context_tokens_estimate=data.session.post_compression_context_tokens_estimate||null; if(typeof _syncCtxIndicator==='function'){ const u=S.lastUsage||{}; const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); @@ -2067,6 +2068,7 @@ function _applySessionContextMetadataUpdate(data){ 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), + post_compression_context_tokens_estimate:S.session.post_compression_context_tokens_estimate, threshold_tokens:S.session.threshold_tokens||0, }); } diff --git a/static/messages.js b/static/messages.js index 4c9e0050..696066ab 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5458,7 +5458,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(d.usage){ const _doneUsageFallback={...(S.lastUsage||{})}; if(S.session){ - for(const _usageField of ['context_length','threshold_tokens','last_prompt_tokens']){ + for(const _usageField of ['context_length','threshold_tokens','last_prompt_tokens','post_compression_context_tokens_estimate']){ if(_doneUsageFallback[_usageField]==null&&S.session[_usageField]!=null){ _doneUsageFallback[_usageField]=S.session[_usageField]; } diff --git a/static/sessions.js b/static/sessions.js index ddaade19..5863cf9f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1341,6 +1341,7 @@ async function newSession(flash, options={}){ cache_hit_percent:data.session.cache_hit_percent, context_length:data.session.context_length||0, last_prompt_tokens:data.session.last_prompt_tokens||0, + post_compression_context_tokens_estimate:data.session.post_compression_context_tokens_estimate||null, threshold_tokens:data.session.threshold_tokens||0, }); } @@ -2117,6 +2118,7 @@ async function loadSession(sid){ cache_hit_percent: _pick(u.cache_hit_percent, _s.cache_hit_percent, null), context_length: _pickPositive(u.context_length, _s.context_length), last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens), + post_compression_context_tokens_estimate:_s.post_compression_context_tokens_estimate||null, threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens), }); } @@ -2726,6 +2728,7 @@ function _resolveSessionModelForDisplaySoon(sid){ S.session.context_length=resolvedContextLength; S.session.threshold_tokens=data.session.threshold_tokens||0; S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; + S.session.post_compression_context_tokens_estimate=data.session.post_compression_context_tokens_estimate||null; S.session._modelResolutionDeferred=false; syncTopbar(); if(typeof _syncCtxIndicator==='function'){ @@ -2740,6 +2743,7 @@ function _resolveSessionModelForDisplaySoon(sid){ cache_hit_percent:_pick(u.cache_hit_percent,S.session.cache_hit_percent,null), context_length:resolvedContextLength||u.context_length||0, last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), + post_compression_context_tokens_estimate:S.session.post_compression_context_tokens_estimate, threshold_tokens:data.session.threshold_tokens||0, }); } diff --git a/static/ui.js b/static/ui.js index 81fd486c..58a6ec3f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6096,6 +6096,9 @@ function _mergeUsageForCtxIndicator(latest, fallback){ merged[field]=fallbackObj[field]; } } + if(!Object.hasOwn(latestObj,'post_compression_context_tokens_estimate')&&fallbackObj.post_compression_context_tokens_estimate!=null){ + merged.post_compression_context_tokens_estimate=fallbackObj.post_compression_context_tokens_estimate; + } return merged; } @@ -6116,7 +6119,10 @@ function _syncCtxIndicator(usage){ // nonsense percentage (often >100%) on long sessions. When we have no // last-prompt data we render "·" + "tokens used" via the !hasPromptTok // branch below — honest "no data" instead of misleading "890% used". + const postCompressionEstimate=Number(usage.post_compression_context_tokens_estimate)||0; + const hasPostCompressionEstimate=postCompressionEstimate>0; const promptTok=usage.last_prompt_tokens||0; + const contextPromptTok=hasPostCompressionEstimate?postCompressionEstimate:promptTok; const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0); const cacheReadTok=usage.cache_read_tokens||0; const cacheWriteTok=usage.cache_write_tokens||0; @@ -6136,8 +6142,9 @@ function _syncCtxIndicator(usage){ wrap.removeAttribute('aria-hidden'); wrap.style.display=''; } - const hasPromptTok=!!promptTok; - const rawPct=hasPromptTok?Math.round((promptTok/ctxWindow)*100):0; + let hasPromptTok=!!promptTok; + if(hasPostCompressionEstimate) hasPromptTok=true; + const rawPct=hasPromptTok?Math.round((contextPromptTok/ctxWindow)*100):0; const pct=Math.min(100,rawPct); const overflowed=rawPct>100; const ring=$('ctxRingValue'); @@ -6165,13 +6172,14 @@ function _syncCtxIndicator(usage){ _setCtxCompressButton(compressBtn,compressText); const cacheHitPct=usage.cache_hit_percent; const cacheText=cacheHitPct!=null?t('usage_cache_hit_detail',cacheHitPct,_fmtTokens(cacheReadTok),_fmtTokens(cacheWriteTok)):''; - let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`; + const contextLabel=hasPostCompressionEstimate?'Estimated next model context':'Context window'; + let label=hasPromptTok?`${contextLabel} ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`; if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)'; if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; if(cacheText) label+=` \u00b7 ${cacheText}`; el.setAttribute('aria-label',label); - const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`; - const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`; + const usageText=hasPromptTok?(overflowed?`${contextLabel}: ${rawPct}% used (context exceeded)`:`${contextLabel}: ${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`; + const tokensText=hasPromptTok?`${contextLabel}: ${_fmtTokens(contextPromptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`; if(usageLine) usageLine.textContent=usageText; if(tokensLine) tokensLine.textContent=tokensText; const threshold=usage.threshold_tokens||0; diff --git a/tests/test_issue4685_post_compression_context_metering.py b/tests/test_issue4685_post_compression_context_metering.py new file mode 100644 index 00000000..bfd40031 --- /dev/null +++ b/tests/test_issue4685_post_compression_context_metering.py @@ -0,0 +1,177 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _run_context_indicator(usage): + source = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + start = source.index("function _syncCtxIndicator") + end = source.index("// ── Touch support: toggle context tooltip on tap", start) + indicator = source[start:end] + script = f""" +const nodes = {{}}; +for (const id of ['ctxIndicatorWrap', 'ctxIndicator', 'ctxRingValue', 'ctxPercent', 'ctxTooltipUsage', 'ctxTooltipTokens', 'ctxTooltipThreshold', 'ctxTooltipCost', 'ctxTooltipCompress', 'ctxCompressBtn']) {{ + nodes[id] = {{style: {{}}, classList: {{remove(){{}}, toggle(){{}}}}, removeAttribute(){{}}, setAttribute(name, value){{ this[name] = value; }}}}; +}} +global.$ = id => nodes[id] || null; +global.window = {{}}; +global._syncMobileCtxDisplay = () => {{}}; +global._setCtxCompressButton = () => {{}}; +global._fmtTokens = value => String(value); +global.t = key => key; +{indicator} +_syncCtxIndicator({json.dumps(usage)}); +console.log(JSON.stringify({{percent: nodes.ctxPercent.textContent, label: nodes.ctxIndicator['aria-label'], usage: nodes.ctxTooltipUsage.textContent, tokens: nodes.ctxTooltipTokens.textContent}})); +""" + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return json.loads(result.stdout) + + +def test_context_indicator_uses_post_compression_estimate(): + indicator = _run_context_indicator( + { + "last_prompt_tokens": 100_000, + "post_compression_context_tokens_estimate": 4_096, + "context_length": 128_000, + } + ) + + assert indicator["percent"] == "3" + assert indicator["label"].startswith("Estimated next model context") + assert indicator["usage"].startswith("Estimated next model context") + + +def test_post_compression_estimate_uses_pruned_request_and_preserves_last_prompt(monkeypatch): + from api.streaming import _estimate_post_compression_context_tokens + + calls = [] + + def estimate(messages, *, system_prompt, tools): + calls.append((messages, system_prompt, tools)) + return 4_096 + + monkeypatch.setattr("agent.model_metadata.estimate_request_tokens_rough", estimate, raising=False) + pruned = [{"role": "assistant", "content": "summary"}] + agent = type("Agent", (), {"tools": [{"name": "read_file"}]})() + + assert _estimate_post_compression_context_tokens(agent, pruned, "workspace") == 4_096 + assert calls == [(pruned, "workspace", agent.tools)] + + +def test_post_compression_estimate_falls_back_when_request_estimator_is_unavailable(monkeypatch): + from api.streaming import _estimate_post_compression_context_tokens + + calls = [] + + def estimate_messages(messages): + calls.append(messages) + return len(messages) * 100 + + monkeypatch.delattr("agent.model_metadata.estimate_request_tokens_rough", raising=False) + monkeypatch.setattr("agent.model_metadata.estimate_messages_tokens_rough", estimate_messages, raising=False) + pruned = [{"role": "assistant", "content": "summary"}] + agent = type("Agent", (), {"tools": [{"name": "read_file"}]})() + + assert _estimate_post_compression_context_tokens(agent, pruned, "workspace") == 300 + assert calls == [ + pruned, + [{"role": "system", "content": "workspace"}], + [{"role": "system", "content": str(agent.tools)}], + ] + + +def test_post_compression_estimate_uses_compressor_budget_counter_without_metadata_estimators(monkeypatch): + import pytest + + from api.streaming import _estimate_post_compression_context_tokens + + context_compressor = pytest.importorskip("agent.context_compressor") + + monkeypatch.delattr("agent.model_metadata.estimate_request_tokens_rough", raising=False) + monkeypatch.delattr("agent.model_metadata.estimate_messages_tokens_rough", raising=False) + pruned = [{"role": "assistant", "content": "summary"}] + agent = type("Agent", (), {"tools": [{"name": "read_file"}]})() + expected_messages = [ + pruned[0], + {"role": "system", "content": "workspace"}, + {"role": "system", "content": str(agent.tools)}, + ] + + assert _estimate_post_compression_context_tokens(agent, pruned, "workspace") == sum( + context_compressor._estimate_msg_budget_tokens(message) + for message in expected_messages + ) + + +def test_chat_start_clears_expired_post_compression_estimate(tmp_path, monkeypatch): + from api.models import Session + from api.routes import _prepare_chat_start_session_for_stream + + saved = [] + monkeypatch.setattr(Session, "save", lambda self, *args, **kwargs: saved.append(self.post_compression_context_tokens_estimate)) + session = Session(session_id="issue4685-clear", post_compression_context_tokens_estimate=4_096) + + _prepare_chat_start_session_for_stream( + session, + msg="next turn", + attachments=[], + workspace=str(tmp_path), + model="test-model", + model_provider=None, + stream_id="stream-4685", + started_at=1.0, + ) + + assert session.post_compression_context_tokens_estimate is None + assert saved == [None] + + +def test_estimate_lineage_matrix(tmp_path, monkeypatch): + from api import models + + monkeypatch.setattr(models, "SESSION_DIR", tmp_path) + direct = models.Session(session_id="issue4685-direct", post_compression_context_tokens_estimate=4_096) + direct.save() + restored = models.Session.load("issue4685-direct") + child = models.Session(session_id="issue4685-child", parent_session_id=direct.session_id) + cron = models.Session(session_id="issue4685-cron", session_source="cron") + + assert restored.compact()["post_compression_context_tokens_estimate"] == 4_096 + assert child.post_compression_context_tokens_estimate is None + assert cron.post_compression_context_tokens_estimate is None + assert "post_compression_context_tokens_estimate" not in child.compact() or child.compact()["post_compression_context_tokens_estimate"] is None + + +def test_context_indicator_without_estimate_preserves_current_behavior(): + historical = _run_context_indicator({"last_prompt_tokens": 100_000, "context_length": 128_000}) + no_data = _run_context_indicator({"input_tokens": 100_000, "output_tokens": 1}) + + assert historical["percent"] == "78" + assert historical["label"].startswith("Context window 78% used") + assert no_data["percent"] == "\N{MIDDLE DOT}" + + +def test_reload_hydration_passes_post_compression_estimate_to_context_indicator(): + expected = "post_compression_context_tokens_estimate" + for path, expected_calls in ((ROOT / "static" / "boot.js", 1), (ROOT / "static" / "sessions.js", 3)): + source = path.read_text(encoding="utf-8") + calls = source.split("_syncCtxIndicator({")[1:] + + assert len(calls) == expected_calls + for call in calls: + assert expected in call.split("});", 1)[0] + + boot = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") + sessions = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + assert "S.session.post_compression_context_tokens_estimate=data.session.post_compression_context_tokens_estimate||null;" in boot + assert "S.session.post_compression_context_tokens_estimate=data.session.post_compression_context_tokens_estimate||null;" in sessions