Merge pull request #80736 from NousResearch/bb/reasoning-summary-blocks

Reasoning steps read as separate blocks again instead of one glued paragraph
This commit is contained in:
brooklyn! 2026-08-06 22:02:13 -06:00 committed by GitHub
commit 623d5c93e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 296 additions and 4 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

@ -1027,6 +1027,10 @@ def _consume_codex_event_stream(
first_delta_fired = False
active_message_phase: str | None = None
commentary_text_deltas: List[str] = []
# Last reasoning summary_index seen. The Responses stream delimits summary
# parts by this index and gives each part no separator of its own, so a
# change of index is where the blank line belongs.
active_summary_index: Any = None
terminal_status: str = "completed"
terminal_usage: Any = None
terminal_response_id: str = None
@ -1120,6 +1124,17 @@ def _consume_codex_event_stream(
if "reasoning" in event_type and "delta" in event_type:
reasoning_text = _event_field(event, "delta", "")
if reasoning_text and on_reasoning_delta is not None:
# Summary parts stream one after another with no separator of
# their own; summary_index is the boundary the wire gives us.
summary_index = _event_field(event, "summary_index")
if (
summary_index is not None
and active_summary_index is not None
and summary_index != active_summary_index
):
reasoning_text = f"\n\n{reasoning_text}"
if summary_index is not None:
active_summary_index = summary_index
try:
on_reasoning_delta(reasoning_text)
except Exception:

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

@ -16,6 +16,7 @@ import { GeneratedImage } from '@/components/chat/generated-image-result'
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@ -249,7 +250,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => {
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
disableArtifacts
isRunning={status.type === 'running' || messageRunning}
text={text.trimStart()}
text={separateGluedReasoningBlocks(text.trimStart())}
/>
)
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks'
describe('separateGluedReasoningBlocks', () => {
it('splits heading-onto-heading parts (the `****` run)', () => {
const glued =
'**Investigating likely culprit PRs****Inspecting message schema****Analyzing interrupted tool call impact**'
expect(separateGluedReasoningBlocks(glued)).toBe(
[
'**Investigating likely culprit PRs**',
'',
'**Inspecting message schema**',
'',
'**Analyzing interrupted tool call impact**'
].join('\n')
)
})
it('splits prose-onto-heading parts (vercel/ai#6742 repro)', () => {
const glued =
'**Simulating a greeting stream**\n\nIt feels like a streaming interaction!**Simulating a greeting stream**\n\nI want to meet the request.'
expect(separateGluedReasoningBlocks(glued)).toContain('interaction!\n\n**Simulating')
expect(separateGluedReasoningBlocks(glued)).not.toContain('interaction!**')
})
it('is idempotent on already-separated text', () => {
const separated = '**One**\n\n**Two**'
expect(separateGluedReasoningBlocks(separated)).toBe(separated)
})
it('leaves emphasis inside prose alone', () => {
const prose = 'Looking at the logs, the **signature** field is missing — so the replay 400s.'
expect(separateGluedReasoningBlocks(prose)).toBe(prose)
})
it('leaves an unclosed emphasis run alone', () => {
expect(separateGluedReasoningBlocks('weighing options **')).toBe('weighing options **')
})
it('does not split a heading that already opens the text', () => {
expect(separateGluedReasoningBlocks('**Only one part**')).toBe('**Only one part**')
})
})

View File

@ -0,0 +1,31 @@
/**
* Reasoning-summary models (OpenAI's gpt-5.x family, and anything relaying the
* Responses API onto the OpenAI chat wire) emit one delta per *completed*
* summary part, each opening with a bold markdown heading:
*
* **Investigating likely culprit PRs**
* **Inspecting message schema**
*
* The Responses API delimits those parts with `summary_index`; the chat wire
* carries no such field, so concatenated deltas glue into
* `...PRs****Inspecting...` a `****` run markdown reads as neither a bold
* close nor a bold open, leaving one unbroken, unspaced, half-bold paragraph.
* The AI SDK hit the same bug (vercel/ai#6742).
*
* The backend now inserts the break as the deltas arrive. This repairs the text
* we display: reasoning persisted before that fix, and any provider still
* gluing its parts. Idempotent a break already present is left alone.
*/
// A heading butting straight onto the previous part, in the two shapes the
// wire produces:
// 1. heading-onto-heading — `**One****Two**`, a bare `****` run.
// 2. prose-onto-heading — `interaction!**Two**`.
// Emphasis that legitimately follows whitespace is left alone, and a heading
// must close on its own line to count as a summary part.
const GLUED_HEADING_RUN = /(?<!\*)\*{4}(?!\*)/g
const GLUED_AFTER_PROSE = /(?<=[^\s*])(\*\*(?=[^\s*])[^\n]*?\*\*)/g
export function separateGluedReasoningBlocks(text: string): string {
return text.replace(GLUED_HEADING_RUN, '**\n\n**').replace(GLUED_AFTER_PROSE, '\n\n$1')
}

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**", "") == ""

View File

@ -1985,11 +1985,59 @@ def test_duplicate_detection_uses_commentary_when_hidden_reasoning_changes(monke
def test_consume_codex_stream_separates_reasoning_summary_parts():
"""summary_index is the part boundary; the wire sends no separator itself."""
from agent.codex_runtime import _consume_codex_event_stream
reasoning_streamed = []
_consume_codex_event_stream(
_FakeCreateStream([
SimpleNamespace(type="response.created"),
SimpleNamespace(
type="response.reasoning_summary_text.delta",
summary_index=0,
delta="**Investigating culprit PRs**",
),
SimpleNamespace(
type="response.reasoning_summary_text.delta",
summary_index=1,
delta="**Inspecting message schema**",
),
SimpleNamespace(
type="response.reasoning_summary_text.delta",
summary_index=1,
delta=" and tool_calls content",
),
SimpleNamespace(type="response.completed", response=SimpleNamespace(status="completed")),
]),
model="gpt-5-codex",
on_reasoning_delta=reasoning_streamed.append,
)
joined = "".join(reasoning_streamed)
assert "****" not in joined
assert joined == (
"**Investigating culprit PRs**"
"\n\n**Inspecting message schema** and tool_calls content"
)
def test_consume_codex_stream_leaves_unindexed_reasoning_untouched():
"""Streams with no summary_index (plain reasoning_text) must not gain breaks."""
from agent.codex_runtime import _consume_codex_event_stream
reasoning_streamed = []
_consume_codex_event_stream(
_FakeCreateStream([
SimpleNamespace(type="response.created"),
SimpleNamespace(type="response.reasoning_text.delta", delta="Need to "),
SimpleNamespace(type="response.reasoning_text.delta", delta="inspect files."),
SimpleNamespace(type="response.completed", response=SimpleNamespace(status="completed")),
]),
model="gpt-5-codex",
on_reasoning_delta=reasoning_streamed.append,
)
assert "".join(reasoning_streamed) == "Need to inspect files."