Skip to content

Commit edefef9

Browse files
joaquinhuiclaude
andcommitted
Fix ToolResultBlockParam to support both string and array content formats
The API docs state that tool_result content can be either a string (e.g. "content": "15 degrees") or an array of content blocks. The SDK previously only supported the array format, causing deserialization errors when content was a plain string. Changes: - Add ContentString field to ToolResultBlockParam and BetaToolResultBlockParam for explicit string content serialization - Update MarshalJSON to output content as a string when ContentString is set - Update UnmarshalJSON to handle string content by normalizing it into a Content array with a single TextBlock - Update NewToolResultBlock to use string format (matches API docs examples) - Add NewToolResultBlockFromArray for explicit array content construction - Add NewBetaToolResultStringBlockParam for explicit string content - Add comprehensive tests for both formats Fixes #182 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2bec06f commit edefef9

3 files changed

Lines changed: 366 additions & 4 deletions

File tree

betamessage.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/anthropics/anthropic-sdk-go/packages/respjson"
1919
"github.com/anthropics/anthropic-sdk-go/packages/ssestream"
2020
"github.com/anthropics/anthropic-sdk-go/shared/constant"
21+
"github.com/tidwall/gjson"
2122
)
2223

2324
// BetaMessageService contains methods and other services that help with
@@ -7943,16 +7944,61 @@ type BetaToolResultBlockParam struct {
79437944
// Create a cache control breakpoint at this content block.
79447945
CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"`
79457946
Content []BetaToolResultBlockParamContentUnion `json:"content,omitzero"`
7947+
// ContentString is an alternative to Content that allows setting a plain string
7948+
// value for the content field. Per the API docs, tool_result content can be
7949+
// either a string (e.g. "content": "15 degrees") or an array of content blocks.
7950+
// When ContentString is non-empty and Content is empty, it will be serialized as
7951+
// a plain string. This field is not serialized directly via struct tags.
7952+
ContentString string `json:"-"`
79467953
// This field can be elided, and will marshal its zero value as "tool_result".
79477954
Type constant.ToolResult `json:"type" api:"required"`
79487955
paramObj
79497956
}
79507957

79517958
func (r BetaToolResultBlockParam) MarshalJSON() (data []byte, err error) {
7959+
if r.ContentString != "" && len(r.Content) == 0 {
7960+
type shadow struct {
7961+
ToolUseID string `json:"tool_use_id"`
7962+
IsError param.Opt[bool] `json:"is_error,omitzero"`
7963+
CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"`
7964+
Content string `json:"content"`
7965+
Type constant.ToolResult `json:"type"`
7966+
}
7967+
return json.Marshal(shadow{
7968+
ToolUseID: r.ToolUseID,
7969+
IsError: r.IsError,
7970+
CacheControl: r.CacheControl,
7971+
Content: r.ContentString,
7972+
Type: r.Type,
7973+
})
7974+
}
79527975
type shadow BetaToolResultBlockParam
79537976
return param.MarshalObject(r, (*shadow)(&r))
79547977
}
79557978
func (r *BetaToolResultBlockParam) UnmarshalJSON(data []byte) error {
7979+
// Check if the content field is a string (the API supports both string and array).
7980+
contentField := gjson.GetBytes(data, "content")
7981+
if contentField.Exists() && contentField.Type == gjson.String {
7982+
type stringContent struct {
7983+
ToolUseID string `json:"tool_use_id"`
7984+
IsError param.Opt[bool] `json:"is_error,omitzero"`
7985+
CacheControl BetaCacheControlEphemeralParam `json:"cache_control,omitzero"`
7986+
Content string `json:"content"`
7987+
Type constant.ToolResult `json:"type"`
7988+
}
7989+
var sc stringContent
7990+
if err := json.Unmarshal(data, &sc); err != nil {
7991+
return err
7992+
}
7993+
r.ToolUseID = sc.ToolUseID
7994+
r.IsError = sc.IsError
7995+
r.CacheControl = sc.CacheControl
7996+
r.Content = []BetaToolResultBlockParamContentUnion{
7997+
{OfText: &BetaTextBlockParam{Text: sc.Content}},
7998+
}
7999+
r.Type = sc.Type
8000+
return nil
8001+
}
79568002
return apijson.UnmarshalRoot(data, r)
79578003
}
79588004

@@ -7966,6 +8012,16 @@ func NewBetaToolResultTextBlockParam(toolUseID string, text string, isError bool
79668012
return p
79678013
}
79688014

8015+
// NewBetaToolResultStringBlockParam creates a tool result block with content as a plain string.
8016+
// Per the API docs, tool_result content can be either a string or an array of content blocks.
8017+
func NewBetaToolResultStringBlockParam(toolUseID string, text string, isError bool) BetaToolResultBlockParam {
8018+
var p BetaToolResultBlockParam
8019+
p.ToolUseID = toolUseID
8020+
p.IsError = param.Opt[bool]{Value: isError}
8021+
p.ContentString = text
8022+
return p
8023+
}
8024+
79698025
// Only one field can be non-zero.
79708026
//
79718027
// Use [param.IsOmitted] to confirm if a field is set.

