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:
commit
623d5c93e0
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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**')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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**", "") == ""
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Reference in New Issue