fix(providers): merge consecutive same-role messages and group tool results
When sanitization drops assistant messages (e.g. corrupted tool calls from old sessions with nil Function), consecutive same-role messages can result. The Anthropic API rejects these with a generic 400 "Error". - Add third pass in sanitizeHistoryForProvider to merge consecutive user messages, drop leading non-user messages, and skip empty user messages - Group consecutive tool results into single user messages in both Anthropic providers (SDK and raw HTTP) to prevent consecutive user messages from parallel tool calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c5b947fff3
commit
1edf31fda6
4 changed files with 113 additions and 28 deletions
|
|
@ -738,7 +738,54 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
final = append(final, msg)
|
final = append(final, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
return final
|
// Third pass: ensure valid message alternation.
|
||||||
|
// Dropping messages in earlier passes can create consecutive same-role
|
||||||
|
// messages (e.g. two user messages with a dropped assistant between them).
|
||||||
|
// LLM APIs (Anthropic, DeepSeek, etc.) require strict user/assistant
|
||||||
|
// alternation and reject requests with consecutive same-role messages.
|
||||||
|
merged := make([]providers.Message, 0, len(final))
|
||||||
|
for _, msg := range final {
|
||||||
|
// Skip empty user messages (no text content, no tool result)
|
||||||
|
if msg.Role == "user" && strings.TrimSpace(msg.Content) == "" && msg.ToolCallID == "" {
|
||||||
|
logger.DebugCF("agent", "Dropping empty user message", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) == 0 {
|
||||||
|
// First message must be a user message (after system is stripped).
|
||||||
|
// Drop leading assistant/tool messages.
|
||||||
|
if msg.Role != "user" {
|
||||||
|
logger.DebugCF("agent", "Dropping leading non-user message", map[string]any{
|
||||||
|
"role": msg.Role,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := &merged[len(merged)-1]
|
||||||
|
|
||||||
|
// Merge consecutive user text messages (not tool results).
|
||||||
|
if msg.Role == "user" && msg.ToolCallID == "" &&
|
||||||
|
prev.Role == "user" && prev.ToolCallID == "" {
|
||||||
|
if prev.Content != "" && msg.Content != "" {
|
||||||
|
prev.Content = prev.Content + "\n" + msg.Content
|
||||||
|
} else if msg.Content != "" {
|
||||||
|
prev.Content = msg.Content
|
||||||
|
}
|
||||||
|
logger.DebugCF("agent", "Merged consecutive user messages", nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool results (role "tool" or "user" with ToolCallID) count as user
|
||||||
|
// messages in the Anthropic API. If the previous message is also a
|
||||||
|
// tool/user, that's fine — but if the previous is a plain user text
|
||||||
|
// message and this is also a plain user text message, we merged above.
|
||||||
|
merged = append(merged, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
|
||||||
|
|
@ -233,12 +233,13 @@ func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
// The assistant message with incomplete tool results should be dropped,
|
// The assistant message with incomplete tool results should be dropped,
|
||||||
// along with its partial tool result. The remaining messages are:
|
// along with its partial tool result. The two consecutive user messages
|
||||||
// user ("do two things"), user ("next question"), assistant ("answer")
|
// are then merged by the third pass.
|
||||||
if len(result) != 3 {
|
// Remaining: user ("do two things\nnext question"), assistant ("answer")
|
||||||
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
assertRoles(t, result, "user", "user", "assistant")
|
assertRoles(t, result, "user", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
||||||
|
|
@ -254,11 +255,12 @@ func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
|
||||||
|
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
// The assistant message with no tool results should be dropped.
|
// The assistant message with no tool results should be dropped.
|
||||||
// Remaining: user ("do something"), user ("hello"), assistant ("hi")
|
// The two consecutive user messages are merged by the third pass.
|
||||||
if len(result) != 3 {
|
// Remaining: user ("do something\nhello"), assistant ("hi")
|
||||||
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
assertRoles(t, result, "user", "user", "assistant")
|
assertRoles(t, result, "user", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
||||||
|
|
@ -283,11 +285,12 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
// First round is complete (user, assistant+tools, tool, assistant),
|
// First round is complete (user, assistant+tools, tool, assistant),
|
||||||
// second round is incomplete and dropped (assistant+tools, partial tool),
|
// second round is incomplete and dropped (assistant+tools, partial tool),
|
||||||
// third round is complete (user, assistant+tools, tool, assistant).
|
// third round is complete (user, assistant+tools, tool, assistant).
|
||||||
// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
|
// The two consecutive user messages ("second", "third") are merged.
|
||||||
if len(result) != 9 {
|
// Remaining: user, assistant, tool, assistant, user, assistant, tool, assistant
|
||||||
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
if len(result) != 8 {
|
||||||
|
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSanitizeHistoryForProvider_EmptyToolCallName tests that tool calls with
|
// TestSanitizeHistoryForProvider_EmptyToolCallName tests that tool calls with
|
||||||
|
|
@ -379,9 +382,10 @@ func TestSanitizeHistoryForProvider_AllToolCallsDroppedEmptyContent(t *testing.T
|
||||||
result := sanitizeHistoryForProvider(history)
|
result := sanitizeHistoryForProvider(history)
|
||||||
// The assistant message has no valid tool calls and no content → dropped.
|
// The assistant message has no valid tool calls and no content → dropped.
|
||||||
// Its tool result is orphaned and also dropped.
|
// Its tool result is orphaned and also dropped.
|
||||||
// Remaining: user ("hello"), user ("follow up")
|
// The two consecutive user messages are merged by the third pass.
|
||||||
if len(result) != 2 {
|
// Remaining: user ("hello\nfollow up")
|
||||||
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
}
|
}
|
||||||
assertRoles(t, result, "user", "user")
|
assertRoles(t, result, "user")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,8 @@ func buildParams(
|
||||||
var system []anthropic.TextBlockParam
|
var system []anthropic.TextBlockParam
|
||||||
var anthropicMessages []anthropic.MessageParam
|
var anthropicMessages []anthropic.MessageParam
|
||||||
|
|
||||||
for _, msg := range messages {
|
for i := 0; i < len(messages); i++ {
|
||||||
|
msg := messages[i]
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
case "system":
|
case "system":
|
||||||
// Prefer structured SystemParts for per-block cache_control.
|
// Prefer structured SystemParts for per-block cache_control.
|
||||||
|
|
@ -165,9 +166,16 @@ func buildParams(
|
||||||
}
|
}
|
||||||
case "user":
|
case "user":
|
||||||
if msg.ToolCallID != "" {
|
if msg.ToolCallID != "" {
|
||||||
anthropicMessages = append(anthropicMessages,
|
// Tool result — group with any following tool results into
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
// a single user message to avoid consecutive user messages.
|
||||||
)
|
var blocks []anthropic.ContentBlockParamUnion
|
||||||
|
blocks = append(blocks, anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false))
|
||||||
|
for i+1 < len(messages) && (messages[i+1].Role == "tool" ||
|
||||||
|
(messages[i+1].Role == "user" && messages[i+1].ToolCallID != "")) {
|
||||||
|
i++
|
||||||
|
blocks = append(blocks, anthropic.NewToolResultBlock(messages[i].ToolCallID, messages[i].Content, false))
|
||||||
|
}
|
||||||
|
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...))
|
||||||
} else {
|
} else {
|
||||||
anthropicMessages = append(anthropicMessages,
|
anthropicMessages = append(anthropicMessages,
|
||||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||||
|
|
@ -198,9 +206,16 @@ func buildParams(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "tool":
|
case "tool":
|
||||||
anthropicMessages = append(anthropicMessages,
|
// Group consecutive tool results into a single user message.
|
||||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
// The Anthropic API requires alternating user/assistant messages;
|
||||||
)
|
// sending each tool result as a separate user message can violate this.
|
||||||
|
var blocks []anthropic.ContentBlockParamUnion
|
||||||
|
blocks = append(blocks, anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false))
|
||||||
|
for i+1 < len(messages) && messages[i+1].Role == "tool" {
|
||||||
|
i++
|
||||||
|
blocks = append(blocks, anthropic.NewToolResultBlock(messages[i].ToolCallID, messages[i].Content, false))
|
||||||
|
}
|
||||||
|
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ func buildRequestBody(
|
||||||
var systemPrompt string
|
var systemPrompt string
|
||||||
var apiMessages []any
|
var apiMessages []any
|
||||||
|
|
||||||
for _, msg := range messages {
|
for i := 0; i < len(messages); i++ {
|
||||||
|
msg := messages[i]
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
case "system":
|
case "system":
|
||||||
// Accumulate system messages
|
// Accumulate system messages
|
||||||
|
|
@ -188,7 +189,8 @@ func buildRequestBody(
|
||||||
|
|
||||||
case "user":
|
case "user":
|
||||||
if msg.ToolCallID != "" {
|
if msg.ToolCallID != "" {
|
||||||
// Tool result message
|
// Tool result message — group consecutive tool results into
|
||||||
|
// a single user message to avoid consecutive user messages.
|
||||||
content := []map[string]any{
|
content := []map[string]any{
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
|
|
@ -196,6 +198,15 @@ func buildRequestBody(
|
||||||
"content": msg.Content,
|
"content": msg.Content,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
for i+1 < len(messages) && (messages[i+1].Role == "tool" ||
|
||||||
|
(messages[i+1].Role == "user" && messages[i+1].ToolCallID != "")) {
|
||||||
|
i++
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": messages[i].ToolCallID,
|
||||||
|
"content": messages[i].Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
apiMessages = append(apiMessages, map[string]any{
|
apiMessages = append(apiMessages, map[string]any{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": content,
|
"content": content,
|
||||||
|
|
@ -236,7 +247,7 @@ func buildRequestBody(
|
||||||
})
|
})
|
||||||
|
|
||||||
case "tool":
|
case "tool":
|
||||||
// Tool result (alternative format)
|
// Group consecutive tool results into a single user message.
|
||||||
content := []map[string]any{
|
content := []map[string]any{
|
||||||
{
|
{
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
|
|
@ -244,6 +255,14 @@ func buildRequestBody(
|
||||||
"content": msg.Content,
|
"content": msg.Content,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
for i+1 < len(messages) && messages[i+1].Role == "tool" {
|
||||||
|
i++
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": messages[i].ToolCallID,
|
||||||
|
"content": messages[i].Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
apiMessages = append(apiMessages, map[string]any{
|
apiMessages = append(apiMessages, map[string]any{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": content,
|
"content": content,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue