From 819054b85d00e4bf18dc38526f93abb46ddef67d Mon Sep 17 00:00:00 2001 From: Lucas Kim Date: Sat, 13 Jun 2026 23:30:45 +0900 Subject: [PATCH 1/2] Python: Fix Bedrock parallel tool-call results rejected by Converse When a model requests multiple tools in a single turn, the function-calling loop appends one ChatMessageContent per tool call and one per tool result. The Bedrock connector mapped each of these 1:1 to a Converse message, so N parallel tool results became N separate user messages each with a single toolResult block. Converse rejects this with: Expected toolResult blocks at messages.X.content for the following Ids: ... but found: ... Merge consecutive messages that map to the same Bedrock role in _prepare_chat_history_for_request, so all toolUse blocks for an assistant turn land in one assistant message and all toolResult blocks land in one user message. This mirrors the existing behavior of the sibling Anthropic connector, which already groups parallel tool messages. Python analogue of #13647 (reported for .NET). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/bedrock_chat_completion.py | 10 +++- .../services/test_bedrock_chat_completion.py | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) 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..bb073ea18250 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,15 @@ 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. + 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..0a453243bf02 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,61 @@ 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_system_message_for_request(mock_client, bedrock_unit_test_env, chat_history) -> None: """Test preparing system message for request""" From 5903a48c9ebd4dbf7a2d5910e8fe5ef8109385ca Mon Sep 17 00:00:00 2001 From: Lucas Kim Date: Sun, 14 Jun 2026 07:25:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Python:=20address=20review=20=E2=80=94=20ex?= =?UTF-8?q?plicit=20list=20merge=20+=20non-tool=20merge=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build a new combined content list instead of in-place += (clearer type intent, no mutation of the previous message), per review feedback. - Add a regression test for merging consecutive non-tool same-role messages. --- .../services/bedrock_chat_completion.py | 6 ++++- .../services/test_bedrock_chat_completion.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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 bb073ea18250..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 @@ -181,7 +181,11 @@ def _prepare_chat_history_for_request( # 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. - messages[-1][content_key] += formatted_message[content_key] + # 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) 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 0a453243bf02..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 @@ -207,6 +207,30 @@ def test_prepare_chat_history_for_request_merges_parallel_tool_results(mock_clie 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"""