Skip to content

Commit 06759bd

Browse files
Copilotjozkeejeffhandley
authored
Add AIFunctionNameAttribute and AIParameterNameAttribute (#7610)
* Implement targeted AI function and parameter name attributes * Suppress reflection-only member warning in schema tests * Address PR feedback: DiagnosticIds merge, netfx fix, duplicate detection, test improvements * Add test for collision detection with ExcludeFromSchema Adds test to verify that parameter name collision detection occurs even when one of the colliding parameters is excluded from schema generation via ExcludeFromSchema = true. This addresses reviewer feedback about potential silent de-duplication in this scenario. * Add InheritedDescriptionAttributes test from PR #7599 Includes AIFunctionFactory_InheritedDescriptionAttributes_OnOverride test that validates inherited Description attributes on overridden methods, including parameter descriptions and return value descriptions. Covers both modern .NET and .NET Framework behavior differences. * Test cleanup * Fix vacuous test * Fix ParamName * Replace local with private function; locals get unspeakable names --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: David Cantu <dacantu@microsoft.com> Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
1 parent 8ba6add commit 06759bd

10 files changed

Lines changed: 454 additions & 15 deletions

File tree

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
135135
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
136136
/// <param name="name">
137137
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
138-
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
138+
/// any <see cref="AIFunctionNameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
139+
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
139140
/// </param>
140141
/// <param name="description">
141142
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
@@ -313,7 +314,8 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
313314
/// </param>
314315
/// <param name="name">
315316
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
316-
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
317+
/// any <see cref="AIFunctionNameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
318+
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
317319
/// </param>
318320
/// <param name="description">
319321
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
@@ -708,7 +710,7 @@ private sealed class ReflectionAIFunctionDescriptor
708710
private static readonly object? _boxedDefaultCancellationToken = default(CancellationToken);
709711

710712
/// <summary>
711-
/// Gets or creates a descriptors using the specified method and options.
713+
/// Gets or creates a descriptor using the specified method and options.
712714
/// </summary>
713715
public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFunctionFactoryOptions options)
714716
{
@@ -806,7 +808,11 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
806808
pType != typeof(IServiceProvider) &&
807809
!string.IsNullOrEmpty(parameters[i].Name))
808810
{
809-
_ = expectedArgumentNames.Add(parameters[i].Name!);
811+
string effectiveName = AIJsonUtilities.GetParameterSchemaName(parameters[i]);
812+
if (!expectedArgumentNames.Add(effectiveName))
813+
{
814+
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+
}
810816
}
811817
}
812818

@@ -815,7 +821,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
815821

