diff --git a/python/semantic_kernel/connectors/ai/bedrock/services/bedrock_chat_completion.py b/python/semantic_kernel/connectors/ai/bedrock/services/bedrock_chat_completion.py index 77a73fcc800a..2176f2836e36 100644 --- a/python/semantic_kernel/connectors/ai/bedrock/services/bedrock_chat_completion.py +++ b/python/semantic_kernel/connectors/ai/bedrock/services/bedrock_chat_completion.py @@ -175,7 +175,19 @@ def _prepare_chat_history_for_request( for message in chat_history.messages: if message.role == AuthorRole.SYSTEM: continue - messages.append(MESSAGE_CONVERTERS[message.role](message)) + formatted_message = MESSAGE_CONVERTERS[message.role](message) + if messages and messages[-1][role_key] == formatted_message[role_key]: + # The Bedrock Converse API requires that consecutive messages with the same role be + # combined into a single message. In particular, SK emits one tool message per parallel + # tool result (all mapped to the "user" role), which Bedrock rejects unless every + # toolResult block for an assistant turn is grouped in a single user message. + # Build a new combined content list rather than mutating the previous message in place. + messages[-1][content_key] = [ + *messages[-1][content_key], + *formatted_message[content_key], + ] + else: + messages.append(formatted_message) return messages diff --git a/python/tests/unit/connectors/ai/bedrock/services/test_bedrock_chat_completion.py b/python/tests/unit/connectors/ai/bedrock/services/test_bedrock_chat_completion.py index efa702a43813..297cf78f3ee7 100644 --- a/python/tests/unit/connectors/ai/bedrock/services/test_bedrock_chat_completion.py +++ b/python/tests/unit/connectors/ai/bedrock/services/test_bedrock_chat_completion.py @@ -13,6 +13,8 @@ from semantic_kernel.connectors.ai.completion_usage import CompletionUsage from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents.chat_message_content import ChatMessageContent +from semantic_kernel.contents.function_call_content import FunctionCallContent +from semantic_kernel.contents.function_result_content import FunctionResultContent from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent from semantic_kernel.contents.text_content import TextContent from semantic_kernel.contents.utils.author_role import AuthorRole @@ -150,6 +152,85 @@ def test_prepare_chat_history_for_request(mock_client, bedrock_unit_test_env, ch assert all([item["role"] in ["user", "assistant"] for item in parsed_chat_history]) +@patch.object(boto3, "client", return_value=Mock()) +def test_prepare_chat_history_for_request_merges_parallel_tool_results(mock_client, bedrock_unit_test_env) -> None: + """Test that parallel tool calls and their results are merged into single Bedrock messages. + + When a model requests multiple tools in one turn, SK emits one assistant message per tool call + and one tool message per tool result. The Bedrock Converse API requires every toolUse block for a + turn to be in a single assistant message and every toolResult block to be in a single user message, + otherwise the request is rejected with an "Expected toolResult blocks ..." error. + """ + chat_history = ChatHistory() + chat_history.add_user_message("What is the weather in Seattle and Tokyo?") + chat_history.add_message( + ChatMessageContent( + role=AuthorRole.ASSISTANT, + items=[FunctionCallContent(id="call_1", name="get_weather", arguments={"city": "Seattle"})], + ) + ) + chat_history.add_message( + ChatMessageContent( + role=AuthorRole.ASSISTANT, + items=[FunctionCallContent(id="call_2", name="get_weather", arguments={"city": "Tokyo"})], + ) + ) + chat_history.add_message( + ChatMessageContent( + role=AuthorRole.TOOL, + items=[FunctionResultContent(id="call_1", result="Sunny")], + ) + ) + chat_history.add_message( + ChatMessageContent( + role=AuthorRole.TOOL, + items=[FunctionResultContent(id="call_2", result="Rainy")], + ) + ) + + bedrock_chat_completion = BedrockChatCompletion() + parsed_chat_history = bedrock_chat_completion._prepare_chat_history_for_request(chat_history) + + # user message + merged assistant message (2 toolUse) + merged user message (2 toolResult) + assert len(parsed_chat_history) == 3 + + assistant_message = parsed_chat_history[1] + assert assistant_message["role"] == "assistant" + tool_use_ids = [block["toolUse"]["toolUseId"] for block in assistant_message["content"] if "toolUse" in block] + assert tool_use_ids == ["call_1", "call_2"] + + tool_result_message = parsed_chat_history[2] + assert tool_result_message["role"] == "user" + tool_result_ids = [ + block["toolResult"]["toolUseId"] for block in tool_result_message["content"] if "toolResult" in block + ] + assert tool_result_ids == ["call_1", "call_2"] + + +@patch.object(boto3, "client", return_value=Mock()) +def test_prepare_chat_history_for_request_merges_consecutive_same_role_messages( + mock_client, bedrock_unit_test_env +) -> None: + """Test that consecutive same-role messages are merged even without tool content. + + The merge applies to any consecutive messages mapping to the same Bedrock role, not just + tool-related ones, so two consecutive user text messages collapse into a single user + message whose content preserves both text blocks in order. + """ + chat_history = ChatHistory() + chat_history.add_user_message("First question.") + chat_history.add_user_message("Second question.") + + bedrock_chat_completion = BedrockChatCompletion() + parsed_chat_history = bedrock_chat_completion._prepare_chat_history_for_request(chat_history) + + assert len(parsed_chat_history) == 1 + merged_message = parsed_chat_history[0] + assert merged_message["role"] == "user" + texts = [block["text"] for block in merged_message["content"] if "text" in block] + assert texts == ["First question.", "Second question."] + + @patch.object(boto3, "client", return_value=Mock()) def test_prepare_system_message_for_request(mock_client, bedrock_unit_test_env, chat_history) -> None: """Test preparing system message for request"""