-
Notifications
You must be signed in to change notification settings - Fork 3.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for multiple native function arguments of many types #1195
Conversation
dotnet/src/SemanticKernel.Abstractions/SkillDefinition/SKFunctionContextParameterAttribute.cs
Outdated
Show resolved
Hide resolved
dotnet/src/SemanticKernel.Abstractions/SkillDefinition/SKFunctionInputAttribute.cs
Outdated
Show resolved
Hide resolved
@shawncal @stephentoub Suggestion:
|
We don't have a great way to do this. It would require a) the compilation to always emit the .xml file (which needs to be explicitly enabled), b) for that .xml file to be deployed as part of the application, which generally doesn't happen (e.g. it's typically not copied into a bin / published folder along with the .dll / .pdb), and c) for the code to then be able to discover and load that .xml file and find the relevant nodes at execution time. You can see this, for example, if you just create a console app and reference the SK nuget(s). You'll be able to see IntelliSense for any SK APIs you use, because the nuget contains the XML file in the right place, but when you build, there won't be any .xml files in the output bin directory. While we might be able to hack this in various ways, I'm not aware of a bulletproof way to rely on it, and it's obviously a critical part of the model. |
@stephentoub, good point on the Xml part. I think we can move forward using the To simplify this verbosity we could have an object (Model) same way ASP.NET does. Define this as the SKFunctionDTO object and pass it as one parameter. |
Happy to use shorter names. I'd just kept what was already there, but yeah, they're wordy.
Can you give an example of what you're imaging that would look like for an example function, such as one of the ones in the PR description? |
We also could choose not to introduce an SK attribute for these at all. There's already a DescriptionAttribute and a DefaultValueAttribute in .NET; we could just use those, e.g. [SKFunction("Add an event to my calendar.", Name = "AddEvent")]
public async Task AddEventAsync(
[Description("Event subject"), SKInput] string subject,
[Description("Event start date/time as DateTimeOffset")] string start,
[Description("Event end date/time as DateTimeOffset")] string end,
[Description("Event location (optional)")] string? location = null,
[Description("Event content/body (optional)")] string? content = null,
[Description("Event attendees, separated by ',' or ';'.")] string? attendees = null)
{
...
}
Yup, that's what this PR does (or, rather, the parameter attribute also is there to supply the description and default value, but it's not required).
I didn't understand this part. Can you elaborate?
We can trim the ending "Async" if that's desirable, or do so only if the return type is a task. I wonder if that might be too much magic, but I'm ok with whatever you both feel is appropriate here. |
@stephentoub I would first like to revisit my suggestions also with @shawncal and @dluc, I'm just thinking what would be my take, but would definitely await for @dluc and @shawncal to agree on my suggestions before doing any of suggested changes.
Today all the function "inputs" are mapped to the property INPUT of the
Sure lets take this example:
Using the suggestion:
|
Hmm, I'm not understanding why that's advantageous... it seems like it's more verbose, and it's not clear to me what benefit it provides over just specifying the arguments to the method itself? |
In this PR, if there's a single string argument and it's not using [SKFunctionInput], it first tries to look up a context variable with that parameter name, and if that fails, then it tries with "input". We could easily tweak the heuristic to allow that to work for any string argument if desired... the problem with that, though, is (at least as far as I can tell) there's always an "Input" value, so we'd never end up using a specified default or failing. I think it'd be reasonable to drop [SKFunctionInput] entirely and just say that if you want it to be mapped to "input", name it "input" 😄, either in the C# parameter name or in the override name in the attribute. |
f7643ee
to
8b7a5c7
Compare
Thanks for all the feedback. I made a bunch of changes in response. The examples from earlier in the post are now: [SKFunction, Description("Makes a PUT request to a uri")]
public Task<string> PutAsync(
[Description("The URI of the request"), SKName("input")] string uri,
[Description("The body of the request")] string body,
CancellationToken cancellationToken = default)
{
return this.SendRequestAsync(uri, HttpMethod.Put, new StringContent(body), cancellationToken);
} and [SKFunction, Description("Add an event to my calendar.")]
public async Task AddEventAsync(
[Description("Event subject"), SKName("input")] string subject,
[Description("Event start date/time as DateTimeOffset")] string start,
[Description("Event end date/time as DateTimeOffset")] string end,
[Description("Event location (optional)")] string? location = null,
[Description("Event content/body (optional)")] string? content = null,
[Description("Event attendees, separated by ',' or ';'.")] string? attendees = null)
{
...
} |
77ed255
to
b1dec86
Compare
I added an additional commit that adds support for non-string args. 😄 For example, this now works: [SKFunction]
static string Test(int a, long b, decimal c, Guid d, DateTimeOffset e, DayOfWeek? f) =>
$"{a} {b} {c} {d} {e:R} {f}"; It relies on TypeDescriptors/TypeConverters to do all the conversion, so it’s using the same mechanism as client frameworks like WinForms and server frameworks like ASP.NET MVC, and it’s extensible, in that a type can attribute itself as [TypeConverter(…)] and specify what type should be used for the conversion (converters can also be globally registered). We can of course change or augment the mechanism if desired; there’s a clear place in the code where we’d just plug in whatever different parser routine we want to use for a given type. |
…and non-string returns - Native skills can now have any number of parameters. The parameters are populated from context variables of the same name. If no context variable exists for that name, it'll be populated with a default value if one was supplied via either an attribute or a default parameter value, or if there is none, the function will fail to be invoked. The first parameter may also be populated from "input" if it fails to get input by its name or default value. - Descriptions are now specified with the .NET DescriptionAttribute, and DefaultValue with the DefaultValueAttribute. The C# compiler is aware of the DefaultValueAttribute and ensures the type of the value provided matches that of the type of the parameter. Default values can now also be specified using optional parameter values. - SKFunction is now purely a marker attribute, other than for sensitivity. It's sole purpose is to subset which public members are imported as native functions when a skill is imported. It was already the case that the attribute wasn't needed when importing a function directly from a delegate; that requirement has also been lifted when importing from a MethodInfo. - SKFunctionContextParameterAttribute has been obsoleted and will be removed subsequently. DescriptionAttribute, DefaultValueAttribute, and SKName attribute are used instead. In rare situations where the method needs access to a variable that's not defined in its signature, it can use the SKParameter attribute on the method, which does have Description and DefaultValue optional properties. - SKFunctionInputAttribute has been obsoleted and will be removed subsequently. DescriptionAttribute, DefaultValueAttribute, and SKName attribute are used instead (the latter with "Input" as the name). However, the need to use SKName should be exceedingly rare. - InvokeAsync will now catch exceptions and store the exception into the context. This means native skills should handle all failures by throwing exceptions rather than by directly interacting with the context. - Updated name selection heuristic to strip off an "Async" suffix for async methods. There are now very few reasons to use [SKName] on a method. - Added support for ValueTasks as return types, just for completeness so that developers don't need to think about it. It just works. - Added ability to accept an ILogger or CancellationToken into a method; they're populated from the SKContext. With that, there are very few reasons left to pass an SKContext into a native function. - Added support for non-string arguments. All C# primitive types and many core .NET types are supported, with their corresponding TypeConverters used to parse the string context variable into the appropriate type. Custom types attributed with TypeConverterAttribute may also be used, and the associated TypeConverter will be used as is appropriate. It's the same mechanism used by UI frameworks like WinForms as well as ASP.NET MVC. - Similarly, added support for non-string return types.
…es (#1554) ### Motivation and Context Please help reviewers and future users, providing the following information: 1. Why is this change required? Move native functions closer to a normal C# experience. ### Description ADR to record this decision: #1195 ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows SK Contribution Guidelines (https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The code follows the .NET coding conventions (https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) verified with `dotnet format` - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
For teams who are using the CopilotChatApp as a starter project for experimenting with SK, does this PR allow for simplified syntax around skills definitions? i cloned the latest version of the semantic kernel repo, but i'm getting errors when using [SKFunction] |
Yes: |
…rosoft#1195) ### Motivation and Context Move native functions closer to a normal C# experience. ### Description - Native skills can now have any number of parameters. The parameters are populated from context variables of the same name. If no context variable exists for that name, it'll be populated with a default value if one was supplied via either an attribute or a default parameter value, or if there is none, the function will fail to be invoked. The first parameter may also be populated from "input" if it fails to get input by its name or default value. - Descriptions are now specified with the .NET DescriptionAttribute, and DefaultValue with the DefaultValueAttribute. The C# compiler is aware of the DefaultValueAttribute and ensures the type of the value provided matches that of the type of the parameter. Default values can now also be specified using optional parameter values. - SKFunction is now purely a marker attribute, other than for sensitivity. It's sole purpose is to subset which public members are imported as native functions when a skill is imported. It was already the case that the attribute wasn't needed when importing a function directly from a delegate; that requirement has also been lifted when importing from a MethodInfo. - SKFunctionContextParameterAttribute has been obsoleted and will be removed subsequently. DescriptionAttribute, DefaultValueAttribute, and SKName attribute are used instead. In rare situations where the method needs access to a variable that's not defined in its signature, it can use the SKParameter attribute on the method, which does have Description and DefaultValue optional properties. - SKFunctionInputAttribute has been obsoleted and will be removed subsequently. DescriptionAttribute, DefaultValueAttribute, and SKName attribute are used instead (the latter with "Input" as the name). However, the need to use SKName should be exceedingly rare. - InvokeAsync will now catch exceptions and store the exception into the context. This means native skills should handle all failures by throwing exceptions rather than by directly interacting with the context. - Updated name selection heuristic to strip off an "Async" suffix for async methods. There are now very few reasons to use [SKName] on a method. - Added support for ValueTasks as return types, just for completeness so that developers don't need to think about it. It just works. - Added ability to accept an ILogger or CancellationToken into a method; they're populated from the SKContext. With that, there are very few reasons left to pass an SKContext into a native function. - Added support for non-string arguments. All C# primitive types and many core .NET types are supported, with their corresponding TypeConverters used to parse the string context variable into the appropriate type. Custom types attributed with TypeConverterAttribute may also be used, and the associated TypeConverter will be used as is appropriate. It's the same mechanism used by UI frameworks like WinForms as well as ASP.NET MVC. - Similarly, added support for non-string return types. **Example** _Before_: ```C# [SKFunction("Adds value to a value")] [SKFunctionName("Add")] [SKFunctionInput(Description = "The value to add")] [SKFunctionContextParameter(Name = "Amount", Description = "Amount to add")] public Task<string> AddAsync(string initialValueText, SKContext context) { if (!int.TryParse(initialValueText, NumberStyles.Any, CultureInfo.InvariantCulture, out var initialValue)) { return Task.FromException<string>(new ArgumentOutOfRangeException( nameof(initialValueText), initialValueText, "Initial value provided is not in numeric format")); } string contextAmount = context["Amount"]; if (!int.TryParse(contextAmount, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) { return Task.FromException<string>(new ArgumentOutOfRangeException( nameof(context), contextAmount, "Context amount provided is not in numeric format")); } var result = initialValue + amount; return Task.FromResult(result.ToString(CultureInfo.InvariantCulture)); } ``` _After_: ```C# [SKFunction, Description("Adds an amount to a value")] public int Add( [Description("The value to add")] int value, [Description("Amount to add")] int amount) => value + amount; ``` **Example** _Before_: ```C# [SKFunction("Wait a given amount of seconds")] [SKFunctionName("Seconds")] [SKFunctionInput(DefaultValue = "0", Description = "The number of seconds to wait")] public async Task SecondsAsync(string secondsText) { if (!decimal.TryParse(secondsText, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) { throw new ArgumentException("Seconds provided is not in numeric format", nameof(secondsText)); } var milliseconds = seconds * 1000; milliseconds = (milliseconds > 0) ? milliseconds : 0; await this._waitProvider.DelayAsync((int)milliseconds).ConfigureAwait(false); } ``` _After_: ```C# [SKFunction, Description("Wait a given amount of seconds")] public async Task SecondsAsync([Description("The number of seconds to wait")] decimal seconds) { var milliseconds = seconds * 1000; milliseconds = (milliseconds > 0) ? milliseconds : 0; await this._waitProvider.DelayAsync((int)milliseconds).ConfigureAwait(false); } ``` **Example** _Before_: ```C# [SKFunction("Add an event to my calendar.")] [SKFunctionInput(Description = "Event subject")] [SKFunctionContextParameter(Name = Parameters.Start, Description = "Event start date/time as DateTimeOffset")] [SKFunctionContextParameter(Name = Parameters.End, Description = "Event end date/time as DateTimeOffset")] [SKFunctionContextParameter(Name = Parameters.Location, Description = "Event location (optional)")] [SKFunctionContextParameter(Name = Parameters.Content, Description = "Event content/body (optional)")] [SKFunctionContextParameter(Name = Parameters.Attendees, Description = "Event attendees, separated by ',' or ';'.")] public async Task AddEventAsync(string subject, SKContext context) { ContextVariables variables = context.Variables; if (string.IsNullOrWhiteSpace(subject)) { context.Fail("Missing variables input to use as event subject."); return; } if (!variables.TryGetValue(Parameters.Start, out string? start)) { context.Fail($"Missing variable {Parameters.Start}."); return; } if (!variables.TryGetValue(Parameters.End, out string? end)) { context.Fail($"Missing variable {Parameters.End}."); return; } CalendarEvent calendarEvent = new() { Subject = variables.Input, Start = DateTimeOffset.Parse(start, CultureInfo.InvariantCulture.DateTimeFormat), End = DateTimeOffset.Parse(end, CultureInfo.InvariantCulture.DateTimeFormat) }; if (variables.TryGetValue(Parameters.Location, out string? location)) { calendarEvent.Location = location; } if (variables.TryGetValue(Parameters.Content, out string? content)) { calendarEvent.Content = content; } if (variables.TryGetValue(Parameters.Attendees, out string? attendees)) { calendarEvent.Attendees = attendees.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); } this._logger.LogInformation("Adding calendar event '{0}'", calendarEvent.Subject); await this._connector.AddEventAsync(calendarEvent).ConfigureAwait(false); } ``` _After_: ```C# [SKFunction, Description("Add an event to my calendar.")] public async Task AddEventAsync( [Description("Event subject"), SKName("input")] string subject, [Description("Event start date/time as DateTimeOffset")] DateTimeOffset start, [Description("Event end date/time as DateTimeOffset")] DateTimeOffset end, [Description("Event location (optional)")] string? location = null, [Description("Event content/body (optional)")] string? content = null, [Description("Event attendees, separated by ',' or ';'.")] string? attendees = null) { if (string.IsNullOrWhiteSpace(subject)) { throw new ArgumentException($"{nameof(subject)} variable was null or whitespace", nameof(subject)); } CalendarEvent calendarEvent = new() { Subject = subject, Start = start, End = end, Location = location, Content = content, Attendees = attendees is not null ? attendees.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) : Enumerable.Empty<string>(), }; this._logger.LogInformation("Adding calendar event '{0}'", calendarEvent.Subject); await this._connector.AddEventAsync(calendarEvent).ConfigureAwait(false); } ``` ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows SK Contribution Guidelines (https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The code follows the .NET coding conventions (https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) verified with `dotnet format` - [x] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄 cc: @dluc, @shawncal --------- Co-authored-by: Shawn Callegari <[email protected]> Co-authored-by: name <email>
…es (microsoft#1554) ### Motivation and Context Please help reviewers and future users, providing the following information: 1. Why is this change required? Move native functions closer to a normal C# experience. ### Description ADR to record this decision: microsoft#1195 ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows SK Contribution Guidelines (https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] The code follows the .NET coding conventions (https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) verified with `dotnet format` - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
Motivation and Context
Move native functions closer to a normal C# experience.
Description
Example
Before:
After:
Example
Before:
After:
Example
Before:
After:
Contribution Checklist
dotnet format
cc: @dluc, @shawncal