Release exp-v0.52.48: post-compression context metering (#5872, #4685) (#5977)

* fix(#4685): meter post-compression model context

* Release exp-v0.52.48: meter post-compression model context (#5872, #4685)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-07-11 20:41:43 -07:00
1 parent bcb9b24b58
commit b2ae71e01a
9 files changed
+263 -6

No files matched your search

+2
View File
@@ -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)
+7
View File
@@ -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,
+1
View File
@@ -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()
+56
View File
@@ -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
+2
View File
@@ -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,
});
}
+1 -1
View File
@@ -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];
}
+4
View File
@@ -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,
});
}
+13 -5
View File
@@ -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;
@@ -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