From 8b8ee4ca292baa3463d481c6235f30176500868d Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Mon, 26 Jan 2026 10:00:08 -0800 Subject: [PATCH 1/5] fix: OpenAI and LiteLLM models handle model responses with tool calls and no other content --- src/strands/models/litellm.py | 2 +- src/strands/models/openai.py | 4 +- tests/strands/models/test_litellm.py | 55 ++++++++++++++++++++++++++++ tests/strands/models/test_openai.py | 55 ++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/strands/models/litellm.py b/src/strands/models/litellm.py index ec6579c58..be5337f0d 100644 --- a/src/strands/models/litellm.py +++ b/src/strands/models/litellm.py @@ -194,7 +194,7 @@ def format_request_messages( formatted_messages = cls._format_system_messages(system_prompt, system_prompt_content=system_prompt_content) formatted_messages.extend(cls._format_regular_messages(messages)) - return [message for message in formatted_messages if message["content"] or "tool_calls" in message] + return [message for message in formatted_messages if "content" in message or "tool_calls" in message] @override def format_chunk(self, event: dict[str, Any], **kwargs: Any) -> StreamEvent: diff --git a/src/strands/models/openai.py b/src/strands/models/openai.py index ab421e6c7..d8682eae2 100644 --- a/src/strands/models/openai.py +++ b/src/strands/models/openai.py @@ -369,7 +369,7 @@ def _format_regular_messages(cls, messages: Messages, **kwargs: Any) -> list[dic formatted_message = { "role": message["role"], - "content": formatted_contents, + **({"content": formatted_contents} if formatted_contents else {}), **({"tool_calls": formatted_tool_calls} if formatted_tool_calls else {}), } formatted_messages.append(formatted_message) @@ -407,7 +407,7 @@ def format_request_messages( formatted_messages = cls._format_system_messages(system_prompt, system_prompt_content=system_prompt_content) formatted_messages.extend(cls._format_regular_messages(messages)) - return [message for message in formatted_messages if message["content"] or "tool_calls" in message] + return [message for message in formatted_messages if "content" in message or "tool_calls" in message] def format_request( self, diff --git a/tests/strands/models/test_litellm.py b/tests/strands/models/test_litellm.py index f5e1837bf..2ffe3f21f 100644 --- a/tests/strands/models/test_litellm.py +++ b/tests/strands/models/test_litellm.py @@ -812,3 +812,58 @@ def __init__(self, usage): assert metadata_events[0]["metadata"]["usage"]["inputTokens"] == 10 assert metadata_events[0]["metadata"]["usage"]["outputTokens"] == 5 assert metadata_events[0]["metadata"]["usage"]["totalTokens"] == 15 + + +def test_format_request_messages_with_tool_calls_no_content(): + """Test that messages with tool calls but no content are properly formatted.""" + messages = [ + {"role": "user", "content": [{"text": "Use the calculator"}]}, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "input": {"expression": "2+2"}, + "name": "calculator", + "toolUseId": "c1", + }, + }, + ], + }, + ] + + result = LiteLLMModel.format_request_messages(messages) + + # Assistant message should have tool_calls but no content field + assert len(result) == 2 + assert result[1]["role"] == "assistant" + assert "tool_calls" in result[1] + assert "content" not in result[1] + assert result[1]["tool_calls"][0]["id"] == "c1" + + +def test_format_request_messages_filters_tool_only_messages(): + """Test that messages with only tool calls (no content) are included in output.""" + messages = [ + {"role": "user", "content": [{"text": "test"}]}, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "input": {}, + "name": "tool1", + "toolUseId": "t1", + }, + }, + ], + }, + ] + + result = LiteLLMModel.format_request_messages(messages) + + # Both messages should be included + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + assert "tool_calls" in result[1] diff --git a/tests/strands/models/test_openai.py b/tests/strands/models/test_openai.py index 4f8652632..329f071f6 100644 --- a/tests/strands/models/test_openai.py +++ b/tests/strands/models/test_openai.py @@ -1397,3 +1397,58 @@ def test_format_request_filters_location_source_document(model, caplog): assert len(formatted_content) == 1 assert formatted_content[0]["type"] == "text" assert "Location sources are not supported by OpenAI" in caplog.text + + +def test_format_request_messages_with_tool_calls_no_content(): + """Test that messages with tool calls but no content are properly formatted.""" + messages = [ + {"role": "user", "content": [{"text": "Use the calculator"}]}, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "input": {"expression": "2+2"}, + "name": "calculator", + "toolUseId": "c1", + }, + }, + ], + }, + ] + + result = OpenAIModel.format_request_messages(messages) + + # Assistant message should have tool_calls but no content field + assert len(result) == 2 + assert result[1]["role"] == "assistant" + assert "tool_calls" in result[1] + assert "content" not in result[1] + assert result[1]["tool_calls"][0]["id"] == "c1" + + +def test_format_request_messages_filters_tool_only_messages(): + """Test that messages with only tool calls (no content) are included in output.""" + messages = [ + {"role": "user", "content": [{"text": "test"}]}, + { + "role": "assistant", + "content": [ + { + "toolUse": { + "input": {}, + "name": "tool1", + "toolUseId": "t1", + }, + }, + ], + }, + ] + + result = OpenAIModel.format_request_messages(messages) + + # Both messages should be included + assert len(result) == 2 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + assert "tool_calls" in result[1] From 9fc2db52a10075c90597eab7648bd1fd5a9daf5d Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Sun, 1 Feb 2026 21:49:36 -0800 Subject: [PATCH 2/5] test: Give more time for multi-agent swarm test --- tests_integ/test_multiagent_swarm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests_integ/test_multiagent_swarm.py b/tests_integ/test_multiagent_swarm.py index e9738d3d9..a244bf753 100644 --- a/tests_integ/test_multiagent_swarm.py +++ b/tests_integ/test_multiagent_swarm.py @@ -113,6 +113,7 @@ def capture_first_node(self, event): return VerifyHook() +@pytest.mark.timeout(120) def test_swarm_execution_with_string(researcher_agent, analyst_agent, writer_agent, hook_provider): """Test swarm execution with string input.""" # Create the swarm From 975549cc0f63d85ffd9fac9d8caac3657656d579 Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Thu, 12 Feb 2026 09:29:40 -0800 Subject: [PATCH 3/5] test: combine tests --- tests/strands/models/test_litellm.py | 39 +++++++--------------------- tests/strands/models/test_openai.py | 39 +++++++--------------------- 2 files changed, 20 insertions(+), 58 deletions(-) diff --git a/tests/strands/models/test_litellm.py b/tests/strands/models/test_litellm.py index 2ffe3f21f..9bb0e09ca 100644 --- a/tests/strands/models/test_litellm.py +++ b/tests/strands/models/test_litellm.py @@ -815,7 +815,7 @@ def __init__(self, usage): def test_format_request_messages_with_tool_calls_no_content(): - """Test that messages with tool calls but no content are properly formatted.""" + """Test that assistant messages with only tool calls are included and have no content field.""" messages = [ {"role": "user", "content": [{"text": "Use the calculator"}]}, { @@ -832,38 +832,19 @@ def test_format_request_messages_with_tool_calls_no_content(): }, ] - result = LiteLLMModel.format_request_messages(messages) + tru_result = LiteLLMModel.format_request_messages(messages) - # Assistant message should have tool_calls but no content field - assert len(result) == 2 - assert result[1]["role"] == "assistant" - assert "tool_calls" in result[1] - assert "content" not in result[1] - assert result[1]["tool_calls"][0]["id"] == "c1" - - -def test_format_request_messages_filters_tool_only_messages(): - """Test that messages with only tool calls (no content) are included in output.""" - messages = [ - {"role": "user", "content": [{"text": "test"}]}, + exp_result = [ + {"role": "user", "content": [{"text": "Use the calculator", "type": "text"}]}, { "role": "assistant", - "content": [ + "tool_calls": [ { - "toolUse": { - "input": {}, - "name": "tool1", - "toolUseId": "t1", - }, - }, + "function": {"arguments": '{"expression": "2+2"}', "name": "calculator"}, + "id": "c1", + "type": "function", + } ], }, ] - - result = LiteLLMModel.format_request_messages(messages) - - # Both messages should be included - assert len(result) == 2 - assert result[0]["role"] == "user" - assert result[1]["role"] == "assistant" - assert "tool_calls" in result[1] + assert tru_result == exp_result diff --git a/tests/strands/models/test_openai.py b/tests/strands/models/test_openai.py index 329f071f6..8cd0c2adb 100644 --- a/tests/strands/models/test_openai.py +++ b/tests/strands/models/test_openai.py @@ -1400,7 +1400,7 @@ def test_format_request_filters_location_source_document(model, caplog): def test_format_request_messages_with_tool_calls_no_content(): - """Test that messages with tool calls but no content are properly formatted.""" + """Test that assistant messages with only tool calls are included and have no content field.""" messages = [ {"role": "user", "content": [{"text": "Use the calculator"}]}, { @@ -1417,38 +1417,19 @@ def test_format_request_messages_with_tool_calls_no_content(): }, ] - result = OpenAIModel.format_request_messages(messages) - - # Assistant message should have tool_calls but no content field - assert len(result) == 2 - assert result[1]["role"] == "assistant" - assert "tool_calls" in result[1] - assert "content" not in result[1] - assert result[1]["tool_calls"][0]["id"] == "c1" - + tru_result = OpenAIModel.format_request_messages(messages) -def test_format_request_messages_filters_tool_only_messages(): - """Test that messages with only tool calls (no content) are included in output.""" - messages = [ - {"role": "user", "content": [{"text": "test"}]}, + exp_result = [ + {"role": "user", "content": [{"text": "Use the calculator", "type": "text"}]}, { "role": "assistant", - "content": [ + "tool_calls": [ { - "toolUse": { - "input": {}, - "name": "tool1", - "toolUseId": "t1", - }, - }, + "function": {"arguments": '{"expression": "2+2"}', "name": "calculator"}, + "id": "c1", + "type": "function", + } ], }, ] - - result = OpenAIModel.format_request_messages(messages) - - # Both messages should be included - assert len(result) == 2 - assert result[0]["role"] == "user" - assert result[1]["role"] == "assistant" - assert "tool_calls" in result[1] + assert tru_result == exp_result From 1e2fc980de97bdfe91e82b4df90d87dee1f49a27 Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Thu, 12 Feb 2026 18:07:12 -0800 Subject: [PATCH 4/5] fix: format OpenAI content as string if only one text content part --- src/strands/models/openai.py | 10 +++++++++- tests/strands/models/test_openai.py | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/strands/models/openai.py b/src/strands/models/openai.py index d8682eae2..28a6b5045 100644 --- a/src/strands/models/openai.py +++ b/src/strands/models/openai.py @@ -204,10 +204,18 @@ def format_request_tool_message(cls, tool_result: ToolResult, **kwargs: Any) -> ], ) + formatted_contents = [cls.format_request_message_content(content) for content in contents] + + # If single text content, use string format for better model compatibility + if len(formatted_contents) == 1 and formatted_contents[0].get("type") == "text": + content: str | list[dict[str, Any]] = formatted_contents[0]["text"] + else: + content = formatted_contents + return { "role": "tool", "tool_call_id": tool_result["toolUseId"], - "content": [cls.format_request_message_content(content) for content in contents], + "content": content, } @classmethod diff --git a/tests/strands/models/test_openai.py b/tests/strands/models/test_openai.py index 8cd0c2adb..b913a6c1f 100644 --- a/tests/strands/models/test_openai.py +++ b/tests/strands/models/test_openai.py @@ -180,6 +180,23 @@ def test_format_request_tool_message(): assert tru_result == exp_result +def test_format_request_tool_message_single_text_returns_string(): + """Test that single text content is returned as string for model compatibility.""" + tool_result = { + "content": [{"text": '{"result": "success"}'}], + "status": "success", + "toolUseId": "c1", + } + + tru_result = OpenAIModel.format_request_tool_message(tool_result) + exp_result = { + "content": '{"result": "success"}', + "role": "tool", + "tool_call_id": "c1", + } + assert tru_result == exp_result + + def test_split_tool_message_images_with_image(): """Test that images are extracted from tool messages.""" tool_message = { @@ -441,7 +458,7 @@ def test_format_request_messages(system_prompt): ], }, { - "content": [{"text": "4", "type": "text"}], + "content": "4", "role": "tool", "tool_call_id": "c1", }, From 46b1e365b2ca47f4371d52f410af900427db4319 Mon Sep 17 00:00:00 2001 From: Clare Liguori Date: Thu, 12 Feb 2026 18:48:27 -0800 Subject: [PATCH 5/5] fix: all tool msgs must come before user messages with images in OpenAI format --- src/strands/models/openai.py | 5 +- tests/strands/models/test_openai.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/strands/models/openai.py b/src/strands/models/openai.py index 28a6b5045..2b217ad91 100644 --- a/src/strands/models/openai.py +++ b/src/strands/models/openai.py @@ -384,11 +384,14 @@ def _format_regular_messages(cls, messages: Messages, **kwargs: Any) -> list[dic # Process tool messages to extract images into separate user messages # OpenAI API requires images to be in user role messages only + # All tool messages must be grouped together before any user messages with images + user_messages_with_images = [] for tool_msg in formatted_tool_messages: tool_msg_clean, user_msg_with_images = cls._split_tool_message_images(tool_msg) formatted_messages.append(tool_msg_clean) if user_msg_with_images: - formatted_messages.append(user_msg_with_images) + user_messages_with_images.append(user_msg_with_images) + formatted_messages.extend(user_messages_with_images) return formatted_messages diff --git a/tests/strands/models/test_openai.py b/tests/strands/models/test_openai.py index b913a6c1f..241c22b64 100644 --- a/tests/strands/models/test_openai.py +++ b/tests/strands/models/test_openai.py @@ -1450,3 +1450,86 @@ def test_format_request_messages_with_tool_calls_no_content(): }, ] assert tru_result == exp_result + + +def test_format_request_messages_multiple_tool_calls_with_images(): + """Test that multiple tool calls with image results are formatted correctly. + + OpenAI requires all tool response messages to immediately follow the assistant + message with tool_calls, before any other messages. When tools return images, + the images are moved to user messages, but these must come after ALL tool messages. + """ + messages = [ + {"role": "user", "content": [{"text": "Run the tools"}]}, + { + "role": "assistant", + "content": [ + {"toolUse": {"input": {}, "name": "tool1", "toolUseId": "call_1"}}, + {"toolUse": {"input": {}, "name": "tool2", "toolUseId": "call_2"}}, + ], + }, + { + "role": "user", + "content": [ + { + "toolResult": { + "toolUseId": "call_1", + "content": [{"image": {"format": "png", "source": {"bytes": b"img1"}}}], + "status": "success", + } + }, + { + "toolResult": { + "toolUseId": "call_2", + "content": [{"image": {"format": "png", "source": {"bytes": b"img2"}}}], + "status": "success", + } + }, + ], + }, + ] + + tru_result = OpenAIModel.format_request_messages(messages) + + image_placeholder = ( + "Tool successfully returned an image. The image is being provided in the following user message." + ) + exp_result = [ + {"role": "user", "content": [{"text": "Run the tools", "type": "text"}]}, + { + "role": "assistant", + "tool_calls": [ + {"function": {"arguments": "{}", "name": "tool1"}, "id": "call_1", "type": "function"}, + {"function": {"arguments": "{}", "name": "tool2"}, "id": "call_2", "type": "function"}, + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": [{"type": "text", "text": image_placeholder}], + }, + { + "role": "tool", + "tool_call_id": "call_2", + "content": [{"type": "text", "text": image_placeholder}], + }, + { + "role": "user", + "content": [ + { + "image_url": {"detail": "auto", "format": "image/png", "url": "data:image/png;base64,aW1nMQ=="}, + "type": "image_url", + } + ], + }, + { + "role": "user", + "content": [ + { + "image_url": {"detail": "auto", "format": "image/png", "url": "data:image/png;base64,aW1nMg=="}, + "type": "image_url", + } + ], + }, + ] + assert tru_result == exp_result