Skip to content

Bedrock Runtime Converse Force Tool Use Not Working w/ Anthropic API #3048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
2 tasks done
bartlebee13 opened this issue Apr 1, 2025 · 5 comments
Closed
2 tasks done
Labels
bug This issue is a bug. needs-reproduction This issue needs reproduction. response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days.

Comments

@bartlebee13
Copy link

bartlebee13 commented Apr 1, 2025

Acknowledgements

Describe the bug

I very recently integrated our backend with Anthropic's API for some structured data analysis/basic chat functionality and I was hoping the switch to AWS Bedrock would be as simple as replacing a couple function calls.. since Bedrock is essentially a wrapper on top of the thing I already integrated with.. but I've run into some problems.

model: us.anthropic.claude-3-7-sonnet-20250219-v1:0

According to the docs:

If you are using an Anthropic Claude 3 model, (which I am) you can force the use of a tool by specifying the toolChoice (ToolChoice) field in the toolConfig request parameter.

There seem to be two issues:

  1. The model does not use the tool when "forced" to - instead it responds with the tool_use StopReason rather than using the tool provided - this is annoying but not as critical.
  2. The model never correctly sets the input schema from my requests in its responses.

The Input field within the returned ToolUse block is consistently empty ({}). I am using the tool schema primarily as a mechanism to obtain reliable, structured JSON output from the model.

This could be because I am forming my JSON schema incorrectly but I've triple checked and ran several tests - its doesn't seem the case.

Expected Behavior

work like Anthropic's API where the LLM uses the tool within a single request rather than needing to make a second request acknowledging the tool use when you have correctly passed in the SpecificToolChoice within the tool config.

Current Behavior

the LLM returns with the stop_reason response with an empty input{}

{
    "Metrics": {
        "LatencyMs": 40423
    },
    "Output": {
        "Value": {
            "Content": [
                {
                    "Value": {
                        "Input": {},
                        "Name": "extract_project_document",
                        "ToolUseId": "tooluse_5D_meFt9Rz28sYNrvgJgsA"
                    }
                }
            ],
            "Role": "assistant"
        }
    },
    "StopReason": "tool_use",
    "Usage": {
        "InputTokens": 2414,
        "OutputTokens": 1626,
        "TotalTokens": 4040,
        "CacheReadInputTokens": 0,
        "CacheWriteInputTokens": 0
    },
    "AdditionalModelResponseFields": null,
    "PerformanceConfig": null,
    "Trace": null,
    "ResultMetadata": {}
}

Reproduction Steps

my imports :

	"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
	bedrockdocument "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document"
	bedrocktypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"

inside my function call:

	document := bedrocktypes.ContentBlockMemberDocument{
		Value: bedrocktypes.DocumentBlock{
			Format: bedrocktypes.DocumentFormatPdf,
			Name:   &filename,
			Source: &bedrocktypes.DocumentSourceMemberBytes{
				Value: bytes,
			},
		},
	}

	userPrompt := bedrocktypes.ContentBlockMemberText{
		Value: prompt,
	}

	toolname := "extract_project_document"
	tooldescription := "this aint the actual description"

	toolChoice := bedrocktypes.ToolChoiceMemberTool{
		Value: bedrocktypes.SpecificToolChoice{
			Name: &toolname,
		},
	}

	tools := []bedrocktypes.Tool{}

	anthropicSchema := GetProjectDocumentSchemaForBedrock()
	schema := bedrockdocument.NewLazyDocument(&anthropicSchema)

	inputSchema := bedrocktypes.ToolInputSchemaMemberJson{
		Value: schema,
	}

	tool := bedrocktypes.ToolMemberToolSpec{
		Value: bedrocktypes.ToolSpecification{
			Name:        &toolname,
			Description: &tooldescription,
			InputSchema: &inputSchema,
		},
	}

	tools = append(tools, &tool)

	toolConfig := bedrocktypes.ToolConfiguration{
		Tools:      tools,
		ToolChoice: &toolChoice,
	}

	contentBlocks := []bedrocktypes.ContentBlock{}

	contentBlocks = append(contentBlocks, &document)
	contentBlocks = append(contentBlocks, &userPrompt)

	message := bedrocktypes.Message{
		Role:    bedrocktypes.ConversationRoleUser,
		Content: contentBlocks,
	}

	request := bedrockruntime.ConverseInput{
		ModelId: aws.String(c.model),
		Messages: []bedrocktypes.Message{
			message,
		},
		ToolConfig: &toolConfig,
	}

        converseResponse, err := client.Converse(ctx, &request)

