diff --git a/src/strands/models/openai_responses.py b/src/strands/models/openai_responses.py index bc2dcfd0e..b2f93ae72 100644 --- a/src/strands/models/openai_responses.py +++ b/src/strands/models/openai_responses.py @@ -469,7 +469,7 @@ def _format_request_messages(cls, messages: Messages) -> list[dict[str, Any]]: formatted_contents = [ cls._format_request_message_content(content, role=role) for content in contents - if not any(block_type in content for block_type in ["toolResult", "toolUse"]) + if not any(block_type in content for block_type in ["toolResult", "toolUse", "reasoningContent"]) ] formatted_tool_calls = [ diff --git a/src/strands/models/writer.py b/src/strands/models/writer.py index 94774b363..0e774134f 100644 --- a/src/strands/models/writer.py +++ b/src/strands/models/writer.py @@ -116,7 +116,7 @@ def _format_content_vision(content: ContentBlock) -> dict[str, Any]: return [ _format_content_vision(content) for content in contents - if not any(block_type in content for block_type in ["toolResult", "toolUse"]) + if not any(block_type in content for block_type in ["toolResult", "toolUse", "reasoningContent"]) ] def _format_request_message_contents(self, contents: list[ContentBlock]) -> str: @@ -142,7 +142,7 @@ def _format_content(content: ContentBlock) -> str: content_blocks = list( filter( lambda content: content.get("text") - and not any(block_type in content for block_type in ["toolResult", "toolUse"]), + and not any(block_type in content for block_type in ["toolResult", "toolUse", "reasoningContent"]), contents, ) ) diff --git a/tests/strands/models/test_openai_responses.py b/tests/strands/models/test_openai_responses.py index 545f128bf..012383f6a 100644 --- a/tests/strands/models/test_openai_responses.py +++ b/tests/strands/models/test_openai_responses.py @@ -317,6 +317,87 @@ def test_format_request_message_content_role_user(): assert result == {"type": "input_text", "text": "question"} +def test_format_request_messages_strips_reasoning_content(): + """reasoningContent blocks from Gemini are silently dropped when formatting for OpenAI. + + In multi-provider Swarms a Gemini agent may store reasoningContent blocks in the shared + conversation history. These blocks have no OpenAI equivalent and must be filtered out + before the request is sent, otherwise _format_request_message_content raises TypeError. + + Regression test for https://github.com/strands-agents/sdk-python/issues/1993 + """ + messages = [ + { + "role": "user", + "content": [{"text": "What is AI?"}], + }, + { + # Simulates an assistant message produced by a Gemini agent in a Swarm — + # contains both a reasoningContent block and the visible text response. + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": { + "text": "The user asked about AI. I should give a brief answer.", + "signature": "SGVsbG8gV29ybGQ=", # base64 blob Gemini attaches + } + } + }, + {"text": "AI is the simulation of human intelligence by machines."}, + ], + }, + ] + + # Must not raise TypeError + result = OpenAIResponsesModel._format_request_messages(messages) + + # The reasoningContent block is dropped; only the visible text survives + assert result == [ + { + "role": "user", + "content": [{"type": "input_text", "text": "What is AI?"}], + }, + { + "role": "assistant", + "content": [ + {"type": "output_text", "text": "AI is the simulation of human intelligence by machines."} + ], + }, + ] + + +def test_format_request_messages_reasoning_content_only_message_is_dropped(): + """An assistant message consisting solely of a reasoningContent block is removed entirely. + + When Gemini emits a turn that is pure reasoning (no visible text), the resulting + formatted message would have an empty content list and should be omitted. + """ + messages = [ + {"role": "user", "content": [{"text": "Think first."}]}, + { + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": {"text": "Let me think...", "signature": ""} + } + } + ], + }, + {"role": "user", "content": [{"text": "Now answer."}]}, + ] + + result = OpenAIResponsesModel._format_request_messages(messages) + + # The pure-reasoning assistant message is dropped entirely (empty content filtered at line 498-502) + roles = [item.get("role") for item in result if "role" in item] + assert roles == ["user", "user"] + assert all( + "reasoningContent" not in str(item) for item in result + ) + + def test_format_request(model, messages, tool_specs, system_prompt): tru_request = model._format_request(messages, tool_specs, system_prompt) exp_request = { diff --git a/tests/strands/models/test_writer.py b/tests/strands/models/test_writer.py index 81745f412..1fa6d9ff8 100644 --- a/tests/strands/models/test_writer.py +++ b/tests/strands/models/test_writer.py @@ -250,7 +250,6 @@ def test_format_request_with_empty_content(model, model_id, stream_options): [ ({"video": {}}, "video"), ({"document": {}}, "document"), - ({"reasoningContent": {}}, "reasoningContent"), ({"other": {}}, "other"), ], ) @@ -266,6 +265,44 @@ def test_format_request_with_unsupported_type(model, content, content_type): model.format_request(messages) +def test_format_request_strips_reasoning_content(model, model_id, stream_options): + """reasoningContent blocks from Gemini are silently dropped when formatting for Writer. + + In multi-provider Swarms a Gemini agent may store reasoningContent blocks in the shared + conversation history. These blocks have no Writer equivalent and must be filtered out + before the request is sent, otherwise _format_request_message_content raises TypeError. + + Regression test for https://github.com/strands-agents/sdk-python/issues/1993 + """ + messages = [ + {"role": "user", "content": [{"text": "What is AI?"}]}, + { + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": { + "text": "The user asked about AI.", + "signature": "SGVsbG8gV29ybGQ=", + } + } + }, + {"text": "AI is the simulation of human intelligence by machines."}, + ], + }, + ] + + # Must not raise TypeError + tru_request = model.format_request(messages) + + # The reasoningContent block is dropped; only the visible text message survives + assistant_messages = [m for m in tru_request["messages"] if m.get("role") == "assistant"] + assert len(assistant_messages) == 1 + assert assistant_messages[0]["content"] == [ + {"type": "text", "text": "AI is the simulation of human intelligence by machines."} + ] + + class AsyncStreamWrapper: def __init__(self, items: list[Any]): self.items = items