core/agent: full reasoning_text.done replaces streamed fragments

This commit is contained in:
2026-08-11 20:08:52 +08:00
parent ec6edbcba4
commit 28a191cd79
5 changed files with 66 additions and 15 deletions
+14 -1
View File
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk, ChatCompletionChunk,
ChatCompletionResponse, ChatCompletionResponse,
ChatCompletionsParam, ChatCompletionsParam,
DeltaMessage,
StreamOptions, StreamOptions,
StreamToolCallDelta, StreamToolCallDelta,
ToolChatMessage, ToolChatMessage,
@@ -254,6 +255,18 @@ async def _non_stream_round(
) )
def _append_reasoning_delta(
reasoning_parts: list[str],
delta: DeltaMessage,
) -> list[str]:
"""Accumulate a reasoning delta; a ``full`` (``.done``) event replaces fragments."""
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
return [delta.reasoning_content]
return [*reasoning_parts, delta.reasoning_content]
return reasoning_parts
async def _stream_round( async def _stream_round(
client: AnyLLMClient, client: AnyLLMClient,
param: ChatCompletionsParam, param: ChatCompletionsParam,
@@ -295,7 +308,7 @@ async def _stream_round(
saw_terminal = True saw_terminal = True
delta = choice.delta delta = choice.delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content: if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
reasoning_parts.append(delta.reasoning_content) reasoning_parts = _append_reasoning_delta(reasoning_parts, delta)
yield ReasoningDeltaEvent(content=delta.reasoning_content) yield ReasoningDeltaEvent(content=delta.reasoning_content)
if isinstance(delta.content, str) and delta.content: if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content) content_parts.append(delta.content)
+19 -10
View File
@@ -47,13 +47,15 @@ if TYPE_CHECKING:
# Responses stream event types that carry reasoning text deltas. # Responses stream event types that carry reasoning text deltas.
# OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams # OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams
# ``response.reasoning_text.delta`` and emits the full text once via # ``response.reasoning_text.delta`` fragments and emits the FULL chain-of-thought
# ``response.reasoning_text.done``. # once via ``response.reasoning_text.done``. Fragments append; a ``.done`` event
# carries the complete text and must replace the accumulated fragments (otherwise
# the CoT is concatenated twice).
_REASONING_DELTA_TYPES = { _REASONING_DELTA_TYPES = {
"response.reasoning_summary_text.delta", "response.reasoning_summary_text.delta",
"response.reasoning_text.delta", "response.reasoning_text.delta",
"response.reasoning_text.done",
} }
_REASONING_FULL_TYPES = {"response.reasoning_text.done"}
# Terminal events that carry the full Response object (status, tool calls, usage). # Terminal events that carry the full Response object (status, tool calls, usage).
# ``response.failed`` also carries a Response but is surfaced as an error. # ``response.failed`` also carries a Response but is surfaced as an error.
_FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"} _FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"}
@@ -98,12 +100,19 @@ def _decode_final_event(event: ResponseStreamEvent) -> ResponseModel | None:
return None return None
def _stream_reasoning_content(event: ResponseStreamEvent) -> str | None: def _stream_reasoning_content(event: ResponseStreamEvent) -> tuple[str | None, bool]:
"""Return reasoning text from a reasoning stream event (delta or full-text done).""" """Return (reasoning_text, is_full) from a reasoning stream event.
if event.type not in _REASONING_DELTA_TYPES:
return None ``.delta`` events carry incremental fragments (``is_full=False``); ``.done``
events carry the full chain-of-thought (``is_full=True``) and must replace
the accumulated fragments.
"""
if event.type not in _REASONING_DELTA_TYPES and event.type not in _REASONING_FULL_TYPES:
return None, False
content = event.delta if isinstance(event.delta, str) else event.text content = event.delta if isinstance(event.delta, str) else event.text
return content if isinstance(content, str) and content else None if not isinstance(content, str) or not content:
return None, False
return content, event.type in _REASONING_FULL_TYPES
def _terminal_chunks(final: ResponseModel, *, model: str) -> list[ChatCompletionChunk]: def _terminal_chunks(final: ResponseModel, *, model: str) -> list[ChatCompletionChunk]:
@@ -148,10 +157,10 @@ async def _stream_as_chat_chunks(
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta: if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta) yield text_delta_chunk(model=model, content=event.delta)
continue continue
reasoning = _stream_reasoning_content(event) reasoning, reasoning_full = _stream_reasoning_content(event)
if reasoning is not None: if reasoning is not None:
saw_reasoning = True saw_reasoning = True
yield reasoning_delta_chunk(model=model, content=reasoning) yield reasoning_delta_chunk(model=model, content=reasoning, full=reasoning_full)
continue continue
if final is None: if final is None:
+8 -2
View File
@@ -58,7 +58,13 @@ def text_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompl
) )
def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> ChatCompletionChunk: def reasoning_delta_chunk(
*,
model: str,
content: str,
created: int = 0,
full: bool = False,
) -> ChatCompletionChunk:
return ChatCompletionChunk( return ChatCompletionChunk(
id="bridge-stream", id="bridge-stream",
object="chat.completion.chunk", object="chat.completion.chunk",
@@ -67,7 +73,7 @@ def reasoning_delta_chunk(*, model: str, content: str, created: int = 0) -> Chat
choices=[ choices=[
ChunkChoice( ChunkChoice(
index=0, index=0,
delta=DeltaMessage(reasoning_content=content), delta=DeltaMessage(reasoning_content=content, reasoning_full=full or UNSET),
) )
], ],
) )
@@ -239,6 +239,9 @@ class DeltaMessage(Struct):
# Providers often send content=null / reasoning_content=null on partial chunks. # Providers often send content=null / reasoning_content=null on partial chunks.
content: str | None | Unset = UNSET content: str | None | Unset = UNSET
reasoning_content: str | None | Unset = UNSET reasoning_content: str | None | Unset = UNSET
# True when reasoning_content is the FULL chain-of-thought (e.g. DeepSeek's
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
tool_calls: list[StreamToolCallDelta] | Unset = UNSET tool_calls: list[StreamToolCallDelta] | Unset = UNSET
+21 -1
View File
@@ -186,7 +186,11 @@ async def test_dispatch_responses_stream_text_and_usage() -> None:
async def _streamed_reasoning( async def _streamed_reasoning(
events: list[ResponseStreamEvent], events: list[ResponseStreamEvent],
) -> str: ) -> str:
"""Run one Responses stream and join all streamed ``reasoning_content`` deltas.""" """Run one Responses stream and join streamed ``reasoning_content`` deltas.
Mirrors the agent loop: ``.done`` (``reasoning_full``) replaces accumulated
fragments instead of appending.
"""
import msgspec import msgspec
final = _completed_response(text="ok") final = _completed_response(text="ok")
@@ -202,6 +206,9 @@ async def _streamed_reasoning(
continue continue
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
if isinstance(delta.reasoning_content, str) and delta.reasoning_content: if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
if delta.reasoning_full is True:
parts = [delta.reasoning_content]
else:
parts.append(delta.reasoning_content) parts.append(delta.reasoning_content)
return "".join(parts) return "".join(parts)
@@ -224,6 +231,19 @@ async def test_dispatch_responses_stream_reasoning_deltas() -> None:
assert reasoning == "step one step two" assert reasoning == "step one step two"
async def test_dispatch_responses_stream_reasoning_full_replaces_fragments() -> None:
"""A ``.done`` event with the full CoT must replace streamed fragments, not
append — otherwise the chain-of-thought is concatenated twice."""
reasoning = await _streamed_reasoning(
[
ResponseStreamEvent(type="response.reasoning_text.delta", delta="partial "),
ResponseStreamEvent(type="response.reasoning_text.delta", delta="thinking"),
ResponseStreamEvent(type="response.reasoning_text.done", delta="full chain of thought"),
]
)
assert reasoning == "full chain of thought"
async def test_dispatch_responses_stream_terminal_only_reasoning() -> None: async def test_dispatch_responses_stream_terminal_only_reasoning() -> None:
"""DeepSeek sometimes delivers reasoning only on the terminal response.""" """DeepSeek sometimes delivers reasoning only on the terminal response."""
reasoning = await _streamed_reasoning([]) reasoning = await _streamed_reasoning([])