Skip to content
Merged
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
22 changes: 19 additions & 3 deletions pkg/model/provider/anthropic/beta_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,30 +126,46 @@ func repairAnthropicSequencingBeta(msgs []anthropic.BetaMessageParam) []anthropi
}
repaired := make([]anthropic.BetaMessageParam, 0, len(msgs)+2)
for i := range msgs {
repaired = append(repaired, msgs[i])

m, ok := marshalToMapBeta(msgs[i])
if !ok || m["role"] != "assistant" {
repaired = append(repaired, msgs[i])
continue
}

toolUseIDs := collectToolUseIDs(contentArrayBeta(m))
if len(toolUseIDs) == 0 {
repaired = append(repaired, msgs[i])
continue
}

// Check if the next message is a user message with tool_results
needsSyntheticMessage := true
if i+1 < len(msgs) {
if next, ok := marshalToMapBeta(msgs[i+1]); ok && next["role"] == "user" {
toolResultIDs := collectToolResultIDs(contentArrayBeta(next))
// Remove tool_use IDs that have corresponding tool_results
for id := range toolResultIDs {
delete(toolUseIDs, id)
}
// If all tool_use IDs have results, no synthetic message needed
if len(toolUseIDs) == 0 {
needsSyntheticMessage = false
}
}
}

if len(toolUseIDs) > 0 {
// Append the assistant message first
repaired = append(repaired, msgs[i])

// If there are missing tool_results, insert a synthetic user message immediately after
if needsSyntheticMessage && len(toolUseIDs) > 0 {
slog.Debug("Inserting synthetic user message for missing tool_results",
"assistant_index", i,
"missing_count", len(toolUseIDs))

blocks := make([]anthropic.BetaContentBlockParamUnion, 0, len(toolUseIDs))
for id := range toolUseIDs {
slog.Debug("Creating synthetic tool_result", "tool_use_id", id)
blocks = append(blocks, anthropic.BetaContentBlockParamUnion{
OfToolResult: &anthropic.BetaToolResultBlockParam{
ToolUseID: id,
Expand Down
73 changes: 54 additions & 19 deletions pkg/model/provider/anthropic/beta_converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package anthropic

import (
"encoding/json"
"log/slog"
"strings"

"github.com/anthropics/anthropic-sdk-go"
Expand All @@ -16,10 +15,13 @@ import (
// - Thinking blocks can appear anywhere in the conversation (not required to be first)
// - Always include the complete, unmodified thinking block from previous assistant turns
// - interleaved parameter is kept for API compatibility but always true
//
// Important: Anthropic API requires that all tool_result blocks corresponding to tool_use
// blocks from the same assistant message MUST be grouped into a single user message.
func convertBetaMessages(messages []chat.Message) []anthropic.BetaMessageParam {
var betaMessages []anthropic.BetaMessageParam

for i := range messages {
for i := 0; i < len(messages); i++ {
msg := &messages[i]
if msg.Role == chat.MessageRoleSystem {
// System messages handled separately
Expand Down Expand Up @@ -137,19 +139,42 @@ func convertBetaMessages(messages []chat.Message) []anthropic.BetaMessageParam {
continue
}
if msg.Role == chat.MessageRoleTool {
betaMessages = append(betaMessages, anthropic.BetaMessageParam{
Role: anthropic.BetaMessageParamRoleUser,
Content: []anthropic.BetaContentBlockParamUnion{
{
OfToolResult: &anthropic.BetaToolResultBlockParam{
ToolUseID: msg.ToolCallID,
Content: []anthropic.BetaToolResultBlockParamContentUnion{
{OfText: &anthropic.BetaTextBlockParam{Text: strings.TrimSpace(msg.Content)}},
},
// Collect consecutive tool messages and merge them into a single user message
// This is required by Anthropic API: all tool_result blocks for tool_use blocks
// from the same assistant message must be in the same user message
toolResultBlocks := []anthropic.BetaContentBlockParamUnion{
{
OfToolResult: &anthropic.BetaToolResultBlockParam{
ToolUseID: msg.ToolCallID,
Content: []anthropic.BetaToolResultBlockParamContentUnion{
{OfText: &anthropic.BetaTextBlockParam{Text: strings.TrimSpace(msg.Content)}},
},
},
},
}

// Look ahead for consecutive tool messages and merge them
j := i + 1
for j < len(messages) && messages[j].Role == chat.MessageRoleTool {
toolResultBlocks = append(toolResultBlocks, anthropic.BetaContentBlockParamUnion{
OfToolResult: &anthropic.BetaToolResultBlockParam{
ToolUseID: messages[j].ToolCallID,
Content: []anthropic.BetaToolResultBlockParamContentUnion{
{OfText: &anthropic.BetaTextBlockParam{Text: strings.TrimSpace(messages[j].Content)}},
},
},
})
j++
}

// Add the merged user message with all tool results
betaMessages = append(betaMessages, anthropic.BetaMessageParam{
Role: anthropic.BetaMessageParamRoleUser,
Content: toolResultBlocks,
})

// Skip the messages we've already processed
i = j - 1
continue
}
}
Expand All @@ -168,18 +193,28 @@ func extractBetaSystemBlocks(messages []chat.Message) []anthropic.BetaTextBlockP

// convertBetaTools converts tools to Beta API format
func convertBetaTools(t []tools.Tool) ([]anthropic.BetaToolUnionParam, error) {
regularTools, err := convertTools(t)
if err != nil {
slog.Error("Failed to convert tools for Anthropic Beta request", "error", err)
return nil, err
}
betaTools := make([]anthropic.BetaToolUnionParam, len(t))

betaTools := make([]anthropic.BetaToolUnionParam, len(regularTools))
for i, tool := range t {
inputSchema, err := ConvertParametersToSchema(tool.Parameters)
if err != nil {
return nil, err
}

for i, tool := range regularTools {
if err := tools.ConvertSchema(tool, &betaTools[i]); err != nil {
// Convert to BetaToolInputSchemaParam
var betaInputSchema anthropic.BetaToolInputSchemaParam
if err := tools.ConvertSchema(inputSchema, &betaInputSchema); err != nil {
return nil, err
}

// Create BetaToolParam and wrap it in BetaToolUnionParam
betaTools[i] = anthropic.BetaToolUnionParam{
OfTool: &anthropic.BetaToolParam{
Name: tool.Name,
Description: anthropic.String(tool.Description),
InputSchema: betaInputSchema,
},
}
}

return betaTools, nil
Expand Down
191 changes: 191 additions & 0 deletions pkg/model/provider/anthropic/beta_converter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package anthropic

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/cagent/pkg/chat"
"github.com/docker/cagent/pkg/tools"
)

func TestConvertBetaMessages_MergesConsecutiveToolMessages(t *testing.T) {
// Simulates the roast battle scenario where:
// - Assistant message has 2 tool_use blocks (transfer_task calls)
// - Two separate tool messages follow (one for each transfer_task result)
// - These should be merged into a single user message with 2 tool_result blocks

messages := []chat.Message{
{
Role: chat.MessageRoleUser,
Content: "Start the roast battle",
},
{
Role: chat.MessageRoleAssistant,
Content: "Let me transfer tasks to the comedians",
ToolCalls: []tools.ToolCall{
{
ID: "tool_call_1",
Type: "function",
Function: tools.FunctionCall{
Name: "transfer_task",
Arguments: `{"agent":"roaster_a","task":"Write roast"}`,
},
},
{
ID: "tool_call_2",
Type: "function",
Function: tools.FunctionCall{
Name: "transfer_task",
Arguments: `{"agent":"roaster_b","task":"Write counter-roast"}`,
},
},
},
},
{
Role: chat.MessageRoleTool,
Content: "Roast A completed",
ToolCallID: "tool_call_1",
},
{
Role: chat.MessageRoleTool,
Content: "Roast B completed",
ToolCallID: "tool_call_2",
},
{
Role: chat.MessageRoleAssistant,
Content: "Both roasts are complete!",
},
}

// Convert to Beta format
betaMessages := convertBetaMessages(messages)

// Verify structure: User -> Assistant (with 2 tool_use) -> User (with 2 tool_result) -> Assistant
require.Len(t, betaMessages, 4, "Should have 4 messages after conversion")

// Verify roles
msg0Map, _ := marshalToMapBeta(betaMessages[0])
msg1Map, _ := marshalToMapBeta(betaMessages[1])
msg2Map, _ := marshalToMapBeta(betaMessages[2])
msg3Map, _ := marshalToMapBeta(betaMessages[3])
assert.Equal(t, "user", msg0Map["role"])
assert.Equal(t, "assistant", msg1Map["role"])
assert.Equal(t, "user", msg2Map["role"])
assert.Equal(t, "assistant", msg3Map["role"])

// Verify the second user message (tool results) has both tool_result blocks
userMsg2Map, ok := marshalToMapBeta(betaMessages[2])
require.True(t, ok)
content := contentArrayBeta(userMsg2Map)
require.Len(t, content, 2, "User message should have 2 tool_result blocks")

// Verify both tool_result IDs are present
toolResultIDs := collectToolResultIDs(content)
assert.Contains(t, toolResultIDs, "tool_call_1")
assert.Contains(t, toolResultIDs, "tool_call_2")

// Most importantly: validate that the sequence is valid for Anthropic API
err := validateAnthropicSequencingBeta(betaMessages)
require.NoError(t, err, "Converted messages should pass Anthropic sequencing validation")
}

func TestConvertBetaMessages_SingleToolMessage(t *testing.T) {
// When there's only one tool message, it should still work correctly
messages := []chat.Message{
{
Role: chat.MessageRoleUser,
Content: "Test",
},
{
Role: chat.MessageRoleAssistant,
Content: "",
ToolCalls: []tools.ToolCall{
{
ID: "tool_1",
Type: "function",
Function: tools.FunctionCall{
Name: "test_tool",
Arguments: `{}`,
},
},
},
},
{
Role: chat.MessageRoleTool,
Content: "Tool result",
ToolCallID: "tool_1",
},
{
Role: chat.MessageRoleAssistant,
Content: "Done",
},
}

betaMessages := convertBetaMessages(messages)
require.Len(t, betaMessages, 4)

// Validate sequence
err := validateAnthropicSequencingBeta(betaMessages)
require.NoError(t, err)
}

func TestConvertBetaMessages_NonConsecutiveToolMessages(t *testing.T) {
// When tool messages are separated by other messages (edge case)
// Each tool message group should be handled independently
messages := []chat.Message{
{
Role: chat.MessageRoleUser,
Content: "First request",
},
{
Role: chat.MessageRoleAssistant,
Content: "",
ToolCalls: []tools.ToolCall{
{
ID: "tool_1",
Type: "function",
Function: tools.FunctionCall{
Name: "test_tool",
Arguments: `{}`,
},
},
},
},
{
Role: chat.MessageRoleTool,
Content: "Tool result 1",
ToolCallID: "tool_1",
},
{
Role: chat.MessageRoleAssistant,
Content: "Intermediate response",
ToolCalls: []tools.ToolCall{
{
ID: "tool_2",
Type: "function",
Function: tools.FunctionCall{
Name: "test_tool",
Arguments: `{}`,
},
},
},
},
{
Role: chat.MessageRoleTool,
Content: "Tool result 2",
ToolCallID: "tool_2",
},
{
Role: chat.MessageRoleAssistant,
Content: "Final response",
},
}

betaMessages := convertBetaMessages(messages)

// Validate the entire sequence
err := validateAnthropicSequencingBeta(betaMessages)
require.NoError(t, err, "Messages with non-consecutive tool calls should still validate")
}
Loading