fix(reasoning): keep gpt-5.x summary parts as separate blocks on the chat wire

Reasoning-summary models emit one reasoning_content delta per completed
summary part, each a self-contained bold heading. The Responses API delimits
those parts with summary_index; the OpenAI chat wire carries no such field —
verified live against Nous Portal, whose reasoning chunks contain nothing but
delta.reasoning_content — so concatenating them glued every part into one
unspaced, half-bold paragraph.

Re-derive the boundary from the signal the wire does carry: a delta opening a
closed bold heading against a mid-line tail. This matches Hermes own Responses
adapter, which already joins its summary parts with a blank line.
This commit is contained in:
Brooklyn Nicholson 2026-08-06 22:02:37 -05:00
parent 226b095a59
commit 0f83661808
3 changed files with 149 additions and 0 deletions

View File

@ -39,6 +39,7 @@ from agent.message_sanitization import (
_sanitize_surrogates,
_repair_tool_call_arguments,
)
from agent.reasoning_summaries import separate_glued_reasoning_blocks
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
@ -3256,6 +3257,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Accumulate reasoning content
reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
if reasoning_text:
# Summary-part models (gpt-5.x and other Responses relays) send
# one complete markdown block per delta with no separator, so
# the parts glue into a single unreadable run. Only the tail of
# what's accumulated matters. See agent/reasoning_summaries.py.
reasoning_text = separate_glued_reasoning_blocks(
reasoning_parts[-1] if reasoning_parts else "",
reasoning_text,
)
reasoning_parts.append(reasoning_text)
_fire_first_delta()
agent._fire_reasoning_delta(reasoning_text)

View File

@ -0,0 +1,67 @@
"""Boundary repair for providers that stream reasoning as discrete summary parts.
Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the
Responses API onto the OpenAI chat wire) do not stream a chain of thought token
by token. They emit one ``reasoning_content`` delta per *completed* summary
part, each opening with a bold markdown heading::
{"delta": {"reasoning_content": "**Investigating likely culprit PRs**"}}
{"delta": {"reasoning_content": "**Inspecting message schema**"}}
On the Responses API those parts are delimited by ``summary_index``
(``response.reasoning_summary_part.added`` / ``.done``). The OpenAI chat wire
carries no such field verified live against Nous Portal's
``openai/gpt-5.6-sol``, whose reasoning chunks contain nothing but
``delta.reasoning_content`` so the boundary cannot be recovered from
metadata, and consumers that concatenate deltas glue the parts together:
**Investigating likely culprit PRs****Inspecting message schema**
That ``****`` run is neither a bold close nor a bold open to a markdown parser,
so the whole trace renders as one unbroken, unspaced, half-bold paragraph.
The AI SDK hit exactly this (vercel/ai#6742) and fixed it upstream by starting
a new reasoning part per ``summary_index``. That route needs the index, which
this wire does not give us, so we re-derive the boundary from the one signal it
does carry: a delta opening a bold heading. Hermes' own Responses adapter
already joins its summary parts with a blank line
(``agent/codex_responses_adapter.py``), so this brings the chat-completions
stream in line with the path that keeps the structure.
"""
from __future__ import annotations
__all__ = ["separate_glued_reasoning_blocks"]
def separate_glued_reasoning_blocks(previous: str, delta: str) -> str:
"""Return *delta*, prefixed with a paragraph break when it glues onto *previous*.
*previous* is the reasoning text accumulated so far (only its tail matters).
A break is inserted when *delta* opens a bold heading and *previous* is
mid-line, which is the summary-part boundary the chat wire drops. Both
shapes the upstream issue reports are covered: a heading-only part butting
against the next heading (``**One****Two**``), and a part whose prose body
butts against the next heading (``...interaction!**Next**``).
Token-streamed reasoning is left alone: its deltas carry their own leading
whitespace, so *previous* ends mid-line only when the model really did run
two parts together.
"""
if not previous or not delta:
return delta
if not delta.startswith("**"):
return delta
# Already separated — the provider (or an earlier part) ended the line.
if previous[-1].isspace():
return delta
# Require a *closed* heading. A token-streamed fragment that merely opens
# emphasis ("**" then "bold" then "**" across three deltas) is not a part
# boundary; a summary part always carries its whole heading in one delta.
if "**" not in delta[2:]:
return delta
return f"\n\n{delta}"

View File

@ -0,0 +1,73 @@
"""Reasoning summary-part boundary repair (agent/reasoning_summaries.py)."""
from agent.reasoning_summaries import separate_glued_reasoning_blocks
def _stream(deltas):
"""Accumulate *deltas* the way the chat-completions stream loop does."""
parts: list[str] = []
for delta in deltas:
parts.append(
separate_glued_reasoning_blocks(parts[-1] if parts else "", delta)
)
return "".join(parts)
def test_heading_only_parts_do_not_glue_into_one_run():
# The shape observed live on Nous Portal's openai/gpt-5.6-sol: each delta
# is a bare heading, so consecutive parts produce a `****` run.
text = _stream(
[
"**Investigating likely culprit PRs**",
"**Inspecting message schema and tool_calls content**",
"**Analyzing interrupted tool call impact**",
]
)
assert "****" not in text
assert text.splitlines() == [
"**Investigating likely culprit PRs**",
"",
"**Inspecting message schema and tool_calls content**",
"",
"**Analyzing interrupted tool call impact**",
]
def test_prose_body_does_not_glue_onto_the_next_heading():
# vercel/ai#6742's repro: a part ends in prose and the next heading butts
# straight onto it, with no `****` run to key off.
text = _stream(
[
"**Simulating a greeting stream**\n\nIt feels like a streaming interaction!",
"**Simulating a greeting stream**\n\nI want to meet the request.",
]
)
assert "interaction!**" not in text
assert "interaction!\n\n**Simulating" in text
def test_token_streamed_reasoning_is_untouched():
deltas = ["Looking at", " the session", " logs, I see", " one bold word."]
assert _stream(deltas) == "".join(deltas)
def test_bold_word_mid_sentence_is_not_a_boundary():
# Emphasis inside token-streamed prose arrives after a space.
assert separate_glued_reasoning_blocks("I see the ", "**signature**") == "**signature**"
def test_unclosed_emphasis_fragment_is_not_a_boundary():
# A token stream splitting emphasis across deltas opens but never closes.
assert separate_glued_reasoning_blocks("weighing", "**") == "**"
def test_boundary_needs_a_bold_opener():
assert separate_glued_reasoning_blocks("**Closing**", "plain head") == "plain head"
def test_empty_operands_pass_through():
assert separate_glued_reasoning_blocks("", "**first**") == "**first**"
assert separate_glued_reasoning_blocks("**first**", "") == ""