Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ func (a *agent) executeSingleTool(ctx context.Context, toolMap map[string]AgentT
Data: base64.StdEncoding.EncodeToString(toolResult.Data),
MediaType: toolResult.MediaType,
Text: toolResult.Content,
Filename: toolResult.Filename,
}
default:
result.Result = ToolResultOutputContentText{Text: toolResult.Content}
Expand Down
4 changes: 3 additions & 1 deletion agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1935,7 +1935,7 @@ func TestAgent_MediaToolResponses(t *testing.T) {
require.Equal(t, "audio/wav", mediaResult.MediaType)
})

t.Run("Media response with text", func(t *testing.T) {
t.Run("Media response with text and filename", func(t *testing.T) {
t.Parallel()

imageTool := &mockTool{
Expand All @@ -1944,6 +1944,7 @@ func TestAgent_MediaToolResponses(t *testing.T) {
executeFunc: func(ctx context.Context, call ToolCall) (ToolResponse, error) {
resp := NewImageResponse(imageData, "image/png")
resp.Content = "Screenshot captured successfully"
resp.Filename = "screenshot.png"
return resp, nil
},
}
Expand Down Expand Up @@ -1988,6 +1989,7 @@ func TestAgent_MediaToolResponses(t *testing.T) {
require.Equal(t, base64.StdEncoding.EncodeToString(imageData), mediaResult.Data)
require.Equal(t, "image/png", mediaResult.MediaType)
require.Equal(t, "Screenshot captured successfully", mediaResult.Text)
require.Equal(t, "screenshot.png", mediaResult.Filename)
})

t.Run("Media response preserves metadata", func(t *testing.T) {
Expand Down
7 changes: 4 additions & 3 deletions content.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,10 @@ func (t ToolResultOutputContentError) GetType() ToolResultContentType {

// ToolResultOutputContentMedia represents media output content of a tool result.
type ToolResultOutputContentMedia struct {
Data string `json:"data"` // for media type (base64)
MediaType string `json:"media_type"` // for media type
Text string `json:"text,omitempty"` // optional text content accompanying the media
Data string `json:"data"` // for media type (base64)
MediaType string `json:"media_type"` // for media type
Text string `json:"text,omitempty"` // optional text content accompanying the media
Filename string `json:"filename,omitempty"` // optional filename of the media
}

// GetType returns the type of the tool result output content media.
Expand Down
49 changes: 49 additions & 0 deletions json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ func TestMessageJSONSerialization(t *testing.T) {
},
},
},
{
name: "message with tool result - PDF media output with filename",
message: Message{
Role: MessageRoleTool,
Content: []MessagePart{
ToolResultPart{
ToolCallID: "call_790",
Output: ToolResultOutputContentMedia{
Data: "JVBERi0xLjQ=",
MediaType: "application/pdf",
Filename: "report.pdf",
},
},
},
},
},
{
name: "complex message with mixed content",
message: Message{
Expand Down Expand Up @@ -283,6 +299,9 @@ func compareToolResultOutput(t *testing.T, index int, original, decoded ToolResu
if orig.MediaType != dec.MediaType {
t.Errorf("content[%d] tool result media type mismatch: got %q, want %q", index, dec.MediaType, orig.MediaType)
}
if orig.Filename != dec.Filename {
t.Errorf("content[%d] tool result media filename mismatch: got %q, want %q", index, dec.Filename, orig.Filename)
}
}
}

Expand Down Expand Up @@ -499,6 +518,36 @@ func TestEdgeCases(t *testing.T) {
}
})

t.Run("media tool result without filename", func(t *testing.T) {
// Messages stored before media had a filename must still decode.
stored := `{
"role": "tool",
"content": [
{
"type": "tool-result",
"data": {
"tool_call_id": "call_123",
"output": {
"type": "media",
"data": {"data": "AQID", "media_type": "image/png", "text": "screenshot"}
}
}
}
]
}`

var decoded Message
if err := json.Unmarshal([]byte(stored), &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}

toolResult := decoded.Content[0].(ToolResultPart)
want := ToolResultOutputContentMedia{Data: "AQID", MediaType: "image/png", Text: "screenshot"}
if got := toolResult.Output.(ToolResultOutputContentMedia); got != want {
t.Errorf("media output mismatch: got %+v, want %+v", got, want)
}
})

t.Run("unicode in text", func(t *testing.T) {
msg := Message{
Role: MessageRoleUser,
Expand Down
121 changes: 88 additions & 33 deletions providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package anthropic
import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand All @@ -17,6 +16,7 @@ import (

"charm.land/fantasy"
"charm.land/fantasy/object"
"charm.land/fantasy/providers/internal/filepart"
"charm.land/fantasy/providers/internal/httpheaders"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/charmbracelet/anthropic-sdk-go"
Expand Down Expand Up @@ -1221,33 +1221,23 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
if !ok {
continue
}
image, document, err := fileBlock(filepart.FromFilePart(file))
switch {
case strings.HasPrefix(file.MediaType, "image/"):
base64Encoded := base64.StdEncoding.EncodeToString(file.Data)
imageBlock := anthropic.NewImageBlockBase64(file.MediaType, base64Encoded)
if cacheControl != nil {
imageBlock.OfImage.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, imageBlock)
case file.MediaType == "application/pdf":
base64Encoded := base64.StdEncoding.EncodeToString(file.Data)
docBlock := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{
Data: base64Encoded,
case err != nil:
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: fmt.Sprintf("file part media type %s has invalid data: %v", file.MediaType, err),
})
docBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename))
case image != nil:
if cacheControl != nil {
docBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam()
image.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, docBlock)
case strings.HasPrefix(file.MediaType, "text/"):
documentBlock := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{
Data: string(file.Data),
})
documentBlock.OfDocument.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename))
anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{OfImage: image})
case document != nil:
if cacheControl != nil {
documentBlock.OfDocument.CacheControl = anthropic.NewCacheControlEphemeralParam()
document.CacheControl = anthropic.NewCacheControlEphemeralParam()
}
anthropicContent = append(anthropicContent, documentBlock)
anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{OfDocument: document})
default:
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Expand Down Expand Up @@ -1288,17 +1278,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
if !ok {
continue
}
contentBlocks := []anthropic.ToolResultBlockParamContentUnion{
{
OfImage: anthropic.NewImageBlockBase64(content.MediaType, content.Data).OfImage,
},
}
if content.Text != "" {
contentBlocks = append(contentBlocks, anthropic.ToolResultBlockParamContentUnion{
OfText: &anthropic.TextBlockParam{
Text: content.Text,
},
})
contentBlocks, warning := toolResultMediaContent(content)
if warning != nil {
warnings = append(warnings, *warning)
}
toolResultBlock.Content = contentBlocks
case fantasy.ToolResultContentTypeError:
Expand Down Expand Up @@ -1444,6 +1426,79 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
return systemBlocks, messages, warnings
}

// fileBlock converts a file to an Anthropic image or document block. It
// returns neither when Anthropic does not support the media type, and an error
// when text content cannot be decoded.
func fileBlock(file filepart.File) (*anthropic.ImageBlockParam, *anthropic.DocumentBlockParam, error) {
switch {
case strings.HasPrefix(file.MediaType, "image/"):
return anthropic.NewImageBlockBase64(file.MediaType, file.Base64()).OfImage, nil, nil
case file.MediaType == "application/pdf":
document := anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{
Data: file.Base64(),
}).OfDocument
document.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename))
return nil, document, nil
case strings.HasPrefix(file.MediaType, "text/"):
text, err := file.Bytes()
if err != nil {
return nil, nil, err
}
document := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{
Data: string(text),
}).OfDocument
document.Title = anthropic.String(sanitizeAnthropicDocumentTitle(file.Filename))
return nil, document, nil
default:
return nil, nil, nil
}
}

