Merge #18: bedrock /btw chats fail from invalid toolConfig

Inject a stub toolConfig when the message history contains toolUse or
toolResult blocks but req.Tools is empty (e.g. the /btw side-chat sends
the frozen main transcript). Bedrock's Converse API otherwise rejects
the request with HTTP 400. Bedrock-only; other providers unaffected.

Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
This commit is contained in:
patriceckhart 2026-06-08 17:25:02 +02:00
commit 6938d13e90
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{