diff --git a/packages/provider/amazon_bedrock.go b/packages/provider/amazon_bedrock.go index a3baaf6..7229ec7 100644 --- a/packages/provider/amazon_bedrock.go +++ b/packages/provider/amazon_bedrock.go @@ -299,6 +299,24 @@ func bedrockModelSupportsCaching(modelID string) bool { return strings.HasPrefix(modelID, "anthropic.claude-") } +// bedrockMessagesHaveToolBlocks reports whether any message in msgs +// contains a toolUse or toolResult content block. Bedrock's Converse API +// rejects the request with HTTP 400 if those block types are present but +// toolConfig is absent. +func bedrockMessagesHaveToolBlocks(msgs []bedrockMessage) bool { + for _, m := range msgs { + for _, c := range m.Content { + if _, ok := c["toolUse"]; ok { + return true + } + if _, ok := c["toolResult"]; ok { + return true + } + } + } + return false +} + func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { out := &bedrockRequest{} @@ -380,6 +398,21 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { tc.Tools = append(tc.Tools, ts) } out.ToolConfig = &tc + } else if bedrockMessagesHaveToolBlocks(out.Messages) { + // Bedrock requires toolConfig to be present whenever the message + // history contains toolUse or toolResult content blocks, even if + // the current request does not intend to call any tools (e.g. the + // /btw side-chat sends the frozen main transcript which may include + // prior tool turns). Inject a stub tool so the API accepts the + // request; it will never be invoked since no tool_use stop reason + // can be triggered when the real tool list is empty. + var stub bedrockToolSpec + stub.ToolSpec.Name = "_placeholder" + stub.ToolSpec.Description = "placeholder required by Bedrock when tool history is present" + stub.ToolSpec.InputSchema.JSON = json.RawMessage(`{"type":"object","properties":{}}`) + out.ToolConfig = &struct { + Tools []bedrockToolSpec `json:"tools"` + }{Tools: []bedrockToolSpec{stub}} } if caching { diff --git a/packages/provider/amazon_bedrock_test.go b/packages/provider/amazon_bedrock_test.go index 2c0038f..aa13c9a 100644 --- a/packages/provider/amazon_bedrock_test.go +++ b/packages/provider/amazon_bedrock_test.go @@ -357,6 +357,75 @@ func TestBedrockBuildRequestCachingWireJSON(t *testing.T) { } } +// TestBedrockBuildRequestBtwToolHistory reproduces the /btw HTTP 400 bug: +// the side-chat sends no tools but the frozen main transcript contains +// toolUse / toolResult blocks. Bedrock rejects such a request unless +// toolConfig is present. buildRequest must inject a stub toolConfig. +func TestBedrockBuildRequestBtwToolHistory(t *testing.T) { + client := &bedrockClient{region: "us-east-1"} + req, err := client.buildRequest(Request{ + Model: "amazon.nova-pro-v1:0", + // No tools — simulates /btw side-chat. + Messages: []Message{ + // Frozen main transcript: one tool call + result already happened. + {Role: RoleAssistant, Content: []Content{ + ToolCallBlock{ID: "tc-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)}, + }}, + {Role: RoleTool, Content: []Content{ + ToolResultBlock{CallID: "tc-1", Content: []Content{TextBlock{Text: "file.go"}}}, + }}, + // Side-chat question appended by btwDialog.submit. + {Role: RoleUser, Content: []Content{TextBlock{Text: "what does that file do?"}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if req.ToolConfig == nil { + t.Fatal("buildRequest should inject a stub toolConfig when history has tool blocks and req.Tools is empty, but ToolConfig is nil") + } + if len(req.ToolConfig.Tools) == 0 { + t.Fatal("stub toolConfig should have at least one tool") + } + // The stub must have a valid name and a non-nil schema so Bedrock won't + // reject it for a different reason. + stub := req.ToolConfig.Tools[0] + if stub.ToolSpec.Name == "" { + t.Error("stub tool name must not be empty") + } + if stub.ToolSpec.InputSchema.JSON == nil { + t.Error("stub tool schema must not be nil") + } + // Serialised wire payload must include toolConfig. + b, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"toolConfig"`) { + t.Errorf("serialised request missing toolConfig: %s", b) + } +} + +// TestBedrockBuildRequestNoToolHistoryNoStub ensures that when there are +// no tool blocks in the history and no tools in the request (ordinary +// conversational call), we do NOT inject a toolConfig at all — the extra +// field is unnecessary and some models may behave differently with it. +func TestBedrockBuildRequestNoToolHistoryNoStub(t *testing.T) { + client := &bedrockClient{region: "us-east-1"} + req, err := client.buildRequest(Request{ + Model: "amazon.nova-pro-v1:0", + Messages: []Message{ + {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if req.ToolConfig != nil { + t.Errorf("expected nil ToolConfig for plain conversation, got: %+v", req.ToolConfig) + } +} + func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) { client := &bedrockClient{} req, err := client.buildRequest(Request{Messages: []Message{