Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1105,8 +1105,49 @@ 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 local function names: <ContainingMethod>g__LocalFunctionName|ordinal_depth
// Extract: ContainingMethod_LocalFunctionName
var match = LocalFunctionNameRegex().Match(memberName);
if (match.Success)
{
string containingMethod = match.Groups[1].Value;
string localFunctionName = match.Groups[2].Value;
return $"{containingMethod}_{localFunctionName}";
}

// Handle compiler-generated lambda names: <ContainingMethod>b__ordinal_depth
// Extract: ContainingMethod_ordinal to ensure uniqueness
match = LambdaNameRegex().Match(memberName);
if (match.Success)
{
string containingMethod = match.Groups[1].Value;
string ordinalPart = match.Groups[2].Value;
return $"{containingMethod}_{ordinalPart}";
}

// For any other cases, just replace invalid characters with underscores
return InvalidNameCharsRegex().Replace(memberName, "_");
}

/// <summary>Regex that matches compiler-generated local function names.</summary>
#if NET
[GeneratedRegex(@"^<([^>]+)>g__([^|]+)\|")]
private static partial Regex LocalFunctionNameRegex();
#else
private static Regex LocalFunctionNameRegex() => _localFunctionNameRegex;
private static readonly Regex _localFunctionNameRegex = new(@"^<([^>]+)>g__([^|]+)\|", RegexOptions.Compiled);
#endif

/// <summary>Regex that matches compiler-generated lambda names.</summary>
#if NET
[GeneratedRegex(@"^<([^>]+)>b__(.+)$")]
private static partial Regex LambdaNameRegex();
#else
private static Regex LambdaNameRegex() => _lambdaNameRegex;
private static readonly Regex _lambdaNameRegex = new(@"^<([^>]+)>b__(.+)$", RegexOptions.Compiled);
#endif

/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
#if NET
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 be: ContainingMethodName_LocalFunctionName
Assert.Equal("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
Assert.Equal("LocalFunction_MultipleInSameMethod_FirstLocal", tool1.Name);
Assert.Equal("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.Contains("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.Contains("Lambda_MultipleInSameMethod", tool1.Name);
Assert.Contains("Lambda_MultipleInSameMethod", tool2.Name);
Assert.NotEqual(tool1.Name, tool2.Name);
}

[Fact]
public void LocalFunction_WithParameters()
{
static int Add(int a, int b) => a + b;

var tool = AIFunctionFactory.Create(Add);

Assert.Equal("LocalFunction_WithParameters_Add", tool.Name);
Assert.Contains("a", tool.JsonSchema.ToString());
Assert.Contains("b", 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
Assert.Equal("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.Equal("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