Skip to content
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

.Net: adds support for strict mode with OpenAI #9924

Open
wants to merge 51 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d3d5edc
Replace stj-schema-mapper source code with M.E.AI schema generation
eiriktsarpalis Nov 25, 2024
535bd6e
feat: adds strict mode flag to function calling
baywet Dec 6, 2024
ecc2cf8
feat: adds strict schema function behaviour and maps it to the metadata
baywet Dec 6, 2024
5ea8112
chore: adds unit test for additional properties false in strict mode
baywet Dec 6, 2024
19e7cca
chore: adds tests for tool call behaviour and strict mode
baywet Dec 6, 2024
9e780e5
chore: adds unit test for new function choice behaviour options property
baywet Dec 6, 2024
733fea9
chore: cleanup reference to default
baywet Dec 6, 2024
6c32a37
fix: badly formatted doc comment
baywet Dec 6, 2024
a504d03
chore: adds test for function metadata to OAI function strict more ma…
baywet Dec 6, 2024
5b6d51a
chore: adds validation for strict property mapping on OpenAIFunction
baywet Dec 6, 2024
d6ca753
chore: migrates to foreach
baywet Dec 6, 2024
4b111cb
chore: adds unit test for required properties behaviour with strict mode
baywet Dec 6, 2024
ef8de5d
chore: adds test for metadata copy constructor
baywet Dec 6, 2024
873ae6a
feat: adds strict parameter to OpenAPI based functions
baywet Dec 6, 2024
dcaaa15
fix: pass strict when cloning function
baywet Dec 6, 2024
8342d73
smell: having to set strict in the function prompt
baywet Dec 6, 2024
1152c7a
fix: reverts additional strict property
baywet Dec 9, 2024
5a2fae9
fix: tests after strict property removal
baywet Dec 9, 2024
99aa4e0
chore: code linting
baywet Dec 9, 2024
4bbff9c
fix: makes schema less parameters optional in strict mode
baywet Dec 9, 2024
34f887c
feat; sanitizes forbidden strict mode keywords
baywet Dec 9, 2024
dff7719
fix: adds missing null type in strict mode
baywet Dec 9, 2024
f34ff32
docs: add links to null type behaviour
baywet Dec 9, 2024
cd7c7bd
chore: adds obsolete method to maintain binary compatibility
baywet Dec 10, 2024
bfa397c
fix: no sanitation in strict mode
baywet Dec 10, 2024
e49cf34
chore: adds experimental tag
baywet Dec 10, 2024
56baeb3
fix: adds missing import
baywet Dec 10, 2024
c5c92d7
chore: fixes nits
baywet Dec 10, 2024
fe37ea7
Merge branch 'main' into feat/strict-mode
baywet Dec 10, 2024
d18da04
fix: assigns parameter filter since it can't be assigned externally
baywet Dec 10, 2024
aec57bb
chore: enables payload namespacing for CAPs sample
baywet Dec 10, 2024
b082a68
docs: adds requested doc comments
baywet Dec 11, 2024
0f1d114
chore: adds doc comment for static method
baywet Dec 13, 2024
e5c7a9d
fix: adds support for normalizing nested properties
baywet Dec 13, 2024
161b199
fix: normalizes additional properties
baywet Dec 13, 2024
1137aea
fix: additional properties can be different types
baywet Dec 13, 2024
167f4f4
fix: handles array types for additional properties normalization
baywet Dec 13, 2024
6a87dd6
fix: makes all properties required
baywet Dec 13, 2024
7049cee
Merge branch 'main' into feat/strict-mode
crickman Dec 16, 2024
f5601b8
chore: enables strict mode for declarative agents
baywet Dec 16, 2024
43f1973
chore: enables strict mode for CAPs and API plugins
baywet Dec 16, 2024
1630cdf
fix: excludes forbidden default keyword
baywet Dec 16, 2024
b8961c3
chore: removes extraneous odata parameter filter
baywet Dec 17, 2024
829812c
chore: removes extraneous filter in api manifest extension
baywet Dec 17, 2024
652b3b8
fix: parameter filter not being passed
baywet Dec 17, 2024
1b3004c
fix: a bug where rest api filters would only work for dynamic payload…
baywet Dec 17, 2024
dcdbe46
feat: makes filtering properties available to the application
baywet Dec 17, 2024
55ca088
fix: adds properties trimming for CAPs demo
baywet Dec 17, 2024
aa7856f
Merge branch 'feat/strict-mode' of https://github.com/baywet/semantic…
baywet Dec 17, 2024
d59e70a
Merge branch 'main' into feat/strict-mode
baywet Dec 17, 2024
3b296e9
fix: drive item test for CAPs
baywet Dec 17, 2024
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
6 changes: 3 additions & 3 deletions dotnet/samples/Demos/OpenAIRealtime/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static async Task Main(string[] args)
await session.AddItemAsync(
ConversationItem.CreateUserMessage(["I'm trying to decide what to wear on my trip."]));

