diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 700825d67f8..a816a391259 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -1111,6 +1111,13 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: all_messages: list[dict[str, Any]] = [] pending_reasoning: Any = None assistant_refusal_parts: list[str] = [] + # The most recently emitted assistant dict when it was built from plain text or tool + # calls (always ``all_messages[-1]`` while set). Text and tool calls from one assistant + # turn belong in a single Chat Completions message: splitting them leaves a provider + # reasoning field (``reasoning_details`` or one added by ``message_preparer``) on only + # one half, which providers like DeepSeek reject on the tool-result follow-up. + # See https://github.com/microsoft/agent-framework/issues/8382 + mergeable_assistant: dict[str, Any] | None = None for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): @@ -1131,8 +1138,22 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: if all_messages and "tool_calls" in all_messages[-1]: # If the last message already has tool calls, append to it all_messages[-1]["tool_calls"].append(self._prepare_content_for_openai(content)) + elif mergeable_assistant is not None: + # Attach to the text message emitted for this same turn + mergeable_assistant["tool_calls"] = [self._prepare_content_for_openai(content)] else: args["tool_calls"] = [self._prepare_content_for_openai(content)] + case "text" if ( + message.role == "assistant" + and mergeable_assistant is not None + and "content" not in mergeable_assistant + and not _is_refusal_text_content(content) + ): + # Text following the tool calls of this same turn (streaming can coalesce + # tool calls first) joins that message instead of starting a new one. + if prepared_text := self._prepare_content_for_openai(content): + mergeable_assistant["content"] = [prepared_text] + continue case "function_result": args["tool_call_id"] = content.call_id if content.items: @@ -1148,6 +1169,7 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: else: args["content"] = content.result if content.result is not None else "" all_messages.append(args) + mergeable_assistant = None continue case "text_reasoning" if (protected_data := content.protected_data) is not None: # Buffer reasoning to attach to the next message with content/tool_calls @@ -1171,6 +1193,9 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: args["reasoning_details"] = pending_reasoning pending_reasoning = None all_messages.append(args) + mergeable_assistant = ( + args if message.role == "assistant" and content.type in ("text", "function_call") else None + ) # If reasoning was the only content, emit a valid message with empty content if pending_reasoning is not None: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index a90185d607f..158baa975ac 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1131,6 +1131,113 @@ def test_prepare_message_with_text_reasoning_before_function_call( assert prepared[0]["role"] == "assistant" +def test_prepare_message_with_text_and_function_call_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Text, tool calls and reasoning from one assistant turn are sent as one assistant message. + + Regression test for https://github.com/microsoft/agent-framework/issues/8382 + Providers such as DeepSeek (thinking mode) reject the follow-up request when the turn is split + into a text-only assistant message and a tool-call assistant message, because the reasoning + field then only travels with the second one. + """ + client = OpenAIChatCompletionClient() + + mock_reasoning_data = {"effort": "medium", "summary": "Deciding to call a function"} + message = Message( + role="assistant", + contents=[ + Content.from_text(text="I'll check the weather in New York City for you."), + Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert prepared == [ + { + "role": "assistant", + "content": "I'll check the weather in New York City for you.", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "NYC"}'}, + } + ], + "reasoning_details": mock_reasoning_data, + } + ] + + +def test_prepare_message_with_function_call_before_text_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Streaming can coalesce tool calls ahead of text; the turn is still one assistant message.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_text(text="Checking the weather now."), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert prepared[0]["content"] == "Checking the weather now." + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_abc"] + + +def test_prepare_message_with_parallel_function_calls_after_text_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Parallel tool calls following text all attach to the same assistant message.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_text(text="Looking both up."), + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_function_call(call_id="call_2", name="get_weather", arguments='{"city": "LA"}'), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert prepared[0]["content"] == "Looking both up." + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_1", "call_2"] + + +def test_prepare_message_with_image_between_text_and_function_call_stays_separate( + openai_unit_test_env: dict[str, str], +) -> None: + """Only plain text and tool calls merge; other content keeps its own message and is not merged across.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_text(text="First."), + Content.from_uri(uri="https://example.com/image.png", media_type="image/png"), + Content.from_function_call(call_id="call_2", name="get_weather", arguments="{}"), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 3 + assert prepared[0] == {"role": "assistant", "content": "First."} + assert prepared[1]["content"][0]["type"] == "image_url" + assert "content" not in prepared[2] + assert [call["id"] for call in prepared[2]["tool_calls"]] == ["call_2"] + + def test_function_approval_content_is_skipped_in_preparation( openai_unit_test_env: dict[str, str], ) -> None: @@ -3055,6 +3162,83 @@ def test_hooks_roundtrip_vllm_reasoning(openai_unit_test_env: dict[str, str]) -> assert prepared[0]["reasoning"] == "Because reasons." +def test_hooks_roundtrip_reasoning_content_with_text_and_function_call_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """End-to-end: DeepSeek-style ``reasoning_content`` next to text and a tool call stays on one message. + + Regression test for https://github.com/microsoft/agent-framework/issues/8382 + """ + from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function + + def deepseek_parser(message: Any, contents: list[Content]) -> list[Content]: + reasoning_content = getattr(message, "reasoning_content", None) + if isinstance(reasoning_content, str) and reasoning_content: + return [*contents, Content.from_text_reasoning(protected_data=json.dumps(reasoning_content))] + return contents + + def deepseek_preparer(message: Message, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + for prepared_message in messages: + if "reasoning_details" in prepared_message: + prepared_message["reasoning_content"] = prepared_message.pop("reasoning_details") + return messages + + client = OpenAIChatCompletionClient(response_parser=deepseek_parser, message_preparer=deepseek_preparer) + message = ChatCompletionMessage.model_construct( + role="assistant", + content="I'll check the weather in New York City for you.", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_repro_0001", + type="function", + function=Function(name="get_weather", arguments='{"location": "New York City"}'), + ) + ], + reasoning_content="The user wants the weather, so I should call get_weather.", + ) + + parsed = client._parse_response_from_openai(_make_chat_completion(message, model="deepseek-flash"), {}) + prepared = client._prepare_message_for_openai(parsed.messages[0]) + + # One assistant message carries content, tool_calls and the echoed reasoning field, so the + # provider sees the reasoning on the message that also carries the content. + assert prepared == [ + { + "role": "assistant", + "content": "I'll check the weather in New York City for you.", + "tool_calls": [ + { + "id": "call_repro_0001", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "New York City"}'}, + } + ], + "reasoning_content": "The user wants the weather, so I should call get_weather.", + } + ] + + +def test_message_preparer_hook_reasoning_text_before_function_call_is_not_swallowed( + openai_unit_test_env: dict[str, str], +) -> None: + """Hook-surfaced reasoning text keeps its own dict, so a preparer can still correlate and remove it.""" + client = OpenAIChatCompletionClient(message_preparer=_vllm_reasoning_preparer) + reasoning = Content.from_text_reasoning( + text="Need the tool.", additional_properties={_VLLM_REASONING_FIELD_KEY: "reasoning"} + ) + message = Message( + role="assistant", + contents=[reasoning, Content.from_function_call(call_id="call_1", name="get_weather", arguments="{}")], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert "content" not in prepared[0] + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_1"] + assert prepared[0]["reasoning"] == "Need the tool." + + def test_no_hooks_keeps_default_behavior(openai_unit_test_env: dict[str, str]) -> None: """Without hooks, top-level `reasoning` is ignored and `reasoning_details` stays opaque.""" client = OpenAIChatCompletionClient()