Skip to content

Commit 4c8b35f

Browse files
Copilotstephentoub
andauthored
Clean up local function names in AIFunctionFactory (#6909)
* Initial plan * Implement local function name cleanup for AIFunctionFactory Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix lambda name uniqueness by capturing full ordinal suffix Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix analyzer errors by adding comments to empty test methods Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Change Assert.Contains to Assert.StartsWith for lambda name tests Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Consolidate regexes and improve test parameter names Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Keep ordinal suffix for local functions to ensure uniqueness Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix build errors for netstandard2.0 and net462 targets Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Cleanup --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent e50ef7c commit 4c8b35f

File tree

2 files changed

+179
-9
lines changed

2 files changed

+179
-9
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -749,11 +749,21 @@ private static string GetFunctionName(MethodInfo method)
749749
string name = SanitizeMemberName(method.Name);
750750

751751
const string AsyncSuffix = "Async";
752-
if (IsAsyncMethod(method) &&
753-
name.EndsWith(AsyncSuffix, StringComparison.Ordinal) &&
754-
name.Length > AsyncSuffix.Length)
752+
if (IsAsyncMethod(method))
755753
{
756-
name = name.Substring(0, name.Length - AsyncSuffix.Length);
754+
// If the method ends in "Async" or contains "Async_", remove the "Async".
755+
int asyncIndex = name.LastIndexOf(AsyncSuffix, StringComparison.Ordinal);
756+
if (asyncIndex > 0 &&
757+
(asyncIndex + AsyncSuffix.Length == name.Length ||
758+
((asyncIndex + AsyncSuffix.Length < name.Length) && (name[asyncIndex + AsyncSuffix.Length] == '_'))))
759+
{
760+
name =
761+
#if NET
762+
string.Concat(name.AsSpan(0, asyncIndex), name.AsSpan(asyncIndex + AsyncSuffix.Length));
763+
#else
764+
string.Concat(name.Substring(0, asyncIndex), name.Substring(asyncIndex + AsyncSuffix.Length));
765+
#endif
766+
}
757767
}
758768

759769
return name;
@@ -1105,16 +1115,37 @@ private record struct DescriptorKey(
11051115
/// Replaces non-alphanumeric characters in the identifier with the underscore character.
11061116
/// Primarily intended to remove characters produced by compiler-generated method name mangling.
11071117
/// </returns>
1108-
private static string SanitizeMemberName(string memberName) =>
1109-
InvalidNameCharsRegex().Replace(memberName, "_");
1118+
private static string SanitizeMemberName(string memberName)
1119+
{
1120+
// Handle compiler-generated names (local functions and lambdas)
1121+
// Local functions: <ContainingMethod>g__LocalFunctionName|ordinal_depth -> ContainingMethod_LocalFunctionName_ordinal_depth
1122+
// Lambdas: <ContainingMethod>b__ordinal_depth -> ContainingMethod_ordinal_depth
1123+
if (CompilerGeneratedNameRegex().Match(memberName) is { Success: true } match)
1124+
{
1125+
memberName = $"{match.Groups[1].Value}_{match.Groups[2].Value}";
1126+
}
1127+
1128+
// Replace all non-alphanumeric characters with underscores.
1129+
return InvalidNameCharsRegex().Replace(memberName, "_");
1130+
}
1131+
1132+
/// <summary>Regex that matches compiler-generated names (local functions and lambdas).</summary>
1133+
#if NET
1134+
[GeneratedRegex(@"^<([^>]+)>\w__(.+)")]
1135+
private static partial Regex CompilerGeneratedNameRegex();
1136+
#else
1137+
private static Regex CompilerGeneratedNameRegex() => _compilerGeneratedNameRegex;
1138+
private static readonly Regex _compilerGeneratedNameRegex = new(@"^<([^>]+)>\w__(.+)", RegexOptions.Compiled);
1139+
#endif
11101140

1111-
/// <summary>Regex that flags any character other than ASCII digits or letters or the underscore.</summary>
1141+
/// <summary>Regex that flags any character other than ASCII digits or letters.</summary>
1142+
/// <remarks>Underscore isn't included so that sequences of underscores are replaced by a single one.</remarks>
11121143
#if NET
1113-
[GeneratedRegex("[^0-9A-Za-z_]")]
1144+
[GeneratedRegex("[^0-9A-Za-z]+")]
11141145
private static partial Regex InvalidNameCharsRegex();
11151146
#else
11161147
private static Regex InvalidNameCharsRegex() => _invalidNameCharsRegex;
1117-
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z_]", RegexOptions.Compiled);
1148+
private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z]+", RegexOptions.Compiled);
11181149
#endif
11191150

11201151
/// <summary>Invokes the MethodInfo with the specified target object and arguments.</summary>

test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,8 @@ private sealed class MyFunctionTypeWithOneArg(MyArgumentType arg)
10051005

10061006
private sealed class MyArgumentType;
10071007

1008+
private static int TestStaticMethod(int a, int b) => a + b;
1009+
10081010
private class A;
10091011
private class B : A;
10101012
private sealed class C : B;
@@ -1039,6 +1041,143 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() =>
10391041
},
10401042
};
10411043