// Use audio file that contains a recorded question: "What's the weather like in San Francisco, California?"
// Use audio file that contains a recorded question: "What's the weather like in San Francisco, California?"
string inputAudioPath = FindFile("Assets\\realtime_whats_the_weather_pcm16_24khz_mono.wav");
using Stream inputAudioStream = File.OpenRead(inputAudioPath);

Expand Down Expand Up @@ -165,7 +165,7 @@ await session.AddItemAsync(
var argumentsString = functionArgumentBuildersById[itemStreamingFinishedUpdate.ItemId].ToString();
var arguments = DeserializeArguments(argumentsString);

// Create a function call content based on received data.
// Create a function call content based on received data.
var functionCallContent = new FunctionCallContent(
functionName: functionName,
pluginName: pluginName,
Expand Down Expand Up @@ -346,7 +346,7 @@ private static IEnumerable<ConversationTool> ConvertFunctions(Kernel kernel)

foreach (var metadata in functionsMetadata)
{
var toolDefinition = metadata.ToOpenAIFunction().ToFunctionDefinition();
var toolDefinition = metadata.ToOpenAIFunction().ToFunctionDefinition(false);
markwallace-microsoft marked this conversation as resolved.
Show resolved Hide resolved

yield return new ConversationFunctionTool()
{
Expand Down
4 changes: 2 additions & 2 deletions dotnet/src/Agents/OpenAI/Internal/AssistantThreadActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ public static async IAsyncEnumerable<ChatMessageContent> GetMessagesAsync(Assist

FunctionCallsProcessor functionProcessor = new(logger);
// This matches current behavior. Will be configurable upon integrating with `FunctionChoice` (#6795/#5200)
FunctionChoiceBehaviorOptions functionOptions = new() { AllowConcurrentInvocation = true, AllowParallelCalls = true };
FunctionChoiceBehaviorOptions functionOptions = new() { AllowConcurrentInvocation = true, AllowParallelCalls = true, AllowStrictSchemaAdherence = true };
Copy link
Member

@SergeyMenshykh SergeyMenshykh Dec 10, 2024

Choose a reason for hiding this comment

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

I'm not sure if agents will fully follow the options and use the new strict property. Also, if they do, it could change their behavior and affect existing scenarios once customers start using the new package.
CC: @crickman

Copy link
Contributor

@crickman crickman Dec 16, 2024

Choose a reason for hiding this comment

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

Direct invocation of agents would support all FunctionChoiceBehaviorOptions no different that direct usage if IChatCompletionService. Its the AgentChat usage that would be limited for certain FunctionChoiceBehavior; although the strict option should be properly regarded across all usage patterns.


// Evaluate status and process steps and messages, as encountered.
HashSet<string> processedStepIds = [];
Expand Down Expand Up @@ -412,7 +412,7 @@ public static async IAsyncEnumerable<StreamingChatMessageContent> InvokeStreamin

FunctionCallsProcessor functionProcessor = new(logger);
// This matches current behavior. Will be configurable upon integrating with `FunctionChoice` (#6795/#5200)
FunctionChoiceBehaviorOptions functionOptions = new() { AllowConcurrentInvocation = true, AllowParallelCalls = true };
FunctionChoiceBehaviorOptions functionOptions = new() { AllowConcurrentInvocation = true, AllowParallelCalls = true, AllowStrictSchemaAdherence = true };

IAsyncEnumerable<StreamingUpdate> asyncUpdates = client.CreateRunStreamingAsync(threadId, agent.Id, options, cancellationToken);
do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
Expand Down Expand Up @@ -45,35 +46,72 @@ public void ItInitializesOpenAIFunctionReturnParameterCorrectly(string? descript
Assert.Same(schema, functionParameter.Schema);
}

[Fact]
public void ItCanConvertToFunctionDefinitionWithNoPluginName()
[InlineData(true)]
[InlineData(false)]
[Theory]
public void ItCanConvertToFunctionDefinitionWithNoPluginName(bool strict)
{
// Arrange
OpenAIFunction sut = KernelFunctionFactory.CreateFromMethod(() => { }, "myfunc", "This is a description of the function.").Metadata.ToOpenAIFunction();

// Act
ChatTool result = sut.ToFunctionDefinition();
ChatTool result = sut.ToFunctionDefinition(strict);

// Assert
Assert.Equal(sut.FunctionName, result.FunctionName);
Assert.Equal(sut.Description, result.FunctionDescription);
}

[Fact]
public void ItCanConvertToFunctionDefinitionWithNullParameters()
[InlineData(true)]
[InlineData(false)]
[Theory]
public void ItCanConvertToFunctionDefinitionWithNullParameters(bool strict)
{
// Arrange
// Arrange
OpenAIFunction sut = new("plugin", "function", "description", null, null);

// Act
var result = sut.ToFunctionDefinition();
var result = sut.ToFunctionDefinition(strict);

// Assert
Assert.Equal("{\"type\":\"object\",\"required\":[],\"properties\":{}}", result.FunctionParameters.ToString());
if (strict)
{
Assert.Equal("{\"type\":\"object\",\"required\":[],\"properties\":{},\"additionalProperties\":false}", result.FunctionParameters.ToString());
}
else
{
Assert.Equal("{\"type\":\"object\",\"required\":[],\"properties\":{}}", result.FunctionParameters.ToString());
}
}

[InlineData(false)]
[InlineData(true)]
[Theory]
public void SetsParametersToRequiredWhenStrict(bool strict)
{
var parameters = new List<OpenAIFunctionParameter>
{
new ("foo", "bar", false, typeof(string), null),
};
OpenAIFunction sut = new("plugin", "function", "description", parameters, null);

var result = sut.ToFunctionDefinition(strict);

Assert.Equal(strict, result.FunctionSchemaIsStrict);
if (strict)
{
Assert.Equal("""{"type":"object","required":["foo"],"properties":{"foo":{"description":"bar","type":["string","null"]}},"additionalProperties":false}""", result.FunctionParameters.ToString());
}
else
{
Assert.Equal("""{"type":"object","required":[],"properties":{"foo":{"description":"bar","type":"string"}}}""", result.FunctionParameters.ToString());
}
}

[Fact]
public void ItCanConvertToFunctionDefinitionWithPluginName()
[InlineData(false)]
[InlineData(true)]
[Theory]
public void ItCanConvertToFunctionDefinitionWithPluginName(bool strict)
{
// Arrange
OpenAIFunction sut = KernelPluginFactory.CreateFromFunctions("myplugin", new[]
Expand All @@ -82,17 +120,21 @@ public void ItCanConvertToFunctionDefinitionWithPluginName()
}).GetFunctionsMetadata()[0].ToOpenAIFunction();

// Act
ChatTool result = sut.ToFunctionDefinition();
ChatTool result = sut.ToFunctionDefinition(strict);

// Assert
Assert.Equal("myplugin-myfunc", result.FunctionName);
Assert.Equal(sut.Description, result.FunctionDescription);
}

[Fact]
public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParameterType()
[InlineData(false)]
[InlineData(true)]
[Theory]
public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParameterType(bool strict)
{
string expectedParameterSchema = """{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } } } """;
string expectedParameterSchema = strict ?
"""{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } },"additionalProperties":false } """ :
"""{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } } } """;

KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[]
{
Expand All @@ -104,7 +146,7 @@ public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParamete

OpenAIFunction sut = plugin.GetFunctionsMetadata()[0].ToOpenAIFunction();

ChatTool functionDefinition = sut.ToFunctionDefinition();
ChatTool functionDefinition = sut.ToFunctionDefinition(strict);

var exp = JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema));
var act = JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.FunctionParameters));
Expand All @@ -115,10 +157,14 @@ public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndReturnParamete
Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.FunctionParameters)));
}

