Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/agents/models/chatcmpl_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,22 @@ def items_to_messages(
result: list[ChatCompletionMessageParam] = []
current_assistant_msg: ChatCompletionAssistantMessageParam | None = None
pending_thinking_blocks: list[dict[str, str]] | None = None
# Track reasoning_content for DeepSeek reasoner models which require this
# field in assistant messages for multi-turn conversations with tool calls
pending_reasoning_content: str | None = None

def flush_assistant_message() -> None:
nonlocal current_assistant_msg
nonlocal current_assistant_msg, pending_reasoning_content
if current_assistant_msg is not None:
# The API doesn't support empty arrays for tool_calls
if not current_assistant_msg.get("tool_calls"):
del current_assistant_msg["tool_calls"]
# Add reasoning_content if pending (for DeepSeek compatibility)
# This ensures the reasoning_content field is included in assistant
# messages that have tool calls, which is required by DeepSeek API
if pending_reasoning_content is not None:
current_assistant_msg["reasoning_content"] = pending_reasoning_content # type: ignore[typeddict-unknown-key]
pending_reasoning_content = None
result.append(current_assistant_msg)
current_assistant_msg = None

Expand Down Expand Up @@ -568,6 +577,15 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:

# 7) reasoning message => extract thinking blocks if present
elif reasoning_item := cls.maybe_reasoning_message(item):
# Extract reasoning_content from summary field for DeepSeek compatibility
# The summary contains the reasoning text that DeepSeek API requires
# in assistant messages for multi-turn conversations with tool calls
summary_items = reasoning_item.get("summary", [])
for summary_item in summary_items:
if isinstance(summary_item, dict) and summary_item.get("type") == "summary_text":
pending_reasoning_content = summary_item.get("text", "")
break
Comment on lines +583 to +587

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Concatenate all summary_text parts before setting reasoning_content

The reasoning summary is a list of parts, but the loop breaks on the first summary_text. If a reasoning item contains multiple summary_text entries (e.g., summaries emitted in multiple parts), only the first chunk is copied into reasoning_content, so the assistant message history will carry a truncated reasoning payload. This can still break DeepSeek continuity because the required reasoning content is incomplete. Consider concatenating all summary_text parts in order instead of stopping at the first one.

Useful? React with 👍 / 👎.


# Reconstruct thinking blocks from content (text) and encrypted_content (signature)
content_items = reasoning_item.get("content", [])
encrypted_content = reasoning_item.get("encrypted_content")
Expand Down
143 changes: 143 additions & 0 deletions tests/test_openai_chatcompletions_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,146 @@ def test_assistant_messages_in_history():
assert messages[1]["content"] == "Hello?"
assert messages[2]["role"] == "user"
assert messages[2]["content"] == "What was my Name?"


def test_reasoning_message_extracts_reasoning_content_for_tool_calls():
"""
Test that reasoning_content is extracted from the summary field of a
reasoning message and included in the following assistant message that
contains tool calls.

This is required for DeepSeek compatibility, where the API expects the
reasoning_content field in assistant messages for multi-turn conversations
with tool calls.

This is a regression test for issue #2155.
"""
# User message followed by reasoning message followed by tool call
items: list[TResponseInputItem] = [
{
"role": "user",
"content": "What is the weather?",
},
{
"type": "reasoning",
"id": "reasoning-1",
"summary": [
{
"type": "summary_text",
"text": "I need to call the weather API to get the current weather.",
}
],
},
{
"type": "function_call",
"id": "fc-1",
"call_id": "call_123",
"name": "get_weather",
"arguments": '{"location": "New York"}',
},
]
messages = Converter.items_to_messages(items)

# Should return user message and assistant message with tool call
assert len(messages) == 2
user_msg = messages[0]
assert user_msg["role"] == "user"
assert user_msg["content"] == "What is the weather?"

assistant_msg = messages[1]
assert assistant_msg["role"] == "assistant"
# The reasoning_content from the summary should be included
assert "reasoning_content" in assistant_msg
assert (
assistant_msg["reasoning_content"]
== "I need to call the weather API to get the current weather."
)
# Tool calls should be present
tool_calls = list(assistant_msg.get("tool_calls", []))
assert len(tool_calls) == 1
assert tool_calls[0]["function"]["name"] == "get_weather"


def test_reasoning_message_without_summary_works():
"""
Test that a reasoning message without a summary field does not cause errors
and that no reasoning_content is added to the assistant message.
"""
items: list[TResponseInputItem] = [
{
"role": "user",
"content": "Hello",
},
{
"type": "reasoning",
"id": "reasoning-1",
# No summary field
},
{
"type": "function_call",
"id": "fc-1",
"call_id": "call_456",
"name": "greet",
"arguments": "{}",
},
]
messages = Converter.items_to_messages(items)

assert len(messages) == 2
assistant_msg = messages[1]
assert assistant_msg["role"] == "assistant"
# No reasoning_content should be added
assert "reasoning_content" not in assistant_msg


def test_reasoning_content_only_added_when_pending():
"""
Test that reasoning_content is only added to the assistant message
that follows the reasoning message, not to subsequent messages.
"""
items: list[TResponseInputItem] = [
{
"role": "user",
"content": "First question",
},
{
"type": "reasoning",
"id": "reasoning-1",
"summary": [
{
"type": "summary_text",
"text": "Reasoning for first response",
}
],
},
{
"type": "function_call",
"id": "fc-1",
"call_id": "call_1",
"name": "tool1",
"arguments": "{}",
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "result1",
},
{
"role": "assistant",
"content": "Here's the answer",
},
]
messages = Converter.items_to_messages(items)

# Should return: user, assistant with tool call and reasoning_content, tool result, assistant
assert len(messages) == 4

# First assistant message should have reasoning_content
first_assistant = messages[1]
assert first_assistant["role"] == "assistant"
assert first_assistant.get("reasoning_content") == "Reasoning for first response"

# Second assistant message should NOT have reasoning_content
second_assistant = messages[3]
assert second_assistant["role"] == "assistant"
assert "reasoning_content" not in second_assistant