Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
Loading