From 0f836618081c289546e81d0a1e117f01bb8d75f2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 6 Aug 2026 22:02:37 -0500 Subject: [PATCH] fix(reasoning): keep gpt-5.x summary parts as separate blocks on the chat wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/chat_completion_helpers.py | 9 +++ agent/reasoning_summaries.py | 67 +++++++++++++++++++++++ tests/agent/test_reasoning_summaries.py | 73 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 agent/reasoning_summaries.py create mode 100644 tests/agent/test_reasoning_summaries.py diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 0fd556f8403d0..11a8c86535e98 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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) diff --git a/agent/reasoning_summaries.py b/agent/reasoning_summaries.py new file mode 100644 index 0000000000000..2dd1144fce992 --- /dev/null +++ b/agent/reasoning_summaries.py @@ -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}" diff --git a/tests/agent/test_reasoning_summaries.py b/tests/agent/test_reasoning_summaries.py new file mode 100644 index 0000000000000..6c93c534e3b75 --- /dev/null +++ b/tests/agent/test_reasoning_summaries.py @@ -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**", "") == ""