From ae88bdcfba2b83d8199b7390cfe933dc0a643135 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:32:29 +0000
Subject: [PATCH 1/9] Implement targeted AI function and parameter name
attributes
---
.../Functions/AIFunctionFactory.cs | 15 +++---
.../Functions/AIFunctionFactoryOptions.cs | 2 +-
.../Functions/AIFunctionNameAttribute.cs | 31 +++++++++++
.../Functions/AIParameterNameAttribute.cs | 31 +++++++++++
.../Microsoft.Extensions.AI.Abstractions.json | 32 ++++++++++++
.../AIJsonUtilities.Schema.Create.cs | 43 ++++++++++++++--
src/Shared/DiagnosticIds/DiagnosticIds.cs | 2 +
.../Functions/AINameAttributesTests.cs | 24 +++++++++
.../Utilities/AIJsonUtilitiesTests.cs | 51 +++++++++++++++++++
.../Functions/AIFunctionFactoryTest.cs | 33 ++++++++++++
10 files changed, 252 insertions(+), 12 deletions(-)
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
index 4625b168aa6..46451ab80a4 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
@@ -135,7 +135,8 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// The method to be represented via the created .
///
/// The name to use for the . If , the name will be derived from
- /// any on , if available, or else from the name of .
+ /// any on , then from any
+ /// on , if available, or else from the name of .
///
///
/// The description to use for the . If , a description will be derived from
@@ -313,7 +314,8 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
///
///
/// The name to use for the . If , the name will be derived from
- /// any on , if available, or else from the name of .
+ /// any on , then from any
+ /// on , if available, or else from the name of .
///
///
/// The description to use for the . If , a description will be derived from
@@ -806,7 +808,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
pType != typeof(IServiceProvider) &&
!string.IsNullOrEmpty(parameters[i].Name))
{
- _ = expectedArgumentNames.Add(parameters[i].Name!);
+ _ = expectedArgumentNames.Add(AIJsonUtilities.GetParameterSchemaName(parameters[i]));
}
}
@@ -815,7 +817,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
Method = key.Method;
- Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
+ Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty;
JsonSerializerOptions = serializerOptions;
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
@@ -949,10 +951,11 @@ static bool IsAsyncMethod(MethodInfo method)
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType);
bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue);
+ string argumentName = AIJsonUtilities.GetParameterSchemaName(parameter);
return (arguments, _) =>
{
// If the parameter has an argument specified in the dictionary, return that argument.
- if (arguments.TryGetValue(parameter.Name, out object? value))
+ if (arguments.TryGetValue(argumentName, out object? value))
{
return value switch
{
@@ -999,7 +1002,7 @@ static bool IsAsyncMethod(MethodInfo method)
// If the parameter is required and there's no argument specified for it, throw.
if (!hasDefaultValue)
{
- Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
+ Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{argumentName}'.");
}
// Otherwise, use the optional parameter's default value.
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
index 094bb09337a..ff80e6978ed 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs
@@ -50,7 +50,7 @@ public AIFunctionFactoryOptions()
/// Gets or sets the name to use for the function.
///
- /// The name to use for the function. The default value is a name derived from the passed or (for example, via a on the method).
+ /// The name to use for the function. The default value is a name derived from the passed or (for example, via an or on the method).
///
public string? Name { get; set; }
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
new file mode 100644
index 00000000000..e6914ab610b
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Specifies the name to use for an .
+///
+/// The name is the identifier a model sees and uses to invoke a function.
+/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
+///
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
+[Experimental(DiagnosticIds.Experiments.AIFunctionName, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class AIFunctionNameAttribute : Attribute
+{
+ /// Initializes a new instance of the class.
+ /// The name to use for the function.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public AIFunctionNameAttribute(string name)
+ {
+ Name = Throw.IfNullOrWhitespace(name);
+ }
+
+ /// Gets the name to use for the function.
+ public string Name { get; }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
new file mode 100644
index 00000000000..943cbf89b60
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+/// Specifies the schema name to use for an parameter.
+///
+/// The name is the identifier a model sees and uses when supplying an argument to a function.
+/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
+///
+[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
+[Experimental(DiagnosticIds.Experiments.AIParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class AIParameterNameAttribute : Attribute
+{
+ /// Initializes a new instance of the class.
+ /// The schema name to use for the parameter.
+ /// is .
+ /// is empty or composed entirely of whitespace.
+ public AIParameterNameAttribute(string name)
+ {
+ Name = Throw.IfNullOrWhitespace(name);
+ }
+
+ /// Gets the schema name to use for the parameter.
+ public string Name { get; }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
index 2d105bc2a32..dc4542d4ae7 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json
@@ -417,6 +417,38 @@
}
]
},
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.AIFunctionNameAttribute : System.Attribute",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.AIFunctionNameAttribute.AIFunctionNameAttribute(string name);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "string Microsoft.Extensions.AI.AIFunctionNameAttribute.Name { get; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.AIParameterNameAttribute : System.Attribute",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.AIParameterNameAttribute.AIParameterNameAttribute(string name);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "string Microsoft.Extensions.AI.AIParameterNameAttribute.Name { get; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
{
"Type": "readonly struct Microsoft.Extensions.AI.AIJsonSchemaCreateContext",
"Stage": "Stable",
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs
index c7d2a34f005..9913c33a18f 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs
@@ -10,6 +10,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
@@ -128,14 +129,20 @@ public static JsonElement CreateFunctionJsonSchema(
serializerOptions,
inferenceOptions);
- parameterSchemas.Add(parameter.Name, parameterSchema);
+ string parameterSchemaName = GetParameterSchemaName(parameter);
+ if (parameterSchemas.ContainsKey(parameterSchemaName))
+ {
+ Throw.ArgumentException(nameof(method), $"Multiple parameters are mapped to the same name '{parameterSchemaName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
+ }
+
+ parameterSchemas.Add(parameterSchemaName, parameterSchema);
bool isRequired = !parameter.IsOptional && !hasDefaultValue;
#if NET || NETFRAMEWORK
isRequired = isRequired || parameter.GetCustomAttribute(inherit: true) is not null;
#endif
if (isRequired)
{
- (requiredProperties ??= []).Add((JsonNode)parameter.Name);
+ (requiredProperties ??= []).Add((JsonNode)parameterSchemaName);
}
}
@@ -282,12 +289,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
// to accommodate the fact that they're being nested inside of a higher-level schema.
if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName))
{
- // Fix up any $ref URIs to match the path from the root document.
+ // Fix up any $ref URIs to match the path from the root document. The schema name becomes a
+ // JSON Pointer segment, so escape it per RFC 6901 ('~' => "~0", '/' => "~1").
+ string parameterSchemaName = EscapeJsonPointerSegment(GetParameterSchemaName(parameter));
string refUri = paramName!.GetValue();
Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}");
refUri = refUri == "#"
- ? $"#/{PropertiesPropertyName}/{parameter.Name}"
- : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}";
+ ? $"#/{PropertiesPropertyName}/{parameterSchemaName}"
+ : $"#/{PropertiesPropertyName}/{parameterSchemaName}/{refUri.AsMemory("#/".Length)}";
objSchema[RefPropertyName] = (JsonNode)refUri;
}
@@ -861,6 +870,30 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou
return false;
}
+ internal static string GetParameterSchemaName(ParameterInfo parameter) =>
+ parameter.GetCustomAttribute(inherit: true)?.Name ?? parameter.Name!;
+
+ private static string EscapeJsonPointerSegment(string segment)
+ {
+ if (segment.IndexOfAny(['~', '/']) < 0)
+ {
+ return segment;
+ }
+
+ StringBuilder sb = new(segment.Length + 2);
+ foreach (char c in segment)
+ {
+ _ = c switch
+ {
+ '~' => sb.Append("~0"),
+ '/' => sb.Append("~1"),
+ _ => sb.Append(c),
+ };
+ }
+
+ return sb.ToString();
+ }
+
///
/// Checks whether a parameter is an F# optional parameter declared with the ?param syntax.
/// F# optional parameters are annotated with Microsoft.FSharp.Core.OptionalArgumentAttribute
diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs
index 4b4925850a4..03358659275 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -54,6 +54,8 @@ internal static class Experiments
internal const string AIMcpServers = AIExperiments;
internal const string AIFunctionApprovals = AIExperiments;
internal const string AIApprovalsInvocationRequired = AIExperiments;
+ internal const string AIFunctionName = AIExperiments;
+ internal const string AIParameterName = AIExperiments;
internal const string AIChatReduction = AIExperiments;
internal const string AIToolSearch = AIExperiments;
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs
new file mode 100644
index 00000000000..ec439f4885c
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributesTests.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Xunit;
+
+namespace Microsoft.Extensions.AI;
+
+public class AINameAttributesTests
+{
+ [Fact]
+ public void AIFunctionNameAttribute_InvalidArguments_Throw()
+ {
+ Assert.Throws("name", () => new AIFunctionNameAttribute(null!));
+ Assert.Throws("name", () => new AIFunctionNameAttribute(" "));
+ }
+
+ [Fact]
+ public void AIParameterNameAttribute_InvalidArguments_Throw()
+ {
+ Assert.Throws("name", () => new AIParameterNameAttribute(null!));
+ Assert.Throws("name", () => new AIParameterNameAttribute(" "));
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index 606fdea9721..3a64a5bbcf3 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -580,6 +580,52 @@ static void TestMethod()
Assert.Equal("override_title", titleElement.GetString());
}
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName()
+ {
+ Delegate method = ([AIParameterName("$select")] string select, int top) =>
+ {
+ };
+
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
+
+ JsonElement properties = schema.GetProperty("properties");
+ Assert.True(properties.TryGetProperty("$select", out _));
+ Assert.False(properties.TryGetProperty("select", out _));
+
+ string[] required = schema.GetProperty("required").EnumerateArray().Select(e => e.GetString()!).ToArray();
+ Assert.Contains("$select", required);
+ Assert.Contains("top", required);
+ }
+
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_DuplicateNamesThrow()
+ {
+ Delegate duplicateByAttribute = ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) =>
+ {
+ };
+ Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByAttribute.Method));
+
+ Delegate duplicateByCollision = ([AIParameterName("filter")] string select, string filter) =>
+ {
+ };
+ Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByCollision.Method));
+ }
+
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJsonPointerSegment()
+ {
+ JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
+ Delegate method = ([AIParameterName("a/b~c")] RecursiveNode node) =>
+ {
+ };
+
+ string schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method, serializerOptions: options).ToString();
+
+ Assert.Contains("#/properties/a~1b~0c", schema);
+ Assert.DoesNotContain("#/properties/a/b~c", schema);
+ }
+
[Fact]
public static void CreateJsonSchema_CanBeBoolean()
{
@@ -1862,6 +1908,11 @@ public DerivedToolCallContent()
public int CustomValue { get; set; }
}
+ private sealed class RecursiveNode
+ {
+ public RecursiveNode? Next { get; set; }
+ }
+
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))]
[JsonSerializable(typeof(DerivedAIContent))]
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 1bd803aded3..082e7bbb5a8 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -315,6 +315,39 @@ public void Metadata_DisplayNameAttribute()
Assert.Contains("Metadata_DisplayNameAttribute", func.Name); // Will contain the lambda method name
}
+ [Fact]
+ public void Metadata_AIFunctionNameAttribute()
+ {
+ Func funcWithAttribute = [AIFunctionName("get_user")] () => "test";
+ AIFunction func = AIFunctionFactory.Create(funcWithAttribute);
+ Assert.Equal("get_user", func.Name);
+
+ Func funcWithBoth = [AIFunctionName("my_function")][DisplayName("display_name")] () => "test";
+ func = AIFunctionFactory.Create(funcWithBoth);
+ Assert.Equal("my_function", func.Name);
+
+ func = AIFunctionFactory.Create(funcWithAttribute, name: "explicit_name");
+ Assert.Equal("explicit_name", func.Name);
+
+ func = AIFunctionFactory.Create(funcWithAttribute, new AIFunctionFactoryOptions { Name = "options_name" });
+ Assert.Equal("options_name", func.Name);
+ }
+
+ [Fact]
+ public async Task Parameters_MappedByAIParameterNameAttribute_Async()
+ {
+ AIFunction func = AIFunctionFactory.Create(([AIParameterName("$select")] string select, int top) => select + top);
+
+ AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new()
+ {
+ ["$select"] = "Name",
+ ["top"] = 2,
+ }));
+
+ ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync(new() { ["top"] = 2 }).AsTask());
+ Assert.Contains("$select", ex.Message);
+ }
+
[Fact]
public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction()
{
From 6da64b795a146c14ddd8ec10ce92ecc3d233e938 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:47:10 +0000
Subject: [PATCH 2/9] Suppress reflection-only member warning in schema tests
---
.../Utilities/AIJsonUtilitiesTests.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index 3a64a5bbcf3..d1a9663dc8d 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -19,6 +19,7 @@
using Xunit;
#pragma warning disable SA1114 // parameter list should follow declaration
+#pragma warning disable S1144 // Unused private types or members should be removed (members are used via reflection in schema tests)
namespace Microsoft.Extensions.AI;
From f34f54f255098cc8774400144561a02e10877ea4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Jul 2026 19:42:03 +0000
Subject: [PATCH 3/9] Address PR feedback: DiagnosticIds merge, netfx fix,
duplicate detection, test improvements
---
.../Functions/AIFunctionFactory.cs | 12 +-
.../Functions/AIFunctionNameAttribute.cs | 2 +-
.../Functions/AIParameterNameAttribute.cs | 2 +-
src/Shared/DiagnosticIds/DiagnosticIds.cs | 3 +-
.../Utilities/AIJsonUtilitiesTests.cs | 26 ++-
.../Functions/AIFunctionFactoryTest.cs | 165 +++++++++++++++++-
6 files changed, 195 insertions(+), 15 deletions(-)
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
index 46451ab80a4..13992fefc2e 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
@@ -808,7 +808,11 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
pType != typeof(IServiceProvider) &&
!string.IsNullOrEmpty(parameters[i].Name))
{
- _ = expectedArgumentNames.Add(AIJsonUtilities.GetParameterSchemaName(parameters[i]));
+ string effectiveName = AIJsonUtilities.GetParameterSchemaName(parameters[i]);
+ if (!expectedArgumentNames.Add(effectiveName))
+ {
+ Throw.ArgumentException(nameof(key.Method), $"Multiple parameters are mapped to the same name '{effectiveName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
+ }
}
}
@@ -1220,9 +1224,11 @@ private static bool IsAIContentRelatedType(Type type) =>
{
return method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description;
}
- catch (Exception e) when (e is ArgumentNullException or NullReferenceException)
+ catch (Exception e) when (e is ArgumentNullException or NullReferenceException or IndexOutOfRangeException)
{
- // DynamicMethod return parameters don't support GetCustomAttribute.
+ // DynamicMethod return parameters don't support GetCustomAttribute. Additionally, on .NET Framework,
+ // querying inherited attributes on the return parameter of an overriding method can throw
+ // IndexOutOfRangeException. In either case, treat the description as absent.
return null;
}
}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
index e6914ab610b..6eb0ba15d42 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionNameAttribute.cs
@@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI;
/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
///
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
-[Experimental(DiagnosticIds.Experiments.AIFunctionName, UrlFormat = DiagnosticIds.UrlFormat)]
+[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class AIFunctionNameAttribute : Attribute
{
/// Initializes a new instance of the class.
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
index 943cbf89b60..2550b779c03 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIParameterNameAttribute.cs
@@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI;
/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
///
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
-[Experimental(DiagnosticIds.Experiments.AIParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
+[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class AIParameterNameAttribute : Attribute
{
/// Initializes a new instance of the class.
diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs
index 03358659275..abddbf61c5a 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -54,8 +54,7 @@ internal static class Experiments
internal const string AIMcpServers = AIExperiments;
internal const string AIFunctionApprovals = AIExperiments;
internal const string AIApprovalsInvocationRequired = AIExperiments;
- internal const string AIFunctionName = AIExperiments;
- internal const string AIParameterName = AIExperiments;
+ internal const string AIFunctionAndParameterName = AIExperiments;
internal const string AIChatReduction = AIExperiments;
internal const string AIToolSearch = AIExperiments;
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index d1a9663dc8d..4a04b46c595 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -584,18 +584,18 @@ static void TestMethod()
[Fact]
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName()
{
- Delegate method = ([AIParameterName("$select")] string select, int top) =>
+ Delegate method = ([AIParameterName("custom_property_name")] string select, int top) =>
{
};
JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
JsonElement properties = schema.GetProperty("properties");
- Assert.True(properties.TryGetProperty("$select", out _));
+ Assert.True(properties.TryGetProperty("custom_property_name", out _));
Assert.False(properties.TryGetProperty("select", out _));
string[] required = schema.GetProperty("required").EnumerateArray().Select(e => e.GetString()!).ToArray();
- Assert.Contains("$select", required);
+ Assert.Contains("custom_property_name", required);
Assert.Contains("top", required);
}
@@ -627,6 +627,26 @@ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJson
Assert.DoesNotContain("#/properties/a/b~c", schema);
}
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForSchemaTitle()
+ {
+ // AIFunctionNameAttribute overrides the AI-facing function name but does not affect the JSON schema title.
+ Delegate method = [AIFunctionName("my_tool")] (string param) => { };
+
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
+
+ // The schema title is not derived from AIFunctionNameAttribute.
+ Assert.False(schema.TryGetProperty("title", out JsonElement titleElement) && titleElement.GetString() == "my_tool");
+ }
+
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_HonoredByAIFunctionFactory()
+ {
+ Func funcWithAttribute = [AIFunctionName("get_user")] () => "test";
+ AIFunction func = AIFunctionFactory.Create(funcWithAttribute);
+ Assert.Equal("get_user", func.Name);
+ }
+
[Fact]
public static void CreateJsonSchema_CanBeBoolean()
{
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 082e7bbb5a8..dbc7e01aba1 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -10,6 +10,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,7 @@
#pragma warning disable IDE0004 // Remove Unnecessary Cast
#pragma warning disable S103 // Lines should not be too long
#pragma warning disable S107 // Methods should not have too many parameters
+#pragma warning disable S1144 // Unused private types or members should be removed (accessed via reflection)
#pragma warning disable S2760 // Sequential tests should not check the same condition
#pragma warning disable S3358 // Ternary operators should not be nested
#pragma warning disable S5034 // "ValueTask" should be consumed correctly
@@ -338,16 +340,154 @@ public async Task Parameters_MappedByAIParameterNameAttribute_Async()
{
AIFunction func = AIFunctionFactory.Create(([AIParameterName("$select")] string select, int top) => select + top);
- AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new()
- {
- ["$select"] = "Name",
- ["top"] = 2,
- }));
+ AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new() { ["$select"] = "Name", ["top"] = 2 }));
ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync(new() { ["top"] = 2 }).AsTask());
Assert.Contains("$select", ex.Message);
}
+ [Fact]
+ public void AIParameterNameAttribute_OverridesSchemaPropertyName()
+ {
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("my_param")] string myParam, int top) => myParam + top);
+
+ JsonElement expectedSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": {
+ "my_param": { "type": "string" },
+ "top": { "type": "integer" }
+ },
+ "required": ["my_param", "top"]
+ }
+ """).RootElement;
+
+ AssertExtensions.EqualJsonValues(expectedSchema, func.JsonSchema);
+ }
+
+ [Fact]
+ public async Task AIParameterNameAttribute_BindsArgumentByOverriddenName_Async()
+ {
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("$select")] string select,
+ [AIParameterName("$expand")] string expand,
+ string filter) =>
+ $"select='{select}', expand='{expand}', filter='{filter}'");
+
+ object? result = await func.InvokeAsync(new()
+ {
+ ["$select"] = "Name,Id",
+ ["$expand"] = "Orders",
+ ["filter"] = "Active",
+ });
+
+ AssertExtensions.EqualFunctionCallResults("select='Name,Id', expand='Orders', filter='Active'", result);
+ }
+
+ [Fact]
+ public async Task AIParameterNameAttribute_MissingRequiredArgument_ReportsSchemaName_Async()
+ {
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("my_param")] string myParam) => myParam);
+
+ ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync().AsTask());
+
+ Assert.Contains("my_param", ex.Message);
+ }
+
+ [Fact]
+ public async Task AIParameterNameAttribute_HonoredByStrictUnmappedMemberHandling_Async()
+ {
+ JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ };
+
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("my_param")] string myParam) => myParam,
+ new AIFunctionFactoryOptions { SerializerOptions = strictOptions });
+
+ // The overridden name is "expected", so it passes strict validation.
+ AssertExtensions.EqualFunctionCallResults("Name", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
+
+ // The underlying C# name is now an unexpected argument.
+ ArgumentException ex = await Assert.ThrowsAsync("arguments", async () =>
+ await func.InvokeAsync(new() { ["myParam"] = "Name" }));
+ Assert.Contains("myParam", ex.Message);
+ }
+
+ [Fact]
+ public async Task AIParameterNameAttribute_InheritedByOverride_Async()
+ {
+ MethodInfo overrideMethod = typeof(MyDerivedType).GetMethod(nameof(MyDerivedType.Method))!;
+ AIFunction func = AIFunctionFactory.Create(overrideMethod, new MyDerivedType());
+
+ Assert.Contains("my_param", func.JsonSchema.ToString());
+ Assert.DoesNotContain("\"myParam\"", func.JsonSchema.ToString());
+
+ AssertExtensions.EqualFunctionCallResults("param='Name'", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
+ }
+
+ [Fact]
+ public async Task AIFunctionAndParameterNameAttributes_BothHonored_Async()
+ {
+ AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
+
+ Assert.Equal("my_tool", func.Name);
+ Assert.Contains("my_param", func.JsonSchema.ToString());
+
+ AssertExtensions.EqualFunctionCallResults("Name", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
+ }
+
+ [Fact]
+ public void AIFunctionAndParameterNameAttributes_NameAndParameterSchema_PreservedByAsDeclarationOnly()
+ {
+ AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
+
+ AIFunctionDeclaration declaration = func.AsDeclarationOnly();
+
+ Assert.Equal("my_tool", declaration.Name);
+ Assert.Equal(func.JsonSchema.ToString(), declaration.JsonSchema.ToString());
+ Assert.Contains("my_param", declaration.JsonSchema.ToString());
+ Assert.IsNotAssignableFrom(declaration);
+ }
+
+ [Fact]
+ public void AIParameterNameAttribute_EscapesNameInJsonPointerRef()
+ {
+ JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
+
+ AIFunction func = AIFunctionFactory.Create(
+ ([AIParameterName("a/b~c")] AIParameterNameAttributeRecursiveNode node) => node.ToString(),
+ new AIFunctionFactoryOptions { SerializerOptions = options });
+
+ string schema = func.JsonSchema.ToString();
+
+ Assert.Contains("#/properties/a~1b~0c", schema);
+ Assert.DoesNotContain("#/properties/a/b~c", schema);
+ }
+
+ [Fact]
+ public void AIParameterNameAttribute_DuplicateNames_Throw()
+ {
+ ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second));
+ Assert.Contains("dup", ex.Message);
+
+ ArgumentException ex2 = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("filter")] string select, string filter) => select + filter));
+ Assert.Contains("filter", ex2.Message);
+ }
+
+ [Fact]
+ public void AIFunctionNameAttribute_DisplayNameAttribute_StillHonoredWhenNoAIFunctionNameAttribute()
+ {
+ AIFunction func = AIFunctionFactory.Create([DisplayName("from-display-name")] () => "result");
+
+ Assert.Equal("from-display-name", func.Name);
+ }
+
[Fact]
public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction()
{
@@ -1625,4 +1765,19 @@ public async Task Parameters_UnmappedMemberHandlingDisallow_CustomBindParameter_
[JsonSerializable(typeof(int?))]
[JsonSerializable(typeof(DateTime?))]
private partial class JsonContext : JsonSerializerContext;
+
+ private abstract class MyBaseType
+ {
+ public abstract string Method([AIParameterName("my_param")] string myParam);
+ }
+
+ private sealed class MyDerivedType : MyBaseType
+ {
+ public override string Method(string myParam) => $"param='{myParam}'";
+ }
+
+ private sealed class AIParameterNameAttributeRecursiveNode
+ {
+ public AIParameterNameAttributeRecursiveNode? Next { get; set; }
+ }
}
From 0652e52fa56c09bd5a92e0d441fdc6c7a65341ca Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Jul 2026 19:53:55 +0000
Subject: [PATCH 4/9] Add test for collision detection with ExcludeFromSchema
Adds test to verify that parameter name collision detection occurs
even when one of the colliding parameters is excluded from schema
generation via ExcludeFromSchema = true. This addresses reviewer
feedback about potential silent de-duplication in this scenario.
---
.../Functions/AIFunctionFactoryTest.cs | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index dbc7e01aba1..56d7d62b24f 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -480,6 +480,28 @@ public void AIParameterNameAttribute_DuplicateNames_Throw()
Assert.Contains("filter", ex2.Message);
}
+ [Fact]
+ public void AIParameterNameAttribute_DuplicateNames_DetectedEvenWhenExcludedFromSchema()
+ {
+ // Validates that collision detection occurs in ExpectedArgumentNames collection
+ // even when one of the colliding parameters is excluded from schema generation.
+ // This addresses the concern that collisions might go undetected when
+ // ExcludeFromSchema = true for one of the parameters.
+
+ var options = new AIFunctionFactoryOptions
+ {
+ ConfigureParameterBinding = p => p.Name == "second"
+ ? new AIFunctionFactoryOptions.ParameterBindingOptions { ExcludeFromSchema = true }
+ : default
+ };
+
+ ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
+ ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second,
+ options));
+ Assert.Contains("dup", ex.Message);
+ Assert.Contains("AIParameterNameAttribute", ex.Message);
+ }
+
[Fact]
public void AIFunctionNameAttribute_DisplayNameAttribute_StillHonoredWhenNoAIFunctionNameAttribute()
{
From ce36a4fa7cafab6d5b7f5708da0328289f8d1cd3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 8 Jul 2026 20:01:14 +0000
Subject: [PATCH 5/9] Add InheritedDescriptionAttributes test from PR #7599
Includes AIFunctionFactory_InheritedDescriptionAttributes_OnOverride test
that validates inherited Description attributes on overridden methods,
including parameter descriptions and return value descriptions.
Covers both modern .NET and .NET Framework behavior differences.
---
.../Functions/AIFunctionFactoryTest.cs | 34 +++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 56d7d62b24f..5ab99c3fcf8 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -510,6 +510,28 @@ public void AIFunctionNameAttribute_DisplayNameAttribute_StillHonoredWhenNoAIFun
Assert.Equal("from-display-name", func.Name);
}
+ [Fact]
+ public void AIFunctionFactory_InheritedDescriptionAttributes_OnOverride()
+ {
+ MethodInfo overrideMethod = typeof(DerivedDescribed).GetMethod(nameof(DerivedDescribed.Compute))!;
+ AIFunction f = AIFunctionFactory.Create(overrideMethod, new DerivedDescribed());
+
+ Assert.Equal("The compute method", f.Description);
+
+ JsonElement valueParam = f.JsonSchema.GetProperty("properties").GetProperty("value");
+ Assert.Equal("The input value", valueParam.GetProperty("description").GetString());
+
+ Assert.NotNull(f.ReturnJsonSchema);
+ Assert.Equal("integer", f.ReturnJsonSchema!.Value.GetProperty("type").GetString());
+#if NET
+ // On modern .NET the return-parameter inheritance walk is fixed, so the inherited description is read.
+ Assert.Equal("The computed result", f.ReturnJsonSchema!.Value.GetProperty("description").GetString());
+#else
+ // On .NET Framework the inherited return-parameter description cannot be read and is silently dropped.
+ Assert.False(f.ReturnJsonSchema!.Value.TryGetProperty("description", out _));
+#endif
+ }
+
[Fact]
public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction()
{
@@ -1802,4 +1824,16 @@ private sealed class AIParameterNameAttributeRecursiveNode
{
public AIParameterNameAttributeRecursiveNode? Next { get; set; }
}
+
+ private abstract class BaseDescribed
+ {
+ [Description("The compute method")]
+ [return: Description("The computed result")]
+ public abstract int Compute([Description("The input value")] int value);
+ }
+
+ private sealed class DerivedDescribed : BaseDescribed
+ {
+ public override int Compute(int value) => value;
+ }
}
From cf47c5833f9c6da016668710517660c82c2327fe Mon Sep 17 00:00:00 2001
From: David Cantu
Date: Wed, 8 Jul 2026 16:36:44 -0500
Subject: [PATCH 6/9] Test cleanup
---
.../Utilities/AIJsonUtilitiesTests.cs | 70 +++++++-------
.../Functions/AIFunctionFactoryTest.cs | 91 ++++---------------
2 files changed, 58 insertions(+), 103 deletions(-)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index 4a04b46c595..09337e0b5c8 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -564,6 +564,25 @@ static void TestMethod(int x, int y)
Assert.Equal("Method description", descElement.GetString());
}
+ [Fact]
+ public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForTitle()
+ {
+ [AIFunctionName("my_tool")]
+ static void TestMethod(string param)
+ {
+ // Test method for schema generation
+ }
+
+ var method = ((Action)TestMethod).Method;
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
+
+ // The schema title is not derived from AIFunctionNameAttribute.
+ if (schema.TryGetProperty("title", out JsonElement titleElement))
+ {
+ Assert.NotEqual("my_tool", titleElement.GetString());
+ }
+ }
+
[Fact]
public static void CreateFunctionJsonSchema_DisplayNameAttribute_CanBeOverridden()
{
@@ -584,11 +603,13 @@ static void TestMethod()
[Fact]
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName()
{
- Delegate method = ([AIParameterName("custom_property_name")] string select, int top) =>
+ static void TestMethod([AIParameterName("custom_property_name")] string select, int top)
{
- };
+ // Test method for schema generation
+ }
- JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
+ var method = ((Action)TestMethod).Method;
+ JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
JsonElement properties = schema.GetProperty("properties");
Assert.True(properties.TryGetProperty("custom_property_name", out _));
@@ -602,51 +623,36 @@ public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForProp
[Fact]
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_DuplicateNamesThrow()
{
- Delegate duplicateByAttribute = ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) =>
+ static void DuplicateByAttribute([AIParameterName("dup")] string first, [AIParameterName("dup")] string second)
{
- };
- Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByAttribute.Method));
+ // Test method for schema generation
+ }
- Delegate duplicateByCollision = ([AIParameterName("filter")] string select, string filter) =>
+ Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByAttribute).Method));
+
+ static void DuplicateByCollision([AIParameterName("filter")] string select, string filter)
{
- };
- Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByCollision.Method));
+ // Test method for schema generation
+ }
+
+ Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByCollision).Method));
}
[Fact]
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJsonPointerSegment()
{
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
- Delegate method = ([AIParameterName("a/b~c")] RecursiveNode node) =>
+ static void TestMethod([AIParameterName("a/b~c")] RecursiveNode node)
{
- };
+ // Test method for schema generation
+ }
- string schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method, serializerOptions: options).ToString();
+ string schema = AIJsonUtilities.CreateFunctionJsonSchema(((Action)TestMethod).Method, serializerOptions: options).ToString();
Assert.Contains("#/properties/a~1b~0c", schema);
Assert.DoesNotContain("#/properties/a/b~c", schema);
}
- [Fact]
- public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForSchemaTitle()
- {
- // AIFunctionNameAttribute overrides the AI-facing function name but does not affect the JSON schema title.
- Delegate method = [AIFunctionName("my_tool")] (string param) => { };
-
- JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
-
- // The schema title is not derived from AIFunctionNameAttribute.
- Assert.False(schema.TryGetProperty("title", out JsonElement titleElement) && titleElement.GetString() == "my_tool");
- }
-
- [Fact]
- public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_HonoredByAIFunctionFactory()
- {
- Func funcWithAttribute = [AIFunctionName("get_user")] () => "test";
- AIFunction func = AIFunctionFactory.Create(funcWithAttribute);
- Assert.Equal("get_user", func.Name);
- }
-
[Fact]
public static void CreateJsonSchema_CanBeBoolean()
{
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index 5ab99c3fcf8..d61e7416455 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -117,6 +117,7 @@ public async Task Parameters_MissingRequiredParametersFail_Async()
AIFunctionFactory.Create((string? theParam) => theParam + " " + theParam),
AIFunctionFactory.Create((int theParam) => theParam * 2),
AIFunctionFactory.Create((int? theParam) => theParam * 2),
+ AIFunctionFactory.Create(([AIParameterName("theParam")] string otherName) => otherName),
];
foreach (AIFunction f in funcs)
@@ -335,19 +336,29 @@ public void Metadata_AIFunctionNameAttribute()
Assert.Equal("options_name", func.Name);
}
+ [Fact]
+ public void Metadata_AIFunctionAndParameterNameAttributes_PreservedByAsDeclarationOnly()
+ {
+ AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
+
+ AIFunctionDeclaration declaration = func.AsDeclarationOnly();
+
+ Assert.Equal("my_tool", declaration.Name);
+ Assert.Equal(func.JsonSchema.ToString(), declaration.JsonSchema.ToString());
+ Assert.Contains("my_param", declaration.JsonSchema.ToString());
+ Assert.IsNotAssignableFrom(declaration);
+ }
+
[Fact]
public async Task Parameters_MappedByAIParameterNameAttribute_Async()
{
AIFunction func = AIFunctionFactory.Create(([AIParameterName("$select")] string select, int top) => select + top);
AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new() { ["$select"] = "Name", ["top"] = 2 }));
-
- ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync(new() { ["top"] = 2 }).AsTask());
- Assert.Contains("$select", ex.Message);
}
[Fact]
- public void AIParameterNameAttribute_OverridesSchemaPropertyName()
+ public void Parameters_AIParameterNameAttribute_OverridesSchemaPropertyName()
{
AIFunction func = AIFunctionFactory.Create(
([AIParameterName("my_param")] string myParam, int top) => myParam + top);
@@ -367,37 +378,7 @@ public void AIParameterNameAttribute_OverridesSchemaPropertyName()
}
[Fact]
- public async Task AIParameterNameAttribute_BindsArgumentByOverriddenName_Async()
- {
- AIFunction func = AIFunctionFactory.Create(
- ([AIParameterName("$select")] string select,
- [AIParameterName("$expand")] string expand,
- string filter) =>
- $"select='{select}', expand='{expand}', filter='{filter}'");
-
- object? result = await func.InvokeAsync(new()
- {
- ["$select"] = "Name,Id",
- ["$expand"] = "Orders",
- ["filter"] = "Active",
- });
-
- AssertExtensions.EqualFunctionCallResults("select='Name,Id', expand='Orders', filter='Active'", result);
- }
-
- [Fact]
- public async Task AIParameterNameAttribute_MissingRequiredArgument_ReportsSchemaName_Async()
- {
- AIFunction func = AIFunctionFactory.Create(
- ([AIParameterName("my_param")] string myParam) => myParam);
-
- ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync().AsTask());
-
- Assert.Contains("my_param", ex.Message);
- }
-
- [Fact]
- public async Task AIParameterNameAttribute_HonoredByStrictUnmappedMemberHandling_Async()
+ public async Task Parameters_AIParameterNameAttribute_StrictUnmappedMemberHandling_Async()
{
JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions)
{
@@ -418,7 +399,7 @@ public async Task AIParameterNameAttribute_HonoredByStrictUnmappedMemberHandling
}
[Fact]
- public async Task AIParameterNameAttribute_InheritedByOverride_Async()
+ public async Task Parameters_AIParameterNameAttribute_InheritedByOverride_Async()
{
MethodInfo overrideMethod = typeof(MyDerivedType).GetMethod(nameof(MyDerivedType.Method))!;
AIFunction func = AIFunctionFactory.Create(overrideMethod, new MyDerivedType());
@@ -430,31 +411,7 @@ public async Task AIParameterNameAttribute_InheritedByOverride_Async()
}
[Fact]
- public async Task AIFunctionAndParameterNameAttributes_BothHonored_Async()
- {
- AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
-
- Assert.Equal("my_tool", func.Name);
- Assert.Contains("my_param", func.JsonSchema.ToString());
-
- AssertExtensions.EqualFunctionCallResults("Name", await func.InvokeAsync(new() { ["my_param"] = "Name" }));
- }
-
- [Fact]
- public void AIFunctionAndParameterNameAttributes_NameAndParameterSchema_PreservedByAsDeclarationOnly()
- {
- AIFunction func = AIFunctionFactory.Create([AIFunctionName("my_tool")] ([AIParameterName("my_param")] string myParam) => myParam);
-
- AIFunctionDeclaration declaration = func.AsDeclarationOnly();
-
- Assert.Equal("my_tool", declaration.Name);
- Assert.Equal(func.JsonSchema.ToString(), declaration.JsonSchema.ToString());
- Assert.Contains("my_param", declaration.JsonSchema.ToString());
- Assert.IsNotAssignableFrom(declaration);
- }
-
- [Fact]
- public void AIParameterNameAttribute_EscapesNameInJsonPointerRef()
+ public void Parameters_AIParameterNameAttribute_EscapesJsonPointerRef()
{
JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
@@ -469,7 +426,7 @@ public void AIParameterNameAttribute_EscapesNameInJsonPointerRef()
}
[Fact]
- public void AIParameterNameAttribute_DuplicateNames_Throw()
+ public void Parameters_AIParameterNameAttribute_DuplicateNames_Throw()
{
ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second));
@@ -481,7 +438,7 @@ public void AIParameterNameAttribute_DuplicateNames_Throw()
}
[Fact]
- public void AIParameterNameAttribute_DuplicateNames_DetectedEvenWhenExcludedFromSchema()
+ public void Parameters_AIParameterNameAttribute_DuplicateNames_ExcludedFromSchema()
{
// Validates that collision detection occurs in ExpectedArgumentNames collection
// even when one of the colliding parameters is excluded from schema generation.
@@ -502,14 +459,6 @@ public void AIParameterNameAttribute_DuplicateNames_DetectedEvenWhenExcludedFrom
Assert.Contains("AIParameterNameAttribute", ex.Message);
}
- [Fact]
- public void AIFunctionNameAttribute_DisplayNameAttribute_StillHonoredWhenNoAIFunctionNameAttribute()
- {
- AIFunction func = AIFunctionFactory.Create([DisplayName("from-display-name")] () => "result");
-
- Assert.Equal("from-display-name", func.Name);
- }
-
[Fact]
public void AIFunctionFactory_InheritedDescriptionAttributes_OnOverride()
{
From cb6350d3769463049139e2f768bdcd0f36e8fd20 Mon Sep 17 00:00:00 2001
From: David Cantu
Date: Wed, 8 Jul 2026 16:50:08 -0500
Subject: [PATCH 7/9] Fix vacuous test
---
.../Utilities/AIJsonUtilitiesTests.cs | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index 09337e0b5c8..50586d9dad7 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -576,11 +576,9 @@ static void TestMethod(string param)
var method = ((Action)TestMethod).Method;
JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
- // The schema title is not derived from AIFunctionNameAttribute.
- if (schema.TryGetProperty("title", out JsonElement titleElement))
- {
- Assert.NotEqual("my_tool", titleElement.GetString());
- }
+ // The schema title is derived from the method name, not from AIFunctionNameAttribute.
+ Assert.True(schema.TryGetProperty("title", out JsonElement titleElement));
+ Assert.Equal("TestMethod", titleElement.GetString());
}
[Fact]
From 5d4446a2a21ef87e59b96c59559c45030dd33502 Mon Sep 17 00:00:00 2001
From: David Cantu
Date: Wed, 8 Jul 2026 17:26:05 -0500
Subject: [PATCH 8/9] Fix ParamName
---
.../Functions/AIFunctionFactory.cs | 4 ++--
.../Utilities/AIJsonUtilitiesTests.cs | 8 ++++++--
.../Functions/AIFunctionFactoryTest.cs | 3 +++
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
index 13992fefc2e..803e80ea8dd 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs
@@ -710,7 +710,7 @@ private sealed class ReflectionAIFunctionDescriptor
private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken);
///
- /// Gets or creates a descriptors using the specified method and options.
+ /// Gets or creates a descriptor using the specified method and options.
///
public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options)
{
@@ -811,7 +811,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
string effectiveName = AIJsonUtilities.GetParameterSchemaName(parameters[i]);
if (!expectedArgumentNames.Add(effectiveName))
{
- Throw.ArgumentException(nameof(key.Method), $"Multiple parameters are mapped to the same name '{effectiveName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
+ Throw.ArgumentException("method", $"Multiple parameters are mapped to the same name '{effectiveName}'. Ensure that any {nameof(AIParameterNameAttribute)} values do not collide with each other or with other parameter names.");
}
}
}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index 50586d9dad7..e46c560afef 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -626,14 +626,18 @@ static void DuplicateByAttribute([AIParameterName("dup")] string first, [AIParam
// Test method for schema generation
}
- Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByAttribute).Method));
+ ArgumentException ex = Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByAttribute).Method));
+ Assert.Contains("dup", ex.Message);
+ Assert.Equal("method", ex.ParamName);
static void DuplicateByCollision([AIParameterName("filter")] string select, string filter)
{
// Test method for schema generation
}
- Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByCollision).Method));
+ ArgumentException ex2 = Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByCollision).Method));
+ Assert.Contains("filter", ex2.Message);
+ Assert.Equal("method", ex2.ParamName);
}
[Fact]
diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
index d61e7416455..cbe6e2812a5 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs
@@ -431,10 +431,12 @@ public void Parameters_AIParameterNameAttribute_DuplicateNames_Throw()
ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create(
([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) => first + second));
Assert.Contains("dup", ex.Message);
+ Assert.Equal("method", ex.ParamName);
ArgumentException ex2 = Assert.Throws(() => AIFunctionFactory.Create(
([AIParameterName("filter")] string select, string filter) => select + filter));
Assert.Contains("filter", ex2.Message);
+ Assert.Equal("method", ex2.ParamName);
}
[Fact]
@@ -457,6 +459,7 @@ public void Parameters_AIParameterNameAttribute_DuplicateNames_ExcludedFromSchem
options));
Assert.Contains("dup", ex.Message);
Assert.Contains("AIParameterNameAttribute", ex.Message);
+ Assert.Equal("method", ex.ParamName);
}
[Fact]
From 3c9b79f5cb7e276cc650ab4c7b4b574afab0eef5 Mon Sep 17 00:00:00 2001
From: Jeff Handley
Date: Wed, 8 Jul 2026 23:27:39 -0400
Subject: [PATCH 9/9] Replace local with private function; locals get
unspeakable names
---
.../Utilities/AIJsonUtilitiesTests.cs | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
index e46c560afef..3fd1169ebdf 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs
@@ -567,18 +567,20 @@ static void TestMethod(int x, int y)
[Fact]
public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForTitle()
{
- [AIFunctionName("my_tool")]
- static void TestMethod(string param)
- {
- // Test method for schema generation
- }
-
- var method = ((Action)TestMethod).Method;
+ MethodInfo method = ((Action)MethodWithAIFunctionName).Method;
JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method);
// The schema title is derived from the method name, not from AIFunctionNameAttribute.
Assert.True(schema.TryGetProperty("title", out JsonElement titleElement));
- Assert.Equal("TestMethod", titleElement.GetString());
+ Assert.Equal(nameof(MethodWithAIFunctionName), titleElement.GetString());
+ Assert.NotEqual("my_tool", titleElement.GetString());
+ }
+
+ // Local functions get unspeakable names; define as a private method instead.
+ [AIFunctionName("my_tool")]
+ private static void MethodWithAIFunctionName()
+ {
+ // Test method for schema generation
}
[Fact]