From dc2fef0e1d05a24e26116542a07b11a2f335e15d Mon Sep 17 00:00:00 2001 From: Simon Soriano Date: Sat, 2 May 2026 10:23:27 +0200 Subject: [PATCH] fix(bedrock): reject tool with empty description before calling Converse Bedrock's Converse API rejects any tool whose description is the empty string with an opaque ValidationException ("Member must have length greater than or equal to 1"), failing the entire call. The mapper used to pass empty descriptions through unchanged, so callers saw a server- side error that pointed at tool indices rather than tool names. Validate upfront in mapToolConfig and surface every offender at once so callers can fix all of them in one pass with a clear, name-keyed error. Co-Authored-By: Claude Opus 4.7 (1M context) --- providers/bedrock/provider_test.go | 36 +++++++++++++++++++++++++++++ providers/bedrock/request_mapper.go | 17 ++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/providers/bedrock/provider_test.go b/providers/bedrock/provider_test.go index cc692ad3..cec6b7ea 100644 --- a/providers/bedrock/provider_test.go +++ b/providers/bedrock/provider_test.go @@ -470,6 +470,42 @@ func TestRequestMapper_ToolDefinitions(t *testing.T) { assert.True(t, ok) } +func TestRequestMapper_EmptyToolDescription(t *testing.T) { + t.Parallel() + + cfg := &Config{ + ModelName: "claude-sonnet-4-6", + setOptions: make(map[string]bool), + } + + mapper := NewRequestMapper(cfg) + + schema := json.RawMessage(`{"type":"object"}`) + + req := &llm.Request{ + Messages: []llm.Message{ + llm.NewMessage(llm.RoleUser, llm.NewTextPart("hi")), + }, + Tools: []llm.ToolDefinition{ + {Name: "search", Description: "Search the web", Parameters: schema}, + {Name: "subagent_a", Description: "", Parameters: schema}, + {Name: "summarize", Description: "Summarize text", Parameters: schema}, + {Name: "subagent_b", Description: "", Parameters: schema}, + }, + ToolChoice: &llm.ToolChoice{Type: llm.ToolChoiceAuto}, + } + + _, err := mapper.ToConverseInput(req) + require.Error(t, err) + require.ErrorIs(t, err, llm.ErrRequestMapping) + // All offenders surfaced in a single error so callers can fix in one pass. + assert.Contains(t, err.Error(), "subagent_a") + assert.Contains(t, err.Error(), "subagent_b") + // Tools with valid descriptions are not flagged. + assert.NotContains(t, err.Error(), "missing on: search") + assert.NotContains(t, err.Error(), "summarize,") +} + func TestRequestMapper_ToolChoiceSpecific(t *testing.T) { t.Parallel() diff --git a/providers/bedrock/request_mapper.go b/providers/bedrock/request_mapper.go index a0aa9347..1cdbdc16 100644 --- a/providers/bedrock/request_mapper.go +++ b/providers/bedrock/request_mapper.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" @@ -314,6 +315,22 @@ func (rm *RequestMapper) mapToolResultBlock(resp *llm.ToolResponse) types.ToolRe // mapToolConfig converts tool definitions and choice to Bedrock ToolConfiguration. func (rm *RequestMapper) mapToolConfig(tools []llm.ToolDefinition, choice *llm.ToolChoice) (*types.ToolConfiguration, error) { + // Bedrock Converse rejects any tool whose description is the empty + // string with an opaque ValidationException ("Member must have length + // greater than or equal to 1"). Validate upfront and surface every + // offender at once so callers can fix all of them in one pass. + var missingDesc []string + + for _, tool := range tools { + if tool.Description == "" { + missingDesc = append(missingDesc, tool.Name) + } + } + + if len(missingDesc) > 0 { + return nil, fmt.Errorf("bedrock: every tool must have a non-empty description; missing on: %s", strings.Join(missingDesc, ", ")) + } + apiTools := make([]types.Tool, 0, len(tools)) for _, tool := range tools {