From 0c940a4d473667e532d8bf032abb251c5c3a39a5 Mon Sep 17 00:00:00 2001 From: cmclark00 Date: Mon, 16 Mar 2026 20:16:34 -0400 Subject: [PATCH] fix(agent): drop content-empty assistant messages after tool call normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all tool calls in an assistant history message are dropped (because both Name and Function.Name are empty after normalization), the message ends up with no tool calls and no text content. Sending it to Anthropic produces either an empty content array ("content": []) or an empty text block — both are rejected with a generic 400 "Error" response. Fix: after the tool-call normalization loop, if the assistant message has no remaining tool calls and no text content, drop the entire message. Any tool results that referenced the dropped calls are already handled as orphans by the existing second-pass logic. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/context.go | 10 ++++++++++ pkg/agent/context_test.go | 39 +++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 1708d8a10..631974337 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -634,6 +634,16 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } msg.ToolCalls = cleaned } + // If all tool calls were dropped and the message has no text + // content either, the message would produce an empty content + // array ("content": []) or an empty text block — both rejected + // by the Anthropic API with a generic 400 "Error". Drop it + // entirely; the second pass will orphan any associated tool + // results, which are also harmless to drop. + if len(msg.ToolCalls) == 0 && strings.TrimSpace(msg.Content) == "" { + logger.DebugCF("agent", "Dropping assistant message with no content and no tool calls", map[string]any{}) + continue + } if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 1e37e3fca..f307a637b 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -340,7 +340,8 @@ func TestSanitizeHistoryForProvider_EmptyToolCallName(t *testing.T) { } // TestSanitizeHistoryForProvider_EmptyToolCallID tests that tool calls with an -// empty ID are dropped (an empty ID also causes Anthropic API errors). +// empty ID are dropped, and that the resulting content-less assistant message +// is also dropped (avoiding an empty content array sent to the API). func TestSanitizeHistoryForProvider_EmptyToolCallID(t *testing.T) { history := []providers.Message{ msg("user", "hello"), @@ -351,14 +352,36 @@ func TestSanitizeHistoryForProvider_EmptyToolCallID(t *testing.T) { } 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 + // The tool call with empty ID is dropped; the assistant message then has no + // tool calls and no text content → it is also dropped to avoid sending an + // empty content array to the API. + // Remaining: user ("hello") only. + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) + } + assertRoles(t, result, "user") +} + +// TestSanitizeHistoryForProvider_AllToolCallsDroppedEmptyContent tests that an +// assistant message whose every tool call is dropped AND whose text content is +// empty is itself dropped. Sending such a message would produce "content": [] +// or an empty text block — both cause a generic Anthropic 400 "Error". +func TestSanitizeHistoryForProvider_AllToolCallsDroppedEmptyContent(t *testing.T) { + history := []providers.Message{ + msg("user", "hello"), + {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ + {ID: "A", Type: "function", Function: &providers.FunctionCall{Name: "", Arguments: "{}"}}, + }}, + toolResult("A"), + msg("user", "follow up"), + } + + result := sanitizeHistoryForProvider(history) + // The assistant message has no valid tool calls and no content → dropped. + // Its tool result is orphaned and also dropped. + // Remaining: user ("hello"), user ("follow up") 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)) - } + assertRoles(t, result, "user", "user") }