Skip to content

Commit d0427cc

Browse files
Copilotstephentoubeiriktsarpalis
authored andcommitted
Support DefaultValueAttribute in AIFunctionFactory parameter handling (#6947)
* Initial plan * Add support for DefaultValue attribute in AIFunctionFactory - Check for DefaultValueAttribute when determining parameter optionality - Use DefaultValueAttribute value as default when parameter not provided - Add helper methods HasEffectiveDefaultValue and GetEffectiveDefaultValue - Add comprehensive tests for DefaultValue attribute support Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Add test for DefaultValue attribute precedence over C# default Verify that when both DefaultValue attribute and C# default are present, the DefaultValue attribute takes precedence. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Add test for DefaultValue precedence and update gitignore - Add test verifying DefaultValue attribute takes precedence over C# default - Add .nuget directory to gitignore to exclude build artifacts Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Address PR feedback: consolidate helpers and fix formatting - Revert changes to .gitignore - Fix formatting issues in test file (remove extra blank lines) - Make HasEffectiveDefaultValue and GetEffectiveDefaultValue internal in AIJsonUtilities - Remove duplicate helper methods from AIFunctionFactory and use the ones from AIJsonUtilities Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Rename parameter 's' to 'text' in test to avoid false positives Use a longer, more descriptive parameter name to avoid false positives when searching for the parameter name in string assertions. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Unify helper methods into TryGetEffectiveDefaultValue Replace HasEffectiveDefaultValue and GetEffectiveDefaultValue with a single TryGetEffectiveDefaultValue method to avoid resolving the DefaultValueAttribute multiple times, improving performance. Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
1 parent df45f65 commit d0427cc

File tree

4 files changed

+80
-6
lines changed

4 files changed

+80
-6
lines changed

.nuget/nuget.exe

8.56 MB
Binary file not shown.

src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -844,10 +844,11 @@ static bool IsAsyncMethod(MethodInfo method)
844844
// For IServiceProvider parameters, we bind to the services passed to InvokeAsync via AIFunctionArguments.
845845
if (parameterType == typeof(IServiceProvider))
846846
{
847+
bool hasDefault = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out _);
847848
return (arguments, _) =>
848849
{
849850
IServiceProvider? services = arguments.Services;
850-
if (!parameter.HasDefaultValue && services is null)
851+
if (!hasDefault && services is null)
851852
{
852853
ThrowNullServices(parameter.Name);
853854
}
@@ -859,6 +860,7 @@ static bool IsAsyncMethod(MethodInfo method)
859860
// For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary.
860861
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
861862
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType);
863+
bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue);
862864
return (arguments, _) =>
863865
{
864866
// If the parameter has an argument specified in the dictionary, return that argument.
@@ -907,13 +909,13 @@ static bool IsAsyncMethod(MethodInfo method)
907909
}
908910

909911
// If the parameter is required and there's no argument specified for it, throw.
910-
if (!parameter.HasDefaultValue)
912+
if (!hasDefaultValue)
911913
{
912914
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
913915
}
914916

915917
// Otherwise, use the optional parameter's default value.
916-
return parameter.DefaultValue;
918+
return effectiveDefaultValue;
917919
};
918920

919921
// Throws an ArgumentNullException indicating that AIFunctionArguments.Services must be provided.

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,18 @@ public static JsonElement CreateFunctionJsonSchema(
108108
continue;
109109
}
110110

111+
bool hasDefaultValue = TryGetEffectiveDefaultValue(parameter, out object? defaultValue);
111112
JsonNode parameterSchema = CreateJsonSchemaCore(
112113
type: parameter.ParameterType,
113114
parameter: parameter,
114115
description: parameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description,
115-
hasDefaultValue: parameter.HasDefaultValue,
116-
defaultValue: GetDefaultValueNormalized(parameter),
116+
hasDefaultValue: hasDefaultValue,
117+
defaultValue: defaultValue,
117118
serializerOptions,
118119
inferenceOptions);
119120

120121
parameterSchemas.Add(parameter.Name, parameterSchema);
121-
if (!parameter.IsOptional)
122+
if (!parameter.IsOptional && !hasDefaultValue)
122123
{
123124
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
124125
}
@@ -760,6 +761,32 @@ private static JsonElement ParseJsonElement(ReadOnlySpan<byte> utf8Json)
760761
return JsonElement.ParseValue(ref reader);
761762
}
762763

