From ec522c28fba55dce86d3bb0a3c07f93346202d73 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Tue, 30 Jun 2026 17:46:55 -0500 Subject: [PATCH 1/4] Add AINameAttribute to override the AI-facing name of a function or parameter Introduce a public experimental AINameAttribute that sets the AI-facing identifier of either an AIFunction or one of its parameters, since both are "the name the model sees" in different envelopes. The attribute targets AttributeTargets.Method | AttributeTargets.Parameter: - On a method, the supplied name becomes the created function's AITool.Name (the tool identifier that lives beside the schema on the tool descriptor, used by providers to serialize the tool name and by FunctionInvokingChatClient to dispatch calls). - On a parameter, it becomes the corresponding property key in the function's JsonSchema and the name used to bind arguments during invocation, enabling names that are not valid C# identifiers (for example OData-style $select and $expand). Type/class targets are intentionally excluded: a type has no machine-significant schema name in CreateJsonSchema (repeated types are inlined, recursion uses a positional $ref, and there is no $defs), so the type name controls nothing. Property keys remain owned by STJ via JsonPropertyName, and title stays with DisplayName/Description; this attribute is identifier-only. Function name resolution precedence is: options.Name (or the Create name argument) -> [AIName] -> [DisplayName] -> name derived from the method. Attributes placed on lambdas and their parameters flow through AIFunctionFactory.Create(Delegate) via the compiler-generated backing method, so [AIName] works on lambdas as well as named methods. Resolves part of dotnet/extensions#7477. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Functions/AIFunctionFactory.cs | 14 +- .../Functions/AIFunctionFactoryOptions.cs | 2 +- .../Functions/AINameAttribute.cs | 34 ++++ .../Microsoft.Extensions.AI.Abstractions.json | 16 ++ .../AIJsonUtilities.Schema.Create.cs | 13 +- src/Shared/DiagnosticIds/DiagnosticIds.cs | 3 +- .../Functions/AINameAttributeTest.cs | 183 ++++++++++++++++++ 7 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AINameAttribute.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.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..385b5ee6285 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,12 @@ 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 { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index 094bb09337a..6d3d98d1633 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/AINameAttribute.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AINameAttribute.cs new file mode 100644 index 00000000000..fa259dbe449 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AINameAttribute.cs @@ -0,0 +1,34 @@ +// 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 or one of its parameters. +/// +/// +/// The name is the identifier a model sees and uses to invoke a function or to supply an argument. +/// By default these names are inferred from .NET metadata: a function's name comes from the method name, and a +/// parameter's name comes from the .NET parameter name. Apply this attribute to use a different identifier. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +[Experimental(DiagnosticIds.Experiments.AIName, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class AINameAttribute : Attribute +{ + /// Initializes a new instance of the class. + /// The name to use for the function or parameter. + /// is . + /// is empty or composed entirely of whitespace. + public AINameAttribute(string name) + { + Name = Throw.IfNullOrWhitespace(name); + } + + /// Gets the name to use for the function or 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..70f70619b16 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 @@ -681,6 +681,22 @@ } ] }, + { + "Type": "sealed class Microsoft.Extensions.AI.AINameAttribute : System.Attribute", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AINameAttribute.AINameAttribute(string name);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.AINameAttribute.Name { get; }", + "Stage": "Experimental" + } + ] + }, { "Type": "abstract class Microsoft.Extensions.AI.AITool", "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..711196bad11 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 @@ -128,14 +128,15 @@ public static JsonElement CreateFunctionJsonSchema( serializerOptions, inferenceOptions); - parameterSchemas.Add(parameter.Name, parameterSchema); + string parameterSchemaName = GetParameterSchemaName(parameter); + 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); } } @@ -283,11 +284,12 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js 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. + string parameterSchemaName = 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 +863,9 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou return false; } + internal static string GetParameterSchemaName(ParameterInfo parameter) => + parameter.GetCustomAttribute(inherit: true)?.Name ?? parameter.Name!; + /// /// 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..691e2ae67ce 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -51,9 +51,8 @@ internal static class Experiments internal const string AIImageGeneration = AIExperiments; internal const string AISpeechToText = AIExperiments; internal const string AITextToSpeech = AIExperiments; - internal const string AIMcpServers = AIExperiments; - internal const string AIFunctionApprovals = AIExperiments; internal const string AIApprovalsInvocationRequired = AIExperiments; + internal const string AIName = AIExperiments; internal const string AIChatReduction = AIExperiments; internal const string AIToolSearch = AIExperiments; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs new file mode 100644 index 00000000000..cf6f4e3f076 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs @@ -0,0 +1,183 @@ +// 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.ComponentModel; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Xunit; + +#pragma warning disable S1144 // Unused private types or members should be removed (methods are invoked via reflection) + +namespace Microsoft.Extensions.AI; + +public class AINameAttributeTest +{ + [Fact] + public void OverridesSchemaPropertyName() + { + AIFunction func = AIFunctionFactory.Create( + ([AIName("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 void HonoredByCreateFunctionJsonSchema() + { + Func query = ([AIName("my_param")] string myParam) => myParam; + + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(query.Method); + + Assert.Contains("my_param", schema.ToString()); + } + + [Fact] + public async Task BindsArgumentByOverriddenName_Async() + { + AIFunction func = AIFunctionFactory.Create( + ([AIName("$select")] string select, + [AIName("$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 MissingRequiredArgument_ReportsSchemaName_Async() + { + AIFunction func = AIFunctionFactory.Create( + ([AIName("my_param")] string myParam) => myParam); + + ArgumentException ex = await Assert.ThrowsAsync(() => func.InvokeAsync().AsTask()); + + Assert.Contains("my_param", ex.Message); + } + + [Fact] + public async Task HonoredByStrictUnmappedMemberHandling_Async() + { + JsonSerializerOptions strictOptions = new(AIJsonUtilities.DefaultOptions) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + AIFunction func = AIFunctionFactory.Create( + ([AIName("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 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 void OverridesFunctionName() + { + AIFunction func = AIFunctionFactory.Create([AIName("my_tool")] () => "result"); + + Assert.Equal("my_tool", func.Name); + } + + [Fact] + public void OverridesFunctionName_TakesPrecedenceOverDisplayName() + { + AIFunction func = AIFunctionFactory.Create([AIName("my_tool")][DisplayName("from-display-name")] () => "result"); + + Assert.Equal("my_tool", func.Name); + } + + [Fact] + public void DisplayNameAttribute_StillHonoredWhenNoAINameAttribute() + { + AIFunction func = AIFunctionFactory.Create([DisplayName("from-display-name")] () => "result"); + + Assert.Equal("from-display-name", func.Name); + } + + [Fact] + public void OptionsName_TakesPrecedenceOverAttribute() + { + AIFunction func = AIFunctionFactory.Create([AIName("my_tool")] () => "result", new AIFunctionFactoryOptions { Name = "explicit" }); + + Assert.Equal("explicit", func.Name); + } + + [Fact] + public async Task FunctionAndParameterNames_BothHonored_Async() + { + AIFunction func = AIFunctionFactory.Create([AIName("my_tool")] ([AIName("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 NameAndParameterSchema_PreservedByAsDeclarationOnly() + { + AIFunction func = AIFunctionFactory.Create([AIName("my_tool")] ([AIName("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 InvalidArguments_Throw() + { + Assert.Throws("name", () => new AINameAttribute(null!)); + Assert.Throws("name", () => new AINameAttribute(" ")); + } + + private abstract class MyBaseType + { + public abstract string Method([AIName("my_param")] string myParam); + } + + private sealed class MyDerivedType : MyBaseType + { + public override string Method(string myParam) => $"param='{myParam}'"; + } +} From d95df6678bf06c2dfd03ed21c4b3989bc356de54 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Tue, 30 Jun 2026 17:47:04 -0500 Subject: [PATCH 2/4] Fix IndexOutOfRangeException reading inherited return-parameter description on .NET Framework On .NET Framework, reading an inherited [Description] from the return parameter of an overriding method throws IndexOutOfRangeException: System.Attribute.GetParentDefinition indexes the result of GetParameters() with the return parameter's Position, which is -1. GetReturnParameterDescription calls GetCustomAttribute(inherit: true) on method.ReturnParameter, so any AIFunction created from such an override would throw instead of producing a schema. Modern .NET already guards this case (returning the base method's return parameter), so the failure is .NET Framework only and cannot be fixed there. Extend the existing catch filter to also treat IndexOutOfRangeException as "description absent", alongside the DynamicMethod ArgumentNullException/ NullReferenceException cases. On .NET Framework the inherited return description is silently dropped; on modern .NET it is preserved. Add a dedicated regression test with TFM-conditional assertions covering the method-level, ordinary-parameter, and return-parameter inherited descriptions on an overriding method. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Functions/AIFunctionFactory.cs | 8 +++-- .../Functions/AIFunctionFactoryTest.cs | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 385b5ee6285..addaaba7b58 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -1003,7 +1003,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. @@ -1221,9 +1221,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/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 1bd803aded3..179bb88b973 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -18,6 +18,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 (override methods are invoked 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 @@ -1068,6 +1069,40 @@ public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() static int Add(int a, int b) => a + b; } + [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 + } + + 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; + } + [Fact] public void CreateDeclaration_Roundtrips() { From 2a609ef14bef96291510d943f8d4e1f563e10da0 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Tue, 30 Jun 2026 18:23:13 -0500 Subject: [PATCH 3/4] Escape AINameAttribute value when used as a JSON Pointer $ref segment When a parameter's overridden name is used as a JSON Pointer segment in a generated $ref, escape it per RFC 6901 ('~' => "~0", '/' => "~1"). Previously the segment was always a C# identifier and never needed escaping; an AINameAttribute value can contain these characters and would otherwise produce an invalid pointer. The property key itself and argument binding continue to use the raw name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AIJsonUtilities.Schema.Create.cs | 28 +++++++++++++++++-- .../Functions/AINameAttributeTest.cs | 21 ++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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 711196bad11..b26aaf8ed42 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; @@ -283,8 +284,9 @@ 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. - string parameterSchemaName = GetParameterSchemaName(parameter); + // 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 == "#" @@ -866,6 +868,28 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou internal static string GetParameterSchemaName(ParameterInfo parameter) => parameter.GetCustomAttribute(inherit: true)?.Name ?? parameter.Name!; + // Escapes a string for use as a single JSON Pointer reference token per RFC 6901 ('~' => "~0", '/' => "~1"). + 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/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs index cf6f4e3f076..81365bac2ab 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading.Tasks; using Xunit; @@ -171,6 +172,21 @@ public void InvalidArguments_Throw() Assert.Throws("name", () => new AINameAttribute(" ")); } + [Fact] + public void EscapesNameInJsonPointerRef() + { + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + AIFunction func = AIFunctionFactory.Create( + ([AIName("a/b~c")] RecursiveNode 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); + } + private abstract class MyBaseType { public abstract string Method([AIName("my_param")] string myParam); @@ -180,4 +196,9 @@ private sealed class MyDerivedType : MyBaseType { public override string Method(string myParam) => $"param='{myParam}'"; } + + private sealed class RecursiveNode + { + public RecursiveNode? Next { get; set; } + } } From d6ab8b819c5bb3c0cd45a6b3953da2c54612e551 Mon Sep 17 00:00:00 2001 From: David Cantu Date: Thu, 2 Jul 2026 12:52:45 -0500 Subject: [PATCH 4/4] Feedback: check param name collision --- .../Utilities/AIJsonUtilities.Schema.Create.cs | 5 +++++ .../Functions/AINameAttributeTest.cs | 12 ++++++++++++ 2 files changed, 17 insertions(+) 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 b26aaf8ed42..6ed7261651b 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 @@ -130,6 +130,11 @@ public static JsonElement CreateFunctionJsonSchema( inferenceOptions); 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(AINameAttribute)} values do not collide with each other or with other parameter names."); + } + parameterSchemas.Add(parameterSchemaName, parameterSchema); bool isRequired = !parameter.IsOptional && !hasDefaultValue; #if NET || NETFRAMEWORK diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs index 81365bac2ab..d30825fa4ef 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs @@ -187,6 +187,18 @@ public void EscapesNameInJsonPointerRef() Assert.DoesNotContain("#/properties/a/b~c", schema); } + [Fact] + public void DuplicateNames_Throw() + { + ArgumentException ex = Assert.Throws(() => AIFunctionFactory.Create( + ([AIName("dup")] string first, [AIName("dup")] string second) => first + second)); + Assert.Contains("dup", ex.Message); + + ArgumentException ex2 = Assert.Throws(() => AIFunctionFactory.Create( + ([AIName("filter")] string select, string filter) => select + filter)); + Assert.Contains("filter", ex2.Message); + } + private abstract class MyBaseType { public abstract string Method([AIName("my_param")] string myParam);