From d2696a91cbc8e6198372a87df3f0d16b197a3cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estefan=C3=ADa=20Tenorio?= <8483207+esttenorio@users.noreply.github.com> Date: Mon, 28 Oct 2024 09:53:28 -0700 Subject: [PATCH] .Net Processes: State Management in Processes Part 1 (#9424) ### Description This is the first one of a few PR that address the State Management in SK Processes. Dividing in smaller PRs since feature contain multiple big components. #### In this PR - **SAVE:** After running a Process, adding capability to extract process state for stateful processes - works also with nested processes - **LOAD:** from a Process State data structure, loading state to ProcessBuilder before `Build()` to apply it to Process and use it on runtime. For now only focusing on Step State only, not focusing on exact replica of processes (using same ids) - **Samples:** Adding more samples in Step02_FoodPrep that load existing State from file and use it to run specific food pre processes, also samples on how to save the state #### Out of Scope but coming up in future PRs - How to deal with processes running that have the same id (processid, stepid) - In the future, ProcessStep State will have versions, so if loading an old state to a process with a new state version there is: - validation - migration strategy placeholder to add custom state migration - A new way to create ProcessBuilders so that on save, processTypeIds can be interpreted instead of needing to pass all steps, and edges when loading a ProcessBuilder from file - current DAPR implementation - More samples showcasing more complex scenarios ### Contribution Checklist - [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 :smile: --- .../Step03/Processes/FishSandwichProcess.cs | 28 ++++ .../FishSandwichStateProcessSuccess.json | 42 ++++++ ...shSandwichStateProcessSuccessLowStock.json | 42 ++++++ .../FriedFishProcessStateSuccess.json | 24 ++++ .../FriedFishProcessStateSuccessLowStock.json | 24 ++++ .../FriedFishProcessStateSuccessNoStock.json | 24 ++++ .../Step03/Step03a_FoodPreparation.cs | 123 +++++++++++++++++- .../Step03/Steps/CutFoodWithSharpeningStep.cs | 2 +- .../Step03/Steps/GatherIngredientsStep.cs | 2 +- .../ProcessStateMetadataUtilities.cs | 70 ++++++++++ .../Process.Abstractions/KernelProcess.cs | 22 ++++ .../KernelProcessStepInfo.cs | 29 +++++ .../Models/KernelProcessStateMetadata.cs | 20 +++ .../Models/KernelProcessStepStateMetadata.cs | 49 +++++++ .../Process.Core/Internal/EndStep.cs | 6 + .../Process.Core/ProcessBuilder.cs | 45 ++++++- .../Process.Core/ProcessStepBuilder.cs | 36 ++++- .../Core/ProcessStepBuilderTests.cs | 6 + .../ProcessTypeExtensionsTests.cs | 2 +- .../KernelProcessStepExtension.cs} | 10 +- .../ProcessExtensions.cs | 0 .../StepExtensions.cs | 30 ----- .../process/InternalUtilities.props | 2 +- 23 files changed, 587 insertions(+), 51 deletions(-) create mode 100644 dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccess.json create mode 100644 dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccessLowStock.json create mode 100644 dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccess.json create mode 100644 dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessLowStock.json create mode 100644 dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessNoStock.json create mode 100644 dotnet/samples/GettingStartedWithProcesses/Utilities/ProcessStateMetadataUtilities.cs create mode 100644 dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStateMetadata.cs create mode 100644 dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStepStateMetadata.cs rename dotnet/src/Experimental/{Process.UnitTests/Core => Process.Utilities.UnitTests}/ProcessTypeExtensionsTests.cs (97%) rename dotnet/src/{Experimental/Process.Core/Extensions/ProcessTypeExtensions.cs => InternalUtilities/process/Abstractions/KernelProcessStepExtension.cs} (79%) rename dotnet/src/InternalUtilities/process/{Runtime => Abstractions}/ProcessExtensions.cs (100%) rename dotnet/src/InternalUtilities/process/{Runtime => Abstractions}/StepExtensions.cs (84%) diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/Processes/FishSandwichProcess.cs b/dotnet/samples/GettingStartedWithProcesses/Step03/Processes/FishSandwichProcess.cs index 3e3779415712..cafa2560d72d 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step03/Processes/FishSandwichProcess.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/Processes/FishSandwichProcess.cs @@ -46,6 +46,34 @@ public static ProcessBuilder CreateProcess(string processName = "FishSandwichPro return processBuilder; } + public static ProcessBuilder CreateProcessWithStatefulSteps(string processName = "FishSandwichWithStatefulStepsProcess") + { + var processBuilder = new ProcessBuilder(processName); + var makeFriedFishStep = processBuilder.AddStepFromProcess(FriedFishProcess.CreateProcessWithStatefulSteps()); + var addBunsStep = processBuilder.AddStepFromType(); + var addSpecialSauceStep = processBuilder.AddStepFromType(); + // An additional step that is the only one that emits an public event in a process can be added to maintain event names unique + var externalStep = processBuilder.AddStepFromType(); + + processBuilder + .OnInputEvent(ProcessEvents.PrepareFishSandwich) + .SendEventTo(makeFriedFishStep.WhereInputEventIs(FriedFishProcess.ProcessEvents.PrepareFriedFish)); + + makeFriedFishStep + .OnEvent(FriedFishProcess.ProcessEvents.FriedFishReady) + .SendEventTo(new ProcessFunctionTargetBuilder(addBunsStep)); + + addBunsStep + .OnEvent(AddBunsStep.OutputEvents.BunsAdded) + .SendEventTo(new ProcessFunctionTargetBuilder(addSpecialSauceStep)); + + addSpecialSauceStep + .OnEvent(AddSpecialSauceStep.OutputEvents.SpecialSauceAdded) + .SendEventTo(new ProcessFunctionTargetBuilder(externalStep)); + + return processBuilder; + } + private sealed class AddBunsStep : KernelProcessStep { public static class Functions diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccess.json b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccess.json new file mode 100644 index 000000000000..c22e3fd8852d --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccess.json @@ -0,0 +1,42 @@ +{ + "stepsState": { + "FriedFishWithStatefulStepsProcess": { + "stepsState": { + "GatherFriedFishIngredientsWithStockStep": { + "state": { + "IngredientsStock": 3 + }, + "id": "cfc7d51f3bfa455c980d282dcdd85b13", + "name": "GatherFriedFishIngredientsWithStockStep" + }, + "chopStep": { + "state": { + "KnifeSharpness": 2 + }, + "id": "daf4a782df094deeb111ae701431ddbb", + "name": "chopStep" + }, + "FryFoodStep": { + "id": "e508ffe1d4714097b9716db1fd48b587", + "name": "FryFoodStep" + } + }, + "id": "a201334a1f534e9db9e1d46dce8345a8", + "name": "FriedFishWithStatefulStepsProcess" + }, + "AddBunsStep": { + "id": "8a9b2d66e0594ee898d1c94c8bc07d0e", + "name": "AddBunsStep" + }, + "AddSpecialSauceStep": { + "id": "6b0e92097cb74f5cbac2a71473a4e9c2", + "name": "AddSpecialSauceStep" + }, + "ExternalFriedFishStep": { + "id": "59ebd4724684469ab19d86d281c205e3", + "name": "ExternalFriedFishStep" + } + }, + "id": "38e8f477-c022-41fc-89f1-3dd3509d0e83", + "name": "FishSandwichWithStatefulStepsProcess" +} \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccessLowStock.json b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccessLowStock.json new file mode 100644 index 000000000000..26446355291a --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FishSandwichStateProcessSuccessLowStock.json @@ -0,0 +1,42 @@ +{ + "stepsState": { + "FriedFishWithStatefulStepsProcess": { + "stepsState": { + "GatherFriedFishIngredientsWithStockStep": { + "state": { + "IngredientsStock": 1 + }, + "id": "cfc7d51f3bfa455c980d282dcdd85b13", + "name": "GatherFriedFishIngredientsWithStockStep" + }, + "chopStep": { + "state": { + "KnifeSharpness": 2 + }, + "id": "daf4a782df094deeb111ae701431ddbb", + "name": "chopStep" + }, + "FryFoodStep": { + "id": "e508ffe1d4714097b9716db1fd48b587", + "name": "FryFoodStep" + } + }, + "id": "a201334a1f534e9db9e1d46dce8345a8", + "name": "FriedFishWithStatefulStepsProcess" + }, + "AddBunsStep": { + "id": "8a9b2d66e0594ee898d1c94c8bc07d0e", + "name": "AddBunsStep" + }, + "AddSpecialSauceStep": { + "id": "6b0e92097cb74f5cbac2a71473a4e9c2", + "name": "AddSpecialSauceStep" + }, + "ExternalFriedFishStep": { + "id": "59ebd4724684469ab19d86d281c205e3", + "name": "ExternalFriedFishStep" + } + }, + "id": "38e8f477-c022-41fc-89f1-3dd3509d0e83", + "name": "FishSandwichWithStatefulStepsProcess" +} \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccess.json b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccess.json new file mode 100644 index 000000000000..42cbe6f06482 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccess.json @@ -0,0 +1,24 @@ +{ + "stepsState": { + "GatherFriedFishIngredientsWithStockStep": { + "state": { + "IngredientsStock": 4 + }, + "id": "51b39c78e71148c9af0a3c44c83bc6f5", + "name": "GatherFriedFishIngredientsWithStockStep" + }, + "chopStep": { + "state": { + "KnifeSharpness": 4 + }, + "id": "6be5207cb71e42c28d7061e45e1127c1", + "name": "chopStep" + }, + "FryFoodStep": { + "id": "9523d47a97a546908985a6ff028783cd", + "name": "FryFoodStep" + } + }, + "id": "1c90a002-68ca-4c68-ac3b-a083054ed628", + "name": "FriedFishWithStatefulStepsProcess" +} \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessLowStock.json b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessLowStock.json new file mode 100644 index 000000000000..8346d5a60ae1 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessLowStock.json @@ -0,0 +1,24 @@ +{ + "stepsState": { + "GatherFriedFishIngredientsWithStockStep": { + "state": { + "IngredientsStock": 1 + }, + "id": "51b39c78e71148c9af0a3c44c83bc6f5", + "name": "GatherFriedFishIngredientsWithStockStep" + }, + "chopStep": { + "state": { + "KnifeSharpness": 4 + }, + "id": "6be5207cb71e42c28d7061e45e1127c1", + "name": "chopStep" + }, + "FryFoodStep": { + "id": "9523d47a97a546908985a6ff028783cd", + "name": "FryFoodStep" + } + }, + "id": "1c90a002-68ca-4c68-ac3b-a083054ed628", + "name": "FriedFishWithStatefulStepsProcess" +} \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessNoStock.json b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessNoStock.json new file mode 100644 index 000000000000..9fbdd4afb3ee --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/ProcessesStates/FriedFishProcessStateSuccessNoStock.json @@ -0,0 +1,24 @@ +{ + "stepsState": { + "GatherFriedFishIngredientsWithStockStep": { + "state": { + "IngredientsStock": 0 + }, + "id": "51b39c78e71148c9af0a3c44c83bc6f5", + "name": "GatherFriedFishIngredientsWithStockStep" + }, + "chopStep": { + "state": { + "KnifeSharpness": 4 + }, + "id": "6be5207cb71e42c28d7061e45e1127c1", + "name": "chopStep" + }, + "FryFoodStep": { + "id": "9523d47a97a546908985a6ff028783cd", + "name": "FryFoodStep" + } + }, + "id": "1c90a002-68ca-4c68-ac3b-a083054ed628", + "name": "FriedFishWithStatefulStepsProcess" +} \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs b/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs index 578e7c1b904a..a4e16d7e8e63 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/Step03a_FoodPreparation.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Process.Models; using Step03.Processes; +using Utilities; namespace Step03; @@ -101,7 +103,7 @@ public async Task UsePrepareStatefulPotatoFriesProcessSharedStateAsync() Console.WriteLine($"=== End SK Process '{processBuilder.Name}' ==="); } - private async Task ExecuteProcessWithStateAsync(KernelProcess process, Kernel kernel, string externalTriggerEvent, string orderLabel) + private async Task ExecuteProcessWithStateAsync(KernelProcess process, Kernel kernel, string externalTriggerEvent, string orderLabel = "Order 1") { Console.WriteLine($"=== {orderLabel} ==="); var runningProcess = await process.StartAsync(kernel, new KernelProcessEvent() @@ -111,6 +113,100 @@ private async Task ExecuteProcessWithStateAsync(KernelProcess pro }); return await runningProcess.GetStateAsync(); } + + #region Running processes and saving Process State Metadata in a file locally + [Fact] + public async Task RunAndStoreStatefulFriedFishProcessStateAsync() + { + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder builder = FriedFishProcess.CreateProcessWithStatefulSteps(); + KernelProcess friedFishProcess = builder.Build(); + + var executedProcess = await ExecuteProcessWithStateAsync(friedFishProcess, kernel, externalTriggerEvent: FriedFishProcess.ProcessEvents.PrepareFriedFish); + var processState = executedProcess.ToProcessStateMetadata(); + DumpProcessStateMetadataLocally(processState, _statefulFriedFishProcessFilename); + } + + [Fact] + public async Task RunAndStoreStatefulFishSandwichProcessStateAsync() + { + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder builder = FishSandwichProcess.CreateProcessWithStatefulSteps(); + KernelProcess friedFishProcess = builder.Build(); + + var executedProcess = await ExecuteProcessWithStateAsync(friedFishProcess, kernel, externalTriggerEvent: FishSandwichProcess.ProcessEvents.PrepareFishSandwich); + var processState = executedProcess.ToProcessStateMetadata(); + DumpProcessStateMetadataLocally(processState, _statefulFishSandwichProcessFilename); + } + #endregion + + #region Reading State from local file and apply to existing ProcessBuilder + [Fact] + public async Task RunStatefulFriedFishProcessFromFileAsync() + { + var processState = LoadProcessStateMetadata(this._statefulFriedFishProcessFilename); + Assert.NotNull(processState); + + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder processBuilder = FriedFishProcess.CreateProcessWithStatefulSteps(); + KernelProcess processFromFile = processBuilder.Build(processState); + + await ExecuteProcessWithStateAsync(processFromFile, kernel, externalTriggerEvent: FriedFishProcess.ProcessEvents.PrepareFriedFish); + } + + [Fact] + public async Task RunStatefulFriedFishProcessWithLowStockFromFileAsync() + { + var processState = LoadProcessStateMetadata(this._statefulFriedFishLowStockProcessFilename); + Assert.NotNull(processState); + + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder processBuilder = FriedFishProcess.CreateProcessWithStatefulSteps(); + KernelProcess processFromFile = processBuilder.Build(processState); + + await ExecuteProcessWithStateAsync(processFromFile, kernel, externalTriggerEvent: FriedFishProcess.ProcessEvents.PrepareFriedFish); + } + + [Fact] + public async Task RunStatefulFriedFishProcessWithNoStockFromFileAsync() + { + var processState = LoadProcessStateMetadata(this._statefulFriedFishNoStockProcessFilename); + Assert.NotNull(processState); + + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder processBuilder = FriedFishProcess.CreateProcessWithStatefulSteps(); + KernelProcess processFromFile = processBuilder.Build(processState); + + await ExecuteProcessWithStateAsync(processFromFile, kernel, externalTriggerEvent: FriedFishProcess.ProcessEvents.PrepareFriedFish); + } + + [Fact] + public async Task RunStatefulFishSandwichProcessFromFileAsync() + { + var processState = LoadProcessStateMetadata(this._statefulFishSandwichProcessFilename); + Assert.NotNull(processState); + + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder processBuilder = FishSandwichProcess.CreateProcessWithStatefulSteps(); + KernelProcess processFromFile = processBuilder.Build(processState); + + await ExecuteProcessWithStateAsync(processFromFile, kernel, externalTriggerEvent: FishSandwichProcess.ProcessEvents.PrepareFishSandwich); + } + + [Fact] + public async Task RunStatefulFishSandwichProcessWithLowStockFromFileAsync() + { + var processState = LoadProcessStateMetadata(this._statefulFishSandwichLowStockProcessFilename); + Assert.NotNull(processState); + + Kernel kernel = CreateKernelWithChatCompletion(); + ProcessBuilder processBuilder = FishSandwichProcess.CreateProcessWithStatefulSteps(); + KernelProcess processFromFile = processBuilder.Build(processState); + + await ExecuteProcessWithStateAsync(processFromFile, kernel, externalTriggerEvent: FishSandwichProcess.ProcessEvents.PrepareFishSandwich); + } + #endregion + #endregion protected async Task UsePrepareSpecificProductAsync(ProcessBuilder processBuilder, string externalTriggerEvent) { @@ -128,4 +224,29 @@ protected async Task UsePrepareSpecificProductAsync(ProcessBuilder processBuilde }); Console.WriteLine($"=== End SK Process '{processBuilder.Name}' ==="); } + + // Step03a Utils for saving and loading SK Processes from/to repository + private readonly string _step03RelativePath = Path.Combine("Step03", "ProcessesStates"); + private readonly string _statefulFriedFishProcessFilename = "FriedFishProcessStateSuccess.json"; + private readonly string _statefulFriedFishLowStockProcessFilename = "FriedFishProcessStateSuccessLowStock.json"; + private readonly string _statefulFriedFishNoStockProcessFilename = "FriedFishProcessStateSuccessNoStock.json"; + private readonly string _statefulFishSandwichProcessFilename = "FishSandwichStateProcessSuccess.json"; + private readonly string _statefulFishSandwichLowStockProcessFilename = "FishSandwichStateProcessSuccessLowStock.json"; + + private void DumpProcessStateMetadataLocally(KernelProcessStateMetadata processStateInfo, string jsonFilename) + { + var sampleRelativePath = GetSampleStep03Filepath(jsonFilename); + ProcessStateMetadataUtilities.DumpProcessStateMetadataLocally(processStateInfo, sampleRelativePath); + } + + private KernelProcessStateMetadata? LoadProcessStateMetadata(string jsonFilename) + { + var sampleRelativePath = GetSampleStep03Filepath(jsonFilename); + return ProcessStateMetadataUtilities.LoadProcessStateMetadata(sampleRelativePath); + } + + private string GetSampleStep03Filepath(string jsonFilename) + { + return Path.Combine(this._step03RelativePath, jsonFilename); + } } diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/CutFoodWithSharpeningStep.cs b/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/CutFoodWithSharpeningStep.cs index 550aacb3e813..db8a3676e9d2 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/CutFoodWithSharpeningStep.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/CutFoodWithSharpeningStep.cs @@ -92,7 +92,7 @@ private string getActionString(string food, string action) /// public sealed class CutFoodWithSharpeningState { - internal int KnifeSharpness { get; set; } = 5; + public int KnifeSharpness { get; set; } = 5; internal int _needsSharpeningLimit = 3; internal int _sharpeningBoost = 5; diff --git a/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/GatherIngredientsStep.cs b/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/GatherIngredientsStep.cs index 7423b9ba51b4..55b8e29a70d6 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/GatherIngredientsStep.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step03/Steps/GatherIngredientsStep.cs @@ -120,5 +120,5 @@ public virtual async Task GatherIngredientsAsync(KernelProcessStepContext contex /// public sealed class GatherIngredientsState { - internal int IngredientsStock { get; set; } = 5; + public int IngredientsStock { get; set; } = 5; } diff --git a/dotnet/samples/GettingStartedWithProcesses/Utilities/ProcessStateMetadataUtilities.cs b/dotnet/samples/GettingStartedWithProcesses/Utilities/ProcessStateMetadataUtilities.cs new file mode 100644 index 000000000000..e8d42de21efc --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Utilities/ProcessStateMetadataUtilities.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Process.Models; + +namespace Utilities; +public static class ProcessStateMetadataUtilities +{ + // Path used for storing json processes samples in repository + private static readonly string s_currentSourceDir = Path.Combine( + Directory.GetCurrentDirectory(), "..", "..", ".."); + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + public static void DumpProcessStateMetadataLocally(KernelProcessStateMetadata processStateInfo, string jsonFilename) + { + var filepath = GetRepositoryProcessStateFilepath(jsonFilename); + StoreProcessStateLocally(processStateInfo, filepath); + } + + public static KernelProcessStateMetadata? LoadProcessStateMetadata(string jsonRelativePath) + { + var filepath = GetRepositoryProcessStateFilepath(jsonRelativePath, checkFilepathExists: true); + + Console.WriteLine($"Loading ProcessStateMetadata from:\n'{Path.GetFullPath(filepath)}'"); + + using StreamReader reader = new(filepath); + var content = reader.ReadToEnd(); + return JsonSerializer.Deserialize(content); + } + + private static string GetRepositoryProcessStateFilepath(string jsonRelativePath, bool checkFilepathExists = false) + { + string filepath = Path.Combine(s_currentSourceDir, jsonRelativePath); + if (checkFilepathExists && !File.Exists(filepath)) + { + throw new KernelException($"Filepath {filepath} does not exist"); + } + + return filepath; + } + + /// + /// Function that stores the definition of the SK Process State`.
+ ///
+ /// Process State to be stored + /// Filepath to store definition of process in json format + private static void StoreProcessStateLocally(KernelProcessStateMetadata processStateInfo, string fullFilepath) + { + if (!(Path.GetDirectoryName(fullFilepath) is string directory && Directory.Exists(directory))) + { + throw new KernelException($"Directory for path '{fullFilepath}' does not exist, could not save process {processStateInfo.Name}"); + } + + if (!(Path.GetExtension(fullFilepath) is string extension && !string.IsNullOrEmpty(extension) && extension == ".json")) + { + throw new KernelException($"Filepath for process {processStateInfo.Name} does not have .json extension"); + } + + var content = JsonSerializer.Serialize(processStateInfo, s_jsonOptions); + Console.WriteLine($"Process State: \n{content}"); + Console.WriteLine($"Saving Process State Locally: \n{Path.GetFullPath(fullFilepath)}"); + File.WriteAllText(fullFilepath, content); + } +} diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs index 37aa7e66695d..b16c25750813 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using Microsoft.SemanticKernel.Process.Models; namespace Microsoft.SemanticKernel; @@ -14,6 +15,27 @@ public sealed record KernelProcess : KernelProcessStepInfo /// public IList Steps { get; } + /// + /// Captures Kernel Process State into + /// + /// + public override KernelProcessStateMetadata ToProcessStateMetadata() + { + KernelProcessStateMetadata metadata = new() + { + Name = this.State.Name, + Id = this.State.Id, + StepsState = [], + }; + + foreach (var step in this.Steps) + { + metadata.StepsState.Add(step.State.Name, step.ToProcessStateMetadata()); + } + + return metadata; + } + /// /// Creates a new instance of the class. /// diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepInfo.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepInfo.cs index 0936eb12a4fc..88e1d4cfdd3c 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepInfo.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepInfo.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.SemanticKernel.Process; +using Microsoft.SemanticKernel.Process.Models; namespace Microsoft.SemanticKernel; @@ -31,6 +33,33 @@ public KernelProcessStepState State } } + /// + /// Captures Kernel Process Step State into + /// + /// + public virtual KernelProcessStateMetadata ToProcessStateMetadata() + { + KernelProcessStateMetadata metadata = new() + { + Name = this.State.Name, + Id = this.State.Id, + }; + + if (this.InnerStepType.TryGetSubtypeOfStatefulStep(out var genericStateType) && genericStateType != null) + { + var userStateType = genericStateType.GetGenericArguments()[0]; + var stateOriginalType = typeof(KernelProcessStepState<>).MakeGenericType(userStateType); + + var innerState = stateOriginalType.GetProperty(nameof(KernelProcessStepState.State))?.GetValue(this._state); + if (innerState != null) + { + metadata.State = innerState; + } + } + + return metadata; + } + /// /// A read-only dictionary of output edges from the Step. /// diff --git a/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStateMetadata.cs b/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStateMetadata.cs new file mode 100644 index 000000000000..5c4a5924bc6e --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStateMetadata.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Process.Models; + +/// +/// Process state used for State Persistence serialization +/// +public record class KernelProcessStateMetadata : KernelProcessStepStateMetadata +{ + /// + /// Process State of Steps if provided + /// + [DataMember] + [JsonPropertyName("stepsState")] + public Dictionary? StepsState { get; init; } +} diff --git a/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStepStateMetadata.cs b/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStepStateMetadata.cs new file mode 100644 index 000000000000..fb83898aedd2 --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/Models/KernelProcessStepStateMetadata.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Process.Models; + +/// +/// Step state used for State Persistence serialization +/// +public record class KernelProcessStepStateMetadata +{ + /// + /// The identifier of the Step which is required to be unique within an instance of a Process. + /// This may be null until a process containing this step has been invoked. + /// + [DataMember] + [JsonPropertyName("id")] + public string? Id { get; init; } + + /// + /// The name of the Step. This is intended to be human readable and is not required to be unique. If + /// not provided, the name will be derived from the steps .NET type. + /// + [DataMember] + [JsonPropertyName("name")] + public string? Name { get; init; } + + /// + /// Version of the state that is stored. Used for validation and versioning + /// purposes when reading a state and applying it to a ProcessStepBuilder/ProcessBuilder + /// + [DataMember] + [JsonPropertyName("versionInfo")] + public string? VersionInfo { get; init; } = null; +} + +/// +/// Step state used for State Persistence serialization for stateful steps +/// +public record class KernelProcessStepStateMetadata : KernelProcessStepStateMetadata where TState : class, new() +{ + /// + /// The user-defined state object associated with the Step. + /// + [DataMember] + [JsonPropertyName("state")] + public TState? State { get; set; } = null; +} diff --git a/dotnet/src/Experimental/Process.Core/Internal/EndStep.cs b/dotnet/src/Experimental/Process.Core/Internal/EndStep.cs index 1eefc586bdc2..432aecf33128 100644 --- a/dotnet/src/Experimental/Process.Core/Internal/EndStep.cs +++ b/dotnet/src/Experimental/Process.Core/Internal/EndStep.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using Microsoft.SemanticKernel.Process.Models; namespace Microsoft.SemanticKernel; @@ -41,6 +42,11 @@ internal override Dictionary GetFunctionMetadata } internal override KernelProcessStepInfo BuildStep() + { + return this.BuildStep(null); + } + + internal override KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata? stateMetadata) { // The end step has no state. return new KernelProcessStepInfo(typeof(KernelProcessStepState), new KernelProcessStepState(EndStepName), []); diff --git a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs index 13c020150bc2..ff5130f47db4 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.SemanticKernel.Process.Models; namespace Microsoft.SemanticKernel; @@ -87,11 +88,33 @@ internal override Dictionary GetFunctionMetadata /// /// Builds the step. /// + /// State to apply to the step on the build process /// + internal override KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata? stateMetadata) + { + // The step is a, process so we can return the step info directly. + if (stateMetadata is KernelProcessStateMetadata processState) + { + return this.BuildStep(processState); + } + + return this.BuildStep(); + } + + /// + /// Build the subprocess step + /// + /// State to apply to the step on the build process + /// + private KernelProcess BuildStep(KernelProcessStateMetadata? stateMetadata) + { + // The step is a process so we can return the step info directly. + return this.Build(stateMetadata); + } + internal override KernelProcessStepInfo BuildStep() { - // The process is a step so we can return the step info directly. - return this.Build(); + return this.Build(null); } #region Public Interface @@ -125,7 +148,7 @@ public ProcessStepBuilder AddStepFromType(string? name = null) where TSte /// An instance of public ProcessStepBuilder AddStepFromType(TState initialState, string? name = null) where TStep : KernelProcessStep where TState : class, new() { - var stepBuilder = new ProcessStepBuilder(name, initialState); + var stepBuilder = new ProcessStepBuilder(name, initialState: initialState); this._steps.Add(stepBuilder); return stepBuilder; @@ -179,13 +202,23 @@ public ProcessFunctionTargetBuilder WhereInputEventIs(string eventId) /// /// An instance of /// - public KernelProcess Build() + public KernelProcess Build(KernelProcessStateMetadata? stateMetadata = null) { // Build the edges first var builtEdges = this.Edges.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Select(e => e.Build()).ToList()); - // Build the steps - var builtSteps = this._steps.Select(step => step.BuildStep()).ToList(); + // Build the steps and injecting initial state if any is provided + List builtSteps = []; + this._steps.ForEach(step => + { + if (stateMetadata != null && stateMetadata.StepsState != null && stateMetadata.StepsState.TryGetValue(step.Name, out var stepStateObject) && stepStateObject != null) + { + builtSteps.Add(step.BuildStep(stepStateObject)); + return; + } + + builtSteps.Add(step.BuildStep()); + }); // Create the process var state = new KernelProcessState(this.Name, id: this.HasParentProcess ? this.Id : null); diff --git a/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs index 0f212245cdbc..27db41d73c0b 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs @@ -3,7 +3,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using Microsoft.SemanticKernel.Process; +using Microsoft.SemanticKernel.Process.Models; namespace Microsoft.SemanticKernel; @@ -71,6 +73,12 @@ public ProcessStepEdgeBuilder OnFunctionError(string functionName) /// internal Dictionary> Edges { get; } + /// + /// Builds the step with step state + /// + /// an instance of . + internal abstract KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata? stateMetadata); + /// /// Builds the step. /// @@ -197,13 +205,13 @@ public sealed class ProcessStepBuilder : ProcessStepBuilder where TStep : /// /// The initial state of the step. This may be null if the step does not have any state. /// - private readonly object? _initialState; + private object? _initialState; /// /// Creates a new instance of the class. If a name is not provided, the name will be derived from the type of the step. /// /// Optional: The name of the step. - /// Optional: The initial state of the step. + /// Initial state of the step to be used on the step building stage internal ProcessStepBuilder(string? name = null, object? initialState = default) : base(name ?? typeof(TStep).Name) { @@ -211,11 +219,16 @@ internal ProcessStepBuilder(string? name = null, object? initialState = default) this._initialState = initialState; } + internal override KernelProcessStepInfo BuildStep() + { + return this.BuildStep(null); + } + /// - /// Builds the step. + /// Builds the step with a state if provided /// /// An instance of - internal override KernelProcessStepInfo BuildStep() + internal override KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata? stateMetadata) { KernelProcessStepState? stateObject = null; @@ -229,6 +242,21 @@ internal override KernelProcessStepInfo BuildStep() var stateType = typeof(KernelProcessStepState<>).MakeGenericType(userStateType); Verify.NotNull(stateType); + if (stateMetadata != null && stateMetadata.State != null) + { + if (stateMetadata.State is JsonElement jsonState) + { + try + { + this._initialState = jsonState.Deserialize(userStateType); + } + catch (JsonException) + { + throw new KernelException($"The initial state provided for step {this.Name} is not of the correct type. The expected type is {userStateType.Name}."); + } + } + } + // If the step has a user-defined state then we need to validate that the initial state is of the correct type. if (this._initialState is not null && this._initialState.GetType() != userStateType) { diff --git a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs index 1b121490c386..ba50da10c6e8 100644 --- a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs +++ b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using Microsoft.SemanticKernel.Process.Models; using Xunit; namespace Microsoft.SemanticKernel.Process.Core.UnitTests; @@ -236,6 +237,11 @@ private sealed class TestProcessStepBuilder : ProcessStepBuilder public TestProcessStepBuilder(string name) : base(name) { } internal override KernelProcessStepInfo BuildStep() + { + return this.BuildStep(null); + } + + internal override KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata? stateMetadata = null) { return new KernelProcessStepInfo(typeof(TestProcessStepBuilder), new KernelProcessStepState(this.Name, this.Id), []); } diff --git a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessTypeExtensionsTests.cs b/dotnet/src/Experimental/Process.Utilities.UnitTests/ProcessTypeExtensionsTests.cs similarity index 97% rename from dotnet/src/Experimental/Process.UnitTests/Core/ProcessTypeExtensionsTests.cs rename to dotnet/src/Experimental/Process.Utilities.UnitTests/ProcessTypeExtensionsTests.cs index 7cb7b2fde709..23e27b6d121b 100644 --- a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessTypeExtensionsTests.cs +++ b/dotnet/src/Experimental/Process.Utilities.UnitTests/ProcessTypeExtensionsTests.cs @@ -6,7 +6,7 @@ namespace Microsoft.SemanticKernel.Process.Core.UnitTests; /// -/// Unit tests for the class. +/// Unit tests for the class. /// public class ProcessTypeExtensionsTests { diff --git a/dotnet/src/Experimental/Process.Core/Extensions/ProcessTypeExtensions.cs b/dotnet/src/InternalUtilities/process/Abstractions/KernelProcessStepExtension.cs similarity index 79% rename from dotnet/src/Experimental/Process.Core/Extensions/ProcessTypeExtensions.cs rename to dotnet/src/InternalUtilities/process/Abstractions/KernelProcessStepExtension.cs index e84f691a7d95..63cf4003127a 100644 --- a/dotnet/src/Experimental/Process.Core/Extensions/ProcessTypeExtensions.cs +++ b/dotnet/src/InternalUtilities/process/Abstractions/KernelProcessStepExtension.cs @@ -4,15 +4,12 @@ namespace Microsoft.SemanticKernel.Process; -/// -/// Provides extension methods for instances related to process steps. -/// -internal static class ProcessTypeExtensions +internal static class KernelProcessStepExtensions { /// /// The generic state type for a process step. /// - private static readonly Type s_genericType = typeof(KernelProcessStep<>); + private static readonly Type s_genericStepType = typeof(KernelProcessStep<>); /// /// Attempts to find an instance of ']]> within the provided types hierarchy. @@ -24,7 +21,7 @@ public static bool TryGetSubtypeOfStatefulStep(this Type? type, out Type? generi { while (type != null && type != typeof(object)) { - if (type.IsGenericType && type.GetGenericTypeDefinition() == s_genericType) + if (type.IsGenericType && type.GetGenericTypeDefinition() == s_genericStepType) { genericStateType = type; return true; @@ -34,6 +31,7 @@ public static bool TryGetSubtypeOfStatefulStep(this Type? type, out Type? generi } genericStateType = null; + return false; } } diff --git a/dotnet/src/InternalUtilities/process/Runtime/ProcessExtensions.cs b/dotnet/src/InternalUtilities/process/Abstractions/ProcessExtensions.cs similarity index 100% rename from dotnet/src/InternalUtilities/process/Runtime/ProcessExtensions.cs rename to dotnet/src/InternalUtilities/process/Abstractions/ProcessExtensions.cs diff --git a/dotnet/src/InternalUtilities/process/Runtime/StepExtensions.cs b/dotnet/src/InternalUtilities/process/Abstractions/StepExtensions.cs similarity index 84% rename from dotnet/src/InternalUtilities/process/Runtime/StepExtensions.cs rename to dotnet/src/InternalUtilities/process/Abstractions/StepExtensions.cs index d993c86aed39..48da16a8fa59 100644 --- a/dotnet/src/InternalUtilities/process/Runtime/StepExtensions.cs +++ b/dotnet/src/InternalUtilities/process/Abstractions/StepExtensions.cs @@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Process.Runtime; internal static class StepExtensions { - /// - /// The generic state type for a process step. - /// - private static readonly Type s_genericType = typeof(KernelProcessStep<>); - public static KernelProcessStepInfo Clone(this KernelProcessStepInfo step, ILogger logger) { if (step is KernelProcess subProcess) @@ -145,29 +140,4 @@ public static void InitializeUserState(this KernelProcessStepState stateObject, return inputs; } - - /// - /// Attempts to find an instance of ']]> within the provided types hierarchy. - /// - /// The type to examine. - /// The matching type if found, otherwise null. - /// True if a match is found, false otherwise. - /// TODO: Move this to a share process utilities project. - private static bool TryGetSubtypeOfStatefulStep(this Type? type, out Type? genericStateType) - { - while (type != null && type != typeof(object)) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == s_genericType) - { - genericStateType = type; - return true; - } - - type = type.BaseType; - } - - genericStateType = null; - - return false; - } } diff --git a/dotnet/src/InternalUtilities/process/InternalUtilities.props b/dotnet/src/InternalUtilities/process/InternalUtilities.props index a78cbdd39b8f..77347f4207ba 100644 --- a/dotnet/src/InternalUtilities/process/InternalUtilities.props +++ b/dotnet/src/InternalUtilities/process/InternalUtilities.props @@ -1,5 +1,5 @@ - + \ No newline at end of file