diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e2..cd8fd5df8 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -615,18 +615,26 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message case "assistant": if len(msg.ToolCalls) > 0 { - if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) + var keep bool + msg, keep = sanitizeAssistantToolCalls(msg) + if !keep { continue } - prev := sanitized[len(sanitized)-1] - if prev.Role != "user" && prev.Role != "tool" { - logger.DebugCF( - "agent", - "Dropping assistant tool-call turn with invalid predecessor", - map[string]any{"prev_role": prev.Role}, - ) - continue + + if len(msg.ToolCalls) > 0 { + if len(sanitized) == 0 { + logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) + continue + } + prev := sanitized[len(sanitized)-1] + if prev.Role != "user" && prev.Role != "tool" { + logger.DebugCF( + "agent", + "Dropping assistant tool-call turn with invalid predecessor", + map[string]any{"prev_role": prev.Role}, + ) + continue + } } } sanitized = append(sanitized, msg) @@ -650,8 +658,10 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message expected[tc.ID] = false } - // Check following messages for matching tool results + // Collect following tool results, keeping only those that still + // match a valid tool_call from the assistant message. toolMsgCount := 0 + matchingToolResults := make([]providers.Message, 0, len(expected)) for j := i + 1; j < len(sanitized); j++ { if sanitized[j].Role != "tool" { break @@ -659,7 +669,12 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message toolMsgCount++ if _, exists := expected[sanitized[j].ToolCallID]; exists { expected[sanitized[j].ToolCallID] = true + matchingToolResults = append(matchingToolResults, sanitized[j]) + continue } + logger.DebugCF("agent", "Dropping unexpected tool result from history", map[string]any{ + "tool_call_id": sanitized[j].ToolCallID, + }) } // If any tool_call_id is missing, drop this assistant message and its partial tool messages @@ -685,6 +700,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message i += toolMsgCount continue } + + final = append(final, msg) + final = append(final, matchingToolResults...) + i += toolMsgCount + continue } final = append(final, msg) } @@ -692,6 +712,33 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message return final } +func sanitizeAssistantToolCalls(msg providers.Message) (providers.Message, bool) { + filtered := make([]providers.ToolCall, 0, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + normalized := providers.NormalizeToolCall(tc) + if strings.TrimSpace(normalized.ID) == "" || strings.TrimSpace(normalized.Name) == "" { + logger.DebugCF("agent", "Dropping invalid tool call from history", map[string]any{ + "tool_call_id": normalized.ID, + "tool_name": normalized.Name, + }) + continue + } + filtered = append(filtered, normalized) + } + + msg.ToolCalls = filtered + if len(filtered) > 0 { + return msg, true + } + + if strings.TrimSpace(msg.Content) != "" || strings.TrimSpace(msg.ReasoningContent) != "" { + return msg, true + } + + logger.DebugCF("agent", "Dropping assistant message with only invalid tool calls", map[string]any{}) + return providers.Message{}, false +} + func (cb *ContextBuilder) AddToolResult( messages []providers.Message, toolCallID, toolName, result string, diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 5756ed911..2fb5c5b18 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -13,7 +13,7 @@ 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: "tool_" + id} } return providers.Message{Role: "assistant", ToolCalls: calls} } @@ -188,6 +188,61 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { assertRoles(t, result, "user", "assistant", "user", "assistant") } +func TestSanitizeHistoryForProvider_DropsInvalidToolCallsAndUnexpectedToolResults(t *testing.T) { + history := []providers.Message{ + msg("user", "check"), + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + {ID: "call_ok", Name: "read_file"}, + {ID: "call_blank_name", Name: " "}, + {ID: "", Name: "exec"}, + }, + }, + toolResult("call_ok"), + toolResult("call_blank_name"), + toolResult(""), + msg("assistant", "done"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 4 { + t.Fatalf("expected 4 messages, got %d: %+v", len(result), result) + } + assertRoles(t, result, "user", "assistant", "tool", "assistant") + if got := len(result[1].ToolCalls); got != 1 { + t.Fatalf("assistant tool call count = %d, want 1", got) + } + if result[1].ToolCalls[0].ID != "call_ok" { + t.Fatalf("assistant tool call id = %q, want %q", result[1].ToolCalls[0].ID, "call_ok") + } + if result[2].ToolCallID != "call_ok" { + t.Fatalf("tool result id = %q, want %q", result[2].ToolCallID, "call_ok") + } +} + +func TestSanitizeHistoryForProvider_DropsAssistantWithOnlyInvalidToolCalls(t *testing.T) { + history := []providers.Message{ + msg("user", "check"), + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + {ID: "call_blank_name", Name: " "}, + {ID: "", Name: "exec"}, + }, + }, + toolResult("call_blank_name"), + msg("user", "next"), + msg("assistant", "ok"), + } + + result := sanitizeHistoryForProvider(history) + if len(result) != 3 { + t.Fatalf("expected 3 messages, got %d: %+v", len(result), result) + } + assertRoles(t, result, "user", "user", "assistant") +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs {