Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
/// </returns>
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: <ContainingMethod>g__LocalFunctionName|ordinal_depth -> ContainingMethod_LocalFunctionName_ordinal_depth
// Lambdas: <ContainingMethod>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, "_");
}

/// <summary>Regex that matches compiler-generated names (local functions and lambdas).</summary>
#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

/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
/// <summary>Regex that flags any character other than ASCII digits or letters.</summary>
/// <remarks>Underscore isn't included so that sequences of underscores are replaced by a single one.</remarks>
#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

/// <summary>Invokes the MethodInfo with the specified target object and arguments.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> 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<int>))]
[JsonSerializable(typeof(int[]))]
[JsonSerializable(typeof(string))]
Expand Down
Loading