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)
|
||||
|
||||
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(sanitized) == 0 {
|
||||
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
|
||||
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||
// 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))
|
||||
for i := 0; i < len(sanitized); i++ {
|
||||
msg := sanitized[i]
|
||||
|
|
@ -650,13 +673,13 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
expected[tc.ID] = false
|
||||
}
|
||||
|
||||
// Check following messages for matching tool results
|
||||
toolMsgCount := 0
|
||||
// Collect following tool messages
|
||||
var toolMsgs []providers.Message
|
||||
for j := i + 1; j < len(sanitized); j++ {
|
||||
if sanitized[j].Role != "tool" {
|
||||
break
|
||||
}
|
||||
toolMsgCount++
|
||||
toolMsgs = append(toolMsgs, sanitized[j])
|
||||
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||
expected[sanitized[j].ToolCallID] = true
|
||||
}
|
||||
|
|
@ -673,7 +696,7 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
map[string]any{
|
||||
"missing_tool_call_id": toolCallID,
|
||||
"expected_count": len(expected),
|
||||
"found_count": toolMsgCount,
|
||||
"found_count": len(toolMsgs),
|
||||
},
|
||||
)
|
||||
break
|
||||
|
|
@ -682,9 +705,25 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||
|
||||
if !allFound {
|
||||
// Skip this assistant message and its tool messages
|
||||
i += toolMsgCount
|
||||
i += len(toolMsgs)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ func msg(role, content string) providers.Message {
|
|||
func assistantWithTools(toolIDs ...string) providers.Message {
|
||||
calls := make([]providers.ToolCall, len(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}
|
||||
}
|
||||
|
|
@ -281,3 +289,76 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
|||
}
|
||||
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