diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe new file mode 100644 index 00000000000..ed048fe8892 Binary files /dev/null and b/.nuget/nuget.exe differ diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index d9318f98585..7daa5a49340 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -844,10 +844,11 @@ static bool IsAsyncMethod(MethodInfo method) // For IServiceProvider parameters, we bind to the services passed to InvokeAsync via AIFunctionArguments. if (parameterType == typeof(IServiceProvider)) { + bool hasDefault = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out _); return (arguments, _) => { IServiceProvider? services = arguments.Services; - if (!parameter.HasDefaultValue && services is null) + if (!hasDefault && services is null) { ThrowNullServices(parameter.Name); } @@ -859,6 +860,7 @@ static bool IsAsyncMethod(MethodInfo method) // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. // 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); return (arguments, _) => { // If the parameter has an argument specified in the dictionary, return that argument. @@ -907,13 +909,13 @@ static bool IsAsyncMethod(MethodInfo method) } // If the parameter is required and there's no argument specified for it, throw. - if (!parameter.HasDefaultValue) + if (!hasDefaultValue) { Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'."); } // Otherwise, use the optional parameter's default value. - return parameter.DefaultValue; + return effectiveDefaultValue; }; // Throws an ArgumentNullException indicating that AIFunctionArguments.Services must be provided. 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 ad15c62aef8..5602ed3d5d9 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 @@ -108,17 +108,18 @@ public static JsonElement CreateFunctionJsonSchema( continue; } + bool hasDefaultValue = TryGetEffectiveDefaultValue(parameter, out object? defaultValue); JsonNode parameterSchema = CreateJsonSchemaCore( type: parameter.ParameterType, parameter: parameter, description: parameter.GetCustomAttribute(inherit: true)?.Description, - hasDefaultValue: parameter.HasDefaultValue, - defaultValue: GetDefaultValueNormalized(parameter), + hasDefaultValue: hasDefaultValue, + defaultValue: defaultValue, serializerOptions, inferenceOptions); parameterSchemas.Add(parameter.Name, parameterSchema); - if (!parameter.IsOptional) + if (!parameter.IsOptional && !hasDefaultValue) { (requiredProperties ??= []).Add((JsonNode)parameter.Name); } @@ -760,6 +761,32 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) return JsonElement.ParseValue(ref reader); } + /// + /// Tries to get the effective default value for a parameter, checking both C# default value syntax and DefaultValueAttribute. + /// + /// The parameter to check. + /// The default value if one exists. + /// if the parameter has a default value; otherwise, . + internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, out object? defaultValue) + { + // First check for DefaultValueAttribute + if (parameterInfo.GetCustomAttribute(inherit: true) is { } attr) + { + defaultValue = attr.Value; + return true; + } + + // Fall back to the parameter's declared default value + if (parameterInfo.HasDefaultValue) + { + defaultValue = GetDefaultValueNormalized(parameterInfo); + return true; + } + + defaultValue = null; + return false; + } + [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", Justification = "Called conditionally on structs whose default ctor never gets trimmed.")] private static object? GetDefaultValueNormalized(ParameterInfo parameterInfo) diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 459f03028ab..fbe4488f319 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -61,6 +61,51 @@ public async Task Parameters_DefaultValuesAreUsedButOverridable_Async() AssertExtensions.EqualFunctionCallResults("hello hello", await func.InvokeAsync(new() { ["a"] = "hello" })); } + [Fact] + public async Task Parameters_DefaultValueAttributeIsRespected_Async() + { + // Test with null default value + AIFunction funcNull = AIFunctionFactory.Create(([DefaultValue(null)] string? text) => text ?? "was null"); + + // Schema should not list 'text' as required and should have default value + string schema = funcNull.JsonSchema.ToString(); + Assert.Contains("\"text\"", schema); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":null", schema); + + // Should be invocable without providing the parameter + AssertExtensions.EqualFunctionCallResults("was null", await funcNull.InvokeAsync()); + + // Should be overridable + AssertExtensions.EqualFunctionCallResults("hello", await funcNull.InvokeAsync(new() { ["text"] = "hello" })); + + // Test with non-null default value + AIFunction funcValue = AIFunctionFactory.Create(([DefaultValue("default")] string text) => text); + schema = funcValue.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":\"default\"", schema); + + AssertExtensions.EqualFunctionCallResults("default", await funcValue.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults("custom", await funcValue.InvokeAsync(new() { ["text"] = "custom" })); + + // Test with int default value + AIFunction funcInt = AIFunctionFactory.Create(([DefaultValue(42)] int x) => x * 2); + schema = funcInt.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":42", schema); + + AssertExtensions.EqualFunctionCallResults(84, await funcInt.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults(10, await funcInt.InvokeAsync(new() { ["x"] = 5 })); + + // Test that DefaultValue attribute takes precedence over C# default value + AIFunction funcBoth = AIFunctionFactory.Create(([DefaultValue(100)] int y = 50) => y); + schema = funcBoth.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":100", schema); // DefaultValue should take precedence + + AssertExtensions.EqualFunctionCallResults(100, await funcBoth.InvokeAsync()); // Should use DefaultValue, not C# default + } + [Fact] public async Task Parameters_MissingRequiredParametersFail_Async() {