fix(bedrock): inject stub toolConfig when history has tool blocks

Bedrock's Converse API returns HTTP 400 with "toolConfig field must be
defined when using toolUse and toolResult content blocks" whenever the
message history contains toolUse or toolResult blocks but toolConfig is
absent from the request.

The /btw side-chat sends the frozen main transcript as context with no
tools defined. If the main conversation included tool calls the serialised
messages will contain toolUse/toolResult blocks, triggering the 400.

Fix: add bedrockMessagesHaveToolBlocks() to detect this case and, when
req.Tools is empty but tool blocks are present in the history, inject a
minimal stub toolConfig with an inert placeholder tool. Bedrock accepts
the request and the stub can never be invoked since no tool_use stop
reason can fire when the advertised tool list is effectively empty.
This commit is contained in:
Raymond Gasper 2026-06-08 10:51:19 -04:00
parent 7eb8a65637
commit fec5ae0bf1
2 changed files with 102 additions and 0 deletions

View file

@ -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 {

View file

@ -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{