Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio
/// <param name="method">The method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="name">
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// any <see cref="AINameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
Expand Down Expand Up @@ -313,7 +314,8 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac
/// </param>
/// <param name="name">
/// The name to use for the <see cref="AIFunction"/>. If <see langword="null"/>, the name will be derived from
/// any <see cref="DisplayNameAttribute"/> on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// any <see cref="AINameAttribute"/> on <paramref name="method"/>, then from any <see cref="DisplayNameAttribute"/>
/// on <paramref name="method"/>, if available, or else from the name of <paramref name="method"/>.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="AIFunction"/>. If <see langword="null"/>, a description will be derived from
Expand Down Expand Up @@ -806,7 +808,7 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions
pType != typeof(IServiceProvider) &&
!string.IsNullOrEmpty(parameters[i].Name))
{
_ = expectedArgumentNames.Add(parameters[i].Name!);
_ = expectedArgumentNames.Add(AIJsonUtilities.GetParameterSchemaName(parameters[i]));
}
}

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

ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType);
Method = key.Method;
Name = key.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Name = key.Name ?? key.Method.GetCustomAttribute<AINameAttribute>(inherit: true)?.Name ?? key.Method.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName ?? GetFunctionName(key.Method);
Description = key.Description ?? key.Method.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description ?? string.Empty;
JsonSerializerOptions = serializerOptions;
ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema(
Expand Down Expand Up @@ -949,10 +951,12 @@ static bool IsAsyncMethod(MethodInfo method)
// 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);

string argumentName = AIJsonUtilities.GetParameterSchemaName(parameter);
return (arguments, _) =>
{
// If the parameter has an argument specified in the dictionary, return that argument.
if (arguments.TryGetValue(parameter.Name, out object? value))
if (arguments.TryGetValue(argumentName, out object? value))
{
return value switch
{
Expand Down Expand Up @@ -999,7 +1003,7 @@ static bool IsAsyncMethod(MethodInfo method)
// If the parameter is required and there's no argument specified for it, throw.
if (!hasDefaultValue)
{
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'.");
Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{argumentName}'.");
}

// Otherwise, use the optional parameter's default value.
Expand Down Expand Up @@ -1217,9 +1221,11 @@ private static bool IsAIContentRelatedType(Type type) =>
{
return method.ReturnParameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description;
}
catch (Exception e) when (e is ArgumentNullException or NullReferenceException)
catch (Exception e) when (e is ArgumentNullException or NullReferenceException or IndexOutOfRangeException)
{
// DynamicMethod return parameters don't support GetCustomAttribute.
// DynamicMethod return parameters don't support GetCustomAttribute. Additionally, on .NET Framework,
// querying inherited attributes on the return parameter of an overriding method can throw
// IndexOutOfRangeException. In either case, treat the description as absent.
return null;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public AIFunctionFactoryOptions()

/// <summary>Gets or sets the name to use for the function.</summary>
/// <value>
/// 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).
/// 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="AINameAttribute"/> or <see cref="DisplayNameAttribute"/> on the method).
/// </value>
public string? Name { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
Comment thread
jozkee marked this conversation as resolved.

namespace Microsoft.Extensions.AI;

/// <summary>
/// Specifies the name to use for an <see cref="AIFunction"/> or one of its parameters.
/// </summary>
/// <remarks>
/// The name is the identifier a model sees and uses to invoke a function or to supply an argument.
/// By default these names are inferred from .NET metadata: a function's name comes from the method name, and a
/// parameter's name comes from the .NET parameter name. Apply this attribute to use a different identifier.
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
[Experimental(DiagnosticIds.Experiments.AIName, UrlFormat = DiagnosticIds.UrlFormat)]
public sealed class AINameAttribute : Attribute

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"AI name" seems slightly vague to me. I'd go for either AIFunctionName or AIFunctionParameterName depending on scope.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is currently scoped to Methods and Params. I thought of AIFunctionName but the "AIFunction" part feels like it can target methods only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on AIInvocationName? It feels slightly less method-oriented than AIFunctionName while keeping it boxed to AIFunction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paging @bartonjs @jeffhandley @halter73 for opinions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AIInvocationName sounds good to me.

{
/// <summary>Initializes a new instance of the <see cref="AINameAttribute"/> class.</summary>
/// <param name="name">The name to use for the function or parameter.</param>
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="name"/> is empty or composed entirely of whitespace.</exception>
public AINameAttribute(string name)
{
Name = Throw.IfNullOrWhitespace(name);
}

/// <summary>Gets the name to use for the function or parameter.</summary>
public string Name { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,22 @@
}
]
},
{
"Type": "sealed class Microsoft.Extensions.AI.AINameAttribute : System.Attribute",
"Stage": "Experimental",
"Methods": [
{
"Member": "Microsoft.Extensions.AI.AINameAttribute.AINameAttribute(string name);",
"Stage": "Experimental"
}
],
"Properties": [
{
"Member": "string Microsoft.Extensions.AI.AINameAttribute.Name { get; }",
"Stage": "Experimental"
}
]
},
{
"Type": "abstract class Microsoft.Extensions.AI.AITool",
"Stage": "Stable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
Expand Down Expand Up @@ -128,14 +129,20 @@ public static JsonElement CreateFunctionJsonSchema(
serializerOptions,
inferenceOptions);

parameterSchemas.Add(parameter.Name, parameterSchema);
string parameterSchemaName = GetParameterSchemaName(parameter);
if (parameterSchemas.ContainsKey(parameterSchemaName))
{
Throw.ArgumentException(nameof(method), $"Multiple parameters are mapped to the same name '{parameterSchemaName}'. Ensure that any {nameof(AINameAttribute)} values do not collide with each other or with other parameter names.");
}

parameterSchemas.Add(parameterSchemaName, parameterSchema);
Comment thread
jozkee marked this conversation as resolved.
bool isRequired = !parameter.IsOptional && !hasDefaultValue;
#if NET || NETFRAMEWORK
isRequired = isRequired || parameter.GetCustomAttribute<RequiredAttribute>(inherit: true) is not null;
#endif
if (isRequired)
{
(requiredProperties ??= []).Add((JsonNode)parameter.Name);
(requiredProperties ??= []).Add((JsonNode)parameterSchemaName);
}
}

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

objSchema[RefPropertyName] = (JsonNode)refUri;
}
Expand Down Expand Up @@ -861,6 +870,31 @@ internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, ou
return false;
}

internal static string GetParameterSchemaName(ParameterInfo parameter) =>
parameter.GetCustomAttribute<AINameAttribute>(inherit: true)?.Name ?? parameter.Name!;

// Escapes a string for use as a single JSON Pointer reference token per RFC 6901 ('~' => "~0", '/' => "~1").
private static string EscapeJsonPointerSegment(string segment)
{
if (segment.IndexOfAny(['~', '/']) < 0)
{
return segment;
}

StringBuilder sb = new(segment.Length + 2);
foreach (char c in segment)
{
_ = c switch
{
'~' => sb.Append("~0"),
'/' => sb.Append("~1"),
_ => sb.Append(c),
};
}

return sb.ToString();
}

/// <summary>
/// Checks whether a parameter is an F# optional parameter declared with the ?param syntax.
/// F# optional parameters are annotated with Microsoft.FSharp.Core.OptionalArgumentAttribute
Expand Down
3 changes: 1 addition & 2 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ internal static class Experiments
internal const string AIImageGeneration = AIExperiments;
internal const string AISpeechToText = AIExperiments;
internal const string AITextToSpeech = AIExperiments;
internal const string AIMcpServers = AIExperiments;
internal const string AIFunctionApprovals = AIExperiments;
internal const string AIApprovalsInvocationRequired = AIExperiments;
internal const string AIName = AIExperiments;

internal const string AIChatReduction = AIExperiments;
internal const string AIToolSearch = AIExperiments;
Expand Down
Loading