semantic-kernel
semantic-kernel copied to clipboard
Add support for multiple native function arguments
Motivation and Context
Move native functions closer to a normal C# experience.
Description
- Native skills can now have any number of string parameters, not just at most one. 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.
- Native skills can also have a CancellationToken parameter. It'll be populated from the SKContext.
- SKFunctionContextParameterAttribute may now be specified on a parameter directly.
- SKFunctionInputAttribute is now applied directly to the parameter rather than to the method. It may be applied to at most one string parameter, in which case it'll override that parameter to be named "Input".
- DefaultValue was removed from SKFunctionInput as it wasn't actually being respected.
- SKFunctionNameAttribute was removed, with the Name moving to being an optional property of the SKFunctionAttribute.
- If there's only one string parameter, it first looks to get its value from a context parameter named the same as the parameter, but if that fails, it'll fall back to using "Input".
- 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.
Example Before:
[SKFunction("Makes a PUT request to a uri")]
[SKFunctionContextParameter(Name = "body", Description = "The body of the request")]
public Task<string> PutAsync(
string uri,
SKContext context)
{
return this.SendRequestAsync(uri, HttpMethod.Put, new StringContent(context["body"]), context.CancellationToken);
}
After:
[SKFunction("Makes a PUT request to a uri")]
public Task<string> PutAsync(
[SKFunctionInput("The URI of the request")] string uri,
[SKFunctionContextParameter("The body of the request")] string body,
CancellationToken cancellationToken = default)
{
return this.SendRequestAsync(uri, HttpMethod.Put, new StringContent(body), cancellationToken);
}
Example Before:
[SKFunction("Add an event to my calendar.")]
[SKFunctionName("AddEvent")]
[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)
{
...
}
After:
[SKFunction("Add an event to my calendar.", Name = "AddEvent")]
public async Task AddEventAsync(
[SKFunctionInput("Event subject")] string subject,
[SKFunctionContextParameter("Event start date/time as DateTimeOffset")] string start,
[SKFunctionContextParameter("Event end date/time as DateTimeOffset")] string end,
[SKFunctionContextParameter("Event location (optional)")] string? location = null,
[SKFunctionContextParameter("Event content/body (optional)")] string? content = null,
[SKFunctionContextParameter("Event attendees, separated by ',' or ';'.")] string? attendees = null)
{
...
}
With few modifications this will also be able to support non-string context variables.
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
- [ ] I didn't break anyone :smile:
cc: @dluc, @shawncal
@shawncal @stephentoub
Since we are introducing this such great change my suggestion would to be way more radical and just obsolete the SKFunctionContextParameter
, SKFunctionInput
, SKFunctionContextParameter
attributes altogether.
Suggestion: Go straight to something similar to what we have in ASP.NET model biding on Controller.Action methods so:
- A parameter variable name by default will be the name used in the context
- The Xml parameter description will be the one used by the LLM to understand what the parameter is about.
- Having the Attribute will be only if we want to map the name to a different name i.e.
[ContextVariableName("diffName")]
- Drop
[SKFunctionInput(Description)]
and use function parameter XML description instead - Drop
[SKFunctionInput]
confusing, in a multiple input scenario everything is the input. - Drop
[SKFunction]
and use the function XML description for LLM awareness - Drop
[SKFunctionContextParameter]
use the parameter name + XML as its description for LLM awareness - Reduce usage of
[SKFunctionName]
, if the method is async, ignore the endingAsync
(We are mostly enforcing the usage of this attribute to make the non Async mapping) - Reduce usage of `
https://learn.microsoft.com/en-us/archive/msdn-magazine/2019/october/csharp-accessing-xml-documentation-via-reflection
use the function XML description
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 SKFunctionContextParameter
for description with a smaller name like Shawn was suggesting then but that got really verbose,
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.
I think we can move forward using the SKFunctionContextParameter for description with a smaller name like Shawn was suggesting then but that got really verbose,
Happy to use shorter names. I'd just kept what was already there, but yeah, they're wordy.
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.
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)
{
...
}
A parameter variable name by default will be the name used in the context Having the Attribute will be only if we want to map the name to a different name i.e. [ContextVariableName("diffName")]
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).
Drop [SKFunctionInput] confusing, in a multiple input scenario everything is the input.
I didn't understand this part. Can you elaborate?
Reduce usage of [SKFunctionName], if the method is async, ignore the ending Async (We are mostly enforcing the usage of this attribute to make the non Async mapping)
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.
I didn't understand this part. Can you elaborate?
Today all the function "inputs" are mapped to the property INPUT of the ContextVariable
, so in ultimate instance all of the parameters are indeed ContextVariables
, dropping the usage of this attribute might reduce the complexity where we can by convention understand that the first parameter/argument is the Internal input for retrocompatibility (regardless of its name) + adding the first attribute as also a name to the ContextVariable to the function contextvariables in execution
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?
Sure lets take this example:
[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)
{
...
}
Using the suggestion:
public record EventModel(
[Description("Event subject")] 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);
// (Not necessarily needs an argument, but this could be a good potential for the SKFunctionInput or better name
public async Task AddEventAsync([SKFunctionInput] EventModel event}
...
}
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?
dropping the usage of this attribute might reduce the complexity where we can by convention understand that the first parameter/argument is the Internal input for retrocompatibility (regardless of its name) + adding the first attribute as also a name to the ContextVariable to the function contextvariables in execution
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" :smile:, either in the C# parameter name or in the override name in the attribute.
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)
{
...
}
I added an additional commit that adds support for non-string args. :smile:
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 I added support for non-string return types, e.g. this now works:
[SKFunction]
public int Multiply(int input, int amount) => input * amount;
Update: Now that we've got new NuGets published, which we've been waiting for since //BUILD, I'll come back to this one and try to get it merged this week.
Thanks. I rebased to resolve a bunch of conflicts with things that went in over the last week.
@shawncal, @dluc, does this look ok?
Thanks for the review, @lemillermicrosoft. I addressed your feedback and what we discussed offline around TextMemorySkill's ctor.
Thanks for the review, @lemillermicrosoft. I addressed your feedback and what we discussed offline around TextMemorySkill's ctor.
Thanks @stephentoub. Added one more small comment there and then opened #1380 based on our conversation. Overall LGTM.
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]
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: https://github.com/microsoft/semantic-kernel/pull/1811