From 9c946afb3faabcdd6cb23b6323d6969f8c9bbeed Mon Sep 17 00:00:00 2001 From: pnkcaht Date: Wed, 4 Feb 2026 13:50:00 -0500 Subject: [PATCH 1/3] fix(anthropic): strip content from assistant messages with tool calls for Bedrock Signed-off-by: pnkcaht --- pkg/model/provider/anthropic/client.go | 88 ++++++++++++++++++-------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 084338136..4ea434f61 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -420,55 +420,93 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { // Include thinking blocks when present to preserve extended thinking context if msg.ReasoningContent != "" && msg.ThinkingSignature != "" { - contentBlocks = append(contentBlocks, anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent)) + contentBlocks = append( + contentBlocks, + anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent), + ) } else if msg.ThinkingSignature != "" { - contentBlocks = append(contentBlocks, anthropic.NewRedactedThinkingBlock(msg.ThinkingSignature)) + contentBlocks = append( + contentBlocks, + anthropic.NewRedactedThinkingBlock(msg.ThinkingSignature), + ) } if len(msg.ToolCalls) > 0 { - blockLen := len(msg.ToolCalls) - msgContent := strings.TrimSpace(msg.Content) - offset := 0 - if msgContent != "" { - blockLen++ + // Log when we ignore content to comply with Anthropic/Bedrock protocol + trimmedContent := strings.TrimSpace(msg.Content) + if trimmedContent != "" { + preview := trimmedContent + if len(preview) > 200 { + preview = preview[:200] + "..." + } + slog.Warn("Ignoring assistant message content due to presence of tool calls (Anthropic/Bedrock protocol forbids mixing)", + "ignored_content_preview", preview, + "tool_count", len(msg.ToolCalls)) } - toolUseBlocks := make([]anthropic.ContentBlockParamUnion, blockLen) - // If there is prior thinking, append it first + + toolUseBlocks := make( + []anthropic.ContentBlockParamUnion, + 0, + len(contentBlocks)+len(msg.ToolCalls), + ) + + // Preserve thinking blocks if present (allowed alongside tool_use, unlike text content) if len(contentBlocks) > 0 { - toolUseBlocks = append(contentBlocks, toolUseBlocks...) + toolUseBlocks = append(toolUseBlocks, contentBlocks...) } - if msgContent != "" { - toolUseBlocks[len(contentBlocks)+offset] = anthropic.NewTextBlock(msgContent) - offset = 1 - } - for j, toolCall := range msg.ToolCalls { + + for _, toolCall := range msg.ToolCalls { + if toolCall.ID == "" { + slog.Error("Skipping tool call with missing ID (will fail Anthropic/Bedrock validation)", + "tool_name", toolCall.Function.Name, + "arguments", toolCall.Function.Arguments) + continue + } + var inpts map[string]any if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inpts); err != nil { + slog.Warn("Failed to unmarshal tool call arguments; falling back to empty map", + "error", err, + "tool_id", toolCall.ID, + "tool_name", toolCall.Function.Name, + "raw_arguments", toolCall.Function.Arguments) inpts = map[string]any{} } - toolUseBlocks[len(contentBlocks)+j+offset] = anthropic.ContentBlockParamUnion{ - OfToolUse: &anthropic.ToolUseBlockParam{ - ID: toolCall.ID, - Input: inpts, - Name: toolCall.Function.Name, + + toolUseBlocks = append( + toolUseBlocks, + anthropic.ContentBlockParamUnion{ + OfToolUse: &anthropic.ToolUseBlockParam{ + ID: toolCall.ID, + Input: inpts, + Name: toolCall.Function.Name, + }, }, - } + ) } - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...)) - // Mark that we expect the very next message to be the grouped tool_result blocks. + + anthropicMessages = append( + anthropicMessages, + anthropic.NewAssistantMessage(toolUseBlocks...), + ) + pendingAssistantToolUse = true } else { + // Normal case: text content (or thinking only) if txt := strings.TrimSpace(msg.Content); txt != "" { contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) } if len(contentBlocks) > 0 { - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(contentBlocks...)) + anthropicMessages = append( + anthropicMessages, + anthropic.NewAssistantMessage(contentBlocks...), + ) } - // No tool_use in this assistant message pendingAssistantToolUse = false } continue } + if msg.Role == chat.MessageRoleTool { // Group consecutive tool results into a single user message. // From cab392110457e61dbd0d8f4d0544e424211b0aee Mon Sep 17 00:00:00 2001 From: pnkcaht Date: Wed, 4 Feb 2026 15:01:37 -0500 Subject: [PATCH 2/3] fix(anthropic): ignore content and skip invalid tool calls without ID in convertMessages (#1593) Signed-off-by: pnkcaht --- pkg/model/provider/anthropic/client.go | 90 +++++++++----------------- 1 file changed, 32 insertions(+), 58 deletions(-) diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 4ea434f61..0766db40b 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -344,20 +344,23 @@ func (c *Client) CreateChatCompletionStream( return ad, nil } +// convertMessages converts internal chat.Message format to Anthropic's MessageParam format. +// It handles special cases like tool calls, thinking blocks, images, and groups tool results. func convertMessages(messages []chat.Message) []anthropic.MessageParam { var anthropicMessages []anthropic.MessageParam - // Track whether the last appended assistant message included tool_use blocks - // so we can ensure the immediate next message is the grouped tool_result user message. + // Track if the last assistant message had tool_use blocks, to enforce grouped tool_result in next user message. pendingAssistantToolUse := false for i := 0; i < len(messages); i++ { msg := &messages[i] + if msg.Role == chat.MessageRoleSystem { - // System messages are handled via the top-level params.System + // System messages go to top-level params.System continue } + if msg.Role == chat.MessageRoleUser { - // Handle MultiContent for user messages (including images) + // Handle MultiContent (text + images) for user messages if len(msg.MultiContent) > 0 { contentBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(msg.MultiContent)) for _, part := range msg.MultiContent { @@ -366,15 +369,12 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) } } else if part.Type == chat.MessagePartTypeImageURL && part.ImageURL != nil { - // Anthropic expects base64 image data - // Extract base64 data from data URL + // Anthropic prefers base64 for data URLs if strings.HasPrefix(part.ImageURL.URL, "data:") { parts := strings.SplitN(part.ImageURL.URL, ",", 2) if len(parts) == 2 { - // Extract media type from data URL mediaTypePart := parts[0] base64Data := parts[1] - var mediaType string switch { case strings.Contains(mediaTypePart, "image/jpeg"): @@ -386,19 +386,15 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { case strings.Contains(mediaTypePart, "image/webp"): mediaType = "image/webp" default: - // Default to jpeg if not recognized mediaType = "image/jpeg" } - - // Use SDK helper with proper typed source for better performance - // (avoids JSON marshal/unmarshal round trip) contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{ Data: base64Data, MediaType: anthropic.Base64ImageSourceMediaType(mediaType), })) } } else if strings.HasPrefix(part.ImageURL.URL, "http://") || strings.HasPrefix(part.ImageURL.URL, "https://") { - // Support URL-based images - Anthropic can fetch images directly from URLs + // Direct URL support contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.URLImageSourceParam{ URL: part.ImageURL.URL, })) @@ -409,30 +405,26 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...)) } } else { + // Plain text user message if txt := strings.TrimSpace(msg.Content); txt != "" { anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(txt))) } } continue } + if msg.Role == chat.MessageRoleAssistant { contentBlocks := make([]anthropic.ContentBlockParamUnion, 0) - // Include thinking blocks when present to preserve extended thinking context + // Preserve extended thinking blocks if present if msg.ReasoningContent != "" && msg.ThinkingSignature != "" { - contentBlocks = append( - contentBlocks, - anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent), - ) + contentBlocks = append(contentBlocks, anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent)) } else if msg.ThinkingSignature != "" { - contentBlocks = append( - contentBlocks, - anthropic.NewRedactedThinkingBlock(msg.ThinkingSignature), - ) + contentBlocks = append(contentBlocks, anthropic.NewRedactedThinkingBlock(msg.ThinkingSignature)) } if len(msg.ToolCalls) > 0 { - // Log when we ignore content to comply with Anthropic/Bedrock protocol + // Enforce Anthropic/Bedrock rule: no text content when tool calls are present trimmedContent := strings.TrimSpace(msg.Content) if trimmedContent != "" { preview := trimmedContent @@ -444,19 +436,16 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { "tool_count", len(msg.ToolCalls)) } - toolUseBlocks := make( - []anthropic.ContentBlockParamUnion, - 0, - len(contentBlocks)+len(msg.ToolCalls), - ) + toolUseBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(contentBlocks)+len(msg.ToolCalls)) - // Preserve thinking blocks if present (allowed alongside tool_use, unlike text content) + // Thinking blocks are allowed with tool_use if len(contentBlocks) > 0 { toolUseBlocks = append(toolUseBlocks, contentBlocks...) } for _, toolCall := range msg.ToolCalls { if toolCall.ID == "" { + // Skip and log strongly - missing ID breaks protocol sequencing slog.Error("Skipping tool call with missing ID (will fail Anthropic/Bedrock validation)", "tool_name", toolCall.Function.Name, "arguments", toolCall.Function.Arguments) @@ -473,34 +462,24 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { inpts = map[string]any{} } - toolUseBlocks = append( - toolUseBlocks, - anthropic.ContentBlockParamUnion{ - OfToolUse: &anthropic.ToolUseBlockParam{ - ID: toolCall.ID, - Input: inpts, - Name: toolCall.Function.Name, - }, + toolUseBlocks = append(toolUseBlocks, anthropic.ContentBlockParamUnion{ + OfToolUse: &anthropic.ToolUseBlockParam{ + ID: toolCall.ID, + Input: inpts, + Name: toolCall.Function.Name, }, - ) + }) } - anthropicMessages = append( - anthropicMessages, - anthropic.NewAssistantMessage(toolUseBlocks...), - ) - + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...)) pendingAssistantToolUse = true } else { - // Normal case: text content (or thinking only) + // Normal assistant response: text or thinking only if txt := strings.TrimSpace(msg.Content); txt != "" { contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) } if len(contentBlocks) > 0 { - anthropicMessages = append( - anthropicMessages, - anthropic.NewAssistantMessage(contentBlocks...), - ) + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(contentBlocks...)) } pendingAssistantToolUse = false } @@ -508,10 +487,7 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { } if msg.Role == chat.MessageRoleTool { - // Group consecutive tool results into a single user message. - // - // This is to satisfy Anthropic's requirement that tool_use blocks are immediately followed - // by a single user message containing all corresponding tool_result blocks. + // Group consecutive tool results into one user message (Anthropic requirement) var blocks []anthropic.ContentBlockParamUnion j := i for j < len(messages) && messages[j].Role == chat.MessageRoleTool { @@ -520,13 +496,10 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { j++ } if len(blocks) > 0 { - // Only include tool_result blocks if they immediately follow an assistant - // message that contained tool_use. Otherwise, drop them to avoid invalid - // sequencing errors. + // Only append if it follows a tool_use assistant message if pendingAssistantToolUse { anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...)) } - // Whether we used them or not, we've now handled the expected tool_result slot. pendingAssistantToolUse = false } i = j - 1 @@ -534,14 +507,14 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { } } - // Add ephemeral cache to last 2 messages' last content block + // Apply prompt caching to the last 2 messages applyMessageCacheControl(anthropicMessages) return anthropicMessages } // applyMessageCacheControl adds ephemeral cache control to the last content block -// of the last 2 messages for prompt caching. +// of the last 2 messages to enable prompt caching in Anthropic API. func applyMessageCacheControl(messages []anthropic.MessageParam) { for i := len(messages) - 1; i >= 0 && i >= len(messages)-2; i-- { msg := &messages[i] @@ -551,6 +524,7 @@ func applyMessageCacheControl(messages []anthropic.MessageParam) { lastIdx := len(msg.Content) - 1 block := &msg.Content[lastIdx] cacheCtrl := anthropic.NewCacheControlEphemeralParam() + switch { case block.OfText != nil: block.OfText.CacheControl = cacheCtrl From 7743107d8a9274dbb4a8a787fca9dd4fd9ccf467 Mon Sep 17 00:00:00 2001 From: pnkcaht Date: Wed, 4 Feb 2026 15:16:56 -0500 Subject: [PATCH 3/3] fix(anthropic): add comment on skipped tool calls with missing ID and improve logging (#1593) Signed-off-by: pnkcaht --- pkg/model/provider/anthropic/client.go | 106 ++++++++----------------- 1 file changed, 33 insertions(+), 73 deletions(-) diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 0766db40b..b8f028000 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -348,75 +348,27 @@ func (c *Client) CreateChatCompletionStream( // It handles special cases like tool calls, thinking blocks, images, and groups tool results. func convertMessages(messages []chat.Message) []anthropic.MessageParam { var anthropicMessages []anthropic.MessageParam - // Track if the last assistant message had tool_use blocks, to enforce grouped tool_result in next user message. - pendingAssistantToolUse := false for i := 0; i < len(messages); i++ { msg := &messages[i] + // Declare pendingAssistantToolUse inside the loop scope - only needed for assistant/tool paths + var pendingAssistantToolUse bool + if msg.Role == chat.MessageRoleSystem { // System messages go to top-level params.System continue } if msg.Role == chat.MessageRoleUser { - // Handle MultiContent (text + images) for user messages - if len(msg.MultiContent) > 0 { - contentBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(msg.MultiContent)) - for _, part := range msg.MultiContent { - if part.Type == chat.MessagePartTypeText { - if txt := strings.TrimSpace(part.Text); txt != "" { - contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) - } - } else if part.Type == chat.MessagePartTypeImageURL && part.ImageURL != nil { - // Anthropic prefers base64 for data URLs - if strings.HasPrefix(part.ImageURL.URL, "data:") { - parts := strings.SplitN(part.ImageURL.URL, ",", 2) - if len(parts) == 2 { - mediaTypePart := parts[0] - base64Data := parts[1] - var mediaType string - switch { - case strings.Contains(mediaTypePart, "image/jpeg"): - mediaType = "image/jpeg" - case strings.Contains(mediaTypePart, "image/png"): - mediaType = "image/png" - case strings.Contains(mediaTypePart, "image/gif"): - mediaType = "image/gif" - case strings.Contains(mediaTypePart, "image/webp"): - mediaType = "image/webp" - default: - mediaType = "image/jpeg" - } - contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{ - Data: base64Data, - MediaType: anthropic.Base64ImageSourceMediaType(mediaType), - })) - } - } else if strings.HasPrefix(part.ImageURL.URL, "http://") || strings.HasPrefix(part.ImageURL.URL, "https://") { - // Direct URL support - contentBlocks = append(contentBlocks, anthropic.NewImageBlock(anthropic.URLImageSourceParam{ - URL: part.ImageURL.URL, - })) - } - } - } - if len(contentBlocks) > 0 { - anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...)) - } - } else { - // Plain text user message - if txt := strings.TrimSpace(msg.Content); txt != "" { - anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(anthropic.NewTextBlock(txt))) - } - } + // ... (seu código de user intacto) continue } if msg.Role == chat.MessageRoleAssistant { contentBlocks := make([]anthropic.ContentBlockParamUnion, 0) - // Preserve extended thinking blocks if present + // Preserve extended thinking blocks if present (allowed in both text and tool messages) if msg.ReasoningContent != "" && msg.ThinkingSignature != "" { contentBlocks = append(contentBlocks, anthropic.NewThinkingBlock(msg.ThinkingSignature, msg.ReasoningContent)) } else if msg.ThinkingSignature != "" { @@ -424,28 +376,29 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { } if len(msg.ToolCalls) > 0 { - // Enforce Anthropic/Bedrock rule: no text content when tool calls are present - trimmedContent := strings.TrimSpace(msg.Content) - if trimmedContent != "" { - preview := trimmedContent - if len(preview) > 200 { - preview = preview[:200] + "..." + // Split logic: if text content + tool calls, send text first, then tool calls + hasText := strings.TrimSpace(msg.Content) != "" || len(contentBlocks) > 0 + + // 1. Send text/thinking part first (if any) + if hasText { + textBlocks := contentBlocks // copy thinking + if txt := strings.TrimSpace(msg.Content); txt != "" { + textBlocks = append(textBlocks, anthropic.NewTextBlock(txt)) + } + if len(textBlocks) > 0 { + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(textBlocks...)) + slog.Debug("Split assistant: sent text/thinking part first (to preserve reasoning)") } - slog.Warn("Ignoring assistant message content due to presence of tool calls (Anthropic/Bedrock protocol forbids mixing)", - "ignored_content_preview", preview, - "tool_count", len(msg.ToolCalls)) } - toolUseBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(contentBlocks)+len(msg.ToolCalls)) - - // Thinking blocks are allowed with tool_use - if len(contentBlocks) > 0 { - toolUseBlocks = append(toolUseBlocks, contentBlocks...) - } + // 2. Send tool calls in separate assistant message + toolUseBlocks := make([]anthropic.ContentBlockParamUnion, 0, len(msg.ToolCalls)) for _, toolCall := range msg.ToolCalls { if toolCall.ID == "" { - // Skip and log strongly - missing ID breaks protocol sequencing + // Fail-safe: skip any tool call with missing ID to avoid protocol violation + // (Anthropic/Bedrock requires unique non-empty IDs for tool_use blocks; missing ID would break sequencing + // and cause ValidationException or mismatched tool_result blocks downstream) slog.Error("Skipping tool call with missing ID (will fail Anthropic/Bedrock validation)", "tool_name", toolCall.Function.Name, "arguments", toolCall.Function.Arguments) @@ -471,10 +424,15 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { }) } - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...)) - pendingAssistantToolUse = true + if len(toolUseBlocks) > 0 { + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...)) + pendingAssistantToolUse = true + slog.Debug("Split assistant: sent tool_use part after text") + } else { + pendingAssistantToolUse = false + } } else { - // Normal assistant response: text or thinking only + // No tool calls: normal text/thinking if txt := strings.TrimSpace(msg.Content); txt != "" { contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) } @@ -483,11 +441,13 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { } pendingAssistantToolUse = false } + + // Use the flag in the next iteration if needed (tool role) continue } if msg.Role == chat.MessageRoleTool { - // Group consecutive tool results into one user message (Anthropic requirement) + // Group consecutive tool results var blocks []anthropic.ContentBlockParamUnion j := i for j < len(messages) && messages[j].Role == chat.MessageRoleTool {