764+
/// <summary>
765+
/// Tries to get the effective default value for a parameter, checking both C# default value syntax and DefaultValueAttribute.
766+
/// </summary>
767+
/// <param name="parameterInfo">The parameter to check.</param>
768+
/// <param name="defaultValue">The default value if one exists.</param>
769+
/// <returns><see langword="true"/> if the parameter has a default value; otherwise, <see langword="false"/>.</returns>
770+
internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, out object? defaultValue)
771+
{
772+
// First check for DefaultValueAttribute
773+
if (parameterInfo.GetCustomAttribute<DefaultValueAttribute>(inherit: true) is { } attr)
774+
{
775+
defaultValue = attr.Value;
776+
return true;
777+
}
778+
779+
// Fall back to the parameter's declared default value
780+
if (parameterInfo.HasDefaultValue)
781+
{
782+
defaultValue = GetDefaultValueNormalized(parameterInfo);
783+
return true;
784+
}
785+
786+
defaultValue = null;
787+
return false;
788+
}
789+
763790
[UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.",
764791
Justification = "Called conditionally on structs whose default ctor never gets trimmed.")]
765792
private static object? GetDefaultValueNormalized(ParameterInfo parameterInfo)

test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,51 @@ public async Task Parameters_DefaultValuesAreUsedButOverridable_Async()
6161
AssertExtensions.EqualFunctionCallResults("hello hello", await func.InvokeAsync(new() { ["a"] = "hello" }));
6262
}
6363

64+
[Fact]
65+
public async Task Parameters_DefaultValueAttributeIsRespected_Async()
66+
{
67+
// Test with null default value
68+
AIFunction funcNull = AIFunctionFactory.Create(([DefaultValue(null)] string? text) => text ?? "was null");
69+
70+
// Schema should not list 'text' as required and should have default value
71+
string schema = funcNull.JsonSchema.ToString();
72+
Assert.Contains("\"text\"", schema);
73+
Assert.DoesNotContain("\"required\"", schema);
74+
Assert.Contains("\"default\":null", schema);
75+
76+
// Should be invocable without providing the parameter
77+
AssertExtensions.EqualFunctionCallResults("was null", await funcNull.InvokeAsync());
78+
79+
// Should be overridable
80+
AssertExtensions.EqualFunctionCallResults("hello", await funcNull.InvokeAsync(new() { ["text"] = "hello" }));
81+
82+
// Test with non-null default value
83+
AIFunction funcValue = AIFunctionFactory.Create(([DefaultValue("default")] string text) => text);
84+
schema = funcValue.JsonSchema.ToString();
85+
Assert.DoesNotContain("\"required\"", schema);
86+
Assert.Contains("\"default\":\"default\"", schema);
87+
88+
AssertExtensions.EqualFunctionCallResults("default", await funcValue.InvokeAsync());
89+
AssertExtensions.EqualFunctionCallResults("custom", await funcValue.InvokeAsync(new() { ["text"] = "custom" }));
90+
91+
// Test with int default value
92+
AIFunction funcInt = AIFunctionFactory.Create(([DefaultValue(42)] int x) => x * 2);
93+
schema = funcInt.JsonSchema.ToString();
94+
Assert.DoesNotContain("\"required\"", schema);
95+
Assert.Contains("\"default\":42", schema);
96+
97+
AssertExtensions.EqualFunctionCallResults(84, await funcInt.InvokeAsync());
98+
AssertExtensions.EqualFunctionCallResults(10, await funcInt.InvokeAsync(new() { ["x"] = 5 }));
99+
100+
// Test that DefaultValue attribute takes precedence over C# default value
101+
AIFunction funcBoth = AIFunctionFactory.Create(([DefaultValue(100)] int y = 50) => y);
102+
schema = funcBoth.JsonSchema.ToString();
103+
Assert.DoesNotContain("\"required\"", schema);
104+
Assert.Contains("\"default\":100", schema); // DefaultValue should take precedence
105+
106+
AssertExtensions.EqualFunctionCallResults(100, await funcBoth.InvokeAsync()); // Should use DefaultValue, not C# default
107+
}
108+
64109
[Fact]
65110
public async Task Parameters_MissingRequiredParametersFail_Async()
66111
{

0 commit comments

Comments
 (0)