1044+
[Fact]
1045+
public void LocalFunction_NameCleanup()
1046+
{
1047+
static void DoSomething()
1048+
{
1049+
// Empty local function for testing name cleanup
1050+
}
1051+
1052+
var tool = AIFunctionFactory.Create(DoSomething);
1053+
1054+
// The name should start with: ContainingMethodName_LocalFunctionName (followed by ordinal)
1055+
Assert.StartsWith("LocalFunction_NameCleanup_DoSomething_", tool.Name);
1056+
}
1057+
1058+
[Fact]
1059+
public void LocalFunction_MultipleInSameMethod()
1060+
{
1061+
static void FirstLocal()
1062+
{
1063+
// Empty local function for testing name cleanup
1064+
}
1065+
1066+
static void SecondLocal()
1067+
{
1068+
// Empty local function for testing name cleanup
1069+
}
1070+
1071+
var tool1 = AIFunctionFactory.Create(FirstLocal);
1072+
var tool2 = AIFunctionFactory.Create(SecondLocal);
1073+
1074+
// Each should have unique names based on the local function name (including ordinal)
1075+
Assert.StartsWith("LocalFunction_MultipleInSameMethod_FirstLocal_", tool1.Name);
1076+
Assert.StartsWith("LocalFunction_MultipleInSameMethod_SecondLocal_", tool2.Name);
1077+
Assert.NotEqual(tool1.Name, tool2.Name);
1078+
}
1079+
1080+
[Fact]
1081+
public void Lambda_NameCleanup()
1082+
{
1083+
Action lambda = () =>
1084+
{
1085+
// Empty lambda for testing name cleanup
1086+
};
1087+
1088+
var tool = AIFunctionFactory.Create(lambda);
1089+
1090+
// The name should be the containing method name with ordinal for uniqueness
1091+
Assert.StartsWith("Lambda_NameCleanup", tool.Name);
1092+
}
1093+
1094+
[Fact]
1095+
public void Lambda_MultipleInSameMethod()
1096+
{
1097+
Action lambda1 = () =>
1098+
{
1099+
// Empty lambda for testing name cleanup
1100+
};
1101+
1102+
Action lambda2 = () =>
1103+
{
1104+
// Empty lambda for testing name cleanup
1105+
};
1106+
1107+
var tool1 = AIFunctionFactory.Create(lambda1);
1108+
var tool2 = AIFunctionFactory.Create(lambda2);
1109+
1110+
// Each lambda should have a unique name based on its ordinal
1111+
// to allow the LLM to distinguish between them
1112+
Assert.StartsWith("Lambda_MultipleInSameMethod", tool1.Name);
1113+
Assert.StartsWith("Lambda_MultipleInSameMethod", tool2.Name);
1114+
Assert.NotEqual(tool1.Name, tool2.Name);
1115+
}
1116+
1117+
[Fact]
1118+
public void LocalFunction_WithParameters()
1119+
{
1120+
static int Add(int firstNumber, int secondNumber) => firstNumber + secondNumber;
1121+
1122+
var tool = AIFunctionFactory.Create(Add);
1123+
1124+
Assert.StartsWith("LocalFunction_WithParameters_Add_", tool.Name);
1125+
Assert.Contains("firstNumber", tool.JsonSchema.ToString());
1126+
Assert.Contains("secondNumber", tool.JsonSchema.ToString());
1127+
}
1128+
1129+
[Fact]
1130+
public async Task LocalFunction_AsyncFunction()
1131+
{
1132+
static async Task<string> FetchDataAsync()
1133+
{
1134+
await Task.Yield();
1135+
return "data";
1136+
}
1137+
1138+
var tool = AIFunctionFactory.Create(FetchDataAsync);
1139+
1140+
// Should strip "Async" suffix and include ordinal
1141+
Assert.StartsWith("LocalFunction_AsyncFunction_FetchData_", tool.Name);
1142+
1143+
var result = await tool.InvokeAsync();
1144+
AssertExtensions.EqualFunctionCallResults("data", result);
1145+
}
1146+
1147+
[Fact]
1148+
public void LocalFunction_ExplicitNameOverride()
1149+
{
1150+
static void DoSomething()
1151+
{
1152+
// Empty local function for testing name cleanup
1153+
}
1154+
1155+
var tool = AIFunctionFactory.Create(DoSomething, name: "CustomName");
1156+
1157+
Assert.Equal("CustomName", tool.Name);
1158+
}
1159+
1160+
[Fact]
1161+
public void LocalFunction_InsideTestMethod()
1162+
{
1163+
// Even local functions defined in test methods get cleaned up
1164+
var tool = AIFunctionFactory.Create(Add, serializerOptions: JsonContext.Default.Options);
1165+
1166+
Assert.StartsWith("LocalFunction_InsideTestMethod_Add_", tool.Name);
1167+
1168+
[return: Description("The summed result")]
1169+
static int Add(int a, int b) => a + b;
1170+
}
1171+
1172+
[Fact]
1173+
public void RegularStaticMethod_NameUnchanged()
1174+
{
1175+
// Test that actual static methods (not local functions) have names unchanged
1176+
var tool = AIFunctionFactory.Create(TestStaticMethod, null, serializerOptions: JsonContext.Default.Options);
1177+
1178+
Assert.Equal("TestStaticMethod", tool.Name);
1179+
}
1180+
10421181
[JsonSerializable(typeof(IAsyncEnumerable<int>))]
10431182
[JsonSerializable(typeof(int[]))]
10441183
[JsonSerializable(typeof(string))]

0 commit comments

Comments
 (0)