Skip to content

Commit 5627989

Browse files
hario90dmytrostrukRogerBarreto
authored
.Net: Support named args (microsoft#2528)
### Motivation and Context 1. Why is this change required? Native functions invoked within semantic functions are currently limited to being called with at most one argument for the input variable. 2. What problem does it solve? Calling native functions with mutliple arguments from semantic functions. 3. What scenario does it contribute to? Template Engine 4. If it fixes an open issue, please link to the issue here. ### Description Please see the ADR for more context on decisions. To understand the implementation, I would start with the changes in CodeTokenizer.cs and CodeBlock.cs. ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 --------- Co-authored-by: Dmytro Struk <[email protected]> Co-authored-by: Roger Barreto <[email protected]>
1 parent d17e038 commit 5627989

15 files changed

+1117
-44
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
# These are optional elements. Feel free to remove any of them.
3+
status: proposed
4+
date: 6/16/2023
5+
deciders: shawncal, hario90
6+
consulted: dmytrostruk, matthewbolanos
7+
informed: lemillermicrosoft
8+
---
9+
# Add support for multiple named arguments in template function calls
10+
11+
## Context and Problem Statement
12+
13+
Native functions now support multiple parameters, populated from context values with the same name. Semantic functions currently only support calling native functions with no more than 1 argument. The purpose of these changes is to add support for calling native functions within semantic functions with multiple named arguments.
14+
15+
## Decision Drivers
16+
17+
* Parity with Guidance
18+
* Readability
19+
* Similarity to languages familiar to SK developers
20+
* YAML compatibility
21+
22+
## Considered Options
23+
24+
### Syntax idea 1: Using commas
25+
26+
```handlebars
27+
{{Skill.MyFunction street: "123 Main St", zip: "98123", city:"Seattle", age: 25}}
28+
```
29+
30+
Pros:
31+
32+
* Commas could make longer function calls easier to read, especially if spaces before and after the arg separator (a colon in this case) are allowed.
33+
34+
Cons:
35+
36+
* Guidance doesn't use commas
37+
* Spaces are already used as delimiters elsewhere so the added complexity of supporting commas isn't necessary
38+
39+
### Syntax idea 2: JavaScript/C#-Style delimiter (colon)
40+
41+
```handlebars
42+
43+
{{MyFunction street:"123 Main St" zip:"98123" city:"Seattle" age: "25"}}
44+
45+
```
46+
47+
Pros:
48+
49+
* Resembles JavaScript Object syntax and C# named argument syntax
50+
51+
Cons:
52+
53+
* Doesn't align with Guidance syntax which uses equal signs as arg part delimiters
54+
* Too similar to YAML key/value pairs if we support YAML prompts in the future. It's likely possible to support colons as delimiters but would be better to have a separator that is distinct from normal YAML syntax.
55+
56+
### Syntax idea 3: Python/Guidance-Style delimiter
57+
58+
```handlebars
59+
{{MyFunction street="123 Main St" zip="98123" city="Seattle"}}
60+
```
61+
62+
Pros:
63+
64+
* Resembles Python's keyword argument syntax
65+
* Resembles Guidance's named argument syntax
66+
* Not too similar to YAML key/value pairs if we support YAML prompts in the future.
67+
68+
Cons:
69+
70+
* Doesn't align with C# syntax
71+
72+
### Syntax idea 4: Allow whitespace between arg name/value delimiter
73+
74+
```handlebars
75+
{{MyFunction street = "123 Main St" zip = "98123" city = "Seattle"}}
76+
```
77+
78+
Pros:
79+
80+
* Follows the convention followed by many programming languages of whitespace flexibility where spaces, tabs, and newlines within code don't impact a program's functionality
81+
82+
Cons:
83+
84+
* Promotes code that is harder to read unless commas can be used (see [Using Commas](#syntax-idea-1-using-commas))
85+
* More complexity to support
86+
* Doesn't align with Guidance which doesn't support spaces before and after the = sign.
87+
88+
## Decision Outcome
89+
90+
Chosen options: "Syntax idea 3: Python/Guidance-Style keyword arguments", because it aligns well with Guidance's syntax and is the most compatible with YAML and "Syntax idea 4: Allow whitespace between arg name/value delimiter" for more flexible developer experience.
91+
92+
Additional decisions:
93+
94+
* Continue supporting up to 1 positional argument for backward compatibility. Currently, the argument passed to a function is assumed to be the `$input` context variable.
95+
96+
Example
97+
98+
```handlebars
99+
100+
{{MyFunction "inputVal" street="123 Main St" zip="98123" city="Seattle"}}
101+
102+
```
103+
104+
* Allow arg values to be defined as strings or variables ONLY, e.g.
105+
106+
```handlebars
107+
{{MyFunction street=$street zip="98123" city='Seattle'}}
108+
```
109+
110+
If function expects a value other than a string for an argument, the SDK will use the corresponding TypeConverter to parse the string provided when evaluating the expression.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Threading.Tasks;
5+
using Microsoft.SemanticKernel;
6+
using Microsoft.SemanticKernel.Skills.Core;
7+
using Microsoft.SemanticKernel.TemplateEngine.Prompt;
8+
using RepoUtils;
9+
10+
// ReSharper disable once InconsistentNaming
11+
public static class Example56_TemplateNativeFunctionsWithMultipleArguments
12+
{
13+
/// <summary>
14+
/// Show how to invoke a Native Function written in C# with multiple arguments
15+
/// from a Semantic Function written in natural language
16+
/// </summary>
17+
public static async Task RunAsync()
18+
{
19+
Console.WriteLine("======== TemplateNativeFunctionsWithMultipleArguments ========");
20+
21+
string serviceId = TestConfiguration.AzureOpenAI.ServiceId;
22+
string apiKey = TestConfiguration.AzureOpenAI.ApiKey;
23+
string deploymentName = TestConfiguration.AzureOpenAI.DeploymentName;
24+
string endpoint = TestConfiguration.AzureOpenAI.Endpoint;
25+
26+
if (serviceId == null || apiKey == null || deploymentName == null || endpoint == null)
27+
{
28+
Console.WriteLine("Azure serviceId, endpoint, apiKey, or deploymentName not found. Skipping example.");
29+
return;
30+
}
31+
32+
IKernel kernel = Kernel.Builder
33+
.WithLoggerFactory(ConsoleLogger.LoggerFactory)
34+
.WithAzureChatCompletionService(
35+
deploymentName: deploymentName,
36+
endpoint: endpoint,
37+
serviceId: serviceId,
38+
apiKey: apiKey)
39+
.Build();
40+
41+
var variableName = "word2";
42+
var variableValue = " Potter";
43+
var context = kernel.CreateNewContext();
44+
context.Variables[variableName] = variableValue;
45+
46+
// Load native skill into the kernel skill collection, sharing its functions with prompt templates
47+
// Functions loaded here are available as "text.*"
48+
kernel.ImportSkill(new TextSkill(), "text");
49+
50+
// Semantic Function invoking text.Concat native function with named arguments input and input2 where input is a string and input2 is set to a variable from context called word2.
51+
const string FunctionDefinition = @"
52+
Write a haiku about the following: {{text.Concat input='Harry' input2=$word2}}
53+
";
54+
55+
// This allows to see the prompt before it's sent to OpenAI
56+
Console.WriteLine("--- Rendered Prompt");
57+
var promptRenderer = new PromptTemplateEngine();
58+
var renderedPrompt = await promptRenderer.RenderAsync(FunctionDefinition, context);
59+
Console.WriteLine(renderedPrompt);
60+
61+
// Run the prompt / semantic function
62+
var haiku = kernel.CreateSemanticFunction(FunctionDefinition, maxTokens: 150);
63+
64+
// Show the result
65+
Console.WriteLine("--- Semantic Function result");
66+
var result = await kernel.RunAsync(context.Variables, haiku);
67+
Console.WriteLine(result);
68+
69+
/* OUTPUT:
70+
71+
--- Rendered Prompt
72+
73+
Write a haiku about the following: Harry Potter
74+
75+
--- Semantic Function result
76+
A boy with a scar,
77+
Wizarding world he explores,
78+
Harry Potter's tale.
79+
*/
80+
}
81+
}

dotnet/samples/KernelSyntaxExamples/Program.cs

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public static async Task Main()
6868
await Example52_ApimAuth.RunAsync().SafeWaitAsync(cancelToken);
6969
await Example54_AzureChatCompletionWithData.RunAsync().SafeWaitAsync(cancelToken);
7070
await Example55_TextChunker.RunAsync().SafeWaitAsync(cancelToken);
71+
await Example56_TemplateNativeFunctionsWithMultipleArguments.RunAsync().SafeWaitAsync(cancelToken);
7172
}
7273

7374
private static void LoadUserSecrets()

dotnet/src/Extensions/Extensions.UnitTests/TemplateEngine/Prompt/Blocks/CodeBlockTests.cs

+63-3
Original file line numberDiff line numberDiff line change
@@ -97,22 +97,40 @@ public void ItRequiresAValidFunctionCall()
9797
var funcId = new FunctionIdBlock("funcName");
9898
var valBlock = new ValBlock("'value'");
9999
var varBlock = new VarBlock("$var");
100+
var namedArgBlock = new NamedArgBlock("varName='foo'");
100101

101102
// Act
102103
var codeBlock1 = new CodeBlock(new List<Block> { funcId, valBlock }, "", NullLoggerFactory.Instance);
103104
var codeBlock2 = new CodeBlock(new List<Block> { funcId, varBlock }, "", NullLoggerFactory.Instance);
104105
var codeBlock3 = new CodeBlock(new List<Block> { funcId, funcId }, "", NullLoggerFactory.Instance);
105106
var codeBlock4 = new CodeBlock(new List<Block> { funcId, varBlock, varBlock }, "", NullLoggerFactory.Instance);
107+
var codeBlock5 = new CodeBlock(new List<Block> { funcId, varBlock, namedArgBlock }, "", NullLoggerFactory.Instance);
108+
var codeBlock6 = new CodeBlock(new List<Block> { varBlock, valBlock }, "", NullLoggerFactory.Instance);
109+
var codeBlock7 = new CodeBlock(new List<Block> { namedArgBlock }, "", NullLoggerFactory.Instance);
106110

107111
// Assert
108112
Assert.True(codeBlock1.IsValid(out _));
109113
Assert.True(codeBlock2.IsValid(out _));
110114

111115
// Assert - Can't pass a function to a function
112-
Assert.False(codeBlock3.IsValid(out _));
116+
Assert.False(codeBlock3.IsValid(out var errorMessage3));
117+
Assert.Equal(errorMessage3, "The first arg of a function must be a quoted string, variable or named argument");
113118

114-
// Assert - Can't pass more than one param
115-
Assert.False(codeBlock4.IsValid(out _));
119+
// Assert - Can't pass more than one unnamed param
120+
Assert.False(codeBlock4.IsValid(out var errorMessage4));
121+
Assert.Equal(errorMessage4, "Functions only support named arguments after the first argument. Argument 2 is not named.");
122+
123+
// Assert - Can pass one unnamed param and named args
124+
Assert.True(codeBlock5.IsValid(out var errorMessage5));
125+
Assert.Empty(errorMessage5);
126+
127+
// Assert - Can't use > 1 block if not a function call
128+
Assert.False(codeBlock6.IsValid(out var errorMessage6));
129+
Assert.Equal(errorMessage6, "Unexpected second token found: 'value'");
130+
131+
// Assert - Can't use a named argument without a function block
132+
Assert.False(codeBlock7.IsValid(out var errorMessage7));
133+
Assert.Equal(errorMessage7, "Unexpected named argument found. Expected function name first.");
116134
}
117135

118136
[Fact]
@@ -291,4 +309,46 @@ public async Task ItInvokesFunctionWithCustomValueAsync()
291309
Assert.Equal(Value, result);
292310
Assert.Equal(Value, canary);
293311
}
312+
313+
[Fact]
314+
public async Task ItInvokesFunctionWithNamedArgsAsync()
315+
{
316+
// Arrange
317+
const string Func = "funcName";
318+
const string Value = "value";
319+
const string FooValue = "bar";
320+
const string BobValue = "bob's value";
321+
var variables = new ContextVariables();
322+
variables.Set("bob", BobValue);
323+
variables.Set("input", Value);
324+
var context = new SKContext(variables: variables, skills: this._skills.Object);
325+
var funcId = new FunctionIdBlock(Func);
326+
var namedArgBlock1 = new NamedArgBlock($"foo='{FooValue}'");
327+
var namedArgBlock2 = new NamedArgBlock("baz=$bob");
328+
329+
var foo = string.Empty;
330+
var baz = string.Empty;
331+
var function = new Mock<ISKFunction>();
332+
function
333+
.Setup(x => x.InvokeAsync(It.IsAny<SKContext>(), It.IsAny<CompleteRequestSettings?>(), It.IsAny<CancellationToken>()))
334+
.Callback<SKContext, CompleteRequestSettings?, CancellationToken>((context, _, _) =>
335+
{
336+
foo = context!.Variables["foo"];
337+
baz = context!.Variables["baz"];
338+
})
339+
.ReturnsAsync((SKContext inputcontext, CompleteRequestSettings _, CancellationToken _) => inputcontext);
340+
341+
ISKFunction? outFunc = function.Object;
342+
this._skills.Setup(x => x.TryGetFunction(Func, out outFunc)).Returns(true);
343+
this._skills.Setup(x => x.GetFunction(Func)).Returns(function.Object);
344+
345+
// Act
346+
var codeBlock = new CodeBlock(new List<Block> { funcId, namedArgBlock1, namedArgBlock2 }, "", NullLoggerFactory.Instance);
347+
string result = await codeBlock.RenderCodeAsync(context);
348+
349+
// Assert
350+
Assert.Equal(FooValue, foo);
351+
Assert.Equal(BobValue, baz);
352+
Assert.Equal(Value, result);
353+
}
294354
}

0 commit comments

Comments
 (0)