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.
This commit is contained in:
Levi Neely 2026-08-03 15:58:29 +02:00
parent aae307046a
commit 2dc4098c09
2 changed files with 134 additions and 0 deletions

View File

@ -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.`

View File

@ -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))
}
}
})
}
}