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 { | |||
| 216 | 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 | 274 | func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |
| 220 | 275 | out := &bedrockRequest{} | |
| 221 | 276 | if req.System != "" { | |
| @@ -226,7 +281,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 226 | 281 | if out.InferenceConfig.MaxTokens == 0 { | |
| 227 | 282 | out.InferenceConfig.MaxTokens = 4096 | |
| 228 | 283 | } | |
| 229 | - | for _, m := range req.Messages { | |
| 284 | + | for _, m := range normalizeBedrockToolResults(req.Messages) { | |
| 230 | 285 | role := string(m.Role) | |
| 231 | 286 | if role == "tool" { | |
| 232 | 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 | 328 | out.Messages = append(out.Messages, bm) | |
| 271 | 329 | } | |
| 272 | 330 | 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 = 4096 | 282 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "strings" | |
| 7 | 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 | 151 | func TestBedrockEventPayloadHelpers(t *testing.T) { | |
| 86 | 152 | wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`) | |
| 87 | 153 | 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" { | ||