the helper func with simplified descriptions:

func GetProjectDocumentSchemaForBedrock() map[string]any {
	schema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"title": map[string]any{
				"type":        "string",
				"description": "title of the document.",
			},
			"type": map[string]any{
				"type":        "string",
				"description": "type of document.",
			},
			"raw_text": map[string]any{
				"type":        "string",
				"description": "raw text of the document.",
			},
		},
		"required": []string{"title", "type", "raw_text"},
	}

	return schema
}

Possible Solution

is the tool part of the request body is being omitted or not correctly marshaled into the call to the bedrock runtime? is there something wrong with the NewLazyDocument() wrapper?

from the example docs:
{"tool" : {"name" : "top_song"}}

Additional Information/Context

the exact same request with the same document & user prompt works with my claude's API implementation:

	anthropicReq := AnthropicAPIRequest{
		Model:     AnthropicModel,
		MaxTokens: AnthropicMaxTokens,
		Messages: []AnthropicRequestMessage{
			{
				Role:    AnthropicRoleUser,
				Content: content,
			},
		},
		Tools: []AnthropicTool{tool},
		ToolChoice: &AnthropicToolChoice{
			Type: "tool",
			Name: "extract_project_document",
		},
	}

AWS Go SDK V2 Module Versions Used

	github.com/aws/aws-sdk-go-v2 v1.36.3
	github.com/aws/aws-sdk-go-v2/config v1.29.12
	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.28.0

	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
	github.com/aws/aws-sdk-go-v2/credentials v1.17.65 // 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.3.34 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // 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.2 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 // indirect
	github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect
	github.com/aws/smithy-go v1.22.3 // indirect

	github.com/aws/aws-lambda-go v1.47.0
	github.com/aws/aws-sdk-go v1.55.6

Compiler and Version used

go version go1.23.3 linux/amd64

Operating System and version

Pop!_OS 22.04 LTS x86_64

@bartlebee13 bartlebee13 added bug This issue is a bug. needs-triage This issue or PR still needs to be triaged. labels Apr 1, 2025
@bartlebee13
Copy link
Author

Adding some further details to this issue after some testing. Ironically, had an LLM help me generate this.. 😂

Problem Summary

When using the Converse API with a model like us.anthropic.claude-3-7-sonnet-20250219-v1:0 and providing ToolConfig with a tool that includes an InputSchema, the API correctly returns StopReason: "tool_use" and identifies the correct tool name. However, the Input field within the returned ToolUse block is consistently empty ({}), even when the model is expected to generate data matching the schema.

Debugging Steps Performed

