diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 4625b168aa6..addaaba7b58 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 { @@ -999,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. @@ -1217,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/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..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 @@ -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(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 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,31 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou return false; } + 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/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..d30825fa4ef --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/AINameAttributeTest.cs @@ -0,0 +1,216 @@ +// 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.Text.Json.Serialization.Metadata; +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(" ")); + } + + [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); + } + + [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); + } + + private sealed class MyDerivedType : MyBaseType + { + public override string Method(string myParam) => $"param='{myParam}'"; + } + + private sealed class RecursiveNode + { + public RecursiveNode? Next { get; set; } + } +} 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() {