detached 84fd98e

Normalize Bedrock tool results

commit
84fd98ea741d3b83a200353dd40f8f4e5219a79a
author
patriceckhart · · 2026-06-05 14:05 UTC
parent
7a7bf0b
signature
unsigned
refs
tag: v0.2.13
2 files · +125 -1
M packages/provider/amazon_bedrock.go +59 -1
@@ -216,6 +216,61 @@ type bedrockRequest struct {
216216 } `json:"toolConfig,omitempty"`
217217 }
218218
219+func normalizeBedrockToolResults(msgs []Message) []Message {
220+ resultByID := map[string]ToolResultBlock{}
221+ for _, m := range msgs {
222+ for _, c := range m.Content {
223+ if tr, ok := c.(ToolResultBlock); ok {
224+ if _, exists := resultByID[tr.CallID]; !exists {
225+ resultByID[tr.CallID] = tr
226+ }
227+ }
228+ }
229+ }
230+
231+ out := make([]Message, 0, len(msgs))
232+ for _, m := range msgs {
233+ copy := m
234+ copy.Content = nil
235+ var toolCalls []ToolCallBlock
236+ for _, c := range m.Content {
237+ switch v := c.(type) {
238+ case ToolResultBlock:
239+ // Bedrock requires toolResult blocks immediately after the
240+ // assistant toolUse they answer. Reinsert them from the
241+ // assistant pass below instead of preserving their original
242+ // location, which may be separated by user text in active
243+ // sessions.
244+ continue
245+ case ToolCallBlock:
246+ toolCalls = append(toolCalls, v)
247+ }
248+ copy.Content = append(copy.Content, c)
249+ }
250+ if len(copy.Content) > 0 {
251+ out = append(out, copy)
252+ }
253+ if m.Role != RoleAssistant || len(toolCalls) == 0 {
254+ continue
255+ }
256+
257+ results := make([]Content, 0, len(toolCalls))
258+ for _, tc := range toolCalls {
259+ if tr, ok := resultByID[tc.ID]; ok {
260+ results = append(results, tr)
261+ continue
262+ }
263+ results = append(results, ToolResultBlock{
264+ CallID: tc.ID,
265+ IsError: true,
266+ Content: []Content{TextBlock{Text: "tool call did not complete before the next user message"}},
267+ })
268+ }
269+ out = append(out, Message{Role: RoleTool, Content: results, Time: m.Time})
270+ }
271+ return out
272+}
273+
219274 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
220275 out := &bedrockRequest{}
221276 if req.System != "" {
@@ -226,7 +281,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
226281 if out.InferenceConfig.MaxTokens == 0 {
227282 out.InferenceConfig.MaxTokens = 4096
228283 }
229- for _, m := range req.Messages {
284+ for _, m := range normalizeBedrockToolResults(req.Messages) {
230285 role := string(m.Role)
231286 if role == "tool" {
232287 role = "user"
@@ -267,6 +322,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
267322 })
268323 }
269324 }
325+ if len(bm.Content) == 0 {
326+ continue
327+ }
270328 out.Messages = append(out.Messages, bm)
271329 }
272330 if len(req.Tools) > 0 {
@@ -216,6 +216,61 @@ type bedrockRequest struct {
216 } `json:"toolConfig,omitempty"`216 } `json:"toolConfig,omitempty"`
217 }217 }
218 218
219+func normalizeBedrockToolResults(msgs []Message) []Message {
220+ resultByID := map[string]ToolResultBlock{}
221+ for _, m := range msgs {
222+ for _, c := range m.Content {
223+ if tr, ok := c.(ToolResultBlock); ok {
224+ if _, exists := resultByID[tr.CallID]; !exists {
225+ resultByID[tr.CallID] = tr
226+ }
227+ }
228+ }
229+ }
230+
231+ out := make([]Message, 0, len(msgs))
232+ for _, m := range msgs {
233+ copy := m
234+ copy.Content = nil
235+ var toolCalls []ToolCallBlock
236+ for _, c := range m.Content {
237+ switch v := c.(type) {
238+ case ToolResultBlock:
239+ // Bedrock requires toolResult blocks immediately after the
240+ // assistant toolUse they answer. Reinsert them from the
241+ // assistant pass below instead of preserving their original
242+ // location, which may be separated by user text in active
243+ // sessions.
244+ continue
245+ case ToolCallBlock:
246+ toolCalls = append(toolCalls, v)
247+ }
248+ copy.Content = append(copy.Content, c)
249+ }
250+ if len(copy.Content) > 0 {
251+ out = append(out, copy)
252+ }
253+ if m.Role != RoleAssistant || len(toolCalls) == 0 {
254+ continue
255+ }
256+
257+ results := make([]Content, 0, len(toolCalls))
258+ for _, tc := range toolCalls {
259+ if tr, ok := resultByID[tc.ID]; ok {
260+ results = append(results, tr)
261+ continue
262+ }
263+ results = append(results, ToolResultBlock{
264+ CallID: tc.ID,
265+ IsError: true,
266+ Content: []Content{TextBlock{Text: "tool call did not complete before the next user message"}},
267+ })
268+ }
269+ out = append(out, Message{Role: RoleTool, Content: results, Time: m.Time})
270+ }
271+ return out
272+}
273+
219 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {274 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
220 out := &bedrockRequest{}275 out := &bedrockRequest{}
221 if req.System != "" {276 if req.System != "" {
@@ -226,7 +281,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
226 if out.InferenceConfig.MaxTokens == 0 {281 if out.InferenceConfig.MaxTokens == 0 {
227 out.InferenceConfig.MaxTokens = 4096282 out.InferenceConfig.MaxTokens = 4096
228 }283 }
229- for _, m := range req.Messages {284+ for _, m := range normalizeBedrockToolResults(req.Messages) {
230 role := string(m.Role)285 role := string(m.Role)
231 if role == "tool" {286 if role == "tool" {
232 role = "user"287 role = "user"
@@ -267,6 +322,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
267 })322 })
268 }323 }
269 }324 }
325+ if len(bm.Content) == 0 {
326+ continue
327+ }
270 out.Messages = append(out.Messages, bm)328 out.Messages = append(out.Messages, bm)
271 }329 }
272 if len(req.Tools) > 0 {330 if len(req.Tools) > 0 {
M packages/provider/amazon_bedrock_test.go +66 -0
@@ -2,6 +2,7 @@ package provider
22
33 import (
44 "bytes"
5+ "encoding/json"
56 "net/http"
67 "strings"
78 "testing"
@@ -82,6 +83,71 @@ func TestReadAWSCredentialsFile(t *testing.T) {
8283 }
8384 }
8485
86+func TestNormalizeBedrockToolResultsMovesResultsAdjacentToToolUse(t *testing.T) {
87+ msgs := []Message{
88+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{"path":"a"}`)}}},
89+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
90+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "edited"}}}}},
91+ }
92+
93+ out := normalizeBedrockToolResults(msgs)
94+ if len(out) != 3 {
95+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
96+ }
97+ if out[0].Role != RoleAssistant {
98+ t.Fatalf("first role = %s, want assistant", out[0].Role)
99+ }
100+ if out[1].Role != RoleTool {
101+ t.Fatalf("second role = %s, want tool", out[1].Role)
102+ }
103+ tr, ok := out[1].Content[0].(ToolResultBlock)
104+ if !ok {
105+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
106+ }
107+ if tr.CallID != "tool-1" || tr.IsError {
108+ t.Fatalf("unexpected tool result: %+v", tr)
109+ }
110+ if out[2].Role != RoleUser {
111+ t.Fatalf("third role = %s, want user", out[2].Role)
112+ }
113+}
114+
115+func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
116+ msgs := []Message{
117+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
118+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
119+ }
120+
121+ out := normalizeBedrockToolResults(msgs)
122+ if len(out) != 3 {
123+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
124+ }
125+ tr, ok := out[1].Content[0].(ToolResultBlock)
126+ if !ok {
127+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
128+ }
129+ if tr.CallID != "tool-1" || !tr.IsError {
130+ t.Fatalf("unexpected synthetic result: %+v", tr)
131+ }
132+}
133+
134+func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135+ client := &bedrockClient{}
136+ req, err := client.buildRequest(Request{Messages: []Message{
137+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
138+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
139+ }})
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if len(req.Messages) != 1 {
144+ t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
145+ }
146+ if req.Messages[0].Role != "user" {
147+ t.Fatalf("role = %s, want user", req.Messages[0].Role)
148+ }
149+}
150+
85151 func TestBedrockEventPayloadHelpers(t *testing.T) {
86152 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
87153 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
@@ -2,6 +2,7 @@ package provider
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "strings"7 "strings"
7 "testing"8 "testing"
@@ -82,6 +83,71 @@ func TestReadAWSCredentialsFile(t *testing.T) {
82 }83 }
83 }84 }
84 85
86+func TestNormalizeBedrockToolResultsMovesResultsAdjacentToToolUse(t *testing.T) {
87+ msgs := []Message{
88+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{"path":"a"}`)}}},
89+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
90+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "edited"}}}}},
91+ }
92+
93+ out := normalizeBedrockToolResults(msgs)
94+ if len(out) != 3 {
95+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
96+ }
97+ if out[0].Role != RoleAssistant {
98+ t.Fatalf("first role = %s, want assistant", out[0].Role)
99+ }
100+ if out[1].Role != RoleTool {
101+ t.Fatalf("second role = %s, want tool", out[1].Role)
102+ }
103+ tr, ok := out[1].Content[0].(ToolResultBlock)
104+ if !ok {
105+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
106+ }
107+ if tr.CallID != "tool-1" || tr.IsError {
108+ t.Fatalf("unexpected tool result: %+v", tr)
109+ }
110+ if out[2].Role != RoleUser {
111+ t.Fatalf("third role = %s, want user", out[2].Role)
112+ }
113+}
114+
115+func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
116+ msgs := []Message{
117+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
118+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
119+ }
120+
121+ out := normalizeBedrockToolResults(msgs)
122+ if len(out) != 3 {
123+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
124+ }
125+ tr, ok := out[1].Content[0].(ToolResultBlock)
126+ if !ok {
127+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
128+ }
129+ if tr.CallID != "tool-1" || !tr.IsError {
130+ t.Fatalf("unexpected synthetic result: %+v", tr)
131+ }
132+}
133+
134+func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135+ client := &bedrockClient{}
136+ req, err := client.buildRequest(Request{Messages: []Message{
137+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
138+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
139+ }})
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if len(req.Messages) != 1 {
144+ t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
145+ }
146+ if req.Messages[0].Role != "user" {
147+ t.Fatalf("role = %s, want user", req.Messages[0].Role)
148+ }
149+}
150+
85 func TestBedrockEventPayloadHelpers(t *testing.T) {151 func TestBedrockEventPayloadHelpers(t *testing.T) {
86 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)152 wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
87 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {153 if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {

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