detached fec5ae0

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.
commit
fec5ae0bf1ed3cc9f99e56311ca388eb742c6f54
author
Raymond Gasper · · 2026-06-08 14:51 UTC
committer
Raymond Gasper · · 2026-06-08 14:56 UTC
parent
7eb8a65
signature
unsigned
2 files · +102 -0
M packages/provider/amazon_bedrock.go +33 -0
@@ -299,6 +299,24 @@ func bedrockModelSupportsCaching(modelID string) bool {
299299 return strings.HasPrefix(modelID, "anthropic.claude-")
300300 }
301301
302+// bedrockMessagesHaveToolBlocks reports whether any message in msgs
303+// contains a toolUse or toolResult content block. Bedrock's Converse API
304+// rejects the request with HTTP 400 if those block types are present but
305+// toolConfig is absent.
306+func bedrockMessagesHaveToolBlocks(msgs []bedrockMessage) bool {
307+ for _, m := range msgs {
308+ for _, c := range m.Content {
309+ if _, ok := c["toolUse"]; ok {
310+ return true
311+ }
312+ if _, ok := c["toolResult"]; ok {
313+ return true
314+ }
315+ }
316+ }
317+ return false
318+}
319+
302320 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
303321 out := &bedrockRequest{}
304322
@@ -380,6 +398,21 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
380398 tc.Tools = append(tc.Tools, ts)
381399 }
382400 out.ToolConfig = &tc
401+ } else if bedrockMessagesHaveToolBlocks(out.Messages) {
402+ // Bedrock requires toolConfig to be present whenever the message
403+ // history contains toolUse or toolResult content blocks, even if
404+ // the current request does not intend to call any tools (e.g. the
405+ // /btw side-chat sends the frozen main transcript which may include
406+ // prior tool turns). Inject a stub tool so the API accepts the
407+ // request; it will never be invoked since no tool_use stop reason
408+ // can be triggered when the real tool list is empty.
409+ var stub bedrockToolSpec
410+ stub.ToolSpec.Name = "_placeholder"
411+ stub.ToolSpec.Description = "placeholder required by Bedrock when tool history is present"
412+ stub.ToolSpec.InputSchema.JSON = json.RawMessage(`{"type":"object","properties":{}}`)
413+ out.ToolConfig = &struct {
414+ Tools []bedrockToolSpec `json:"tools"`
415+ }{Tools: []bedrockToolSpec{stub}}
383416 }
384417
385418 if caching {
@@ -299,6 +299,24 @@ func bedrockModelSupportsCaching(modelID string) bool {
299 return strings.HasPrefix(modelID, "anthropic.claude-")299 return strings.HasPrefix(modelID, "anthropic.claude-")
300 }300 }
301 301
302+// bedrockMessagesHaveToolBlocks reports whether any message in msgs
303+// contains a toolUse or toolResult content block. Bedrock's Converse API
304+// rejects the request with HTTP 400 if those block types are present but
305+// toolConfig is absent.
306+func bedrockMessagesHaveToolBlocks(msgs []bedrockMessage) bool {
307+ for _, m := range msgs {
308+ for _, c := range m.Content {
309+ if _, ok := c["toolUse"]; ok {
310+ return true
311+ }
312+ if _, ok := c["toolResult"]; ok {
313+ return true
314+ }
315+ }
316+ }
317+ return false
318+}
319+
302 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {320 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
303 out := &bedrockRequest{}321 out := &bedrockRequest{}
304 322
@@ -380,6 +398,21 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
380 tc.Tools = append(tc.Tools, ts)398 tc.Tools = append(tc.Tools, ts)
381 }399 }
382 out.ToolConfig = &tc400 out.ToolConfig = &tc
401+ } else if bedrockMessagesHaveToolBlocks(out.Messages) {
402+ // Bedrock requires toolConfig to be present whenever the message
403+ // history contains toolUse or toolResult content blocks, even if
404+ // the current request does not intend to call any tools (e.g. the
405+ // /btw side-chat sends the frozen main transcript which may include
406+ // prior tool turns). Inject a stub tool so the API accepts the
407+ // request; it will never be invoked since no tool_use stop reason
408+ // can be triggered when the real tool list is empty.
409+ var stub bedrockToolSpec
410+ stub.ToolSpec.Name = "_placeholder"
411+ stub.ToolSpec.Description = "placeholder required by Bedrock when tool history is present"
412+ stub.ToolSpec.InputSchema.JSON = json.RawMessage(`{"type":"object","properties":{}}`)
413+ out.ToolConfig = &struct {
414+ Tools []bedrockToolSpec `json:"tools"`
415+ }{Tools: []bedrockToolSpec{stub}}
383 }416 }
384 417
385 if caching {418 if caching {
M packages/provider/amazon_bedrock_test.go +69 -0
@@ -357,6 +357,75 @@ func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
357357 }
358358 }
359359
360+// TestBedrockBuildRequestBtwToolHistory reproduces the /btw HTTP 400 bug:
361+// the side-chat sends no tools but the frozen main transcript contains
362+// toolUse / toolResult blocks. Bedrock rejects such a request unless
363+// toolConfig is present. buildRequest must inject a stub toolConfig.
364+func TestBedrockBuildRequestBtwToolHistory(t *testing.T) {
365+ client := &bedrockClient{region: "us-east-1"}
366+ req, err := client.buildRequest(Request{
367+ Model: "amazon.nova-pro-v1:0",
368+ // No tools — simulates /btw side-chat.
369+ Messages: []Message{
370+ // Frozen main transcript: one tool call + result already happened.
371+ {Role: RoleAssistant, Content: []Content{
372+ ToolCallBlock{ID: "tc-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)},
373+ }},
374+ {Role: RoleTool, Content: []Content{
375+ ToolResultBlock{CallID: "tc-1", Content: []Content{TextBlock{Text: "file.go"}}},
376+ }},
377+ // Side-chat question appended by btwDialog.submit.
378+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what does that file do?"}}},
379+ },
380+ })
381+ if err != nil {
382+ t.Fatal(err)
383+ }
384+ if req.ToolConfig == nil {
385+ t.Fatal("buildRequest should inject a stub toolConfig when history has tool blocks and req.Tools is empty, but ToolConfig is nil")
386+ }
387+ if len(req.ToolConfig.Tools) == 0 {
388+ t.Fatal("stub toolConfig should have at least one tool")
389+ }
390+ // The stub must have a valid name and a non-nil schema so Bedrock won't
391+ // reject it for a different reason.
392+ stub := req.ToolConfig.Tools[0]
393+ if stub.ToolSpec.Name == "" {
394+ t.Error("stub tool name must not be empty")
395+ }
396+ if stub.ToolSpec.InputSchema.JSON == nil {
397+ t.Error("stub tool schema must not be nil")
398+ }
399+ // Serialised wire payload must include toolConfig.
400+ b, err := json.Marshal(req)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if !strings.Contains(string(b), `"toolConfig"`) {
405+ t.Errorf("serialised request missing toolConfig: %s", b)
406+ }
407+}
408+
409+// TestBedrockBuildRequestNoToolHistoryNoStub ensures that when there are
410+// no tool blocks in the history and no tools in the request (ordinary
411+// conversational call), we do NOT inject a toolConfig at all — the extra
412+// field is unnecessary and some models may behave differently with it.
413+func TestBedrockBuildRequestNoToolHistoryNoStub(t *testing.T) {
414+ client := &bedrockClient{region: "us-east-1"}
415+ req, err := client.buildRequest(Request{
416+ Model: "amazon.nova-pro-v1:0",
417+ Messages: []Message{
418+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
419+ },
420+ })
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ if req.ToolConfig != nil {
425+ t.Errorf("expected nil ToolConfig for plain conversation, got: %+v", req.ToolConfig)
426+ }
427+}
428+
360429 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
361430 client := &bedrockClient{}
362431 req, err := client.buildRequest(Request{Messages: []Message{
@@ -357,6 +357,75 @@ func TestBedrockBuildRequestCachingWireJSON(t *testing.T) {
357 }357 }
358 }358 }
359 359
360+// TestBedrockBuildRequestBtwToolHistory reproduces the /btw HTTP 400 bug:
361+// the side-chat sends no tools but the frozen main transcript contains
362+// toolUse / toolResult blocks. Bedrock rejects such a request unless
363+// toolConfig is present. buildRequest must inject a stub toolConfig.
364+func TestBedrockBuildRequestBtwToolHistory(t *testing.T) {
365+ client := &bedrockClient{region: "us-east-1"}
366+ req, err := client.buildRequest(Request{
367+ Model: "amazon.nova-pro-v1:0",
368+ // No tools — simulates /btw side-chat.
369+ Messages: []Message{
370+ // Frozen main transcript: one tool call + result already happened.
371+ {Role: RoleAssistant, Content: []Content{
372+ ToolCallBlock{ID: "tc-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls"}`)},
373+ }},
374+ {Role: RoleTool, Content: []Content{
375+ ToolResultBlock{CallID: "tc-1", Content: []Content{TextBlock{Text: "file.go"}}},
376+ }},
377+ // Side-chat question appended by btwDialog.submit.
378+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what does that file do?"}}},
379+ },
380+ })
381+ if err != nil {
382+ t.Fatal(err)
383+ }
384+ if req.ToolConfig == nil {
385+ t.Fatal("buildRequest should inject a stub toolConfig when history has tool blocks and req.Tools is empty, but ToolConfig is nil")
386+ }
387+ if len(req.ToolConfig.Tools) == 0 {
388+ t.Fatal("stub toolConfig should have at least one tool")
389+ }
390+ // The stub must have a valid name and a non-nil schema so Bedrock won't
391+ // reject it for a different reason.
392+ stub := req.ToolConfig.Tools[0]
393+ if stub.ToolSpec.Name == "" {
394+ t.Error("stub tool name must not be empty")
395+ }
396+ if stub.ToolSpec.InputSchema.JSON == nil {
397+ t.Error("stub tool schema must not be nil")
398+ }
399+ // Serialised wire payload must include toolConfig.
400+ b, err := json.Marshal(req)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if !strings.Contains(string(b), `"toolConfig"`) {
405+ t.Errorf("serialised request missing toolConfig: %s", b)
406+ }
407+}
408+
409+// TestBedrockBuildRequestNoToolHistoryNoStub ensures that when there are
410+// no tool blocks in the history and no tools in the request (ordinary
411+// conversational call), we do NOT inject a toolConfig at all — the extra
412+// field is unnecessary and some models may behave differently with it.
413+func TestBedrockBuildRequestNoToolHistoryNoStub(t *testing.T) {
414+ client := &bedrockClient{region: "us-east-1"}
415+ req, err := client.buildRequest(Request{
416+ Model: "amazon.nova-pro-v1:0",
417+ Messages: []Message{
418+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
419+ },
420+ })
421+ if err != nil {
422+ t.Fatal(err)
423+ }
424+ if req.ToolConfig != nil {
425+ t.Errorf("expected nil ToolConfig for plain conversation, got: %+v", req.ToolConfig)
426+ }
427+}
428+
360 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {429 func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
361 client := &bedrockClient{}430 client := &bedrockClient{}
362 req, err := client.buildRequest(Request{Messages: []Message{431 req, err := client.buildRequest(Request{Messages: []Message{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close