From 2dc4098c0959b040dbb87c807a3bac4ebf769a2e Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 3 Aug 2026 15:58:29 +0200 Subject: [PATCH] fix(backend): strip tool blocks when no tools defined Bedrock rejects requests containing toolUse/toolResult content blocks without a corresponding toolConfig. When agents have zero tools loaded (e.g., autoload disabled/failed), conversation history containing tool interactions would cause 'toolConfig field must be defined' errors. Add kiroStripToolBlocks() to remove tool-related content from messages when len(tools) == 0, allowing requests to proceed without tool definitions. --- backend/codewhisperer.go | 55 ++++++++++++++++++++++++ backend/codewhisperer_test.go | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/backend/codewhisperer.go b/backend/codewhisperer.go index a4889d9..5c9b042 100644 --- a/backend/codewhisperer.go +++ b/backend/codewhisperer.go @@ -290,6 +290,14 @@ func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN return nil, fmt.Errorf("at least one message is required") } + // Bedrock requires toolConfig when toolUse/toolResult blocks are present. + // If no tools are defined, strip tool interactions from history to avoid + // "The toolConfig field must be defined when using toolUse and toolResult + // content blocks" errors. + if len(tools) == 0 { + messages = kiroStripToolBlocks(messages) + } + // Apply counter-thinking tags to user messages before assistant responses. messages = kiroApplyCounterThinkingTags(messages) @@ -569,6 +577,53 @@ func kiroCurrentEnvState() *kiroEnvState { } } +// ── Tool block stripping ───────────────────────────────────────────────────── + +// kiroStripToolBlocks removes tool-related content from messages so that +// the request can be sent without tool definitions. Bedrock rejects requests +// that contain toolUse/toolResult blocks without a corresponding toolConfig. +// +// Strategy: +// - Assistant messages with ToolCalls: keep only the text content, drop ToolCalls. +// - Tool-role messages: drop entirely (they are responses to now-removed tool calls). +// - All other messages: pass through unchanged. +func kiroStripToolBlocks(messages []Message) []Message { + // Fast path: if no tool content exists, return as-is. + hasToolContent := false + for _, m := range messages { + if strings.EqualFold(m.Role, "tool") || len(m.ToolCalls) > 0 { + hasToolContent = true + break + } + } + if !hasToolContent { + return messages + } + + out := make([]Message, 0, len(messages)) + for _, m := range messages { + role := strings.ToLower(strings.TrimSpace(m.Role)) + switch { + case role == "tool": + // Drop tool result messages entirely. + continue + case role == "assistant" && len(m.ToolCalls) > 0: + // Keep text content, drop tool calls. + stripped := m + stripped.ToolCalls = nil + // If the assistant message was only tool calls with no text, skip it + // to avoid sending an empty assistant turn. + if strings.TrimSpace(stripped.Content) == "" && len(stripped.ContentBlocks) == 0 { + continue + } + out = append(out, stripped) + default: + out = append(out, m) + } + } + return out +} + // ── Counter-thinking & reasoning support ────────────────────────────────────── const kiroCounterThinkingInstructions = `Be thorough in your work; don't skip steps or cut corners to save time. Think deeply and step by step, do not skip ahead prematurely.` diff --git a/backend/codewhisperer_test.go b/backend/codewhisperer_test.go index db5655f..2b43ac5 100644 --- a/backend/codewhisperer_test.go +++ b/backend/codewhisperer_test.go @@ -1,6 +1,7 @@ package backend import ( + "encoding/json" "net/http" "testing" ) @@ -24,3 +25,81 @@ func TestKiroAPIError_IsThrottled(t *testing.T) { }) } } + +func TestKiroStripToolBlocks(t *testing.T) { + tests := []struct { + name string + messages []Message + want int // expected number of messages after stripping + desc string + }{ + { + name: "no tool content passes through unchanged", + messages: []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + }, + want: 3, + desc: "all messages preserved", + }, + { + name: "tool messages are removed", + messages: []Message{ + {Role: "user", Content: "Read a file"}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "tc1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/x"}`)}}}, + {Role: "tool", Content: "file contents here", ToolCallID: "tc1"}, + {Role: "assistant", Content: "The file says..."}, + }, + want: 2, // user + final assistant (tool-only assistant and tool msg dropped) + desc: "tool-only assistant and tool result dropped", + }, + { + name: "assistant with text and tool calls keeps text", + messages: []Message{ + {Role: "user", Content: "Do something"}, + {Role: "assistant", Content: "Let me check that.", ToolCalls: []ToolCall{{ID: "tc1", Name: "shell", Arguments: json.RawMessage(`{}`)}}}, + {Role: "tool", Content: "ok", ToolCallID: "tc1"}, + {Role: "assistant", Content: "Done!"}, + }, + want: 3, // user + assistant(text only) + final assistant + desc: "assistant with text preserved without tool calls", + }, + { + name: "multiple consecutive tool messages all stripped", + messages: []Message{ + {Role: "user", Content: "Run stuff"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "tc1", Name: "a", Arguments: json.RawMessage(`{}`)}, + {ID: "tc2", Name: "b", Arguments: json.RawMessage(`{}`)}, + }}, + {Role: "tool", Content: "r1", ToolCallID: "tc1"}, + {Role: "tool", Content: "r2", ToolCallID: "tc2"}, + {Role: "user", Content: "Thanks"}, + }, + want: 2, // user + user("Thanks") + desc: "tool-only assistant + tool results all dropped", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := kiroStripToolBlocks(tt.messages) + if len(got) != tt.want { + t.Errorf("%s: got %d messages, want %d", tt.desc, len(got), tt.want) + for i, m := range got { + t.Logf(" [%d] role=%s content=%q toolCalls=%d", i, m.Role, m.Content, len(m.ToolCalls)) + } + } + // Verify no remaining messages have tool content. + for i, m := range got { + if m.Role == "tool" { + t.Errorf("message[%d] still has role=tool", i) + } + if len(m.ToolCalls) > 0 { + t.Errorf("message[%d] still has %d tool calls", i, len(m.ToolCalls)) + } + } + }) + } +}