[Fact]
public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParameterType()
[InlineData(false)]
[InlineData(true)]
[Theory]
public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParameterType(bool strict)
{
string expectedParameterSchema = """{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } } } """;
string expectedParameterSchema = strict ?
"""{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } }, "additionalProperties":false} """ :
"""{ "type": "object", "required": ["param1", "param2"], "properties": { "param1": { "description": "String param 1", "type": "string" }, "param2": { "description": "Int param 2", "type": "integer" } } } """;

KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("Tests", new[]
{
Expand All @@ -130,54 +176,129 @@ public void ItCanConvertToFunctionDefinitionsWithParameterTypesAndNoReturnParame

OpenAIFunction sut = plugin.GetFunctionsMetadata()[0].ToOpenAIFunction();

ChatTool functionDefinition = sut.ToFunctionDefinition();
ChatTool functionDefinition = sut.ToFunctionDefinition(strict);

Assert.NotNull(functionDefinition);
Assert.Equal("Tests-TestFunction", functionDefinition.FunctionName);
Assert.Equal("My test function", functionDefinition.FunctionDescription);
Assert.Equal(JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedParameterSchema)), JsonSerializer.Serialize(KernelJsonSchema.Parse(functionDefinition.FunctionParameters)));
}

[Fact]
public void ItCanConvertToFunctionDefinitionsWithNoParameterTypes()
[InlineData(false)]
[InlineData(true)]
[Theory]
public void ItCanConvertToFunctionDefinitionsWithNoParameterTypes(bool strict)
{
// Arrange
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
() => { },
parameters: [new KernelParameterMetadata("param1")]).Metadata.ToOpenAIFunction();

