fix(agent): normalize tool call names from session history to prevent Anthropic API errors
ToolCall.Name is tagged json:"-" and is therefore lost when session history is serialized to JSONL. When that history is later loaded and sent back to the Anthropic API, the tool_use blocks have an empty "name" field, causing: 400 Bad Request: messages.N.content.N.tooluse.name: String should have at least 1 character Fix: in sanitizeHistoryForProvider, call the existing NormalizeToolCall helper on each ToolCall in assistant history messages. NormalizeToolCall already restores Name from Function.Name (which IS persisted). After normalization, any tool call still missing a Name or ID is dropped to prevent the API error. The second pass is also tightened to only include tool result messages whose ToolCallID has a matching entry in the preceding assistant's tool calls. This prevents orphaned tool_result blocks (for any tool calls dropped above) from reaching the API and causing a follow-on error. Fixes #1658 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79b0568d75
commit
134f5b54d4
2 changed files with 126 additions and 6 deletions
|
|
@ -614,6 +614,26 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
sanitized = append(sanitized, msg)
|
sanitized = append(sanitized, msg)
|
||||||
|
|
||||||
case "assistant":
|
case "assistant":
|
||||||
|
if len(msg.ToolCalls) > 0 {
|
||||||
|
// Normalize tool calls: restores Name from Function.Name after JSON
|
||||||
|
// deserialization (Name is json:"-" and is lost when sessions are
|
||||||
|
// persisted). Drop any tool calls that still have an empty name or
|
||||||
|
// ID after normalization — these would cause Anthropic API errors
|
||||||
|
// ("tooluse.name: String should have at least 1 character").
|
||||||
|
cleaned := make([]providers.ToolCall, 0, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
tc = providers.NormalizeToolCall(tc)
|
||||||
|
if strings.TrimSpace(tc.ID) == "" || strings.TrimSpace(tc.Name) == "" {
|
||||||
|
logger.DebugCF("agent", "Dropping tool call with empty id or name", map[string]any{
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Name,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, tc)
|
||||||
|
}
|
||||||
|
msg.ToolCalls = cleaned
|
||||||
|
}
|
||||||
if len(msg.ToolCalls) > 0 {
|
if len(msg.ToolCalls) > 0 {
|
||||||
if len(sanitized) == 0 {
|
if len(sanitized) == 0 {
|
||||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
||||||
|
|
@ -640,6 +660,9 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
// tool result messages following it. This is required by strict providers
|
// tool result messages following it. This is required by strict providers
|
||||||
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
// be followed by tool messages responding to each 'tool_call_id'."
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
// Also drops tool results whose ToolCallID has no matching tool call in the
|
||||||
|
// preceding assistant message (can occur when some tool calls were dropped
|
||||||
|
// by the normalization step above).
|
||||||
final := make([]providers.Message, 0, len(sanitized))
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
for i := 0; i < len(sanitized); i++ {
|
for i := 0; i < len(sanitized); i++ {
|
||||||
msg := sanitized[i]
|
msg := sanitized[i]
|
||||||
|
|
@ -650,13 +673,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
expected[tc.ID] = false
|
expected[tc.ID] = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check following messages for matching tool results
|
// Collect following tool messages
|
||||||
toolMsgCount := 0
|
var toolMsgs []providers.Message
|
||||||
for j := i + 1; j < len(sanitized); j++ {
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
if sanitized[j].Role != "tool" {
|
if sanitized[j].Role != "tool" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
toolMsgCount++
|
toolMsgs = append(toolMsgs, sanitized[j])
|
||||||
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
expected[sanitized[j].ToolCallID] = true
|
expected[sanitized[j].ToolCallID] = true
|
||||||
}
|
}
|
||||||
|
|
@ -673,7 +696,7 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"missing_tool_call_id": toolCallID,
|
"missing_tool_call_id": toolCallID,
|
||||||
"expected_count": len(expected),
|
"expected_count": len(expected),
|
||||||
"found_count": toolMsgCount,
|
"found_count": len(toolMsgs),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
@ -682,9 +705,25 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
|
|
||||||
if !allFound {
|
if !allFound {
|
||||||
// Skip this assistant message and its tool messages
|
// Skip this assistant message and its tool messages
|
||||||
i += toolMsgCount
|
i += len(toolMsgs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the assistant message, then only tool results that match an
|
||||||
|
// expected tool call ID. Drop orphaned results (referencing a tool
|
||||||
|
// call that was removed by the normalization step above).
|
||||||
|
final = append(final, msg)
|
||||||
|
for _, tm := range toolMsgs {
|
||||||
|
if expected[tm.ToolCallID] {
|
||||||
|
final = append(final, tm)
|
||||||
|
} else {
|
||||||
|
logger.DebugCF("agent", "Dropping tool result with no matching tool call", map[string]any{
|
||||||
|
"tool_call_id": tm.ToolCallID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += len(toolMsgs)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
final = append(final, msg)
|
final = append(final, msg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,15 @@ func msg(role, content string) providers.Message {
|
||||||
func assistantWithTools(toolIDs ...string) providers.Message {
|
func assistantWithTools(toolIDs ...string) providers.Message {
|
||||||
calls := make([]providers.ToolCall, len(toolIDs))
|
calls := make([]providers.ToolCall, len(toolIDs))
|
||||||
for i, id := range toolIDs {
|
for i, id := range toolIDs {
|
||||||
calls[i] = providers.ToolCall{ID: id, Type: "function"}
|
calls[i] = providers.ToolCall{
|
||||||
|
ID: id,
|
||||||
|
Type: "function",
|
||||||
|
Name: "test_tool",
|
||||||
|
Function: &providers.FunctionCall{
|
||||||
|
Name: "test_tool",
|
||||||
|
Arguments: "{}",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return providers.Message{Role: "assistant", ToolCalls: calls}
|
return providers.Message{Role: "assistant", ToolCalls: calls}
|
||||||
}
|
}
|
||||||
|
|
@ -281,3 +289,76 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
}
|
}
|
||||||
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_EmptyToolCallName tests that tool calls with
|
||||||
|
// an empty name are dropped. This can occur when sessions are deserialized from
|
||||||
|
// JSON storage: ToolCall.Name is json:"-" so it is lost, but Function.Name is
|
||||||
|
// preserved. NormalizeToolCall restores Name from Function.Name; tool calls
|
||||||
|
// where both are empty are dropped to prevent "tooluse.name: String should
|
||||||
|
// have at least 1 character" errors from the Anthropic API.
|
||||||
|
func TestSanitizeHistoryForProvider_EmptyToolCallName(t *testing.T) {
|
||||||
|
// Tool call with empty Name but Function.Name set — should be normalized and kept.
|
||||||
|
toolCallRestorable := providers.ToolCall{
|
||||||
|
ID: "A",
|
||||||
|
Type: "function",
|
||||||
|
// Name intentionally empty (simulates post-JSON-deserialization state)
|
||||||
|
Function: &providers.FunctionCall{Name: "my_tool", Arguments: "{}"},
|
||||||
|
}
|
||||||
|
// Tool call with both Name and Function.Name empty — truly invalid, should be dropped.
|
||||||
|
toolCallInvalid := providers.ToolCall{
|
||||||
|
ID: "B",
|
||||||
|
Type: "function",
|
||||||
|
// Name empty, Function.Name empty
|
||||||
|
Function: &providers.FunctionCall{Name: "", Arguments: "{}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
{Role: "assistant", ToolCalls: []providers.ToolCall{toolCallRestorable, toolCallInvalid}},
|
||||||
|
toolResult("A"),
|
||||||
|
toolResult("B"), // orphaned result for the dropped tool call
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// toolCallRestorable: Name restored from Function.Name → kept.
|
||||||
|
// toolCallInvalid: Name still empty after normalization → dropped.
|
||||||
|
// toolResult("B") references the dropped call → also dropped.
|
||||||
|
// Result: user, assistant (with only tc_A), tool_A
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool")
|
||||||
|
if len(result[1].ToolCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 tool call in assistant, got %d", len(result[1].ToolCalls))
|
||||||
|
}
|
||||||
|
if result[1].ToolCalls[0].Name != "my_tool" {
|
||||||
|
t.Errorf("expected tool call name %q, got %q", "my_tool", result[1].ToolCalls[0].Name)
|
||||||
|
}
|
||||||
|
if result[2].ToolCallID != "A" {
|
||||||
|
t.Errorf("expected surviving tool result id %q, got %q", "A", result[2].ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_EmptyToolCallID tests that tool calls with an
|
||||||
|
// empty ID are dropped (an empty ID also causes Anthropic API errors).
|
||||||
|
func TestSanitizeHistoryForProvider_EmptyToolCallID(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
{Role: "assistant", ToolCalls: []providers.ToolCall{
|
||||||
|
{ID: "", Name: "my_tool", Type: "function", Function: &providers.FunctionCall{Name: "my_tool"}},
|
||||||
|
}},
|
||||||
|
// No tool result since the call has no ID to reference
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The tool call with empty ID is dropped; the assistant message has no tool
|
||||||
|
// calls after filtering, so it passes through as a plain assistant message.
|
||||||
|
// user + assistant (plain) = 2
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant")
|
||||||
|
if len(result[1].ToolCalls) != 0 {
|
||||||
|
t.Fatalf("expected 0 tool calls in assistant, got %d", len(result[1].ToolCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue