diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 4625b168aa6..803e80ea8dd 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 @@ -708,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) { @@ -806,7 +808,11 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions pType != typeof(IServiceProvider) && !string.IsNullOrEmpty(parameters[i].Name)) { - _ = expectedArgumentNames.Add(parameters[i].Name!); + string effectiveName = AIJsonUtilities.GetParameterSchemaName(parameters[i]); + if (!expectedArgumentNames.Add(effectiveName)) + { + 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."); + } } } @@ -815,7 +821,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 +955,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 +1006,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 +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/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..6eb0ba15d42 --- /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.AIFunctionAndParameterName, 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..2550b779c03 --- /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.AIFunctionAndParameterName, 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..abddbf61c5a 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -54,6 +54,7 @@ internal static class Experiments internal const string AIMcpServers = AIExperiments; internal const string AIFunctionApprovals = AIExperiments; internal const string AIApprovalsInvocationRequired = 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/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..3fd1169ebdf 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; @@ -563,6 +564,25 @@ static void TestMethod(int x, int y) Assert.Equal("Method description", descElement.GetString()); } + [Fact] + public static void CreateFunctionJsonSchema_AIFunctionNameAttribute_NotUsedForTitle() + { + 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(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] public static void CreateFunctionJsonSchema_DisplayNameAttribute_CanBeOverridden() { @@ -580,6 +600,63 @@ static void TestMethod() Assert.Equal("override_title", titleElement.GetString()); } + [Fact] + public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName() + { + static void TestMethod([AIParameterName("custom_property_name")] string select, int top) + { + // Test method for schema generation + } + + var method = ((Action)TestMethod).Method; + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method); + + JsonElement properties = schema.GetProperty("properties"); + 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("custom_property_name", required); + Assert.Contains("top", required); + } + + [Fact] + public static void CreateFunctionJsonSchema_AIParameterNameAttribute_DuplicateNamesThrow() + { + static void DuplicateByAttribute([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) + { + // Test method for schema generation + } + + 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 + } + + ArgumentException ex2 = Assert.Throws(() => AIJsonUtilities.CreateFunctionJsonSchema(((Action)DuplicateByCollision).Method)); + Assert.Contains("filter", ex2.Message); + Assert.Equal("method", ex2.ParamName); + } + + [Fact] + public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJsonPointerSegment() + { + JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + static void TestMethod([AIParameterName("a/b~c")] RecursiveNode node) + { + // Test method for schema generation + } + + 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 CreateJsonSchema_CanBeBoolean() { @@ -1862,6 +1939,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..cbe6e2812a5 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 @@ -115,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) @@ -315,6 +318,172 @@ 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 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 })); + } + + [Fact] + public void Parameters_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 Parameters_AIParameterNameAttribute_StrictUnmappedMemberHandling_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 Parameters_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 void Parameters_AIParameterNameAttribute_EscapesJsonPointerRef() + { + 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 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] + 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. + // 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); + Assert.Equal("method", ex.ParamName); + } + + [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() { @@ -1592,4 +1761,31 @@ 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; } + } + + 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; + } }