diff --git a/llms/anthropic/anthropicllm.go b/llms/anthropic/anthropicllm.go index 417de1b25..cabef38ae 100644 --- a/llms/anthropic/anthropicllm.go +++ b/llms/anthropic/anthropicllm.go @@ -384,34 +384,40 @@ func handleHumanMessage(msg llms.MessageContent) (anthropicclient.ChatMessage, e } func handleAIMessage(msg llms.MessageContent) (anthropicclient.ChatMessage, error) { - if toolCall, ok := msg.Parts[0].(llms.ToolCall); ok { - var inputStruct map[string]interface{} - err := json.Unmarshal([]byte(toolCall.FunctionCall.Arguments), &inputStruct) - if err != nil { - return anthropicclient.ChatMessage{}, fmt.Errorf("anthropic: failed to unmarshal tool call arguments: %w", err) - } - toolUse := anthropicclient.ToolUseContent{ - Type: "tool_use", - ID: toolCall.ID, - Name: toolCall.FunctionCall.Name, - Input: inputStruct, + // An AI message may contain multiple parts, e.g. text alongside one or more + // tool calls. Iterate over every part so that no content block is dropped. + contents := make([]anthropicclient.Content, 0, len(msg.Parts)) + for _, part := range msg.Parts { + switch p := part.(type) { + case llms.ToolCall: + var inputStruct map[string]interface{} + if err := json.Unmarshal([]byte(p.FunctionCall.Arguments), &inputStruct); err != nil { + return anthropicclient.ChatMessage{}, fmt.Errorf("anthropic: failed to unmarshal tool call arguments: %w", err) + } + contents = append(contents, anthropicclient.ToolUseContent{ + Type: "tool_use", + ID: p.ID, + Name: p.FunctionCall.Name, + Input: inputStruct, + }) + case llms.TextContent: + contents = append(contents, &anthropicclient.TextContent{ + Type: "text", + Text: p.Text, + }) + default: + return anthropicclient.ChatMessage{}, fmt.Errorf("anthropic: %w for AI message", ErrInvalidContentType) } - - return anthropicclient.ChatMessage{ - Role: RoleAssistant, - Content: []anthropicclient.Content{toolUse}, - }, nil } - if textContent, ok := msg.Parts[0].(llms.TextContent); ok { - return anthropicclient.ChatMessage{ - Role: RoleAssistant, - Content: []anthropicclient.Content{&anthropicclient.TextContent{ - Type: "text", - Text: textContent.Text, - }}, - }, nil + + if len(contents) == 0 { + return anthropicclient.ChatMessage{}, fmt.Errorf("anthropic: %w for AI message", ErrInvalidContentType) } - return anthropicclient.ChatMessage{}, fmt.Errorf("anthropic: %w for AI message", ErrInvalidContentType) + + return anthropicclient.ChatMessage{ + Role: RoleAssistant, + Content: contents, + }, nil } type ToolResult struct { diff --git a/llms/anthropic/anthropicllm_test.go b/llms/anthropic/anthropicllm_test.go index d0750622a..bb98b1ffc 100644 --- a/llms/anthropic/anthropicllm_test.go +++ b/llms/anthropic/anthropicllm_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/llms/anthropic/internal/anthropicclient" ) func TestNew(t *testing.T) { @@ -230,3 +231,71 @@ func TestGenerateMessagesContent_EmptyContent(t *testing.T) { // returns a response with nil or empty content (addresses issue #993) t.Skip("Requires mock client - would demonstrate panic without len(result.Content) == 0 check") } + +// TestHandleAIMessageMultipleParts verifies that an assistant message +// containing both text and tool-call parts is fully serialized. Previously +// handleAIMessage only inspected Parts[0], silently dropping any additional +// content blocks (issue #1468). +func TestHandleAIMessageMultipleParts(t *testing.T) { + t.Parallel() + + msg := llms.MessageContent{ + Role: llms.ChatMessageTypeAI, + Parts: []llms.ContentPart{ + llms.TextContent{Text: "I'll look that up for you."}, + llms.ToolCall{ + ID: "toolu_123", + FunctionCall: &llms.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"Paris"}`, + }, + }, + }, + } + + result, err := handleAIMessage(msg) + if err != nil { + t.Fatalf("handleAIMessage returned error: %v", err) + } + + contents, ok := result.Content.([]anthropicclient.Content) + if !ok { + t.Fatalf("expected Content to be []anthropicclient.Content, got %T", result.Content) + } + if len(contents) != 2 { + t.Fatalf("expected 2 content blocks, got %d", len(contents)) + } + + text, ok := contents[0].(*anthropicclient.TextContent) + if !ok { + t.Fatalf("expected first block to be *TextContent, got %T", contents[0]) + } + if text.Text != "I'll look that up for you." { + t.Errorf("unexpected text content: %q", text.Text) + } + + toolUse, ok := contents[1].(anthropicclient.ToolUseContent) + if !ok { + t.Fatalf("expected second block to be ToolUseContent, got %T", contents[1]) + } + if toolUse.ID != "toolu_123" { + t.Errorf("unexpected tool use ID: %q", toolUse.ID) + } + if toolUse.Name != "get_weather" { + t.Errorf("unexpected tool use name: %q", toolUse.Name) + } + if got := toolUse.Input["location"]; got != "Paris" { + t.Errorf("unexpected tool use input location: %v", got) + } +} + +// TestHandleAIMessageEmptyParts verifies that a message with no usable parts +// returns an error rather than panicking on an empty slice. +func TestHandleAIMessageEmptyParts(t *testing.T) { + t.Parallel() + + _, err := handleAIMessage(llms.MessageContent{Role: llms.ChatMessageTypeAI}) + if err == nil { + t.Fatal("expected error for AI message with no parts, got nil") + } +}