From a0976afdafb0f1fe36ff7c8f95a3725cf8cf8697 Mon Sep 17 00:00:00 2001 From: Lee Miller Date: Wed, 21 Jun 2023 17:09:43 -0700 Subject: [PATCH] Add option to deserialize plan without requiring functions (#1652) This commit adds a new parameter to the Plan.FromJson method that allows deserializing a plan without requiring the functions to be registered in the skill collection. This is useful for scenarios where the plan is only used for inspection or analysis, and not for execution. The default behavior is still to require the functions, and throw an exception if they are not found. The commit also adds unit tests for both cases, and updates the JSON serialization options to ignore default values. Resolves #1631 ### 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: --- .../Planning/PlanSerializationTests.cs | 76 ++++++++++++++++++- dotnet/src/SemanticKernel/Planning/Plan.cs | 16 +++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs b/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs index 2c9f7ee388d8..1101e8cf4802 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Planning/PlanSerializationTests.cs @@ -489,8 +489,10 @@ public async Task CanStepAndSerializeAndDeserializePlanWithStepsAndContextAsync( Assert.Contains("\"next_step_index\":2", serializedPlan2, StringComparison.OrdinalIgnoreCase); } - [Fact] - public void CanDeserializePlan() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanDeserializePlan(bool requireFunctions) { // Arrange var goal = "Write a poem or joke and send it in an e-mail to Kai."; @@ -516,11 +518,20 @@ public void CanDeserializePlan() returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) .Returns(() => Task.FromResult(returnContext)); + if (requireFunctions) + { + mockFunction.Setup(x => x.Name).Returns(string.Empty); + ISKFunction? outFunc = mockFunction.Object; + skills.Setup(x => x.TryGetFunction(It.IsAny(), out outFunc)).Returns(true); + skills.Setup(x => x.TryGetFunction(It.IsAny(), It.IsAny(), out outFunc)).Returns(true); + skills.Setup(x => x.GetFunction(It.IsAny(), It.IsAny())).Returns(mockFunction.Object); + } + plan.AddSteps(new Plan("Step1", mockFunction.Object), mockFunction.Object); // Act var serializedPlan = plan.ToJson(); - var deserializedPlan = Plan.FromJson(serializedPlan, returnContext); + var deserializedPlan = Plan.FromJson(serializedPlan, returnContext, requireFunctions); // Assert Assert.NotNull(deserializedPlan); @@ -536,4 +547,63 @@ public void CanDeserializePlan() Assert.Equal(plan.Steps[0].Name, deserializedPlan.Steps[0].Name); Assert.Equal(plan.Steps[1].Name, deserializedPlan.Steps[1].Name); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DeserializeWithMissingFunctions(bool requireFunctions) + { + // Arrange + var goal = "Write a poem or joke and send it in an e-mail to Kai."; + var stepOutput = "Output: The input was: "; + var plan = new Plan(goal); + + // Arrange + var kernel = new Mock(); + var log = new Mock(); + var memory = new Mock(); + var skills = new Mock(); + + var returnContext = new SKContext( + new ContextVariables(stepOutput), + memory.Object, + skills.Object, + log.Object + ); + + var mockFunction = new Mock(); + mockFunction.Setup(x => x.InvokeAsync(It.IsAny(), null)) + .Callback((c, s) => + returnContext.Variables.Update(returnContext.Variables.Input + c.Variables.Input)) + .Returns(() => Task.FromResult(returnContext)); + + plan.AddSteps(new Plan("Step1", mockFunction.Object), mockFunction.Object); + + var serializedPlan = plan.ToJson(); + + if (requireFunctions) + { + // Act + Assert + Assert.Throws(() => Plan.FromJson(serializedPlan, returnContext)); + } + else + { + // Act + var deserializedPlan = Plan.FromJson(serializedPlan, returnContext, requireFunctions); + + // Assert + Assert.NotNull(deserializedPlan); + Assert.Equal(goal, deserializedPlan.Description); + + Assert.Equal(string.Join(",", plan.Outputs), + string.Join(",", deserializedPlan.Outputs)); + Assert.Equal(string.Join(",", plan.Parameters.Select(kv => $"{kv.Key}:{kv.Value}")), + string.Join(",", deserializedPlan.Parameters.Select(kv => $"{kv.Key}:{kv.Value}"))); + Assert.Equal(string.Join(",", plan.State.Select(kv => $"{kv.Key}:{kv.Value}")), + string.Join(",", deserializedPlan.State.Select(kv => $"{kv.Key}:{kv.Value}"))); + + Assert.Equal(plan.Steps[0].Name, deserializedPlan.Steps[0].Name); + Assert.Equal(plan.Steps[1].Name, deserializedPlan.Steps[1].Name); + } + } } diff --git a/dotnet/src/SemanticKernel/Planning/Plan.cs b/dotnet/src/SemanticKernel/Planning/Plan.cs index bc5c4df365e5..de5c2a0838c0 100644 --- a/dotnet/src/SemanticKernel/Planning/Plan.cs +++ b/dotnet/src/SemanticKernel/Planning/Plan.cs @@ -173,15 +173,16 @@ public Plan( /// /// JSON string representation of a Plan /// The context to use for function registrations. + /// Whether to require functions to be registered. Only used when context is not null. /// An instance of a Plan object. /// If Context is not supplied, plan will not be able to execute. - public static Plan FromJson(string json, SKContext? context = null) + public static Plan FromJson(string json, SKContext? context = null, bool requireFunctions = true) { var plan = JsonSerializer.Deserialize(json, new JsonSerializerOptions { IncludeFields = true }) ?? new Plan(string.Empty); if (context != null) { - plan = SetAvailableFunctions(plan, context); + plan = SetAvailableFunctions(plan, context, requireFunctions); } return plan; @@ -420,8 +421,9 @@ internal string ExpandFromVariables(ContextVariables variables, string input) /// /// Plan to set functions for. /// Context to use. + /// Whether to throw an exception if a function is not found. /// The plan with functions set. - private static Plan SetAvailableFunctions(Plan plan, SKContext context) + private static Plan SetAvailableFunctions(Plan plan, SKContext context, bool requireFunctions = true) { if (plan.Steps.Count == 0) { @@ -436,12 +438,18 @@ private static Plan SetAvailableFunctions(Plan plan, SKContext context) { plan.SetFunction(skillFunction); } + else if (requireFunctions) + { + throw new KernelException( + KernelException.ErrorCodes.FunctionNotAvailable, + $"Function '{plan.SkillName}.{plan.Name}' not found in skill collection"); + } } else { foreach (var step in plan.Steps) { - SetAvailableFunctions(step, context); + SetAvailableFunctions(step, context, requireFunctions); } }