// Act
ChatTool result = f.ToFunctionDefinition();
ChatTool result = f.ToFunctionDefinition(strict);
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;

// Assert
Assert.NotNull(pd.properties);
Assert.Single(pd.properties);
var expectedSchema = strict ?
"""{ "type":["string","null"] }""" :
"""{ "type":"string" }""";
Assert.Equal(
JsonSerializer.Serialize(KernelJsonSchema.Parse("""{ "type":"string" }""")),
JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
}

[Fact]
public void ItCanConvertToFunctionDefinitionsWithNoParameterTypesButWithDescriptions()
[InlineData(false)]
[InlineData(true)]
[Theory]
public void ItCanConvertToFunctionDefinitionsWithNoParameterTypesButWithDescriptions(bool strict)
{
// Arrange
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
() => { },
parameters: [new KernelParameterMetadata("param1") { Description = "something neat" }]).Metadata.ToOpenAIFunction();

// Act
ChatTool result = f.ToFunctionDefinition();
ChatTool result = f.ToFunctionDefinition(strict);
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;

// Assert
Assert.NotNull(pd.properties);
Assert.Single(pd.properties);
var expectedSchema = strict ?
"""{ "description":"something neat", "type":["string","null"] }""" :
"""{ "description":"something neat", "type":"string" }""";
Assert.Equal(
JsonSerializer.Serialize(KernelJsonSchema.Parse("""{ "description":"something neat", "type":"string" }""")),
JsonSerializer.Serialize(KernelJsonSchema.Parse(expectedSchema)),
JsonSerializer.Serialize(pd.properties.First().Value.RootElement));
}

