Merge remote-tracking branch 'origin/master' into paperclip/pr-6222

This commit is contained in:
Frank Song committed 2026-07-18 15:07:38 +08:00
commit 486d2038f2
4 files changed
+153 -17

No files matched your search

+2
View File
@@ -9,6 +9,8 @@
### Fixed
- **The "Compressing context" card no longer flashes on turns that aren't actually compressing.** The card was driven by a broad status match (anything containing `compressing`), so skip/cooldown/defer notices and post-compression summaries (`Compressed X → Y`) could paint a phantom compression card mid-turn. A dedicated `_is_agent_compression_start_status()` now recognizes only the five genuine start-of-compression markers the Hermes agent emits, and the transcript renderer no longer lets an unrecognized `terminal_status` row paint a compression anchor. Real compression turns still show the card unchanged; verified against every current agent compression emitter. Thanks @carlotestor. (#6184)
- **Transparent Stream no longer shows the start of the final answer twice after a multi-segment turn settles.** In a turn with interim assistant prose interleaved with tool calls, the beginning of the final answer could render twice after the live→settled transition — once as a stale live-token accumulator snapshot, once as the real assistant segment — persisting until reload. The earlier suppression gate (#5758) computed its "after the last tool row" boundary over the combined row list, where settled per-message tool rows re-list tools that ran earlier in the turn, pushing the boundary past the final segment's accumulator so the dedupe never fired. Final-segment eligibility is now judged against the live projection's own chronology (a live-prose row belongs to the final segment only when no *projected* tool row follows it), so the stale prefix is dropped while pre-tool narration and every tool/thinking row are preserved. Thanks @ai-ag2026. (#6189, #5749)
- **The frontend now reads the `msg_limit` ceiling from `/api/session` metadata instead of hand-mirroring the constant.** Following the ceiling clamp shipped in exp-v0.52.98, the backend advertises its `_MAX_MSG_LIMIT` as `_msg_limit_max` in every `/api/session` response, and the frontend reads it dynamically (`_msgLimitMax`, a module-scope value defaulting to the static fallback), so the two can no longer drift and the mirrored constant's drift-guard test is no longer needed. Older backends that omit the field fall back to the built-in default, so mixed-version deployments are unaffected. Thanks @webtecnica. (#6214, #6177)
+43 -10
View File
@@ -542,6 +542,48 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
)
def _is_agent_compression_start_status(kind: str, message: str) -> bool:
"""Return True only for real Hermes context-compression start notices.
WebUI bridges matching lifecycle statuses into an SSE ``compressing`` event
and paints the live "Compressing context" worklog divider. The previous
matcher used broad substrings such as ``'compressing' in message`` and
``'preflight compression' in message``, which can false-positive on skip /
cooldown / unrelated notices and make brand-new low-token turns look like
auto-compression.
Positive markers below match the agent emitters in hermes-agent
(``turn_context`` preflight, ``conversation_loop`` pre-API / 413 / too-large,
``conversation_compression`` compaction status). Explicitly reject skip /
defer notices so "Skipping preflight compression…" never surfaces as a
running compress divider.
"""
k = str(kind or '').strip().lower()
m = str(message or '').strip().lower()
if k != 'lifecycle' or not m:
return False
# Skip / cooldown / defer logs must never look like a live compression start.
if (
'skipping' in m
or 'defer' in m
or 'cooldown' in m
or 'will not start' in m
):
return False
# Post-compress retry chatter is not a start event.
if 'compressed' in m and 'compressing' not in m and 'compression attempt' not in m:
return False
return (
'preflight compression:' in m
or 'pre-api compression:' in m
or 'compacting context' in m
or 'context too large' in m
or '— compressing (' in m
or '- compressing (' in m
or 'compression attempt' in m
)
def _prewarm_skill_tool_modules():
"""Import tools.skills_tool and tools.skill_manager_tool outside any lock.
@@ -7148,16 +7190,7 @@ def _run_agent_streaming(
and 'http' in _lower
):
_captured_terminal_error[0] = _message
_is_compression_start = (
_kind == 'lifecycle'
and (
'preflight compression' in _lower
or 'compressing' in _lower
or 'compacting context' in _lower
or 'context too large' in _lower
)
)
if _is_compression_start:
if _is_agent_compression_start_status(_kind, _message):
put('compressing', {
'session_id': session_id,
'message': 'Compressing context',
+32 -2
View File
@@ -3936,8 +3936,38 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(role==='prose'||kind==='process_prose') return 'token';
if(role==='thinking'||kind==='reasoning') return 'reasoning';
if(role==='tool') return row&&row.status==='running'?'tool':'tool_complete';
if(role==='terminal'||kind==='terminal_status') return row&&row.status==='running'?'compressing':'done';
if(role==='lifecycle'||kind==='lifecycle_status') return 'compressing';
// Terminal statuses are done/cancel/error/apperror — never invent a
// compression start from a running terminal row (false "Compressing context").
if(role==='terminal'||kind==='terminal_status'){
const termStatus=String(row&&row.status||'').trim().toLowerCase();
if(termStatus==='cancelled'||termStatus==='canceled'||termStatus==='interrupted') return 'cancel';
if(termStatus==='error'||termStatus==='failed'||termStatus==='errored') return 'error';
if(termStatus==='running') return '';
return 'done';
}
// lifecycle_status is shared by compressing + compressed. Prefer explicit
// cues; do not default every lifecycle row to a running compress divider.
if(role==='lifecycle'||kind==='lifecycle_status'){
const phase=String(row&&(row.phase||row.status)||'').trim().toLowerCase();
const text=String(row&&(row.text||row.message||row.label)||'').trim().toLowerCase();
if(
phase==='done'||phase==='completed'||phase==='compressed'
|| text.includes('auto-compressed')
|| text.includes('compression finished')
|| (text.includes('compressed')&&!text.includes('compressing'))
) return 'compressed';
if(
phase==='running'||phase==='compressing'
|| text.includes('compressing context')
|| text.includes('compacting context')
|| text.includes('preflight compression')
|| text.includes('pre-api compression')
|| text.includes('context too large')
|| text.includes('compression attempt')
|| (text.includes('compressing')&&!text.includes('skipping'))
) return 'compressing';
return '';
}
return '';
}
function _hydrateAnchorRegistryFromActivityScene(scene){
+76 -5
View File
@@ -5,6 +5,7 @@ from api.models import Session
from api.streaming import (
_POST_COMPRESSION_TOOL_RESULT_SUMMARY_FLAG,
_compressed_context_tool_result_summary,
_is_agent_compression_start_status,
_is_fallback_lifecycle_message,
_merge_display_messages_after_agent_result,
_message_text,
@@ -447,14 +448,13 @@ def test_agent_status_callback_emits_compressing_and_warning_events():
assert end != -1, "status callback block end marker not found"
block = src[start:end]
# compressing events for compression lifecycle notices
# compressing events only via the narrowed helper (no broad substring matcher)
assert "put('compressing'" in block
assert "'session_id': session_id" in block
assert "'message': 'Compressing context'" in block
assert "'preflight compression'" in block
assert "'compressing'" in block
assert "'compacting context'" in block
assert "'context too large'" in block
assert "_is_agent_compression_start_status(_kind, _message)" in block
assert "or 'compressing' in _lower" not in block
assert "or 'preflight compression' in _lower" not in block
# warning events with type:fallback for rate-limit/fallback lifecycle notices
assert "put('warning'" in block
@@ -471,6 +471,77 @@ def test_agent_status_callback_emits_compressing_and_warning_events():
assert "agent.status_callback = _agent_kwargs.get('status_callback')" in src
def test_agent_compression_start_status_matches_real_emitters_only():
# Real start notices from hermes-agent emitters
assert _is_agent_compression_start_status(
"lifecycle",
"📦 Preflight compression: ~101,000 tokens >= 96,000 threshold. This may take a moment.",
)
assert _is_agent_compression_start_status(
"lifecycle",
"📦 Pre-API compression: ~521,055 tokens near the context/output limit. Compacting before the next model call.",
)
assert _is_agent_compression_start_status(
"lifecycle",
"🗜️ Compacting context — summarizing earlier conversation so I can continue...",
)
assert _is_agent_compression_start_status(
"lifecycle",
"🗜️ Context too large (~120,000 tokens) — compressing (1/3)...",
)
assert _is_agent_compression_start_status(
"lifecycle",
"⚠️ Request payload too large (413) — compression attempt 1/3...",
)
# False positives that previously (or could) light the divider on low-token turns
assert not _is_agent_compression_start_status(
"lifecycle",
"Skipping preflight compression: rough estimate ~20,000 >= 96,000, but last real provider prompt was 18,000 after compression",
)
assert not _is_agent_compression_start_status(
"lifecycle",
"Skipping preflight compression: same-session cooldown active (~30 seconds remaining, session abc)",
)
assert not _is_agent_compression_start_status(
"lifecycle",
"Skipping Hermes preflight compression for codex app-server (mode=native); Hermes will not start thread compaction here.",
)
assert not _is_agent_compression_start_status(
"lifecycle",
"🗜️ Compressed 924 → 143 messages, retrying...",
)
assert not _is_agent_compression_start_status(
"lifecycle",
"Rate limited — switching to fallback provider...",
)
assert not _is_agent_compression_start_status("tool", "compacting context")
assert not _is_agent_compression_start_status("lifecycle", "")
assert not _is_agent_compression_start_status("lifecycle", "Working on request")
def test_snapshot_anchor_hydration_does_not_invent_compressing_rows():
src = _read("static/messages.js")
start = src.find("function _sourceEventTypeForSnapshotAnchorRow")
assert start != -1, "snapshot anchor source helper not found"
end = src.find("function _hydrateAnchorRegistryFromActivityScene", start)
assert end != -1, "hydrate helper after snapshot source helper not found"
block = src[start:end]
# Old false-positive defaults must be gone
assert "return row&&row.status==='running'?'compressing':'done'" not in block
assert "if(role==='lifecycle'||kind==='lifecycle_status') return 'compressing';" not in block
# Terminal running no longer maps to compressing
assert "if(termStatus==='running') return '';" in block
# Lifecycle requires positive compression cues
assert "text.includes('compacting context')" in block
assert "return 'compressed';" in block
assert "return 'compressing';" in block
# Unknown lifecycle rows stay empty (no invented divider)
assert "return '';" in block
def test_agent_status_callback_wiring():
src = _read("api/streaming.py")
assert "_agent_status_callback" in src