816822
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
817823
Method = key.Method;
818-
Name = key.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
824+
Name = key.Name ?? key.Method.GetCustomAttribute<AIFunctionNameAttribute>(inherit: true)?.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
819825
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
820826
JsonSerializerOptions = serializerOptions;
821827
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
@@ -949,10 +955,11 @@ static bool IsAsyncMethod(MethodInfo method)
949955
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
950956
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType);
951957
bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue);
958+
string argumentName = AIJsonUtilities.GetParameterSchemaName(parameter);
952959
return (arguments, _) =>
953960
{
954961
// If the parameter has an argument specified in the dictionary, return that argument.
955-
if (arguments.TryGetValue(parameter.Name, out object? value))
962+
if (arguments.TryGetValue(argumentName, out object? value))
956963
{
957964
return value switch
958965
{
@@ -999,7 +1006,7 @@ static bool IsAsyncMethod(MethodInfo method)
9991006
// If the parameter is required and there's no argument specified for it, throw.
10001007
if (!hasDefaultValue)
10011008
{
1002-
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
1009+
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{argumentName}'.");
10031010
}
10041011

10051012
// Otherwise, use the optional parameter's default value.
@@ -1217,9 +1224,11 @@ private static bool IsAIContentRelatedType(Type type) =>
12171224
{
12181225
return method.ReturnParameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description;
12191226
}
1220-
catch (Exception e) when (e is ArgumentNullException or NullReferenceException)
1227+
catch (Exception e) when (e is ArgumentNullException or NullReferenceException or IndexOutOfRangeException)
12211228
{
1222-
// DynamicMethod return parameters don't support GetCustomAttribute.
1229+
// DynamicMethod return parameters don't support GetCustomAttribute. Additionally, on .NET Framework,
1230+
// querying inherited attributes on the return parameter of an overriding method can throw
1231+
// IndexOutOfRangeException. In either case, treat the description as absent.
12231232
return null;
12241233
}
12251234
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public AIFunctionFactoryOptions()
5050

5151
/// <summary>Gets or sets the name to use for the function.</summary>
5252
/// <value>
53-
/// The name to use for the function. The default value is a name derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/> (for example, via a <see cref="DisplayNameAttribute"/> on the method).
53+
/// The name to use for the function. The default value is a name derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/> (for example, via an <see cref="AIFunctionNameAttribute"/> or <see cref="DisplayNameAttribute"/> on the method).
5454
/// </value>
5555
public string? Name { get; set; }
5656

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Diagnostics.CodeAnalysis;
6+
using Microsoft.Shared.DiagnosticIds;
7+
using Microsoft.Shared.Diagnostics;
8+
9+
namespace Microsoft.Extensions.AI;
10+
11+
/// <summary>Specifies the name to use for an <see cref="AIFunction"/>.</summary>
12+
/// <remarks>
13+
/// The name is the identifier a model sees and uses to invoke a function.
14+
/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
15+
/// </remarks>
16+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
17+
[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
18+
public sealed class AIFunctionNameAttribute : Attribute
19+
{
20+
/// <summary>Initializes a new instance of the <see cref="AIFunctionNameAttribute"/> class.</summary>
21+
/// <param name="name">The name to use for the function.</param>
22+
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
23+
/// <exception cref="ArgumentException"><paramref name="name"/> is empty or composed entirely of whitespace.</exception>
24+
public AIFunctionNameAttribute(string name)
25+
{
26+
Name = Throw.IfNullOrWhitespace(name);
27+
}
28+
29+
/// <summary>Gets the name to use for the function.</summary>
30+
public string Name { get; }
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Diagnostics.CodeAnalysis;
6+
using Microsoft.Shared.DiagnosticIds;
7+
using Microsoft.Shared.Diagnostics;
8+
9+
namespace Microsoft.Extensions.AI;
10+
11+
/// <summary>Specifies the schema name to use for an <see cref="AIFunction"/> parameter.</summary>
12+
/// <remarks>
13+
/// The name is the identifier a model sees and uses when supplying an argument to a function.
14+
/// By default this is inferred from .NET metadata. Apply this attribute to use a different identifier.
15+
/// </remarks>
16+
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
17+
[Experimental(DiagnosticIds.Experiments.AIFunctionAndParameterName, UrlFormat = DiagnosticIds.UrlFormat)]
18+
public sealed class AIParameterNameAttribute : Attribute
19+
{
20+
/// <summary>Initializes a new instance of the <see cref="AIParameterNameAttribute"/> class.</summary>
21+
/// <param name="name">The schema name to use for the parameter.</param>
22+
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
23+
/// <exception cref="ArgumentException"><paramref name="name"/> is empty or composed entirely of whitespace.</exception>
24+
public AIParameterNameAttribute(string name)
25+
{
26+
Name = Throw.IfNullOrWhitespace(name);
27+
}
28+
29+
/// <summary>Gets the schema name to use for the parameter.</summary>
30+
public string Name { get; }
31+
}

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,38 @@
417417
}
418418
]
419419
},
420+
{
421+
"Type": "sealed class Microsoft.Extensions.AI.AIFunctionNameAttribute : System.Attribute",
422+
"Stage": "Experimental",
423+
"Methods": [
424+
{
425+
"Member": "Microsoft.Extensions.AI.AIFunctionNameAttribute.AIFunctionNameAttribute(string name);",
426+
"Stage": "Experimental"
427+
}
428+
],
429+
"Properties": [
430+
{
431+
"Member": "string Microsoft.Extensions.AI.AIFunctionNameAttribute.Name { get; }",
432+
"Stage": "Experimental"
433+
}
434+
]
435+
},
436+
{
437+
"Type": "sealed class Microsoft.Extensions.AI.AIParameterNameAttribute : System.Attribute",
438+
"Stage": "Experimental",
439+
"Methods": [
440+
{
441+
"Member": "Microsoft.Extensions.AI.AIParameterNameAttribute.AIParameterNameAttribute(string name);",
442+
"Stage": "Experimental"
443+
}
444+
],
445+
"Properties": [
446+
{
447+
"Member": "string Microsoft.Extensions.AI.AIParameterNameAttribute.Name { get; }",
448+
"Stage": "Experimental"
449+
}
450+
]
451+
},
420452
{
421453
"Type": "readonly struct Microsoft.Extensions.AI.AIJsonSchemaCreateContext",
422454
"Stage": "Stable",

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Diagnostics.CodeAnalysis;
1111
using System.Reflection;
1212
using System.Runtime.CompilerServices;
13+
using System.Text;
1314
using System.Text.Json;
1415
using System.Text.Json.Nodes;
1516
using System.Text.Json.Schema;
@@ -128,14 +129,20 @@ public static JsonElement CreateFunctionJsonSchema(
128129
serializerOptions,
129130
inferenceOptions);
130131

131-
parameterSchemas.Add(parameter.Name, parameterSchema);
132+
string parameterSchemaName = GetParameterSchemaName(parameter);
133+
if (parameterSchemas.ContainsKey(parameterSchemaName))
134+
{
135+
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.");
136+
}
137+
138+
parameterSchemas.Add(parameterSchemaName, parameterSchema);
132139
bool isRequired = !parameter.IsOptional && !hasDefaultValue;
133140
#if NET || NETFRAMEWORK
134141
isRequired = isRequired || parameter.GetCustomAttribute<RequiredAttribute>(inherit: true) is not null;
135142
#endif
136143
if (isRequired)
137144
{
138-
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
145+
(requiredProperties ??= []).Add((JsonNode)parameterSchemaName);
139146
}
140147
}
141148

@@ -282,12 +289,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
282289
// to accommodate the fact that they're being nested inside of a higher-level schema.
283290
if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName))
284291
{
285-
// Fix up any $ref URIs to match the path from the root document.
292+
// Fix up any $ref URIs to match the path from the root document. The schema name becomes a
293+
// JSON Pointer segment, so escape it per RFC 6901 ('~' => "~0", '/' => "~1").
294+
string parameterSchemaName = EscapeJsonPointerSegment(GetParameterSchemaName(parameter));
286295
string refUri = paramName!.GetValue<string>();
287296
Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}");
288297
refUri = refUri == "#"
289-
? $"#/{PropertiesPropertyName}/{parameter.Name}"
290-
: $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}";
298+
? $"#/{PropertiesPropertyName}/{parameterSchemaName}"
299+
: $"#/{PropertiesPropertyName}/{parameterSchemaName}/{refUri.AsMemory("#/".Length)}";
291300

