diff --git a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs index 5116238763f..29920409fbd 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Functions/AIFunctionFactory.cs @@ -71,6 +71,15 @@ public static partial class AIFunctionFactory /// . /// /// + /// + /// + /// By default, parameters attributed with are resolved from the + /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, + /// is allowed to be ; otherwise, + /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. + /// The handling of such parameters may be overridden via . + /// + /// /// /// All other parameter types are, by default, bound from the dictionary passed into /// and are included in the generated JSON schema. This may be overridden by the provided @@ -131,7 +140,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. /// /// /// @@ -140,7 +149,6 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . /// /// /// @@ -149,8 +157,15 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via - /// . + /// optional or not. + /// + /// + /// + /// + /// By default, parameters attributed with are resolved from the + /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, + /// is allowed to be ; otherwise, + /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. /// /// /// @@ -236,6 +251,15 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// . /// /// + /// + /// + /// By default, parameters attributed with are resolved from the + /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, + /// is allowed to be ; otherwise, + /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. + /// The handling of such parameters may be overridden via . + /// + /// /// /// All other parameter types are, by default, bound from the dictionary passed into /// and are included in the generated JSON schema. This may be overridden by the provided @@ -306,7 +330,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. /// /// /// @@ -315,7 +339,6 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . /// /// /// @@ -324,8 +347,15 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via - /// . + /// optional or not. + /// + /// + /// + /// + /// By default, parameters attributed with are resolved from the + /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, + /// is allowed to be ; otherwise, + /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. /// /// /// @@ -426,6 +456,15 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// . /// /// + /// + /// + /// By default, parameters attributed with are resolved from the + /// property and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, + /// is allowed to be ; otherwise, + /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. + /// The handling of such parameters may be overridden via . + /// + /// /// /// All other parameter types are, by default, bound from the dictionary passed into /// and are included in the generated JSON schema. This may be overridden by the provided @@ -668,6 +707,13 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions return false; } + // If the parameter is attributed as [FromKeyedServices], exclude it, as we'll instead + // get its value from the IServiceProvider. + if (parameterInfo.GetCustomAttribute(inherit: true) is not null) + { + return false; + } + // If there was an existing IncludeParameter delegate, now defer to it as we've // excluded everything we need to exclude. if (key.SchemaOptions.IncludeParameter is { } existingIncludeParameter) @@ -806,6 +852,25 @@ static bool IsAsyncMethod(MethodInfo method) }; } + // For [FromKeyedServices] parameters, we bind to the services passed directly to InvokeAsync via AIFunctionArguments. + if (parameter.GetCustomAttribute(inherit: true) is { } keyedAttr) + { + return (arguments, _) => + { + if ((arguments.Services as IKeyedServiceProvider)?.GetKeyedService(parameterType, keyedAttr.Key) is { } service) + { + return service; + } + + if (!parameter.HasDefaultValue) + { + Throw.ArgumentException(nameof(arguments), $"No service of type '{parameterType}' with key '{keyedAttr.Key}' was found."); + } + + return parameter.DefaultValue; + }; + } + // 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); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 9501b4afe7d..b983189dac5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -427,7 +427,7 @@ public async Task Create_NoInstance_DisposableAndAsyncDisposableInstanceCreatedD } [Fact] - public async Task ConfigureParameterBinding_CanBeUsedToSupportFromKeyedServices() + public async Task FromKeyedServices_ResolvesFromServiceProvider() { MyService service = new(42); @@ -435,38 +435,61 @@ public async Task ConfigureParameterBinding_CanBeUsedToSupportFromKeyedServices( sc.AddKeyedSingleton("key", service); IServiceProvider sp = sc.BuildServiceProvider(); - AIFunction f = AIFunctionFactory.Create( - ([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger, - new AIFunctionFactoryOptions - { - ConfigureParameterBinding = p => - { - if (p.GetCustomAttribute() is { } attr) - { - return new() - { - BindParameter = (p, a) => - (a.Services as IKeyedServiceProvider)?.GetKeyedService(p.ParameterType, attr.Key) is { } s ? s : - p.HasDefaultValue ? p.DefaultValue : - throw new ArgumentException($"Unable to resolve argument for '{p.Name}'."), - ExcludeFromSchema = true - }; - } + AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService service, int myInteger) => service.Value + myInteger); - return default; - }, - }); + Assert.Contains("myInteger", f.JsonSchema.ToString()); + Assert.DoesNotContain("service", f.JsonSchema.ToString()); + + Exception e = await Assert.ThrowsAsync(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask()); + Assert.Contains("No service of type", e.Message); + + var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp }); + Assert.Contains("43", result?.ToString()); + } + + [Fact] + public async Task FromKeyedServices_NullKeysBindToNonKeyedServices() + { + MyService service = new(42); + + ServiceCollection sc = new(); + sc.AddSingleton(service); + IServiceProvider sp = sc.BuildServiceProvider(); + + AIFunction f = AIFunctionFactory.Create(([FromKeyedServices(null!)] MyService service, int myInteger) => service.Value + myInteger); Assert.Contains("myInteger", f.JsonSchema.ToString()); Assert.DoesNotContain("service", f.JsonSchema.ToString()); Exception e = await Assert.ThrowsAsync(() => f.InvokeAsync(new() { ["myInteger"] = 1 }).AsTask()); - Assert.Contains("Unable to resolve", e.Message); + Assert.Contains("No service of type", e.Message); var result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp }); Assert.Contains("43", result?.ToString()); } + [Fact] + public async Task FromKeyedServices_OptionalDefaultsToNull() + { + MyService service = new(42); + + ServiceCollection sc = new(); + sc.AddKeyedSingleton("key", service); + IServiceProvider sp = sc.BuildServiceProvider(); + + AIFunction f = AIFunctionFactory.Create(([FromKeyedServices("key")] MyService? service = null, int myInteger = 0) => + service is null ? "null " + 1 : (service.Value + myInteger).ToString()); + + Assert.Contains("myInteger", f.JsonSchema.ToString()); + Assert.DoesNotContain("service", f.JsonSchema.ToString()); + + var result = await f.InvokeAsync(new() { ["myInteger"] = 1 }); + Assert.Contains("null 1", result?.ToString()); + + result = await f.InvokeAsync(new() { ["myInteger"] = 1, Services = sp }); + Assert.Contains("43", result?.ToString()); + } + [Fact] public async Task ConfigureParameterBinding_CanBeUsedToSupportFromContext() {