The streaming accumulator dropped delta.reasoning_details entirely — only non-streaming responses preserved the OpenRouter unified reasoning replay data (signatures, encrypted reasoning blocks). Providers that require the reasoning_details sequence passed back on the next turn (OpenRouter reasoning models, Anthropic signed thinking via shims) lost continuity on every streamed turn, which is nearly all turns. - Accumulate delta.reasoning_details (attr or model_extra) during streaming and attach the merged list to the final mock message, where _build_assistant_message's existing passthrough persists it. - _append_streamed_reasoning_detail merges consecutive reasoning.text / reasoning.summary fragments into one logical entry (OpenRouter streams them as word-level deltas; unmerged they bloat the replayed signature payload) while encrypted/opaque entries stay discrete. Later fragments backfill signature/id/format/index fields the first fragment omitted. Ported from earendil-works/pi#8605 (commit c5ad7c1b0), credit @cristinaponcela for the merge-fragments pattern. Tests: tests/run_agent/test_streamed_reasoning_details.py (8 tests; sabotage-verified: disabling accumulation fails the E2E test, disabling merging fails 4 unit tests). Neighboring suites green (test_streaming.py 40 passed).
66 lines
3.2 KiB
Python
66 lines
3.2 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 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))
|