Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions backend/pkg/providers/bedrock/bedrock.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,16 @@ func (p *bedrockProvider) CallWithTools(
// includes toolConfig in the request.
tools = restoreMissedToolsFromChain(chain, tools)

// Build options with provider config first, then append WithTools last
// so that tool definitions are never accidentally overwritten.
options := []llms.CallOption{
llms.WithStreamingFunc(streamCb),
}
options = append(options, p.providerConfig.GetOptionsForType(opt)...)
options = append(options, llms.WithTools(tools))

return provider.WrapGenerateContent(
ctx, p, opt, p.llm.GenerateContent, chain,
append([]llms.CallOption{
llms.WithTools(tools),
llms.WithStreamingFunc(streamCb),
}, p.providerConfig.GetOptionsForType(opt)...)...,
ctx, p, opt, p.llm.GenerateContent, chain, options...,
)
}

Expand Down
114 changes: 114 additions & 0 deletions backend/pkg/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,120 @@ func TestRestoreMissedToolsFromChain(t *testing.T) {
})
}

// TestToolConfigPresentWhenChainContainsToolBlocks verifies the fix for issue #160:
// the Bedrock Converse API requires toolConfig whenever the message history contains
// toolUse or toolResult content blocks, even when the current turn provides no tools.
func TestToolConfigPresentWhenChainContainsToolBlocks(t *testing.T) {
// Simulate a conversation chain that contains tool calls and responses
// but where the current turn provides NO explicit tools.
chainWithToolBlocks := []llms.MessageContent{
llms.TextParts(llms.ChatMessageTypeHuman, "scan port 80"),
{
Role: llms.ChatMessageTypeAI,
Parts: []llms.ContentPart{
llms.ToolCall{
ID: "call_001",
Type: "function",
FunctionCall: &llms.FunctionCall{
Name: "nmap_scan",
Arguments: `{"target":"10.0.0.1","port":"80"}`,
},
},
},
},
{
Role: llms.ChatMessageTypeTool,
Parts: []llms.ContentPart{
llms.ToolCallResponse{
ToolCallID: "call_001",
Name: "nmap_scan",
Content: "port 80 open",
},
},
},
llms.TextParts(llms.ChatMessageTypeAI, "Port 80 is open."),
llms.TextParts(llms.ChatMessageTypeHuman, "now check port 443"),
}

t.Run("CallWithTools with nil tools restores from chain", func(t *testing.T) {
restored := restoreMissedToolsFromChain(chainWithToolBlocks, nil)
if len(restored) == 0 {
t.Fatal("expected non-empty tools after restoring from chain with toolUse/toolResult blocks")
}

found := false
for _, tool := range restored {
if tool.Function != nil && tool.Function.Name == "nmap_scan" {
found = true
// Verify schema was inferred from the arguments
schema, ok := tool.Function.Parameters.(map[string]any)
if !ok {
t.Fatal("expected inferred schema")
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatal("expected properties in inferred schema")
}
if _, exists := props["target"]; !exists {
t.Error("expected 'target' property in inferred schema")
}
if _, exists := props["port"]; !exists {
t.Error("expected 'port' property in inferred schema")
}
break
}
}
if !found {
t.Error("expected nmap_scan tool to be restored from chain")
}
})

t.Run("CallWithTools with empty tools restores from chain", func(t *testing.T) {
restored := restoreMissedToolsFromChain(chainWithToolBlocks, []llms.Tool{})
if len(restored) == 0 {
t.Fatal("expected non-empty tools when passing empty slice with tool-containing chain")
}
})

t.Run("CallEx option building restores tools from chain", func(t *testing.T) {
// Simulate CallEx option building: extract tools from options (none),
// then restore from chain.
options := []llms.CallOption{
llms.WithTemperature(0.7),
llms.WithMaxTokens(1000),
}
tools := extractToolsFromOptions(options)
tools = restoreMissedToolsFromChain(chainWithToolBlocks, tools)
if len(tools) == 0 {
t.Fatal("expected tools to be restored from chain in CallEx flow")
}
})

t.Run("chain with only ToolCallResponse restores tools", func(t *testing.T) {
// Edge case: chain has tool results but no tool calls (e.g., truncated history)
chainOnlyResults := []llms.MessageContent{
{
Role: llms.ChatMessageTypeTool,
Parts: []llms.ContentPart{
llms.ToolCallResponse{
ToolCallID: "call_099",
Name: "curl_request",
Content: "HTTP 200 OK",
},
},
},
}

restored := restoreMissedToolsFromChain(chainOnlyResults, nil)
if len(restored) == 0 {
t.Fatal("expected tool restored from ToolCallResponse-only chain")
}
if restored[0].Function == nil || restored[0].Function.Name != "curl_request" {
t.Error("expected curl_request tool definition")
}
})
}

// TestExtractToolsFromOptions verifies tool extraction from CallOptions.
func TestExtractToolsFromOptions(t *testing.T) {
t.Run("empty options returns nil", func(t *testing.T) {
Expand Down