292301
objSchema[RefPropertyName] = (JsonNode)refUri;
293302
}
@@ -861,6 +870,30 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou
861870
return false;
862871
}
863872

873+
internal static string GetParameterSchemaName(ParameterInfo parameter) =>
874+
parameter.GetCustomAttribute<AIParameterNameAttribute>(inherit: true)?.Name ?? parameter.Name!;
875+
876+
private static string EscapeJsonPointerSegment(string segment)
877+
{
878+
if (segment.IndexOfAny(['~', '/']) < 0)
879+
{
880+
return segment;
881+
}
882+
883+
StringBuilder sb = new(segment.Length + 2);
884+
foreach (char c in segment)
885+
{
886+
_ = c switch
887+
{
888+
'~' => sb.Append("~0"),
889+
'/' => sb.Append("~1"),
890+
_ => sb.Append(c),
891+
};
892+
}
893+
894+
return sb.ToString();
895+
}
896+
864897
/// <summary>
865898
/// Checks whether a parameter is an F# optional parameter declared with the ?param syntax.
866899
/// F# optional parameters are annotated with Microsoft.FSharp.Core.OptionalArgumentAttribute

src/Shared/DiagnosticIds/DiagnosticIds.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ internal static class Experiments
5454
internal const string AIMcpServers = AIExperiments;
5555
internal const string AIFunctionApprovals = AIExperiments;
5656
internal const string AIApprovalsInvocationRequired = AIExperiments;
57+
internal const string AIFunctionAndParameterName = AIExperiments;
5758

5859
internal const string AIChatReduction = AIExperiments;
5960
internal const string AIToolSearch = AIExperiments;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using Xunit;
6+
7+
namespace Microsoft.Extensions.AI;
8+
9+
public class AINameAttributesTests
10+
{
11+
[Fact]
12+
public void AIFunctionNameAttribute_InvalidArguments_Throw()
13+
{
14+
Assert.Throws<ArgumentNullException>("name", () => new AIFunctionNameAttribute(null!));
15+
Assert.Throws<ArgumentException>("name", () => new AIFunctionNameAttribute(" "));
16+
}
17+
18+
[Fact]
19+
public void AIParameterNameAttribute_InvalidArguments_Throw()
20+
{
21+
Assert.Throws<ArgumentNullException>("name", () => new AIParameterNameAttribute(null!));
22+
Assert.Throws<ArgumentException>("name", () => new AIParameterNameAttribute(" "));
23+
}
24+
}

0 commit comments

Comments
 (0)