// toolResultMediaContent converts tool-result media with the same rules as
// user files. When the media cannot be converted, a text block tells the
// model about it. The blocks get no cache_control: the breakpoint stays on
// the tool_result block because Anthropic allows only four breakpoints.
func toolResultMediaContent(media fantasy.ToolResultOutputContentMedia) ([]anthropic.ToolResultBlockParamContentUnion, *fantasy.CallWarning) {
var blocks []anthropic.ToolResultBlockParamContentUnion
var warning *fantasy.CallWarning
image, document, err := fileBlock(filepart.FromToolResult(media))
switch {
case err != nil:
warning = &fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: fmt.Sprintf("tool result media type %s has invalid data: %v", media.MediaType, err),
}
case image != nil:
blocks = append(blocks, anthropic.ToolResultBlockParamContentUnion{OfImage: image})
case document != nil:
blocks = append(blocks, anthropic.ToolResultBlockParamContentUnion{OfDocument: document})
default:
warning = &fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: fmt.Sprintf("tool result media type %s not supported, sending text placeholder only", media.MediaType),
}
}
if warning != nil {
mediaType := media.MediaType
if mediaType == "" {
mediaType = "file"
}
blocks = append(blocks, anthropic.ToolResultBlockParamContentUnion{
OfText: &anthropic.TextBlockParam{
Text: fmt.Sprintf("The tool returned %s content, which cannot be displayed.", mediaType),
},
})
}
if media.Text != "" {
blocks = append(blocks, anthropic.ToolResultBlockParamContentUnion{
OfText: &anthropic.TextBlockParam{
Text: media.Text,
},
})
}
return blocks, warning
}

func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool {
for _, block := range content {
if block.OfText != nil || block.OfImage != nil || block.OfDocument != nil || block.OfToolResult != nil {
Expand Down
Loading
Loading