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:
parent
aae307046a
commit
2dc4098c09
|
|
@ -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.`
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue