Files
hermes-agent/agent/reasoning_summaries.py
Wenfengcheng cd582e63fd fix(agent): deliver readable reasoning details during streaming
The streaming reasoning display read only reasoning_content/reasoning deltas;
reasoning_details deltas were accumulated for replay continuity but their text
was never routed to the live display, so models that stream thought solely via
reasoning_details (xiaomi/mimo-v2.6-pro via OpenRouter) showed no thinking
display at all. The non-streaming extract_reasoning already reads detail text —
this closes the asymmetry.

One representation per chunk is emitted: detail text when the details carry
it, otherwise the plain reasoning field. Persisted/replayed fields are not
rewritten; opaque replay material (encrypted/unknown types) stays unexposed.
A callback that raises (or is None) no longer breaks the stream.

Fixes #118851
Salvaged from #118888 by Wenfengcheng
2026-09-27 02:48:48 -05:00

76 lines
3.7 KiB
Python

"""Boundary repair for providers that stream reasoning as discrete summary parts.
Reasoning-summary models (OpenAI gpt-5.x and Responses-API relays onto the chat wire) emit one
``reasoning_content`` delta per *completed* summary part, each opening with a bold heading.
The chat wire lacks the Responses API's ``summary_index`` delimiter (verified live on Nous
Portal ``openai/gpt-5.6-sol``), so plain concatenation glues ``**One****Two**`` into one
half-bold paragraph. The boundary is re-derived from a delta opening a bold heading, matching
the blank-line join Hermes' own Responses adapter does.
"""
from __future__ import annotations
from typing import Any
from agent.message_content import flatten_message_text
__all__ = ["append_streamed_reasoning_detail", "separate_glued_reasoning_blocks"]
def separate_glued_reasoning_blocks(previous: str, delta: Any) -> str:
"""Return *delta*, prefixed with a paragraph break when it glues onto *previous*.
A break is inserted when *delta* opens a *closed* bold heading and *previous* is mid-line
(heading butting heading, or prose butting heading). Token-streamed reasoning is left
alone: its deltas carry their own whitespace, and a fragment that merely opens emphasis
(``**`` alone) is not a part boundary — summary parts carry the whole heading in one delta.
"""
# Relays also emit content-part lists/dicts; fragments carry their own whitespace.
delta = flatten_message_text(delta, sep="")
glued = previous and delta and not previous[-1].isspace() and delta.startswith("**") and "**" in delta[2:]
return f"\n\n{delta}" if glued else delta
# reasoning_details entry types whose consecutive fragments are ONE logical block.
_MERGEABLE_DETAIL_TEXT_KEYS = {"reasoning.text": "text", "reasoning.summary": "summary"}
_BACKFILL_DETAIL_KEYS = ("signature", "id", "format", "index")
def streamed_reasoning_detail_text(detail: Any) -> str:
"""Readable text from a detail delta; never expose opaque replay material."""
dtype = detail.get("type") if isinstance(detail, dict) else getattr(detail, "type", None)
key = _MERGEABLE_DETAIL_TEXT_KEYS.get(dtype) if isinstance(dtype, str) else None
if key is None:
return ""
text = detail.get(key) if isinstance(detail, dict) else getattr(detail, key, None)
return text if isinstance(text, str) else ""
def append_streamed_reasoning_detail(details_acc: list, detail: Any) -> None:
"""Accumulate one streamed ``reasoning_details`` delta entry into *details_acc*.
OpenRouter streams ``reasoning_details`` as word-level deltas: consecutive
``reasoning.text`` / ``reasoning.summary`` entries are fragments of one logical
block and are merged (later fragments backfill ``signature``/``id`` the first
omitted); encrypted/opaque entries stay discrete. Unmerged, a long thought
replays as hundreds of one-word entries and providers that validate the
sequence shape on the next turn reject it. SDK objects are normalized to dicts.
"""
if not isinstance(detail, dict):
if hasattr(detail, "model_dump"):
detail = detail.model_dump(warnings=False)
elif hasattr(detail, "__dict__"):
detail = dict(detail.__dict__)
else:
return
dtype = detail.get("type")
merge_key = _MERGEABLE_DETAIL_TEXT_KEYS.get(dtype)
last = details_acc[-1] if details_acc else None
if last is not None and merge_key and last.get("type") == dtype and isinstance(detail.get(merge_key), str):
last[merge_key] = (last.get(merge_key) or "") + detail[merge_key]
for k in _BACKFILL_DETAIL_KEYS:
if last.get(k) in (None, "") and detail.get(k) not in (None, ""):
last[k] = detail[k]
return
details_acc.append(dict(detail))