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
12 changes: 11 additions & 1 deletion python/packages/openai/agent_framework_openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1491,10 +1491,20 @@ def _prepare_message_for_openai(
# (replays_local_storage) still need stripping when the request also carries a continuation
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
# without its required following item"), so the reasoning branch always drops.
# without its required following item"). Hosted MCP calls are the exception: if we keep
# the mcp_call item without storage, keep its paired reasoning item too.
has_hosted_mcp_call = any(content.type == "mcp_server_tool_call" for content in message.contents)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should retention be per-pair rather than per-message? has_hosted_mcp_call keeps every text_reasoning in the message as soon as any mcp_call is present, but the API needs each reasoning item immediately followed by its required item. Shapes like [reasoning, mcp_call, reasoning] (trailing reasoning) or [reasoning_1, reasoning_2, mcp_call] emit a reasoning item not immediately followed by a valid one -> the exact "reasoning was provided without its required following item" this fix targets. coalesce doesn't reorder, and plain text is appended last, so neither rescues these. Wondering if the gate should check the next surviving content is the mcp_call, not just "mcp_call somewhere in the message".

for content in message.contents:
match content.type:
case "text_reasoning":
if not request_uses_service_side_storage and has_hosted_mcp_call:
reasoning = self._prepare_content_for_openai(
message.role,
content,
replays_local_storage=replays_local_storage,
)
if reasoning:
all_messages.append(reasoning)
continue
case "function_result":
if request_uses_service_side_storage:
Expand Down
36 changes: 36 additions & 0 deletions python/packages/openai/tests/openai/test_openai_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5603,6 +5603,42 @@ def test_prepare_messages_for_openai_serializes_mcp_server_tool_call_as_mcp_call
assert "output" not in item or item["output"] is None


def test_prepare_messages_for_openai_keeps_reasoning_with_mcp_call_without_storage() -> None:
"""Hosted MCP calls returned by reasoning models need their paired reasoning item."""
client = OpenAIChatClient(model="test-model", api_key="test-key")

messages = [
Message(
role="assistant",
contents=[
Content.from_text_reasoning(
id="rs_abc123",
text="I need to query the hosted MCP server.",
additional_properties={"status": "completed"},
),
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
),
],
),
]

storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True)
assert "reasoning" not in [item.get("type") for item in storage_on]

storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we covering the orderings where reasoning isn't adjacent to the mcp_call? This pins only [reasoning, mcp_call], the one case least likely to break. Could we add [reasoning_1, reasoning_2, mcp_call] (all kept, in order) and a non-adjacent / trailing-reasoning shape, since that's where the message-level gate could emit an orphan reasoning item?

types = [item.get("type") for item in storage_off]
assert types == ["reasoning", "mcp_call"]

reasoning = storage_off[0]
assert reasoning["id"] == "rs_abc123"
assert reasoning["status"] == "completed"
assert reasoning["summary"] == [{"type": "summary_text", "text": "I need to query the hosted MCP server."}]


def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_item() -> None:
"""An mcp_server_tool_call followed by an mcp_server_tool_result with the
same call_id (in same or separate Messages) must produce ONE mcp_call
Expand Down
Loading