When using the Converse API for multi-turn tool-use conversations, numeric values in tool arguments are corrupted if the caller stores arguments as map[string]any between turns. The second Converse call fails with a 500 InternalServerException. I believe aws/smithy-go#596 is the ultimate root cause. I'm making a separate issue because that's a pretty old issue, and the customer impact might not have been clear enough from it, and also it's possible that I'm simply holding the bedrockruntime API wrong.
As I understand it, I have to unmarshal the document that the LLM provides as tool calls, and use it myself. I'd like to re-marshal that decoded input for when I construct my next turn in the conversation, rather than have to keep track of the historical documents, because I'm making a provider-agnostic bit of code here.
Here's a reproducer main.go (and associated go.mod). For me, it produces:
Model called tool "add" (ID: tooluse_EQqEboQbnMTI7bOTOdK1JV)
Turn 2 Converse FAILED: operation error Bedrock Runtime: Converse, exceeded maximum number of attempts, 3, https response error StatusCode: 500, RequestID: 1e5051f7-ccc0-4c5d-9b1f-7feb707d126b, InternalServerException: The system encountered an unexpected error during processing. Try your request again.
Input document bytes: {"a":3,"a":"3","b":4,"b":"4"}
exit status 1
// Run with:
// go mod tidy && go run .
//
// Requires AWS credentials in the environment (standard credential chain:
// env vars, ~/.aws/credentials, IAM role, etc.) and an AWS region with
// Bedrock access.
package main
import (
"context"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
)
const modelID = "us.amazon.nova-micro-v1:0"
func main() {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx)
assertNoError(err)
rt := bedrockruntime.NewFromConfig(cfg)
// Define a tool that accepts two numeric values - "add(a: int, b: int)".
toolConfig := &types.ToolConfiguration{
Tools: []types.Tool{
&types.ToolMemberToolSpec{Value: types.ToolSpecification{
Name: aws.String("add"),
Description: aws.String("Add two integers together."),
InputSchema: &types.ToolInputSchemaMemberJson{
Value: document.NewLazyDocument(map[string]any{
"type": "object",
"properties": map[string]any{
"a": map[string]any{"type": "integer", "description": "first operand"},
"b": map[string]any{"type": "integer", "description": "second operand"},
},
"required": []any{"a", "b"},
}),
},
}},
},
}
// Ask the model to call the add tool ---
out1, err := rt.Converse(ctx, &bedrockruntime.ConverseInput{
ModelId: aws.String(modelID),
ToolConfig: toolConfig,
Messages: []types.Message{{
Role: types.ConversationRoleUser,
Content: []types.ContentBlock{
&types.ContentBlockMemberText{Value: "What is 3 plus 4? Use the add tool."},
},
}},
})
assertNoError(err)
// Pull out the tool use from the response so we can evaluate it.
msg1 := out1.Output.(*types.ConverseOutputMemberMessage)
var toolUse *types.ContentBlockMemberToolUse
for _, block := range msg1.Value.Content {
if tu, ok := block.(*types.ContentBlockMemberToolUse); ok {
toolUse = tu
break
}
}
fmt.Printf("Model called tool %q (ID: %s)\n", *toolUse.Value.Name, *toolUse.Value.ToolUseId)
// Deserialize into map[string]any - in my application, this is important for
// dynamic tool dispatch patterns.
var args map[string]any
err = toolUse.Value.Input.UnmarshalSmithyDocument(&args)
assertNoError(err)
// Compute our answer...
result, err := eval(*toolUse.Value.Name, args)
assertNoError(err)
// Turn 2: send tool result and expect a natural-language answer ---
_, err = rt.Converse(ctx, &bedrockruntime.ConverseInput{
ModelId: aws.String(modelID),
ToolConfig: toolConfig,
Messages: []types.Message{
{
Role: types.ConversationRoleUser,
Content: []types.ContentBlock{
&types.ContentBlockMemberText{Value: "What is 3 plus 4? Use the add tool."},
},
},
{
Role: types.ConversationRoleAssistant,
Content: []types.ContentBlock{
// Reconstruct the tool use block from the extracted args —
// the idiomatic approach when args have been stored as
// map[string]any between turns.
&types.ContentBlockMemberToolUse{Value: types.ToolUseBlock{
ToolUseId: toolUse.Value.ToolUseId,
Name: toolUse.Value.Name,
// I believe this following line is the bug:
Input: document.NewLazyDocument(args),
}},
},
},
{
Role: types.ConversationRoleUser,
Content: []types.ContentBlock{
&types.ContentBlockMemberToolResult{Value: types.ToolResultBlock{
ToolUseId: toolUse.Value.ToolUseId,
Content: []types.ToolResultContentBlock{
&types.ToolResultContentBlockMemberText{Value: result},
},
}},
},
},
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "\nTurn 2 Converse FAILED: %v\n", err)
inputDoc := document.NewLazyDocument(args)
inputBytes, err := inputDoc.MarshalSmithyDocument()
if err != nil {
fmt.Fprintf(os.Stderr, "MarshalSmithyDocument: %v\n", err)
os.Exit(1)
}
fmt.Printf("Input document bytes: %s\n", string(inputBytes))
os.Exit(1)
}
}
// minor helpers below
func eval(toolName string, args map[string]any) (string, error) {
if toolName == "add" {
a, _ := args["a"].(float64)
b, _ := args["b"].(float64)
sum := a + b
return fmt.Sprintf("%v", sum), nil
}
return "", fmt.Errorf("unknown tool: %s", toolName)
}
func fatal(err error) {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
func assertNoError(err error) {
if err != nil {
fatal(err)
}
}
go.mod:
module smithy596reproducer
go 1.24
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
)
When using the Converse API for multi-turn tool-use conversations, numeric values in tool arguments are corrupted if the caller stores arguments as map[string]any between turns. The second Converse call fails with a 500 InternalServerException. I believe aws/smithy-go#596 is the ultimate root cause. I'm making a separate issue because that's a pretty old issue, and the customer impact might not have been clear enough from it, and also it's possible that I'm simply holding the bedrockruntime API wrong.
As I understand it, I have to unmarshal the document that the LLM provides as tool calls, and use it myself. I'd like to re-marshal that decoded input for when I construct my next turn in the conversation, rather than have to keep track of the historical documents, because I'm making a provider-agnostic bit of code here.
Here's a reproducer
main.go(and associatedgo.mod). For me, it produces:go.mod: