mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
fix: marker-based compression anchor calculation
Instead of using len(visible_after)-1 (which points to the last visible message and gets pushed behind the render window as more turns accumulate), find the last [CONTEXT COMPACTION] marker in s.messages and compute the anchor from visible messages before it. This keeps the compression reference card at the correct boundary even after 50+ subsequent turns have scrolled the render window past the old anchor position. Fixes a bug where the assistant's output message appeared to disappear after automatic context compression because the reference card was placed at the wrong position.
This commit is contained in:
+49
-4
@@ -4684,11 +4684,56 @@ def _run_agent_streaming(
|
||||
# Notify the frontend that compression happened
|
||||
if _compressed:
|
||||
visible_after = visible_messages_for_anchor(s.messages, auto_compression=True)
|
||||
s.compression_anchor_visible_idx = (
|
||||
max(0, len(visible_after) - 1) if visible_after else None
|
||||
)
|
||||
# Find the LAST [CONTEXT COMPACTION] marker in s.messages
|
||||
# and count visible messages before it. This is the correct
|
||||
# anchor — it points to the compression boundary regardless
|
||||
# of how many turns have been added since the boundary was
|
||||
# established. Using len(visible_before)-1 is fragile when
|
||||
# _previous_messages doesn't include markers or when extra
|
||||
# messages accumulate between compression and the done event.
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(s.messages):
|
||||
if _is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
if _last_marker_raw_idx is not None:
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
s.messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
s.compression_anchor_visible_idx = max(0, len(_visible_before_marker) - 1)
|
||||
logger.info(
|
||||
'[ANCHOR-MARKER] session=%s marker_raw=%d vis_before=%d anchor=%d',
|
||||
getattr(s, 'session_id', '?'),
|
||||
_last_marker_raw_idx,
|
||||
len(_visible_before_marker),
|
||||
s.compression_anchor_visible_idx,
|
||||
)
|
||||
else:
|
||||
# Fallback: use pre-turn display messages
|
||||
visible_before = visible_messages_for_anchor(
|
||||
_previous_messages, auto_compression=True,
|
||||
)
|
||||
if visible_before:
|
||||
s.compression_anchor_visible_idx = max(0, len(visible_before) - 1)
|
||||
elif visible_after:
|
||||
s.compression_anchor_visible_idx = 0
|
||||
else:
|
||||
s.compression_anchor_visible_idx = None
|
||||
logger.info(
|
||||
'[ANCHOR-FALLBACK] session=%s vis_before=%d anchor=%d',
|
||||
getattr(s, 'session_id', '?'),
|
||||
len(visible_before) if visible_before else 0,
|
||||
s.compression_anchor_visible_idx if s.compression_anchor_visible_idx is not None else -1,
|
||||
)
|
||||
# Pick anchor_msg for _compression_anchor_message_key
|
||||
_anchor_vis_idx = s.compression_anchor_visible_idx
|
||||
if _anchor_vis_idx is not None and visible_after and _anchor_vis_idx < len(visible_after):
|
||||
anchor_msg = visible_after[_anchor_vis_idx]
|
||||
elif visible_after:
|
||||
anchor_msg = visible_after[-1]
|
||||
else:
|
||||
anchor_msg = None
|
||||
s.compression_anchor_message_key = (
|
||||
_compression_anchor_message_key(visible_after[-1]) if visible_after else None
|
||||
_compression_anchor_message_key(anchor_msg) if anchor_msg else None
|
||||
)
|
||||
s.compression_anchor_summary = _compact_summary_text(
|
||||
_compression_summary_from_messages(s.messages)
|
||||
|
||||
+25
-7
@@ -570,10 +570,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// A same-stream transport can be reused unless the browser has already
|
||||
// marked it closed; closed streams must still fall through to reopen.
|
||||
(typeof EventSource==='undefined'||existingLive.source.readyState!==EventSource.CLOSED)
|
||||
){
|
||||
return;
|
||||
}
|
||||
closeOtherLiveStreams(activeSid);
|
||||
){
|
||||
window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
|
||||
}
|
||||
// Compression is complete — clear the transient UI state so
|
||||
// renderMessages() uses the session's anchor metadata (reference card)
|
||||
// instead of the live compression card, allowing compaction marker
|
||||
// messages to be properly handled.
|
||||
window._compressionUi = null;
|
||||
// Find the last assistant message
|
||||
closeLiveStream(activeSid);
|
||||
|
||||
let assistantText='';
|
||||
@@ -1649,6 +1654,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
){
|
||||
window._compressionUi={...window._compressionUi, sessionId:d.session.session_id};
|
||||
}
|
||||
// Compression is complete — clear the transient UI state so
|
||||
// renderMessages() uses the session's anchor metadata (reference card)
|
||||
// instead of the live compression card, allowing compaction marker
|
||||
// messages to be properly handled.
|
||||
window._compressionUi = null;
|
||||
// Find the last assistant message once for both reasoning persistence and timestamp
|
||||
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
|
||||
// Persist reasoning trace so thinking card survives page reload
|
||||
@@ -1721,6 +1731,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
|
||||
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
|
||||
syncTopbar();renderMessages({preserveScroll:true});
|
||||
// Clear stale auto-compression UI state once the turn is fully
|
||||
// committed, so subsequent renderMessages() calls do not project
|
||||
// a ghost compression card or skip compaction marker messages.
|
||||
if(window._compressionUi&&window._compressionUi.automatic){
|
||||
window._compressionUi=null;
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
}
|
||||
if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom();
|
||||
loadDir('.');
|
||||
// TTS auto-read: speak the last assistant response if enabled (#499)
|
||||
@@ -1787,13 +1804,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
source.addEventListener('compressing',e=>{
|
||||
// Context auto-compression is starting. Surface the same calm running
|
||||
// compression card as manual /compress while the summarizer LLM call runs.
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
if(!S.session) return;
|
||||
const currentSid=S.session.session_id;
|
||||
let d={};
|
||||
try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; }
|
||||
if(d.session_id&&d.session_id!==activeSid) return;
|
||||
if(d.session_id&&d.session_id!==currentSid) return;
|
||||
if(typeof setCompressionUi==='function'){
|
||||
const state={
|
||||
sessionId:activeSid,
|
||||
sessionId:currentSid,
|
||||
phase:'running',
|
||||
automatic:true,
|
||||
message:d.message||'Auto-compressing context...',
|
||||
|
||||
@@ -97,3 +97,101 @@ def test_compression_summary_ignores_tool_output_that_mentions_compression():
|
||||
|
||||
assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"]
|
||||
assert _compression_summary_from_messages([skill_tool_output]) is None
|
||||
|
||||
|
||||
def test_marker_based_anchor_calculation():
|
||||
"""Verify that the marker-based anchor logic used in streaming.py's done
|
||||
handler correctly computes compression_anchor_visible_idx from the last
|
||||
[CONTEXT COMPACTION] marker position. This prevents the reference card
|
||||
from being pushed behind the render window after subsequent turns."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "first reply"},
|
||||
{"role": "tool", "content": "tool output"},
|
||||
{"role": "user", "content": "second question"},
|
||||
{"role": "assistant", "content": "second reply"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] summary"},
|
||||
{"role": "user", "content": "third question"},
|
||||
{"role": "assistant", "content": "third reply"},
|
||||
{"role": "tool", "content": "more tool output"},
|
||||
{"role": "user", "content": "fourth question"},
|
||||
{"role": "assistant", "content": "fourth reply"},
|
||||
]
|
||||
|
||||
# Simulate the fix logic from streaming.py
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx == 5, "expected marker at index 5"
|
||||
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
anchor = max(0, len(_visible_before_marker) - 1)
|
||||
# Visible before marker: 4 messages (hello, first reply, second question, second reply)
|
||||
# tool at index 2 is filtered out. Anchor = 3 (last visible before marker)
|
||||
assert anchor == 3, f"expected anchor 3, got {anchor}"
|
||||
|
||||
# Verify the anchor points to the right message
|
||||
full_vis = visible_messages_for_anchor(messages, auto_compression=True)
|
||||
# full_vis: hello, first reply, second question, second reply, third question, third reply, fourth question, fourth reply = 8
|
||||
assert len(full_vis) == 8, f"expected 8, got {len(full_vis)}"
|
||||
assert full_vis[anchor]["content"] == "second reply"
|
||||
|
||||
# After additional turns, anchor stays at marker boundary
|
||||
messages_extended = messages + [
|
||||
{"role": "user", "content": "fifth question"},
|
||||
{"role": "assistant", "content": "fifth reply"},
|
||||
{"role": "tool", "content": "hidden"},
|
||||
]
|
||||
full_vis_ext = visible_messages_for_anchor(messages_extended, auto_compression=True)
|
||||
# The anchor should still point to the same position in full_vis_ext
|
||||
assert full_vis_ext[anchor]["content"] == "second reply"
|
||||
assert anchor < len(full_vis_ext) - 3 # anchor is not at the end
|
||||
|
||||
|
||||
def test_marker_based_anchor_multiple_compressions():
|
||||
"""With multiple compression markers, the anchor uses the LAST marker."""
|
||||
messages = [
|
||||
{"role": "user", "content": "A"},
|
||||
{"role": "assistant", "content": "B"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] first"},
|
||||
{"role": "user", "content": "C"},
|
||||
{"role": "assistant", "content": "D"},
|
||||
{"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] second"},
|
||||
{"role": "user", "content": "E"},
|
||||
{"role": "assistant", "content": "F"},
|
||||
]
|
||||
# Find last marker
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx == 5
|
||||
|
||||
_visible_before_marker = visible_messages_for_anchor(
|
||||
messages[:_last_marker_raw_idx], auto_compression=True,
|
||||
)
|
||||
anchor = max(0, len(_visible_before_marker) - 1)
|
||||
# Visible before last marker at raw[5]: messages[0:5] = [A, B, marker, C, D]
|
||||
# After filtering: A, B, C, D = 4 visible. Anchor = 3 (last visible = D)
|
||||
assert anchor == 3, f"expected anchor 3, got {anchor}"
|
||||
assert _visible_before_marker[anchor]["content"] == "D"
|
||||
|
||||
|
||||
def test_marker_based_anchor_fallback_when_no_marker():
|
||||
"""When no compression marker exists, fall back to old behavior."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
_last_marker_raw_idx = None
|
||||
for _mi, _m in enumerate(messages):
|
||||
if is_context_compression_marker(_m):
|
||||
_last_marker_raw_idx = _mi
|
||||
assert _last_marker_raw_idx is None
|
||||
|
||||
# Should fall through (not crash) - verified by the old path
|
||||
visible_after = visible_messages_for_anchor(messages, auto_compression=True)
|
||||
assert len(visible_after) > 0
|
||||
|
||||
Reference in New Issue
Block a user