diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 1b804e282dd..7df48182e52 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -749,11 +749,21 @@ private static string GetFunctionName(MethodInfo method) string name = SanitizeMemberName(method.Name); const string AsyncSuffix = "Async"; - if (IsAsyncMethod(method) && - name.EndsWith(AsyncSuffix, StringComparison.Ordinal) && - name.Length > AsyncSuffix.Length) + if (IsAsyncMethod(method)) { - name = name.Substring(0, name.Length - AsyncSuffix.Length); + // If the method ends in "Async" or contains "Async_", remove the "Async". + int asyncIndex = name.LastIndexOf(AsyncSuffix, StringComparison.Ordinal); + if (asyncIndex > 0 && + (asyncIndex + AsyncSuffix.Length == name.Length || + ((asyncIndex + AsyncSuffix.Length < name.Length) && (name[asyncIndex + AsyncSuffix.Length] == '_')))) + { + name = +#if NET + string.Concat(name.AsSpan(0, asyncIndex), name.AsSpan(asyncIndex + AsyncSuffix.Length)); +#else + string.Concat(name.Substring(0, asyncIndex), name.Substring(asyncIndex + AsyncSuffix.Length)); +#endif + } } return name; @@ -1105,16 +1115,37 @@ private record struct DescriptorKey( /// Replaces non-alphanumeric characters in the identifier with the underscore character. /// Primarily intended to remove characters produced by compiler-generated method name mangling. /// - private static string SanitizeMemberName(string memberName) => - InvalidNameCharsRegex().Replace(memberName, "_"); + private static string SanitizeMemberName(string memberName) + { + // Handle compiler-generated names (local functions and lambdas) + // Local functions: g__LocalFunctionName|ordinal_depth -> ContainingMethod_LocalFunctionName_ordinal_depth + // Lambdas: b__ordinal_depth -> ContainingMethod_ordinal_depth + if (CompilerGeneratedNameRegex().Match(memberName) is { Success: true } match) + { + memberName = $"{match.Groups[1].Value}_{match.Groups[2].Value}"; + } + + // Replace all non-alphanumeric characters with underscores. + return InvalidNameCharsRegex().Replace(memberName, "_"); + } + + /// Regex that matches compiler-generated names (local functions and lambdas). +#if NET + [GeneratedRegex(@"^<([^>]+)>\w__(.+)")] + private static partial Regex CompilerGeneratedNameRegex(); +#else + private static Regex CompilerGeneratedNameRegex() => _compilerGeneratedNameRegex; + private static readonly Regex _compilerGeneratedNameRegex = new(@"^<([^>]+)>\w__(.+)", RegexOptions.Compiled); +#endif - /// Regex that flags any character other than ASCII digits or letters or the underscore. + /// Regex that flags any character other than ASCII digits or letters. + /// Underscore isn't included so that sequences of underscores are replaced by a single one. #if NET - [GeneratedRegex("[^0-9A-Za-z_]")] + [GeneratedRegex("[^0-9A-Za-z]+")] private static partial Regex InvalidNameCharsRegex(); #else private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex; - private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled); + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z]+", RegexOptions.Compiled); #endif /// Invokes the MethodInfo with the specified target object and arguments. diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 7e61ad745c4..f99f9f94bc9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -1005,6 +1005,8 @@ private sealed class MyFunctionTypeWithOneArg(MyArgumentType arg) private sealed class MyArgumentType; + private static int TestStaticMethod(int a, int b) => a + b; + private class A; private class B : A; private sealed class C : B; @@ -1039,6 +1041,143 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() => }, }; + [Fact] + public void LocalFunction_NameCleanup() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething); + + // The name should start with: ContainingMethodName_LocalFunctionName (followed by ordinal) + Assert.StartsWith("LocalFunction_NameCleanup_DoSomething_", tool.Name); + } + + [Fact] + public void LocalFunction_MultipleInSameMethod() + { + static void FirstLocal() + { + // Empty local function for testing name cleanup + } + + static void SecondLocal() + { + // Empty local function for testing name cleanup + } + + var tool1 = AIFunctionFactory.Create(FirstLocal); + var tool2 = AIFunctionFactory.Create(SecondLocal); + + // Each should have unique names based on the local function name (including ordinal) + Assert.StartsWith("LocalFunction_MultipleInSameMethod_FirstLocal_", tool1.Name); + Assert.StartsWith("LocalFunction_MultipleInSameMethod_SecondLocal_", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void Lambda_NameCleanup() + { + Action lambda = () => + { + // Empty lambda for testing name cleanup + }; + + var tool = AIFunctionFactory.Create(lambda); + + // The name should be the containing method name with ordinal for uniqueness + Assert.StartsWith("Lambda_NameCleanup", tool.Name); + } + + [Fact] + public void Lambda_MultipleInSameMethod() + { + Action lambda1 = () => + { + // Empty lambda for testing name cleanup + }; + + Action lambda2 = () => + { + // Empty lambda for testing name cleanup + }; + + var tool1 = AIFunctionFactory.Create(lambda1); + var tool2 = AIFunctionFactory.Create(lambda2); + + // Each lambda should have a unique name based on its ordinal + // to allow the LLM to distinguish between them + Assert.StartsWith("Lambda_MultipleInSameMethod", tool1.Name); + Assert.StartsWith("Lambda_MultipleInSameMethod", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void LocalFunction_WithParameters() + { + static int Add(int firstNumber, int secondNumber) => firstNumber + secondNumber; + + var tool = AIFunctionFactory.Create(Add); + + Assert.StartsWith("LocalFunction_WithParameters_Add_", tool.Name); + Assert.Contains("firstNumber", tool.JsonSchema.ToString()); + Assert.Contains("secondNumber", tool.JsonSchema.ToString()); + } + + [Fact] + public async Task LocalFunction_AsyncFunction() + { + static async Task FetchDataAsync() + { + await Task.Yield(); + return "data"; + } + + var tool = AIFunctionFactory.Create(FetchDataAsync); + + // Should strip "Async" suffix and include ordinal + Assert.StartsWith("LocalFunction_AsyncFunction_FetchData_", tool.Name); + + var result = await tool.InvokeAsync(); + AssertExtensions.EqualFunctionCallResults("data", result); + } + + [Fact] + public void LocalFunction_ExplicitNameOverride() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething, name: "CustomName"); + + Assert.Equal("CustomName", tool.Name); + } + + [Fact] + public void LocalFunction_InsideTestMethod() + { + // Even local functions defined in test methods get cleaned up + var tool = AIFunctionFactory.Create(Add, serializerOptions: JsonContext.Default.Options); + + Assert.StartsWith("LocalFunction_InsideTestMethod_Add_", tool.Name); + + [return: Description("The summed result")] + static int Add(int a, int b) => a + b; + } + + [Fact] + public void RegularStaticMethod_NameUnchanged() + { + // Test that actual static methods (not local functions) have names unchanged + var tool = AIFunctionFactory.Create(TestStaticMethod, null, serializerOptions: JsonContext.Default.Options); + + Assert.Equal("TestStaticMethod", tool.Name); + } + [JsonSerializable(typeof(IAsyncEnumerable))] [JsonSerializable(typeof(int[]))] [JsonSerializable(typeof(string))]