I've systematically simplified the request to isolate the cause of the empty Input field:

  1. Initial Setup:

    • Input: PDF document via DocumentBlock.
    • Schema: Included multiple fields (title, type, raw_text), all required.
    • Tool Config: Included ToolChoice to force the specific tool.
    • Result: StopReason: "tool_use", correct tool name, but Input: {} (empty).
  2. Simplified Schema (Removed raw_text):

    • Removed the potentially large raw_text field from the schema and required list.
    • Kept DocumentBlock, ToolChoice.
    • Result: StopReason: "tool_use", correct tool name, but Input: {} (still empty).
  3. Removed ToolChoice (Natural Tool Selection):

    • Used the simplified schema (no raw_text).
    • Kept DocumentBlock.
    • Removed ToolChoice from ToolConfig.
    • Result: Model naturally chose the correct tool (StopReason: "tool_use"), sometimes added conversational text before the ToolUse block, but Input: {} (still empty). This indicated ToolChoice wasn't the root cause.
  4. Replaced DocumentBlock with TextBlock:

    • Used the simplified schema (no raw_text).
    • Removed ToolChoice.
    • Replaced the PDF DocumentBlock with a simple TextBlock containing plain text.
    • Result: StopReason: "tool_use", correct tool name, but Input: {} (still empty). This indicated the issue wasn't specific to DocumentBlock processing.
  5. Minimal Schema (e.g., "title" only):

    • Reduced schema to the absolute minimum (e.g., only a "title" field required).
    • Used TextBlock input.
    • Removed ToolChoice.
    • Result: StopReason: "tool_use", correct tool name, but Input: {} (still empty).
  6. No Input Data (Generation Task):

    • Used the minimal schema ("title" only).
    • Removed ToolChoice.
    • Removed all input data blocks (DocumentBlock/TextBlock). Sent only a prompt asking the model to generate data using the tool (e.g., "Generate a title using the tool.").
    • Result: Model still chose the correct tool (StopReason: "tool_use"), but Input: {} (still empty).

Conclusion

The extensive testing, culminating in the failure even with no input data and a minimal generation task (Step 6), strongly suggests this is not an issue with:

  • Input data parsing (PDF or text).
  • Schema complexity or specific fields (raw_text).
  • The ToolChoice forcing mechanism.
  • Prompt construction.

The evidence points towards an issue with Bedrock's handling of tool use when an InputSchema is defined for the tool, specifically regarding the step where the model should populate the ToolUse.Input field.

While the SDK appears to be constructing the request as documented, the Bedrock service itself seems unable to fulfill the tool input generation part of the workflow correctly in this scenario.

example code test (Step 6):

func (c *BedrockClient) CallMinimalBedrockToolTest(ctx context.Context) (*bedrockruntime.ConverseOutput, error) {

	// 1. Define the absolute minimal schema (e.g., just a title)
	minimalSchema := map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"title": map[string]interface{}{
				"type":        "string",
				"description": "A sample title to be generated", // Simple description focused on generation
			},
		},
		"required": []string{"title"},
	}

	// 2. Define the minimal tool specification using the schema
	toolName := "generate_sample_title" // Naming reflects the task
	toolDesc := "Use this tool to generate a sample title."
	toolSpec := bedrocktypes.ToolMemberToolSpec{
		Value: bedrocktypes.ToolSpecification{
			Name:        &toolName,
			Description: &toolDesc,
			InputSchema: &bedrocktypes.ToolInputSchemaMemberJson{
				// Use NewLazyDocument to correctly serialize the schema map
				Value: bedrockdocument.NewLazyDocument(minimalSchema),
			},
		},
	}
	tools := []bedrocktypes.Tool{&toolSpec}

	// 3. Define the minimal tool configuration (NO ToolChoice)
	toolConfig := bedrocktypes.ToolConfiguration{
		Tools: tools,
		// Intentionally omit ToolChoice to allow natural selection
	}

	// 4. Define the minimal prompt asking the model to use the tool
	//    (No document or other data block provided)
	promptText := "Please generate a title using the 'generate_sample_title' tool."
	promptBlock := bedrocktypes.ContentBlockMemberText{
		Value: promptText,
	}

	// 5. Create the message list containing only the prompt
	message := bedrocktypes.Message{
		Role:    bedrocktypes.ConversationRoleUser,
		Content: []bedrocktypes.ContentBlock{&promptBlock}, // Only the prompt block
	}
	messages := []bedrocktypes.Message{message}

	// 6. Assemble the Converse API request
	request := bedrockruntime.ConverseInput{
		ModelId:    aws.String(c.model),
		Messages:   messages,
		ToolConfig: &toolConfig,
		// InferenceConfig: nil, // Use default inference parameters
	}

	log.Println("Sending minimal Converse request to Bedrock...")

	// 7. Call the Converse API
	converseResponse, err := c.client.Converse(ctx, &request)
	if err != nil {
		log.Printf("ERROR: Bedrock Converse API call failed: %v", err)
		return nil, err
	}

	log.Println("Received Bedrock Converse response.")
	return converseResponse, nil
}

@bartlebee13
Copy link
Author

the specific functionality I'm trying to replicate in AWS Bedrock is "use tools to get Claude produce JSON output that follows a schema" as outlined in the Anthropic API docs.

@Madrigal
Copy link
Contributor

I choose the latest code that you posted on

example code test (Step 6):

however, I'm actually able to see a non-empty input on the request and the response from the server.

My code (modified your signature a bit to pass the struct members as parameters)

	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx,
		config.WithRegion("us-west-2"),
		config.WithClientLogMode(aws.LogRequestWithBody|aws.LogResponseWithBody),
	)
	if err != nil {
		log.Fatal(err)
	}
	client := bedrockruntime.NewFromConfig(cfg)
	res, err := CallMinimalBedrockToolTest(client, "us.anthropic.claude-3-7-sonnet-20250219-v1:0", ctx)
	if err != nil {
		log.Fatal(err)
	}
	message, ok := res.Output.(*bedrocktypes.ConverseOutputMemberMessage)
	if !ok {
		panic("Failed to cast to ConverseOutputMemberMessage")
	}
	contents := message.Value.Content
	for _, content := range contents {
		switch content.(type) {
		case *bedrocktypes.ContentBlockMemberText:
			content := content.(*bedrocktypes.ContentBlockMemberText)
			fmt.Println("ContentBlockMemberText", content.Value)
		case *bedrocktypes.ContentBlockMemberToolUse:
			content := content.(*bedrocktypes.ContentBlockMemberToolUse)
			fmt.Printf("ContentBlockMemberToolUse %v\n", content.Value.Input)
		default:
			fmt.Println("Unknown content", content)
		}
	}

I can see both on the raw HTTP response

SDK 2025/04/15 17:11:51 DEBUG Response
HTTP/2.0 200 OK
Content-Length: 518
Content-Type: application/json
Date: Tue, 15 Apr 2025 21:11:51 GMT
X-Amzn-Requestid: d8868808-c4c7-4465-932a-dd5d60e7b19a

{
    "metrics": {
        "latencyMs": 2803
    },
    "output": {
        "message": {
            "content": [{
                "text": "I'd be happy to generate a sample title for you using the available tool. Let me do that for you now."
            }, {
                "toolUse": {
                    "input": {
                        "title": "Sample Title"
                    },
                    "name": "generate_sample_title",
                    "toolUseId": "tooluse_w3FAsFAISgeHb52IsJIuRw"
                }
            }],
            "role": "assistant"
        }
    },
    "stopReason": "tool_use",
    "usage": {
        "cacheReadInputTokenCount": 0,
        "cacheReadInputTokens": 0,
        "cacheWriteInputTokenCount": 0,
        "cacheWriteInputTokens": 0,
        "inputTokens": 405,
        "outputTokens": 81,
        "totalTokens": 486
    }
}

And on the Go content that input is not nil

ContentBlockMemberText I'd be happy to generate a sample title for you using the available tool. Let me do that for you now.
ContentBlockMemberToolUse &{map[title:Sample Title]}

@Madrigal Madrigal added response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. needs-reproduction This issue needs reproduction. and removed needs-triage This issue or PR still needs to be triaged. labels Apr 15, 2025
@bartlebee13
Copy link
Author

thanks for your response! Using your type casting code I was also able to see non-empty input on the response from the server.

I was able to store the content.Value.Input into a document.Interface variable and then use the UnmarshalSmithyDocument func to decode the data from that variable into my local struct.

Copy link

This issue is now closed. Comments on closed issues are hard for our team to see.
If you need more assistance, please open a new issue that references this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug This issue is a bug. needs-reproduction This issue needs reproduction. response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days.
Projects
None yet
Development

No branches or pull requests

2 participants