message.go

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,12 +1830,21 @@ func NewToolUseBlock(id string, input any, name string) ContentBlockParamUnion {
18301830
}
18311831

18321832
func NewToolResultBlock(toolUseID string, content string, isError bool) ContentBlockParamUnion {
1833+
toolBlock := ToolResultBlockParam{
1834+
ToolUseID: toolUseID,
1835+
ContentString: content,
1836+
IsError: Bool(isError),
1837+
}
1838+
return ContentBlockParamUnion{OfToolResult: &toolBlock}
1839+
}
1840+
1841+
// NewToolResultBlockFromArray creates a tool result block with content as an array of content
1842+
// blocks. Use this when you need to pass structured content (e.g., text + images).
1843+
func NewToolResultBlockFromArray(toolUseID string, content []ToolResultBlockParamContentUnion, isError bool) ContentBlockParamUnion {
18331844
toolBlock := ToolResultBlockParam{
18341845
ToolUseID: toolUseID,
1835-
Content: []ToolResultBlockParamContentUnion{
1836-
{OfText: &TextBlockParam{Text: content}},
1837-
},
1838-
IsError: Bool(isError),
1846+
Content: content,
1847+
IsError: Bool(isError),
18391848
}
18401849
return ContentBlockParamUnion{OfToolResult: &toolBlock}
18411850
}
@@ -6725,16 +6734,65 @@ type ToolResultBlockParam struct {
67256734
// Create a cache control breakpoint at this content block.
67266735
CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"`
67276736
Content []ToolResultBlockParamContentUnion `json:"content,omitzero"`
6737+
// ContentString is an alternative to Content that allows setting a plain string
6738+
// value for the content field. Per the API docs, tool_result content can be
6739+
// either a string (e.g. "content": "15 degrees") or an array of content blocks.
6740+
// When ContentString is non-empty and Content is empty, it will be serialized as
6741+
// a plain string. This field is not serialized directly via struct tags.
6742+
ContentString string `json:"-"`
67286743
// This field can be elided, and will marshal its zero value as "tool_result".
67296744
Type constant.ToolResult `json:"type" api:"required"`
67306745
paramObj
67316746
}
67326747

67336748
func (r ToolResultBlockParam) MarshalJSON() (data []byte, err error) {
6749+
// If ContentString is set and Content array is empty, serialize content as a
6750+
// plain string instead of an array, matching the API's support for both formats.
6751+
if r.ContentString != "" && len(r.Content) == 0 {
6752+
type shadow struct {
6753+
ToolUseID string `json:"tool_use_id"`
6754+
IsError param.Opt[bool] `json:"is_error,omitzero"`
6755+
CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"`
6756+
Content string `json:"content"`
6757+
Type constant.ToolResult `json:"type"`
6758+
}
6759+
return json.Marshal(shadow{
6760+
ToolUseID: r.ToolUseID,
6761+
IsError: r.IsError,
6762+
CacheControl: r.CacheControl,
6763+
Content: r.ContentString,
6764+
Type: r.Type,
6765+
})
6766+
}
67346767
type shadow ToolResultBlockParam
67356768
return param.MarshalObject(r, (*shadow)(&r))
67366769
}
67376770
func (r *ToolResultBlockParam) UnmarshalJSON(data []byte) error {
6771+
// Check if the content field is a string (the API supports both string and array).
6772+
contentField := gjson.GetBytes(data, "content")
6773+
if contentField.Exists() && contentField.Type == gjson.String {
6774+
// Content is a plain string — deserialize other fields normally, then
6775+
// normalize the string into a Content array with a single TextBlock.
6776+
type stringContent struct {
6777+
ToolUseID string `json:"tool_use_id"`
6778+
IsError param.Opt[bool] `json:"is_error,omitzero"`
6779+
CacheControl CacheControlEphemeralParam `json:"cache_control,omitzero"`
6780+
Content string `json:"content"`
6781+
Type constant.ToolResult `json:"type"`
6782+
}
6783+
var sc stringContent
6784+
if err := json.Unmarshal(data, &sc); err != nil {
6785+
return err
6786+
}
6787+
r.ToolUseID = sc.ToolUseID
6788+
r.IsError = sc.IsError
6789+
r.CacheControl = sc.CacheControl
6790+
r.Content = []ToolResultBlockParamContentUnion{
6791+
{OfText: &TextBlockParam{Text: sc.Content}},
6792+
}
6793+
r.Type = sc.Type
6794+
return nil
6795+
}
67386796
return apijson.UnmarshalRoot(data, r)
67396797
}
67406798

0 commit comments

Comments
 (0)