[InlineData("number", "maximum", "10", false)]
[InlineData("number", "maximum", "10", true)]
[InlineData("number", "minimum", "10", false)]
[InlineData("number", "minimum", "10", true)]
[InlineData("number", "maxContains", "10", false)]
[InlineData("number", "maxContains", "10", true)]
[InlineData("number", "minContains", "10", false)]
[InlineData("number", "minContains", "10", true)]
[InlineData("number", "multipleOf", "10", false)]
[InlineData("number", "multipleOf", "10", true)]
[InlineData("number", "format", "\"int64\"", false)]
[InlineData("number", "format", "\"int64\"", true)]
[InlineData("array", "maxItems", "5", false)]
[InlineData("array", "maxItems", "5", true)]
[InlineData("array", "minItems", "5", false)]
[InlineData("array", "minItems", "5", true)]
[InlineData("array", "contains", "5", false)]
[InlineData("array", "contains", "5", true)]
[InlineData("array", "uniqueItems", "true", false)]
[InlineData("array", "uniqueItems", "true", true)]
[InlineData("string", "minLength", "5", false)]
[InlineData("string", "minLength", "5", true)]
[InlineData("string", "maxLength", "5", false)]
[InlineData("string", "maxLength", "5", true)]
[InlineData("object", "maxProperties", "5", false)]
[InlineData("object", "maxProperties", "5", true)]
[InlineData("object", "minProperties", "5", false)]
[InlineData("object", "minProperties", "5", true)]
[InlineData("object", "pattern", "\"foo*\"", false)]
[InlineData("object", "pattern", "\"foo*\"", true)]
[InlineData("object", "patternProperties", "\"foo*\"", false)]
[InlineData("object", "patternProperties", "\"foo*\"", true)]
[InlineData("object", "propertyNames", """{ "maxLength": 3, "minLength": 3 }""", false)]
[InlineData("object", "propertyNames", """{ "maxLength": 3, "minLength": 3 }""", true)]
[InlineData("object", "unevaluatedItems", "true", false)]
[InlineData("object", "unevaluatedItems", "true", true)]
[InlineData("object", "unevaluatedProperties", "true", false)]
[InlineData("object", "unevaluatedProperties", "true", true)]
[Theory]
public void ItCleansUpRestrictedSchemaKeywords(string typeName, string keyword, string keywordValue, bool strict)
{
// Arrange
var parameterSchema = KernelJsonSchema.Parse($$"""{ "description":"something neat", "type":"{{typeName}}", "{{keyword}}":{{keywordValue}} }""");
OpenAIFunction f = KernelFunctionFactory.CreateFromMethod(
() => { },
parameters: [new KernelParameterMetadata("param1") { Description = "something neat", Schema = parameterSchema }]).Metadata.ToOpenAIFunction();

// Act
ChatTool result = f.ToFunctionDefinition(strict);
ParametersData pd = JsonSerializer.Deserialize<ParametersData>(result.FunctionParameters.ToString())!;

// Assert
Assert.NotNull(pd.properties);
Assert.Single(pd.properties);
var resultSchema = JsonSerializer.Serialize(pd.properties.First().Value.RootElement);
if (strict)
{
Assert.DoesNotContain(keyword, resultSchema, StringComparison.OrdinalIgnoreCase);
}
else
{
Assert.Contains(keyword, resultSchema, StringComparison.OrdinalIgnoreCase);
}
}

#pragma warning disable CA1812 // uninstantiated internal class
private sealed class ParametersData
{
Expand Down
Loading
Loading