From 3c37151e16a4672910b42f3509e2e5e950381c5e Mon Sep 17 00:00:00 2001 From: Esko Lahti Date: Tue, 23 Dec 2025 10:52:01 +0200 Subject: [PATCH 1/4] Bedrock: group parallel tool calls into single message When using parallel tool calls with Bedrock, messages need to be grouped into a single message having multiple toolResult blocks. Without grouping messages like this, parallel tool calls, at least when using Anthropic's models, will cause following error: Expected toolResult blocks at messages.N.content for the following Ids: tooluse_xyz This commit groups parallel tool calls into a single user message. --- core/providers/bedrock/utils.go | 145 ++++++++++++++++++-------------- 1 file changed, 80 insertions(+), 65 deletions(-) diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go index 5b4e00e54..1ad87dc16 100644 --- a/core/providers/bedrock/utils.go +++ b/core/providers/bedrock/utils.go @@ -271,7 +271,8 @@ func convertMessages(bifrostMessages []schemas.ChatMessage) ([]BedrockMessage, [ var messages []BedrockMessage var systemMessages []BedrockSystemMessage - for _, msg := range bifrostMessages { + for i := 0; i < len(bifrostMessages); i++ { + msg := bifrostMessages[i] switch msg.Role { case schemas.ChatMessageRoleSystem: // Convert system message @@ -290,10 +291,20 @@ func convertMessages(bifrostMessages []schemas.ChatMessage) ([]BedrockMessage, [ messages = append(messages, bedrockMsg) case schemas.ChatMessageRoleTool: - // Convert tool message - this should be part of the conversation - bedrockMsg, err := convertToolMessage(msg) + // Collect all consecutive tool messages and group them into a single user message + var toolMessages []schemas.ChatMessage + toolMessages = append(toolMessages, msg) + + // Look ahead for more consecutive tool messages + for j := i + 1; j < len(bifrostMessages) && bifrostMessages[j].Role == schemas.ChatMessageRoleTool; j++ { + toolMessages = append(toolMessages, bifrostMessages[j]) + i = j + } + + // Convert all collected tool messages into a single Bedrock message + bedrockMsg, err := convertToolMessages(toolMessages) if err != nil { - return nil, nil, fmt.Errorf("failed to convert tool message: %w", err) + return nil, nil, fmt.Errorf("failed to convert tool messages: %w", err) } messages = append(messages, bedrockMsg) @@ -362,83 +373,87 @@ func convertMessage(msg schemas.ChatMessage) (BedrockMessage, error) { return bedrockMsg, nil } -// convertToolMessage converts a Bifrost tool message to Bedrock format -func convertToolMessage(msg schemas.ChatMessage) (BedrockMessage, error) { - bedrockMsg := BedrockMessage{ - Role: "user", // Tool messages are typically treated as user messages in Bedrock +// convertToolMessages converts multiple consecutive Bifrost tool messages to a single Bedrock message +func convertToolMessages(msgs []schemas.ChatMessage) (BedrockMessage, error) { + if len(msgs) == 0 { + return BedrockMessage{}, fmt.Errorf("no tool messages provided") } - // Tool messages should have a tool_call_id - if msg.ChatToolMessage == nil || msg.ChatToolMessage.ToolCallID == nil { - return BedrockMessage{}, fmt.Errorf("tool message missing tool_call_id") + bedrockMsg := BedrockMessage{ + Role: "user", } - // Convert content to tool result - var toolResultContent []BedrockContentBlock - if msg.Content.ContentStr != nil { - // Bedrock expects JSON to be a parsed object, not a string - // Try to unmarshal the string content as JSON - var parsedOutput interface{} - if err := json.Unmarshal([]byte(*msg.Content.ContentStr), &parsedOutput); err != nil { - // If it's not valid JSON, wrap it as a text block instead - toolResultContent = append(toolResultContent, BedrockContentBlock{ - Text: msg.Content.ContentStr, - }) - } else { - // Use the parsed JSON object - toolResultContent = append(toolResultContent, BedrockContentBlock{ - JSON: parsedOutput, - }) - } - } else if msg.Content.ContentBlocks != nil { - for _, block := range msg.Content.ContentBlocks { - switch block.Type { - case schemas.ChatContentBlockTypeText: - if block.Text != nil { - toolResultContent = append(toolResultContent, BedrockContentBlock{ - Text: block.Text, - }) - // Cache point must be in a separate block - if block.CacheControl != nil { + var contentBlocks []BedrockContentBlock + + for _, msg := range msgs { + var toolResultContent []BedrockContentBlock + if msg.Content.ContentStr != nil { + // Bedrock expects JSON to be a parsed object, not a string + // Try to unmarshal the string content as JSON + var parsedOutput interface{} + if err := json.Unmarshal([]byte(*msg.Content.ContentStr), &parsedOutput); err != nil { + // If it's not valid JSON, wrap it as a text block instead + toolResultContent = append(toolResultContent, BedrockContentBlock{ + Text: msg.Content.ContentStr, + }) + } else { + // Use the parsed JSON object + toolResultContent = append(toolResultContent, BedrockContentBlock{ + JSON: parsedOutput, + }) + } + } else if msg.Content.ContentBlocks != nil { + for _, block := range msg.Content.ContentBlocks { + switch block.Type { + case schemas.ChatContentBlockTypeText: + if block.Text != nil { toolResultContent = append(toolResultContent, BedrockContentBlock{ - CachePoint: &BedrockCachePoint{ - Type: BedrockCachePointTypeDefault, - }, + Text: block.Text, }) + // Cache point must be in a separate block + if block.CacheControl != nil { + toolResultContent = append(toolResultContent, BedrockContentBlock{ + CachePoint: &BedrockCachePoint{ + Type: BedrockCachePointTypeDefault, + }, + }) + } } - } - case schemas.ChatContentBlockTypeImage: - if block.ImageURLStruct != nil { - imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) - if err != nil { - return BedrockMessage{}, fmt.Errorf("failed to convert image in tool result: %w", err) - } - toolResultContent = append(toolResultContent, BedrockContentBlock{ - Image: imageSource, - }) - // Cache point must be in a separate block - if block.CacheControl != nil { + case schemas.ChatContentBlockTypeImage: + if block.ImageURLStruct != nil { + imageSource, err := convertImageToBedrockSource(block.ImageURLStruct.URL) + if err != nil { + return BedrockMessage{}, fmt.Errorf("failed to convert image in tool result: %w", err) + } toolResultContent = append(toolResultContent, BedrockContentBlock{ - CachePoint: &BedrockCachePoint{ - Type: BedrockCachePointTypeDefault, - }, + Image: imageSource, }) + // Cache point must be in a separate block + if block.CacheControl != nil { + toolResultContent = append(toolResultContent, BedrockContentBlock{ + CachePoint: &BedrockCachePoint{ + Type: BedrockCachePointTypeDefault, + }, + }) + } } } } } - } - // Create tool result content block - toolResultBlock := BedrockContentBlock{ - ToolResult: &BedrockToolResult{ - ToolUseID: *msg.ChatToolMessage.ToolCallID, - Content: toolResultContent, - Status: schemas.Ptr("success"), // Default to success - }, + // Create tool result content block for this tool message + toolResultBlock := BedrockContentBlock{ + ToolResult: &BedrockToolResult{ + ToolUseID: *msg.ChatToolMessage.ToolCallID, + Content: toolResultContent, + Status: schemas.Ptr("success"), // Default to success + }, + } + + contentBlocks = append(contentBlocks, toolResultBlock) } - bedrockMsg.Content = []BedrockContentBlock{toolResultBlock} + bedrockMsg.Content = contentBlocks return bedrockMsg, nil } From 7e186850678e9d53b01271d180ed8de8cfbb6002 Mon Sep 17 00:00:00 2001 From: Esko Lahti Date: Tue, 23 Dec 2025 13:42:46 +0200 Subject: [PATCH 2/4] Bedrock: test parallel tool calls --- core/providers/bedrock/bedrock_test.go | 150 +++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/core/providers/bedrock/bedrock_test.go b/core/providers/bedrock/bedrock_test.go index 474a19bc7..6cd9a873a 100644 --- a/core/providers/bedrock/bedrock_test.go +++ b/core/providers/bedrock/bedrock_test.go @@ -448,6 +448,156 @@ func TestBifrostToBedrockRequestConversion(t *testing.T) { AdditionalModelResponseFieldPaths: []string{"field1", "field2"}, }, }, + { + name: "ParallelToolCalls", + input: &schemas.BifrostChatRequest{ + Model: "claude-3-sonnet", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("Invoke all tools in parallel that are available to you"), + }, + }, + { + Role: schemas.ChatMessageRoleAssistant, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("I'll invoke both available tools in parallel for you."), + }, + ChatAssistantMessage: &schemas.ChatAssistantMessage{ + ToolCalls: []schemas.ChatAssistantMessageToolCall{ + { + Index: 0, + Type: schemas.Ptr("function"), + ID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"), + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: schemas.Ptr("hello"), + Arguments: "{}", + }, + }, + { + Index: 1, + Type: schemas.Ptr("function"), + ID: schemas.Ptr("tooluse_eARDw2iqRXak8uyRC2KxXw"), + Function: schemas.ChatAssistantMessageToolCallFunction{ + Name: schemas.Ptr("world"), + Arguments: "{}", + }, + }, + }, + }, + }, + { + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("Hello"), + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: schemas.Ptr("tooluse_Yl388l8ES0G_3TQtDcKq_g"), + }, + }, + { + Role: schemas.ChatMessageRoleTool, + Content: &schemas.ChatMessageContent{ + ContentStr: schemas.Ptr("World"), + }, + ChatToolMessage: &schemas.ChatToolMessage{ + ToolCallID: schemas.Ptr("tooluse_eARDw2iqRXak8uyRC2KxXw"), + }, + }, + }, + }, + expected: &bedrock.BedrockConverseRequest{ + ModelID: "claude-3-sonnet", + Messages: []bedrock.BedrockMessage{ + { + Role: bedrock.BedrockMessageRoleUser, + Content: []bedrock.BedrockContentBlock{ + { + Text: schemas.Ptr("Invoke all tools in parallel that are available to you"), + }, + }, + }, + { + Role: bedrock.BedrockMessageRoleAssistant, + Content: []bedrock.BedrockContentBlock{ + { + Text: schemas.Ptr("I'll invoke both available tools in parallel for you."), + }, + { + ToolUse: &bedrock.BedrockToolUse{ + ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g", + Name: "hello", + Input: map[string]interface{}{}, + }, + }, + { + ToolUse: &bedrock.BedrockToolUse{ + ToolUseID: "tooluse_eARDw2iqRXak8uyRC2KxXw", + Name: "world", + Input: map[string]interface{}{}, + }, + }, + }, + }, + { + Role: bedrock.BedrockMessageRoleUser, + Content: []bedrock.BedrockContentBlock{ + { + ToolResult: &bedrock.BedrockToolResult{ + ToolUseID: "tooluse_Yl388l8ES0G_3TQtDcKq_g", + Content: []bedrock.BedrockContentBlock{ + { + Text: schemas.Ptr("Hello"), + }, + }, + Status: schemas.Ptr("success"), + }, + }, + { + ToolResult: &bedrock.BedrockToolResult{ + ToolUseID: "tooluse_eARDw2iqRXak8uyRC2KxXw", + Content: []bedrock.BedrockContentBlock{ + { + Text: schemas.Ptr("World"), + }, + }, + Status: schemas.Ptr("success"), + }, + }, + }, + }, + }, + ToolConfig: &bedrock.BedrockToolConfig{ + Tools: []bedrock.BedrockTool{ + { + ToolSpec: &bedrock.BedrockToolSpec{ + Name: "hello", + Description: schemas.Ptr("Tool extracted from conversation history"), + InputSchema: bedrock.BedrockToolInputSchema{ + JSON: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + }, + }, + { + ToolSpec: &bedrock.BedrockToolSpec{ + Name: "world", + Description: schemas.Ptr("Tool extracted from conversation history"), + InputSchema: bedrock.BedrockToolInputSchema{ + JSON: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + }, + }, + }, + }, + }, + }, { name: "NilRequest", input: nil, From b0e09e6da095c7ae2a2bbdb7118352ef9109ea68 Mon Sep 17 00:00:00 2001 From: Esko Lahti Date: Fri, 26 Dec 2025 05:45:21 +0000 Subject: [PATCH 3/4] Add nil check for ToolCallID in Bedrock tool messages --- core/providers/bedrock/utils.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go index 1ad87dc16..68224cdab 100644 --- a/core/providers/bedrock/utils.go +++ b/core/providers/bedrock/utils.go @@ -441,6 +441,14 @@ func convertToolMessages(msgs []schemas.ChatMessage) (BedrockMessage, error) { } } + if msg.ChatToolMessage == nil { + return BedrockMessage{}, fmt.Errorf("tool message missing required ChatToolMessage") + } + + if msg.ChatToolMessage.ToolCallID == nil { + return BedrockMessage{}, fmt.Errorf("tool message missing required ToolCallID") + } + // Create tool result content block for this tool message toolResultBlock := BedrockContentBlock{ ToolResult: &BedrockToolResult{ From 87e8dbb136131f6104d9ad2c89f709bec21e112f Mon Sep 17 00:00:00 2001 From: Esko Lahti Date: Fri, 26 Dec 2025 08:08:20 +0200 Subject: [PATCH 4/4] Bedrock: parallel tool call test to be independent of tool ordering Previous implementation depended on the ordering of tool calls which made core tests flaky. --- core/providers/bedrock/bedrock_test.go | 62 +++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/core/providers/bedrock/bedrock_test.go b/core/providers/bedrock/bedrock_test.go index 6cd9a873a..526190039 100644 --- a/core/providers/bedrock/bedrock_test.go +++ b/core/providers/bedrock/bedrock_test.go @@ -29,6 +29,62 @@ var ( } ) +// assertBedrockRequestEqual compares two BedrockConverseRequest objects +// but ignores the order of tools in ToolConfig +func assertBedrockRequestEqual(t *testing.T, expected, actual *bedrock.BedrockConverseRequest) { + t.Helper() + + assert.Equal(t, expected.ModelID, actual.ModelID) + assert.Equal(t, expected.Messages, actual.Messages) + assert.Equal(t, expected.System, actual.System) + assert.Equal(t, expected.InferenceConfig, actual.InferenceConfig) + assert.Equal(t, expected.GuardrailConfig, actual.GuardrailConfig) + assert.Equal(t, expected.AdditionalModelRequestFields, actual.AdditionalModelRequestFields) + assert.Equal(t, expected.AdditionalModelResponseFieldPaths, actual.AdditionalModelResponseFieldPaths) + assert.Equal(t, expected.PerformanceConfig, actual.PerformanceConfig) + assert.Equal(t, expected.PromptVariables, actual.PromptVariables) + assert.Equal(t, expected.RequestMetadata, actual.RequestMetadata) + assert.Equal(t, expected.ServiceTier, actual.ServiceTier) + assert.Equal(t, expected.Stream, actual.Stream) + assert.Equal(t, expected.ExtraParams, actual.ExtraParams) + assert.Equal(t, expected.Fallbacks, actual.Fallbacks) + + if expected.ToolConfig == nil { + assert.Nil(t, actual.ToolConfig) + return + } + + require.NotNil(t, actual.ToolConfig) + assert.Equal(t, expected.ToolConfig.ToolChoice, actual.ToolConfig.ToolChoice) + + expectedTools := expected.ToolConfig.Tools + actualTools := actual.ToolConfig.Tools + + assert.Equal(t, len(expectedTools), len(actualTools), "Tool count mismatch") + + expectedToolMap := make(map[string]bedrock.BedrockTool) + for _, tool := range expectedTools { + if tool.ToolSpec != nil { + expectedToolMap[tool.ToolSpec.Name] = tool + } + } + + actualToolMap := make(map[string]bedrock.BedrockTool) + for _, tool := range actualTools { + if tool.ToolSpec != nil { + actualToolMap[tool.ToolSpec.Name] = tool + } + } + + for name, expectedTool := range expectedToolMap { + actualTool, exists := actualToolMap[name] + assert.True(t, exists, "Tool %s not found in actual tools", name) + if exists { + assert.Equal(t, expectedTool, actualTool, "Tool %s differs", name) + } + } +} + func TestBedrock(t *testing.T) { t.Parallel() @@ -628,7 +684,11 @@ func TestBifrostToBedrockRequestConversion(t *testing.T) { } } else { require.NoError(t, err) - assert.Equal(t, tt.expected, actual) + if tt.name == "ParallelToolCalls" { + assertBedrockRequestEqual(t, tt.expected, actual) + } else { + assert.Equal(t, tt.expected, actual) + } } }) }