core/agent: fix deepseek responses reasoning_content for tool calls

This commit is contained in:
2026-08-11 15:27:01 +08:00
parent 9640f86b89
commit 3fbf2dc388
4 changed files with 183 additions and 26 deletions
+31 -14
View File
@@ -171,7 +171,7 @@ def response_to_assistant_message(response: Response) -> AssistantChatMessage:
)
for call in calls
]
reasoning = _reasoning_summary_text(response)
reasoning = reasoning_summary_text(response)
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls,
@@ -179,23 +179,40 @@ def response_to_assistant_message(response: Response) -> AssistantChatMessage:
)
def _reasoning_summary_text(response: Response) -> str:
def _reasoning_text_blocks(raw: object) -> list[str]:
"""Collect text from a reasoning block list (``summary``/``content``)."""
parts: list[str] = []
if not isinstance(raw, list):
return parts
for block_obj in cast("list[object]", raw):
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text", "reasoning_content"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
return parts
def reasoning_summary_text(response: Response) -> str:
"""Concatenate reasoning text from the response.
OpenAI puts ``reasoning`` items in ``output`` with a ``summary`` block list.
DeepSeek (Responses convention) additionally returns the full chain-of-thought
in the top-level ``response.reasoning`` (``content``/``summary`` block lists);
prefer it when present so the two sources are not duplicated.
"""
reasoning = response.reasoning
if isinstance(reasoning, dict):
parts = _reasoning_text_blocks(reasoning.get("content")) + _reasoning_text_blocks(reasoning.get("summary"))
if parts:
return "".join(parts)
parts: list[str] = []
for raw in response.output:
if raw.get("type") != "reasoning":
continue
summary = raw.get("summary")
if not isinstance(summary, list):
continue
summary_items = cast("list[object]", summary)
for block_obj in summary_items:
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
parts.extend(_reasoning_text_blocks(raw.get("summary")))
return "".join(parts)
+44 -11
View File
@@ -15,6 +15,7 @@ from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
reasoning_summary_text,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
@@ -45,7 +46,14 @@ if TYPE_CHECKING:
)
# Responses stream event types that carry reasoning text deltas.
_REASONING_DELTA_TYPES = {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}
# OpenAI streams ``response.reasoning_summary_text.delta``; DeepSeek streams
# ``response.reasoning_text.delta`` and emits the full text once via
# ``response.reasoning_text.done``.
_REASONING_DELTA_TYPES = {
"response.reasoning_summary_text.delta",
"response.reasoning_text.delta",
"response.reasoning_text.done",
}
# Terminal events that carry the full Response object (status, tool calls, usage).
# ``response.failed`` also carries a Response but is surfaced as an error.
_FINAL_EVENT_TYPES = {"response.completed", "response.incomplete"}
@@ -90,6 +98,27 @@ def _decode_final_event(event: ResponseStreamEvent) -> ResponseModel | None:
return None
def _stream_reasoning_content(event: ResponseStreamEvent) -> str | None:
"""Return reasoning text from a reasoning stream event (delta or full-text done)."""
if event.type not in _REASONING_DELTA_TYPES:
return None
content = event.delta if isinstance(event.delta, str) else event.text
return content if isinstance(content, str) and content else None
def _terminal_chunks(final: ResponseModel, *, model: str) -> list[ChatCompletionChunk]:
"""Build the post-stream chunks (tool calls, finish_reason, usage)."""
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
chunks: list[ChatCompletionChunk] = list(tool_call_chunks_from_response(final, model=model))
chunks.append(finish_reason_chunk(model=model, finish_reason=finish))
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
chunks.append(usage)
return chunks
async def _stream_as_chat_chunks(
client: OpenAIClient,
create: ResponsesCreateParam,
@@ -108,6 +137,7 @@ async def _stream_as_chat_chunks(
"""
stream = await client.responses(create, stream=True)
final: ResponseModel | None = None
saw_reasoning = False
async for event in stream:
etype = event.type
if etype in {"error", "response.failed"}:
@@ -118,8 +148,10 @@ async def _stream_as_chat_chunks(
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if etype in _REASONING_DELTA_TYPES and isinstance(event.delta, str) and event.delta:
yield reasoning_delta_chunk(model=model, content=event.delta)
reasoning = _stream_reasoning_content(event)
if reasoning is not None:
saw_reasoning = True
yield reasoning_delta_chunk(model=model, content=reasoning)
continue
if final is None:
@@ -127,15 +159,16 @@ async def _stream_as_chat_chunks(
# The loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
if not saw_reasoning:
# DeepSeek sometimes delivers reasoning only on the terminal response
# (top-level ``reasoning``); surface it so tool-call turns keep their
# reasoning_content when the assistant message goes back to the API.
terminal_reasoning = reasoning_summary_text(final)
if terminal_reasoning:
yield reasoning_delta_chunk(model=model, content=terminal_reasoning)
for chunk in _terminal_chunks(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
async def dispatch_responses(