Skip to content

Commit ae88bdc

Browse files
authored
Implement targeted AI function and parameter name attributes
1 parent 7e8c785 commit ae88bdc

10 files changed

Lines changed: 252 additions & 12 deletions

File tree

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

Lines changed: 9 additions & 6 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
@@ -806,7 +808,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
806808
pType != typeof(IServiceProvider) &&
807809
!string.IsNullOrEmpty(parameters[i].Name))
808810
{
809-
_ = expectedArgumentNames.Add(parameters[i].Name!);
811+
_ = expectedArgumentNames.Add(AIJsonUtilities.GetParameterSchemaName(parameters[i]));
810812
}
811813
}
812814

@@ -815,7 +817,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
815817

816818
ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
817819
Method = key.Method;
818-
Name = key.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
820+
Name = key.Name ?? key.Method.GetCustomAttribute<AIFunctionNameAttribute>(inherit: true)?.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
819821
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
820822
JsonSerializerOptions = serializerOptions;
821823
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
@@ -949,10 +951,11 @@ static bool IsAsyncMethod(MethodInfo method)
949951
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
950952
JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType);
951953
bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue);
954+
string argumentName = AIJsonUtilities.GetParameterSchemaName(parameter);
952955
return (arguments, _) =>
953956
{
954957
// If the parameter has an argument specified in the dictionary, return that argument.
955-
if (arguments.TryGetValue(parameter.Name, out object? value))
958+
if (arguments.TryGetValue(argumentName, out object? value))
956959
{
957960
return value switch
958961
{
@@ -999,7 +1002,7 @@ static bool IsAsyncMethod(MethodInfo method)
9991002
// If the parameter is required and there's no argument specified for it, throw.
10001003
if (!hasDefaultValue)
10011004
{
1002-
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
1005+
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{argumentName}'.");
10031006
}
10041007

10051008
// Otherwise, use the optional parameter's default value.

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.AIFunctionName, 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.AIParameterName, 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ 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 AIFunctionName = AIExperiments;
58+
internal const string AIParameterName = AIExperiments;
5759

5860
internal const string AIChatReduction = AIExperiments;
5961
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+
}

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,52 @@ static void TestMethod()
580580
Assert.Equal("override_title", titleElement.GetString());
581581
}
582582

583+
[Fact]
584+
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_UsedForPropertyName()
585+
{
586+
Delegate method = ([AIParameterName("$select")] string select, int top) =>
587+
{
588+
};
589+
590+
JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method);
591+
592+
JsonElement properties = schema.GetProperty("properties");
593+
Assert.True(properties.TryGetProperty("$select", out _));
594+
Assert.False(properties.TryGetProperty("select", out _));
595+
596+
string[] required = schema.GetProperty("required").EnumerateArray().Select(e => e.GetString()!).ToArray();
597+
Assert.Contains("$select", required);
598+
Assert.Contains("top", required);
599+
}
600+
601+
[Fact]
602+
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_DuplicateNamesThrow()
603+
{
604+
Delegate duplicateByAttribute = ([AIParameterName("dup")] string first, [AIParameterName("dup")] string second) =>
605+
{
606+
};
607+
Assert.Throws<ArgumentException>(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByAttribute.Method));
608+
609+
Delegate duplicateByCollision = ([AIParameterName("filter")] string select, string filter) =>
610+
{
611+
};
612+
Assert.Throws<ArgumentException>(() => AIJsonUtilities.CreateFunctionJsonSchema(duplicateByCollision.Method));
613+
}
614+
615+
[Fact]
616+
public static void CreateFunctionJsonSchema_AIParameterNameAttribute_EscapesJsonPointerSegment()
617+
{
618+
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
619+
Delegate method = ([AIParameterName("a/b~c")] RecursiveNode node) =>
620+
{
621+
};
622+
623+
string schema = AIJsonUtilities.CreateFunctionJsonSchema(method.Method, serializerOptions: options).ToString();
624+
625+
Assert.Contains("#/properties/a~1b~0c", schema);
626+
Assert.DoesNotContain("#/properties/a/b~c", schema);
627+
}
628+
583629
[Fact]
584630
public static void CreateJsonSchema_CanBeBoolean()
585631
{
@@ -1862,6 +1908,11 @@ public DerivedToolCallContent()
18621908
public int CustomValue { get; set; }
18631909
}
18641910

1911+
private sealed class RecursiveNode
1912+
{
1913+
public RecursiveNode? Next { get; set; }
1914+
}
1915+
18651916
[JsonSerializable(typeof(JsonElement))]
18661917
[JsonSerializable(typeof(CreateJsonSchema_IncorporatesTypesAndAnnotations_Type))]
18671918
[JsonSerializable(typeof(DerivedAIContent))]

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,39 @@ public void Metadata_DisplayNameAttribute()
315315
Assert.Contains("Metadata_DisplayNameAttribute", func.Name); // Will contain the lambda method name
316316
}
317317

318+
[Fact]
319+
public void Metadata_AIFunctionNameAttribute()
320+
{
321+
Func<string> funcWithAttribute = [AIFunctionName("get_user")] () => "test";
322+
AIFunction func = AIFunctionFactory.Create(funcWithAttribute);
323+
Assert.Equal("get_user", func.Name);
324+
325+
Func<string> funcWithBoth = [AIFunctionName("my_function")][DisplayName("display_name")] () => "test";
326+
func = AIFunctionFactory.Create(funcWithBoth);
327+
Assert.Equal("my_function", func.Name);
328+
329+
func = AIFunctionFactory.Create(funcWithAttribute, name: "explicit_name");
330+
Assert.Equal("explicit_name", func.Name);
331+
332+
func = AIFunctionFactory.Create(funcWithAttribute, new AIFunctionFactoryOptions { Name = "options_name" });
333+
Assert.Equal("options_name", func.Name);
334+
}
335+
336+
[Fact]
337+
public async Task Parameters_MappedByAIParameterNameAttribute_Async()
338+
{
339+
AIFunction func = AIFunctionFactory.Create(([AIParameterName("$select")] string select, int top) => select + top);
340+
341+
AssertExtensions.EqualFunctionCallResults("Name2", await func.InvokeAsync(new()
342+
{
343+
["$select"] = "Name",
344+
["top"] = 2,
345+
}));
346+
347+
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>(() => func.InvokeAsync(new() { ["top"] = 2 }).AsTask());
348+
Assert.Contains("$select", ex.Message);
349+
}
350+
318351
[Fact]
319352
public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction()
320353
{

0 commit comments

Comments
 (0)