Release v0.51.266 — Release IH (stage-r16) (#3641)
## Release v0.51.266 — Release IH (stage-r16) One agent-authored APPROVED fix + two un-held streaming/SSE fixes. ### Fixed | Issue/PR | Author | Fix | |----------|--------|-----| | #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. | | #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** | | #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** | ### Gate - Full pytest suite: **7770 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered). Co-authored-by: nesquena <nesquena@users.noreply.github.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
20 files changed
+801
-63
No files matched your search
+54
-10
@@ -4782,7 +4782,12 @@ def _run_agent_streaming(
|
||||
try:
|
||||
_token_sent = False # tracks whether any streamed tokens were sent
|
||||
_self_healed = False # (#1401) prevents infinite self-heal retries
|
||||
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
||||
# Per-message reasoning: dict maps assistant-message index → accumulated text
|
||||
# (#3587) replaces the flat _reasoning_text string so each intermediate
|
||||
# assistant turn (before tool calls) keeps its own reasoning segment.
|
||||
_reasoning_segments: dict = {}
|
||||
_current_reasoning_idx = 0
|
||||
_tool_boundary_advanced = False
|
||||
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
|
||||
|
||||
# Throttle: emit metering events at most every 100 ms so the per-message
|
||||
@@ -4832,9 +4837,10 @@ def _run_agent_streaming(
|
||||
_emit_metering()
|
||||
|
||||
def on_reasoning(text):
|
||||
nonlocal _reasoning_text
|
||||
nonlocal _reasoning_segments, _current_reasoning_idx, _tool_boundary_advanced
|
||||
if text is None:
|
||||
return
|
||||
_tool_boundary_advanced = False
|
||||
reasoning_delta = str(text)
|
||||
# Some runtimes mirror user-visible progress text through the
|
||||
# reasoning channel after it already streamed as normal assistant
|
||||
@@ -4842,8 +4848,13 @@ def _run_agent_streaming(
|
||||
# same sentence again inside a Thinking card.
|
||||
if _is_visible_output_echo(reasoning_delta):
|
||||
return
|
||||
_reasoning_text += reasoning_delta
|
||||
# Mirror to shared dict so cancel_stream() can persist it (#1361 §A)
|
||||
# Accumulate into the current message's segment (#3587)
|
||||
_reasoning_segments[_current_reasoning_idx] = (
|
||||
_reasoning_segments.get(_current_reasoning_idx, '') + reasoning_delta
|
||||
)
|
||||
# Mirror full concatenation to shared dict so cancel_stream() can persist
|
||||
# it (#1361 §A). Cancel only creates one partial message, so the flat
|
||||
# concatenation is correct there.
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += reasoning_delta
|
||||
put('reasoning', {'text': reasoning_delta})
|
||||
@@ -4853,6 +4864,12 @@ def _run_agent_streaming(
|
||||
_emit_metering()
|
||||
|
||||
def on_interim_assistant(text, **cb_kwargs):
|
||||
nonlocal _current_reasoning_idx
|
||||
# Advance the per-message reasoning index unconditionally (#3587):
|
||||
# even if this callback fires with empty text, a new assistant
|
||||
# segment is starting and subsequent reasoning must be attributed
|
||||
# to the next message.
|
||||
_current_reasoning_idx += 1
|
||||
if text is None:
|
||||
return
|
||||
visible = str(text).strip()
|
||||
@@ -4911,7 +4928,7 @@ def _run_agent_streaming(
|
||||
return True
|
||||
|
||||
def on_tool(*cb_args, **cb_kwargs):
|
||||
nonlocal _reasoning_text
|
||||
nonlocal _reasoning_segments, _current_reasoning_idx, _tool_boundary_advanced
|
||||
event_type = None
|
||||
name = None
|
||||
preview = None
|
||||
@@ -4937,8 +4954,11 @@ def _run_agent_streaming(
|
||||
# Suppress those echoes like the dedicated reasoning callback.
|
||||
if _is_visible_output_echo(reason_delta):
|
||||
return
|
||||
_reasoning_text += reason_delta
|
||||
# Mirror to shared dict so cancel_stream() can persist it (#1361 §A)
|
||||
# Accumulate into the current message's segment (#3587)
|
||||
_reasoning_segments[_current_reasoning_idx] = (
|
||||
_reasoning_segments.get(_current_reasoning_idx, '') + reason_delta
|
||||
)
|
||||
# Mirror full concatenation to shared dict (#1361 §A)
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += reason_delta
|
||||
put('reasoning', {'text': reason_delta})
|
||||
@@ -4947,6 +4967,15 @@ def _run_agent_streaming(
|
||||
_emit_metering()
|
||||
return
|
||||
|
||||
# (#3587) Advance reasoning index at tool-call boundaries.
|
||||
# on_interim_assistant is suppressed for contentless tool-call
|
||||
# messages (run_agent.py:3834), so the index never advances
|
||||
# there. The first tool.started event after reasoning indicates
|
||||
# a new assistant message boundary.
|
||||
if not _tool_boundary_advanced and _current_reasoning_idx in _reasoning_segments:
|
||||
_current_reasoning_idx += 1
|
||||
_tool_boundary_advanced = True
|
||||
|
||||
args_snap = _tool_args_snapshot(args)
|
||||
|
||||
# Modern Hermes Agent builds can call both tool_progress_callback
|
||||
@@ -6329,11 +6358,27 @@ def _run_agent_streaming(
|
||||
# persisted session file 30-50% and bypassing the thinking card. The
|
||||
# split is leading-only/single-block so mid-body literal tags (e.g. in
|
||||
# a fenced code block) stay visible content.
|
||||
#
|
||||
# #3587: use per-message segments so intermediate assistant turns
|
||||
# (before tool calls) each receive their own reasoning trace rather
|
||||
# than all reasoning being written only to the last assistant message.
|
||||
# Scope the walk to this turn's newly-appended assistant messages
|
||||
# to prevent cross-turn reasoning clobber (multi-turn off-by-N).
|
||||
if s.messages:
|
||||
for _rm in reversed(s.messages):
|
||||
_prev_asst = sum(
|
||||
1 for m in (_previous_messages or [])
|
||||
if isinstance(m, dict) and m.get('role') == 'assistant'
|
||||
)
|
||||
_asst_count = 0
|
||||
for _rm in s.messages:
|
||||
if not (isinstance(_rm, dict) and _rm.get('role') == 'assistant'):
|
||||
continue
|
||||
_existing_reasoning = _reasoning_text or _rm.get('reasoning') or ''
|
||||
_turn_idx = _asst_count
|
||||
_asst_count += 1
|
||||
if _turn_idx < _prev_asst:
|
||||
continue # prior-turn message — never touch its reasoning
|
||||
_seg_reasoning = _reasoning_segments.get(_turn_idx - _prev_asst, '')
|
||||
_existing_reasoning = _seg_reasoning or _rm.get('reasoning') or ''
|
||||
_content = _rm.get('content')
|
||||
if isinstance(_content, str) and _content:
|
||||
_new_content, _merged_reasoning = _split_thinking_from_content(
|
||||
@@ -6344,7 +6389,6 @@ def _run_agent_streaming(
|
||||
_rm['reasoning'] = _merged_reasoning
|
||||
elif _existing_reasoning:
|
||||
_rm['reasoning'] = _existing_reasoning
|
||||
break
|
||||
try:
|
||||
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
|
||||
except Exception:
|
||||
|
||||
Reference in new issue
Block a user