diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index f8b42e63f18b..78733cf5bc1c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -53,6 +53,7 @@ + @@ -60,6 +61,7 @@ + diff --git a/dotnet/SK-dotnet.slnx b/dotnet/SK-dotnet.slnx index b662baf25562..cc045e5d73ff 100644 --- a/dotnet/SK-dotnet.slnx +++ b/dotnet/SK-dotnet.slnx @@ -35,6 +35,7 @@ + diff --git a/dotnet/samples/Demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj b/dotnet/samples/Demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj new file mode 100644 index 000000000000..071493533e0e --- /dev/null +++ b/dotnet/samples/Demos/DeclarativeWorkflow/DeclarativeWorkflow.csproj @@ -0,0 +1,35 @@ + + + + Exe + net8.0 + enable + enable + CA2007;CS0612;VSTHRD111;SKEXP0080 + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/Demos/DeclarativeWorkflow/Program.cs b/dotnet/samples/Demos/DeclarativeWorkflow/Program.cs new file mode 100644 index 000000000000..b7955aa66fd7 --- /dev/null +++ b/dotnet/samples/Demos/DeclarativeWorkflow/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel; + +namespace Demo.DeclarativeWorkflow; + +internal static class Program +{ + private const string InputEventId = "start-workflow"; + + public static async Task Main(string[] args) + { + // Load configuration and create kernel with Azure OpenAI Chat Completion service + IConfiguration config = InitializeConfig(); + // Note: "Kernel" isn't required as part of the new "process framework". + Kernel kernel = CreateKernel(config["AzureOpenAI:Endpoint"]!, config["AzureOpenAI:ChatDeploymentName"]!); + + Notify("PROCESS INIT\n"); + + Stopwatch timer = Stopwatch.StartNew(); + + ////////////////////////////////////////////// + // Interpret the workflow YAML into a KernelProcess + using StreamReader yamlReader = File.OpenText("demo250729.yaml"); + KernelProcess process = ObjectModelBuilder.Build(yamlReader, InputEventId); + ////////////////////////////////////////////// + + Notify($"\nPROCESS DEFINED: {timer.Elapsed}\n"); + + Notify("\nPROCESS INVOKE\n"); + + ////////////////////////////////////////////// + // Run the process, just like any other KernelProcess + // NOTE: The pattern here is expected to change in the new "process framework" + // (ideally, it will become less complex) + await using LocalKernelProcessContext context = + await process.StartAsync( + kernel, + new KernelProcessEvent() + { + Id = InputEventId, + // Pass the first argument as the input data for the process, if present. + Data = args.FirstOrDefault() ?? string.Empty + }); + ////////////////////////////////////////////// + + Notify("\nPROCESS DONE"); + } + + // Load configuration from user-secrets + private static IConfigurationRoot InitializeConfig() => + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .Build(); + + // Create kernel with Azure OpenAI Chat Completion service + private static Kernel CreateKernel(string endpoint, string model) + { + IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); + + kernelBuilder.AddAzureOpenAIChatCompletion(model, endpoint, new AzureCliCredential()); + + return kernelBuilder.Build(); + } + + private static void Notify(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + try + { + Console.WriteLine(message); + } + finally + { + Console.ResetColor(); + } + } +} diff --git a/dotnet/samples/Demos/DeclarativeWorkflow/demo250729.yaml b/dotnet/samples/Demos/DeclarativeWorkflow/demo250729.yaml new file mode 100644 index 000000000000..611ec7912b13 --- /dev/null +++ b/dotnet/samples/Demos/DeclarativeWorkflow/demo250729.yaml @@ -0,0 +1,57 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Capture optional agent instructions + - kind: SetVariable + id: setVariable_NZ2u0l + variable: Topic.Instructions + value: =System.LastMessage.Text + + # Assign a list of inputs in JSON format to a variable + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all of questions for LLM + variable: Topic.Questions + value: |- + =[ + "Why is the sky blue?", + "What is the capital of France?", + "Where do rainbows come from?", + ] + + # Loop over each question in the list + - kind: Foreach + id: foreach_mVIecC + items: =Topic.Questions + index: Topic.LoopIndex + value: Topic.Question + actions: + + # Display the current question + - kind: SendActivity + id: sendActivity_lMn07p + activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: =Topic.Question + additionalInstructions: "{Topic.Instructions}" + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" + + # After processing all questions, display a completion message + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Complete! + + # End the conversation + - kind: EndConversation + id: end_8nXE8H diff --git a/dotnet/samples/Demos/DeclarativeWorkflow/readme.md b/dotnet/samples/Demos/DeclarativeWorkflow/readme.md new file mode 100644 index 000000000000..dc4d8ee9b6e4 --- /dev/null +++ b/dotnet/samples/Demos/DeclarativeWorkflow/readme.md @@ -0,0 +1,24 @@ +# Summary + +This demo showcases the ability to parse a YAML workflow based on Copilo Studio actions +and produce a `KernelProcess` that can be executed in the same fashion as any other `KernelProcess`. + +## Key Features + +This demo illustrates the following capabilities: + +- Parse YAML workflow actions using `Microsoft.Bot.ObjectModel` +- Store and retrieve variable state +- Evaluate expressions using `Microsoft.PowerFx.Interpreter` +- Support control flow (foreach, goto, etc...) +- Generate response from LLM using _Semantic Kernel_ + +## Status Details + +- This is using a POC based on the _Process Framework_ from the _Semantic Kernel_ repo. + - When the redesigned _Process Framework_ is available in the _Agent Framework_ repo it must + be re-implemented using the new API patterns. + - Capturing and restoring workflow state is not yet available in either version of the _Process Framework_. + - The ability to emit events from the _KernelProcess_ to the host API is not yet supported. +- `Microsoft.Bot.ObjectModel` is not (yet) available as a dependency that may be referenced by a _GitHub_ repository. +- The full set of CPSDL actions to be supported is not fully defined, nor are the "Pri-0" samples. diff --git a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj index 7244d2cb967b..5b2c0008628f 100644 --- a/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj +++ b/dotnet/samples/GettingStartedWithProcesses/GettingStartedWithProcesses.csproj @@ -66,4 +66,10 @@ + + + Always + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Step02b_AccountOpening.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Step02b_AccountOpening.cs index 15e6fc692701..2110593cac5e 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Step02b_AccountOpening.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Step02b_AccountOpening.cs @@ -29,7 +29,6 @@ private KernelProcess SetupAccountOpeningProcess() where TUserIn var accountVerificationStep = process.AddStepFromProcess(NewAccountVerificationProcess.CreateProcess()); var accountCreationStep = process.AddStepFromProcess(NewAccountCreationProcess.CreateProcess()); - var mailServiceStep = process.AddStepFromType(); process diff --git a/dotnet/samples/GettingStartedWithProcesses/Step04/Steps/RenderMessageStep.cs b/dotnet/samples/GettingStartedWithProcesses/Step04/Steps/RenderMessageStep.cs index c095373d5041..e843ca5c52a9 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step04/Steps/RenderMessageStep.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step04/Steps/RenderMessageStep.cs @@ -23,7 +23,7 @@ public static class ProcessStepFunctions public const string RenderUserText = nameof(RenderMessageStep.RenderUserText); } - private readonly static Stopwatch s_timer = Stopwatch.StartNew(); + private static readonly Stopwatch s_timer = Stopwatch.StartNew(); /// /// Render an explicit message to indicate the process has completed in the expected state. diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/DeepResearchAgent.fdl b/dotnet/samples/GettingStartedWithProcesses/Step06/DeepResearchAgent.fdl new file mode 100644 index 000000000000..536342dd325c --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/DeepResearchAgent.fdl @@ -0,0 +1,144 @@ +name: deepresearch +states: + - name: GatherFacts + actors: + - agent: LedgerFacts + inputs: + instructions: instructions + outputs: + task: task + facts: facts + thread: Planning + humanInLoopMode: onNoMessage + streamOutput: false + isFinal: false + - name: Plan + actors: + - agent: LedgerPlanner + inputs: + task: task + facts: facts + team: team + instructions: instructions + messagesOut: plannerMessages + thread: Planning + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: ProcessProgress + actors: + - agent: ProgressLedger + inputs: + task: task + team: team + systemAgents: systemAgents + messagesOut: nextStepMessages + messagesIn: + - plannerMessages + thread: Run + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: actionRouter + actors: + - agent: ActionRouterAgent + messagesIn: + - nextStepMessages + inputs: + team: team + systemAgents: systemAgents + outputs: + targetAgent: nextAgent + humanInLoopMode: never + streamOutput: true + - name: dynamicStepAgent + actors: + - agent: nextAgent + thread: Run + humanInLoopMode: never + streamOutput: true + - name: UpdateLedgerFact + actors: + - agent: LedgerFactsUpdate + thread: Run + inputs: + task: task + facts: facts + outputs: + updatedFacts: facts + humanInLoopMode: never + streamOutput: false + isFinal: false + - name: LedgerPlanUpdate + actors: + - agent: LedgerPlanUpdate + inputs: + facts: facts + team: team + messagesOut: plannerMessages + thread: Run + humanInLoopMode: never + streamOutput: true + isFinal: false + - name: Summarizer + actors: + - agent: FinalStepAgent + thread: Run + inputs: + task: task + humanInLoopMode: never + streamOutput: true + isFinal: true +transitions: + - from: GatherFacts + to: Plan + - from: Plan + to: ProcessProgress + - from: LedgerPlanUpdate + to: ProcessProgress + - from: ProcessProgress + to: actionRouter + - from: actionRouter + to: UpdateLedgerFact + condition: nextAgent.Equals(LedgerFactsUpdate) + - from: actionRouter + to: Summarizer + condition: nextAgent.Equals(FinalStepAgent) + - from: actionRouter + to: dynamicStepAgent + condition: nextAgent.NotContains(FinalStepAgent) + - from: dynamicStepAgent + to: ProcessProgress + - from: UpdateLedgerFact + to: LedgerPlanUpdate +variables: + - Type: userDefined + name: team + - Type: userDefined + name: instructions + - Type: userDefined + name: task + - Type: userDefined + name: facts + - Type: userDefined + name: plan + - Type: messages + name: plannerMessages + - Type: thread + name: Planning + - Type: thread + name: Run + - Type: messages + name: nextStepMessages + - Type: userDefined + name: nextAgent + - Type: userDefined + name: systemAgents + value: + - agent: FinalStepAgent + description: >- + Agent which summarizes the output after task is complete. When next speaker is none. + - agent: LedgerFactsUpdate + description: >- + Agent which can update the plan if we are looping without making progress or stall is detected. +startstate: GatherFacts diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/Step06_WorkflowProcess.cs b/dotnet/samples/GettingStartedWithProcesses/Step06/Step06_WorkflowProcess.cs new file mode 100644 index 000000000000..e70d2183b620 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/Step06_WorkflowProcess.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Azure.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Step06; + +public class Step06_WorkflowProcess : BaseTest +{ + // Target Open AI Services + protected override bool ForceOpenAI => true; + + public Step06_WorkflowProcess(ITestOutputHelper output) + : base(output, redirectSystemConsoleOutput: true) { } + + [Theory] + [InlineData("deepResearch")] + [InlineData("demo250729")] + [InlineData("testChat")] + [InlineData("testCondition")] + [InlineData("testEnd")] + [InlineData("testExpression")] + [InlineData("testGoto")] + [InlineData("testLoop")] + [InlineData("testLoopBreak")] + [InlineData("testLoopContinue")] + [InlineData("testTopic")] + public async Task RunWorkflow(string fileName) + { + using InterceptHandler customHandler = new(); + using HttpClient customClient = new(customHandler, disposeHandler: false); + + const string InputEventId = "question"; + + Console.WriteLine("PROCESS INIT\n"); + + using StreamReader yamlReader = File.OpenText(@$"{nameof(Step06)}\{fileName}.yaml"); + WorkflowContext workflowContext = + new() + { + HttpClient = customClient, + LoggerFactory = this.LoggerFactory, + ActivityChannel = this.Console, + ProjectEndpoint = TestConfiguration.AzureAI.Endpoint, + ProjectCredentials = new AzureCliCredential(), + }; + KernelProcess process = ObjectModelBuilder.Build(yamlReader, InputEventId, workflowContext); + + Console.WriteLine("\nPROCESS INVOKE\n"); + + Kernel kernel = this.CreateKernel(customClient); + IChatCompletionService chatService = kernel.GetRequiredService(); + await using LocalKernelProcessContext context = await process.StartAsync(kernel, new KernelProcessEvent() { Id = InputEventId, Data = "" }); + Console.WriteLine("\nPROCESS DONE"); + } + + private Kernel CreateKernel(HttpClient httpClient, bool withLogger = false) + { + IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); + + if (withLogger) + { + kernelBuilder.Services.AddSingleton(this.LoggerFactory); + } + + kernelBuilder.AddAzureOpenAIChatCompletion( + TestConfiguration.AzureOpenAI.ChatDeploymentName, + TestConfiguration.AzureOpenAI.Endpoint, + new AzureCliCredential(), + httpClient: httpClient); + + return kernelBuilder.Build(); + } +} + +internal sealed class InterceptHandler : HttpClientHandler +{ + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true }; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Call the inner handler to process the request and get the response + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + + // Intercept and modify the response + Console.WriteLine($"{request.Method} {request.RequestUri}"); + if (response.Content != null) + { + string responseContent; + try + { + JsonDocument responseDocument = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + responseContent = JsonSerializer.Serialize(responseDocument, s_options); + } + catch (ArgumentException) + { + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (JsonException) + { + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + } + response.Content = new StringContent(responseContent); + //Console.WriteLine(responseContent); // %%% RAISE EVENT + } + + return response; + } +} diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/deepResearch.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/deepResearch.yaml new file mode 100644 index 000000000000..4763cb17515e --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/deepResearch.yaml @@ -0,0 +1,475 @@ +# TaskDialog +# AgentDialog +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + condition: =Global.OrchestratorRunning <> true + type: Message + actions: + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all available agents for this orchestrator + variable: Topic.AgentToSchemaMapping + value: |- + =[ + { + name: "WeatherAgent", + description: "Able to retrieve weather information", + schema: "cr36e_agentRd2yAT.topic.Deterministic" + }, + { + name: "WebAgent", + description: "Able to perform generic websearches", + schema: "cr36e_agentRd2yAT.topic.WebSearch" + } + ] + + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Get all names + variable: Topic.AvailableAgents + value: =DropColumns(Topic.AgentToSchemaMapping, description, schema) + + - kind: SetVariable + id: setVariable_V6yEbo + displayName: Get a summary of all the agents for use in prompts + variable: Topic.TeamDescription + value: "=Concat(ForAll(Topic.AgentToSchemaMapping, name & $\": \" & description), Value, \".\\n\\n\")" + + - kind: SetVariable + id: setVariable_eLmgKQ + displayName: Toggle Orchestration Enabled Flag + variable: Global.OrchestratorRunning + value: =true + + - kind: SetVariable + id: setVariable_NZ2u0l + displayName: Set Task + variable: Topic.NewTask + value: =System.LastMessage.Text + + - kind: SetVariable + id: setVariable_PKmRsz + variable: Topic.ReTaskCount + value: 0 + + - kind: SetVariable + id: setVariable_EpFEKQ + displayName: Initialize Stall Count + variable: Topic.StallCount + value: =0 + + - kind: SendActivity + id: sendActivity_yFsbRz + activity: |- + Creating a Task Ledger, defining a plan, based on the following ask: + + {Topic.NewTask} + + - kind: SetVariable + id: setVariable_s8hR6q + variable: Topic.ContextHistory + value: |- + =["Below I will present you a request. Before we begin addressing the request, please answer the following pre-survey to the best of your ability. Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from. + + Here is the request: + + " & Topic.NewTask & " + + Here is the pre-survey: + + 1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none. + 2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself. + 3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation) + 4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc. + + When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer should use headings: + + 1. GIVEN OR VERIFIED FACTS + 2. FACTS TO LOOK UP + 3. FACTS TO DERIVE + 4. EDUCATED GUESSES + + DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so."] + + - kind: AnswerQuestionWithAI + id: question_wEJ123 + displayName: Get Facts Prompt + autoSend: false + variable: Topic.TaskFacts + userInput: =First(Topic.ContextHistory).Value + + - kind: EditTableV2 + id: editTableV2_Pry8em + displayName: Add Fact Response to Context + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: =Topic.TaskFacts + + - kind: AnswerQuestionWithAI + id: question_wEJ456 + displayName: Create a Plan Prompt + autoSend: false + variable: Topic.Plan + userInput: |- + ="Fantastic. To address this request we have assembled the following team: + + " & Topic.TeamDescription & " + + Based on the team composition, and known and unknown facts, please devise a short bullet-point plan for addressing the original request. Remember, there is no requirement to involve all team members -- a team member's particular expertise may not be needed for this task." + additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") + + - kind: SetVariable + id: setVariable_Kk2LDL + displayName: Set Plan as Context + variable: Topic.ContextHistory + value: |- + =[" + + We are working to address the following user request: + + " & Topic.NewTask &" + + + To answer this request we have assembled the following team: + + " & Topic.TeamDescription &" + + + Here is an initial fact sheet to consider: + + " & Topic.TaskFacts &" + + Here is the plan to follow as best as possible: + + " + & Topic.Plan + + ] + + - kind: SendActivity + id: sendActivity_bwNZiM + activity: "{First(Topic.ContextHistory).Value}" + + - kind: AnswerQuestionWithAI + id: sendActivity_YhpNE8 + displayName: Progress Ledger Prompt + autoSend: false + variable: Topic.ProgressLedgerUpdateString + userInput: |- + =" + Recall we are working on the following request: + + " & Topic.NewTask & " + + And we have assembled the following team: + + " & Topic.TeamDescription & " + + To make progress on the request, please answer the following questions, including necessary reasoning: + + - Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed) + - Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times. + - Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file) + - Who should speak next? (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + - What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need) + + Please output an answer in pure JSON format according to the following schema. The JSON object must be parsable as-is. DO NOT OUTPUT ANYTHING OTHER THAN JSON, AND DO NOT DEVIATE FROM THIS SCHEMA: + + {{ + ""is_request_satisfied"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""is_in_loop"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""is_progress_being_made"": {{ + ""reason"": string, + ""answer"": boolean + }}, + ""next_speaker"": {{ + ""reason"": string, + ""answer"": string (select from: " & Concat(Topic.AvailableAgents, name, ",") & ") + }}, + ""instruction_or_question"": {{ + ""reason"": string, + ""answer"": string + }} + }} + " + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: ParseValue + id: rNZtlV + displayName: Parse ledger response + variable: Topic.TypedProgressLedger + valueType: + kind: Record + properties: + instruction_or_question: + type: + kind: Record + properties: + answer: String + reason: String + + is_in_loop: + type: + kind: Record + properties: + answer: Boolean + reason: String + + is_progress_being_made: + type: + kind: Record + properties: + answer: Boolean + reason: String + + is_request_satisfied: + type: + kind: Record + properties: + answer: Boolean + reason: String + + next_speaker: + type: + kind: Record + properties: + answer: String + reason: String + + value: =Topic.ProgressLedgerUpdateString + + - kind: SendActivity + id: sendActivity_1GMmNq + activity: |- + Progress Ledger response: + {Topic.ProgressLedgerUpdateString} + + - kind: ConditionGroup + id: conditionGroup_mVIecC + conditions: + - id: conditionItem_fj432c + condition: =Topic.TypedProgressLedger.is_request_satisfied.answer + displayName: If Done + actions: + - kind: AnswerQuestionWithAI + id: sendActivity_Pkkmpq + displayName: Generate Response + variable: Topic.FinalResponse + userInput: |- + =" + We are working on the following task: + " & Topic.NewTask & " + + We have completed the task. + + The above messages contain the conversation that took place to complete the task. + + Based on the information gathered, provide the final answer to the original request. + The answer should be phrased as if you were speaking to the user. + " + additionalInstructions: =Concat(Topic.ContextHistory, Value, \".\\n\\n\") + + - kind: SendActivity + id: sendActivity_fpaNL9 + activity: Done with Task! + + - kind: SetVariable + id: setVariable_H2GWZ4 + variable: Global.OrchestratorRunning + value: =false + + - kind: EndConversation + id: SVoNSV + + - id: conditionItem_yiqund + condition: =Topic.TypedProgressLedger.is_in_loop.answer || Not(Topic.TypedProgressLedger.is_progress_being_made.answer) + displayName: If Stalling + actions: + - kind: SetVariable + id: setVariable_H5lXdD + displayName: Increase stall count + variable: Topic.StallCount + value: =Topic.StallCount + 1 + + - kind: ConditionGroup + id: conditionGroup_xzNrdM + conditions: + - id: conditionItem_NlQTBv + condition: =Topic.StallCount > 2 + displayName: Stall Count Exceeded + actions: + - kind: ConditionGroup + id: conditionGroup_4s1Z27 + conditions: + - id: conditionItem_EXAlhZ + condition: =Topic.ReTaskCount > 2 + actions: + - kind: SendActivity + id: sendActivity_xKxFUU + activity: We tried to re-task 3 times. Short-Circuiting + + - kind: EndConversation + id: GHVrFh + + - kind: AnswerQuestionWithAI + id: question_wFJ123 + displayName: Get New Facts Prompt + autoSend: false + variable: Topic.TaskFacts + userInput: |- + ="As a reminder, we are working to solve the following task: + + " & Topic.NewTask & " + + It's clear we aren't making as much progress as we would like, but we may have learned something new. Please rewrite the following fact sheet, updating it to include anything new we have learned that may be helpful. Example edits can include (but are not limited to) adding new guesses, moving educated guesses to verified facts if appropriate, etc. Updates may be made to any section of the fact sheet, and more than one section of the fact sheet can be edited. This is an especially good time to update educated guesses, so please at least add or update one educated guess or hunch, and explain your reasoning. + + Here is the old fact sheet: + + " & Topic.TaskFacts + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: AnswerQuestionWithAI + id: question_uEJ456 + displayName: Create new Plan Prompt + autoSend: false + variable: Topic.Plan + userInput: |- + ="Please briefly explain what went wrong on this last run (the root cause of the failure), and then come up with a new plan that takes steps and/or includes hints to overcome prior challenges and especially avoids repeating the same mistakes. As before, the new plan should be concise, be expressed in bullet-point form, and consider the following team composition (do not involve any other outside people since we cannot contact anyone else): + + " & Topic.TeamDescription + additionalInstructions: =Concat(Topic.ContextHistory, Value, ".\\n\\n") + + - kind: EditTableV2 + id: editTableV2_jW7tmM + displayName: Add new plan to history + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: | + =" + + We are working to address the following user request: + + " & Topic.NewTask & " + + + To answer this request we have assembled the following team: + + " & Topic.TeamDescription & " + + + Here is an initial fact sheet to consider: + + " & Topic.TaskFacts & " + + Here is the plan to follow as best as possible: + + " + & Topic.Plan + + - kind: SetVariable + id: setVariable_6J2snP + displayName: Reset Stall count + variable: Topic.StallCount + value: 0 + + - kind: SetVariable + id: setVariable_S6HCgh + displayName: Increase ReTask count + variable: Topic.ReTaskCount + value: =Topic.ReTaskCount + 1 + + - kind: SendActivity + id: sendActivity_cwNZiM + activity: |- + We have Stalled - Adjusting plan: + + {Last(Topic.ContextHistory).Value} + + - kind: GotoAction + id: LzfJ8u + actionId: sendActivity_YhpNE8 + + elseActions: + - kind: SetVariable + id: setVariable_L7ooQO + variable: Topic.StallCount + value: 0 + + - kind: ConditionGroup + id: conditionGroup_QFPiF5 + conditions: + - id: conditionItem_GmigcU + condition: =CountRows(Search(Topic.AvailableAgents, Topic.TypedProgressLedger.next_speaker.answer, name)) > 0 + displayName: If next Agent tool Exists + actions: + - kind: SetVariable + id: setVariable_TdKfOn + displayName: Set Current Goal for Sub-Agent + variable: Global.AgentGoal + value: =Topic.TypedProgressLedger.instruction_or_question.answer + + - kind: SetVariable + id: setVariable_C2AoCu + displayName: Reset Output + variable: Global.AgentResponse + value: "\"\"" + + - kind: BeginDialog + id: fqLWPt + displayName: Invoke Agent + input: {} + dialog: =First(Search(Topic.AgentToSchemaMapping, Topic.TypedProgressLedger.next_speaker.answer, name)).schema + output: {} + + - kind: EditTableV2 + id: editTableV2_fhfYJi + displayName: Add agent response to context + itemsVariable: Topic.ContextHistory + changeType: + kind: AddItemOperation + value: |- + ="Agent: " & Topic.TypedProgressLedger.next_speaker.answer & " + + Question or Instruction: + + " + & Topic.TypedProgressLedger.instruction_or_question.answer & + " + + Agent Response: + + " + & Global.AgentResponse + + - kind: SendActivity + id: sendActivity_MjWETC + activity: |- + Agent invoked: + {Last(Topic.ContextHistory).Value} + + - kind: GotoAction + id: 76Hne8 + actionId: sendActivity_YhpNE8 + + elseActions: + - kind: SendActivity + id: sendActivity_BhcsI7 + activity: Redirecting to unknown agent + + - kind: SetVariable + id: setVariable_H2GW44 + variable: Global.OrchestratorRunning + value: =false + + - kind: EndConversation + id: 8nXE8H diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/demo250729.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/demo250729.yaml new file mode 100644 index 000000000000..611ec7912b13 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/demo250729.yaml @@ -0,0 +1,57 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Capture optional agent instructions + - kind: SetVariable + id: setVariable_NZ2u0l + variable: Topic.Instructions + value: =System.LastMessage.Text + + # Assign a list of inputs in JSON format to a variable + - kind: SetVariable + id: setVariable_aASlmF + displayName: List all of questions for LLM + variable: Topic.Questions + value: |- + =[ + "Why is the sky blue?", + "What is the capital of France?", + "Where do rainbows come from?", + ] + + # Loop over each question in the list + - kind: Foreach + id: foreach_mVIecC + items: =Topic.Questions + index: Topic.LoopIndex + value: Topic.Question + actions: + + # Display the current question + - kind: SendActivity + id: sendActivity_lMn07p + activity: "Question {Topic.LoopIndex + 1} - {Topic.Question}" + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: =Topic.Question + additionalInstructions: "{Topic.Instructions}" + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" + + # After processing all questions, display a completion message + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Complete! + + # End the conversation + - kind: EndConversation + id: end_8nXE8H diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testChat.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testChat.yaml new file mode 100644 index 000000000000..fbf8af2bc5bb --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testChat.yaml @@ -0,0 +1,16 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + actions: + + # Use AI to answer the question + - kind: AnswerQuestionWithAI + id: question_wEJ456 + variable: Topic.Answer + userInput: Why is the sky blue? + + # Display the AI's answer + - kind: SendActivity + id: sendActivity_zA3f0p + activity: "AI - {Topic.Answer}" diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testCondition.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testCondition.yaml new file mode 100644 index 000000000000..8fbb3d703d3a --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testCondition.yaml @@ -0,0 +1,72 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: GotoAction + id: goto_skJ8u + actionId: setVariable_a9f4o2 + + - kind: SendActivity + id: sendActivity_skJ8u + activity: NEVER A! + + - kind: SetVariable + id: setVariable_a9f4o2 + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Looping (x{Topic.Count}) + + - kind: ConditionGroup + id: conditionGroup_mVIecC + conditions: + - id: conditionItem_fj432c + condition: =Topic.Count < 5 + displayName: Just started + actions: + - kind: SendActivity + id: sendActivity_Pkkmpq + activity: Just started (x{Topic.Count}) + + - id: conditionItem_yiqund + condition: =Topic.Count > 5 && Topic.Count < 10 + displayName: Making progress + actions: + - kind: SendActivity + id: sendActivity_aLM1o3 + activity: Making progress (x{Topic.Count}) + + - kind: GotoAction + id: goto_LzfJ8u + actionId: setVariable_a9f4o2 + + elseActions: + - kind: SendActivity + id: sendActivity_rOk31p + activity: All done (x{Topic.Count}) + + - kind: EndConversation + id: end_SVoNSV + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Fallthrough (x{Topic.Count}) + + - kind: GotoAction + id: goto_fTJ8u + actionId: setVariable_a9f4o2 + + - kind: SendActivity + id: sendActivity_ohn03s + activity: NEVER B! diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testEnd.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testEnd.yaml new file mode 100644 index 000000000000..5c6e1b7ac96b --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testEnd.yaml @@ -0,0 +1,18 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: EndConversation + id: end_skJ8u + actionId: sendActivity_fJsbRz + + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Never diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testExpression.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testExpression.yaml new file mode 100644 index 000000000000..2db7bc7a199e --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testExpression.yaml @@ -0,0 +1,37 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable1 + variable: Topic.TestList + value: =["zaz", "zbz", "zcz", "zdz", "zez", "zfz"] + + - kind: SetVariable + id: setVariable2 + variable: Topic.TestResult + value: |- + =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 && + !IsBlank(3) + +# value: =CountIf(Topic.TestList, !IsBlank(Find("e", Value))) > 0 + #value: =CountIf(Topic.TestList, 1) +# value: =!IsBlank(Topic.TestList) + + - kind: SetVariable + id: setVariable3 + variable: Topic.TestFind + value: =Find("e", "abcdefg") + #value: =CountIf(Topic.TestList, 1) +# value: =!IsBlank(Topic.TestList) + + - kind: SendActivity + id: sendActivity2 + activity: "Result (CountIf): {Topic.TestResult}" + + + - kind: SendActivity + id: sendActivity3 + activity: "Result (Find): {Topic.TestFind}" diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testGoto.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testGoto.yaml new file mode 100644 index 000000000000..99caa8e7f2b8 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testGoto.yaml @@ -0,0 +1,45 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: First + + - kind: GotoAction + id: goto_skJ8u + actionId: sendActivity_fJsbRz + + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_SVoNSV + activity: Last + + - kind: GotoAction + id: goto_SVoNSV + actionId: end_SVoNSV + + - kind: SendActivity + id: sendActivity_nev2 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Next + + - kind: GotoAction + id: goto_ajd01z + actionId: sendActivity_SVoNSV + + - kind: SendActivity + id: sendActivity_nev3 + activity: NEVER! + + - kind: EndConversation + id: end_SVoNSV diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testLoop.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoop.yaml new file mode 100644 index 000000000000..6be5bba164c7 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoop.yaml @@ -0,0 +1,34 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: SendActivity + id: sendActivity_Pkkmpq + activity: Looping (x{Topic.Count}) - {Topic.LoopValue} [{Topic.LoopIndex}] + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopBreak.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopBreak.yaml new file mode 100644 index 000000000000..072f1c58e6a5 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopBreak.yaml @@ -0,0 +1,36 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: BreakLoop + id: breakLoop_9JsbRz + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopContinue.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopContinue.yaml new file mode 100644 index 000000000000..fdb800187a1e --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testLoopContinue.yaml @@ -0,0 +1,36 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.Count + value: =0 + + - kind: SendActivity + id: sendActivity_aGsbRo + activity: Starting + + - kind: Foreach + id: foreach_mVIecC + items: =["a", "b", "c", "d", "e", "f"] + index: Topic.LoopIndex + value: Topic.LoopValue + actions: + - kind: SetVariable + id: setVariable_A4iBtN + displayName: Invocation count + variable: Topic.Count + value: =Topic.Count + 1 + - kind: ContinueLoop + id: continueLoop_9JsbRz + - kind: SendActivity + id: sendActivity_nev1 + activity: NEVER! + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: Complete! (x{Topic.Count}) diff --git a/dotnet/samples/GettingStartedWithProcesses/Step06/testTopic.yaml b/dotnet/samples/GettingStartedWithProcesses/Step06/testTopic.yaml new file mode 100644 index 000000000000..a4380697d993 --- /dev/null +++ b/dotnet/samples/GettingStartedWithProcesses/Step06/testTopic.yaml @@ -0,0 +1,26 @@ +kind: AdaptiveDialog +beginDialog: + kind: OnActivity + id: activity_xyz123 + type: Message + actions: + + - kind: SetVariable + id: setVariable_u4cBtN + displayName: Invocation count + variable: Topic.FirstValue + value: ABC + + - kind: SetVariable + id: setVariable_a8ybTn + displayName: Invocation count + variable: Workflow.SecondValue + value: 123 + + - kind: SendActivity + id: sendActivity_SVoNSV + activity: {Workflow.FirstValue} + + - kind: SendActivity + id: sendActivity_fJsbRz + activity: {Topic.SecondValue} diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStep.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStep.cs new file mode 100644 index 000000000000..f3100ed19dd1 --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStep.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel; + +/// +/// Signature for a step function targeted by . +/// +/// The kernel instance used for processing. +/// The context for the step execution. +public delegate Task StepFunction(Kernel kernel, KernelProcessStepContext context); + +/// +/// Step in a process that represents an ObjectModel. +/// +public class KernelDelegateProcessStep : KernelProcessStep +{ + /// + /// The name assigned to the delegate function that will be invoked by the step. + /// + public const string FunctionName = "Invoke"; + + private readonly StepFunction _stepFunction; + + /// + /// Initializes a new instance of the class with the specified step function. + /// + /// The step function to execute. + /// + public KernelDelegateProcessStep(StepFunction stepFunction) + { + this._stepFunction = stepFunction ?? throw new ArgumentNullException(nameof(stepFunction)); + } + + /// + /// Invokes the step function with the provided kernel and context. + /// + /// The kernel instance used for processing. + /// The context for the step execution. + [KernelFunction(FunctionName)] + public Task InvokeAsync(Kernel kernel, KernelProcessStepContext context) => this._stepFunction(kernel, context); +} diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStepInfo.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStepInfo.cs new file mode 100644 index 000000000000..c133c19f7216 --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessDelegateStepInfo.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Process; + +/// +/// Delegate step in a Kernel Process. +/// +public record KernelProcessDelegateStepInfo : KernelProcessStepInfo +{ + /// + /// Initializes a new instance of the class. + /// + public KernelProcessDelegateStepInfo( + KernelProcessStepState state, + StepFunction stepFunction, + Dictionary> edges) : + base(typeof(KernelDelegateProcessStep), state, edges, incomingEdgeGroups: null) + { + this.StepFunction = stepFunction; + } + + /// + /// Step function + /// + public StepFunction StepFunction { get; } +} diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs index 403fa2537d2e..df1743e019c2 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs @@ -33,7 +33,7 @@ public sealed class KernelProcessEdge /// /// The condition that must be met for the edge to be activated. /// - public KernelProcessEdgeCondition Condition { get; init; } + public KernelProcessEdgeCondition? Condition { get; init; } /// /// The list of variable updates to be performed when the edge fires. @@ -51,7 +51,7 @@ public KernelProcessEdge(string sourceStepId, KernelProcessTarget outputTarget, this.SourceStepId = sourceStepId; this.OutputTarget = outputTarget; this.GroupId = groupId; - this.Condition = condition ?? new KernelProcessEdgeCondition(callback: (_, _) => Task.FromResult(true)); + this.Condition = condition; //this.Metadata = metadata ?? []; this.Update = update; } diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepState.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepState.cs index e4e2b816cb8c..24bfd3e7128d 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepState.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessStepState.cs @@ -17,7 +17,7 @@ public record KernelProcessStepState /// /// A set of known types that may be used in serialization. /// - private readonly static ConcurrentDictionary s_knownTypes = []; + private static readonly ConcurrentDictionary s_knownTypes = []; /// /// Used to dynamically provide the set of known types for serialization. diff --git a/dotnet/src/Experimental/Process.Core/Process.Core.csproj b/dotnet/src/Experimental/Process.Core/Process.Core.csproj index 74bd7123e780..ceec1b1a10c1 100644 --- a/dotnet/src/Experimental/Process.Core/Process.Core.csproj +++ b/dotnet/src/Experimental/Process.Core/Process.Core.csproj @@ -26,20 +26,18 @@ - - - - - + + - + + diff --git a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs index 830ed59f141c..0c44046dee6b 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs @@ -138,17 +138,6 @@ internal override KernelProcessStepInfo BuildStep(ProcessBuilder processBuilder, return this.Build(stateMetadata as KernelProcessStateMetadata); } - /// - /// Add the provided step builder to the process. - /// - /// - /// Utilized by only. - /// - internal void AddStepFromBuilder(ProcessStepBuilder stepBuilder) - { - this._steps.Add(stepBuilder); - } - /// /// Check to ensure stepName is not used yet in another step /// @@ -228,6 +217,19 @@ public ProcessStepBuilder AddStepFromType(Type stepType, string? id = null, IRea return this.AddStep(stepBuilder, aliases); } + /// + /// Add a step + /// + /// + /// + /// Aliases that have been used by previous versions of the step, used for supporting backward compatibility when reading old version Process States + /// + public ProcessStepBuilder AddStepFromFunction(string id, StepFunction stepFunction, IReadOnlyList? aliases = null) + { + ProcessDelegateBuilder stepBuilder = new(id, stepFunction, this); + return this.AddStep(stepBuilder, aliases); + } + /// /// Adds a step to the process from a declarative agent. /// diff --git a/dotnet/src/Experimental/Process.Core/ProcessDelegateBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessDelegateBuilder.cs new file mode 100644 index 000000000000..5d3ac7f34cd1 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/ProcessDelegateBuilder.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.SemanticKernel.Process.Models; + +namespace Microsoft.SemanticKernel.Process; + +/// +/// Process step builder for a delegate step. +/// +public class ProcessDelegateBuilder : ProcessStepBuilder +{ + private readonly StepFunction _stepFunction; + + /// + /// Initializes a new instance of the class. + /// + public ProcessDelegateBuilder(string id, StepFunction stepFunction, ProcessBuilder? processBuilder) : base(id, processBuilder) + { + this._stepFunction = stepFunction ?? throw new ArgumentNullException(nameof(stepFunction), "Step function cannot be null."); + } + + internal override KernelProcessStepInfo BuildStep(ProcessBuilder processBuilder, KernelProcessStepStateMetadata? stateMetadata = null) // %%% NEEDED: stateMetadata ??? + { + // Build the edges first + var builtEdges = this.Edges.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Select(e => e.Build()).ToList()); + + KernelProcessStepState stateObject = new(this.Name, "none", this.Id); + + return new KernelProcessDelegateStepInfo(stateObject, this._stepFunction, builtEdges); + } + + internal override Dictionary GetFunctionMetadataMap() + { + // Nothing to do here, as this is a delegate step + return []; + } +} diff --git a/dotnet/src/Experimental/Process.Core/ProcessFunctionTargetBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessFunctionTargetBuilder.cs index d407e227eeca..4301248f7469 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessFunctionTargetBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessFunctionTargetBuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; +using Microsoft.SemanticKernel.Process; namespace Microsoft.SemanticKernel; @@ -165,6 +166,13 @@ public ProcessFunctionTargetBuilder(ProcessStepBuilder step, string? functionNam return; } + if (step is ProcessDelegateBuilder) + { + this.FunctionName = KernelDelegateProcessStep.FunctionName; + this.ParameterName = null; + return; + } + // Make sure the function target is valid. var target = step.ResolveFunctionTarget(functionName, parameterName); if (target == null) diff --git a/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs index d37350743b23..d00713472501 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs @@ -97,9 +97,8 @@ public ProcessStepEdgeBuilder SendEventTo(ProcessTargetBuilder target) /// /// /// - public ProcessStepEdgeBuilder OnCondition(KernelProcessEdgeCondition condition) + public ProcessStepEdgeBuilder OnCondition(KernelProcessEdgeCondition? condition) { - Verify.NotNull(condition, nameof(condition)); this.Condition = condition; return this; } @@ -129,7 +128,7 @@ internal virtual ProcessStepEdgeBuilder SendEventTo_Internal(ProcessTargetBuilde this.Target = target; this.Source.LinkTo(this.EventData.EventId, this); - return new ProcessStepEdgeBuilder(this.Source, this.EventData.EventId, this.EventData.EventName, this.EdgeGroupBuilder, this.Condition); + return this; } /// diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AnswerQuestionWithAIAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AnswerQuestionWithAIAction.cs new file mode 100644 index 000000000000..d3bc0006670f --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AnswerQuestionWithAIAction.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.AzureAI; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class AnswerQuestionWithAIAction : AssignmentAction +{ + public AnswerQuestionWithAIAction(AnswerQuestionWithAI model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + PersistentAgentsClient client = context.ClientFactory.Invoke(); + PersistentAgent model = await client.Administration.GetAgentAsync("asst_ueIjfGxAjsnZ4A61LlbjG9vJ", cancellationToken).ConfigureAwait(false); + AzureAIAgent agent = new(model, client); + + string? userInput = null; + if (this.Model.UserInput is not null) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.UserInput!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + userInput = result.Value; + } + + AgentInvokeOptions options = + new() + { + AdditionalInstructions = context.Engine.Format(this.Model.AdditionalInstructions) ?? string.Empty, + }; + AgentResponseItem response = + userInput != null ? + await agent.InvokeAsync(userInput, thread: null, options, cancellationToken).LastAsync(cancellationToken).ConfigureAwait(false) : + await agent.InvokeAsync(thread: null, options, cancellationToken).LastAsync(cancellationToken).ConfigureAwait(false); + StringValue responseValue = FormulaValue.New(response.Message.ToString()); + + this.AssignTarget(context, responseValue); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AssignmentAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AssignmentAction.cs new file mode 100644 index 000000000000..b6c002bf818f --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/AssignmentAction.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal abstract class AssignmentAction : ProcessAction where TAction : DialogAction +{ + protected AssignmentAction(TAction model, PropertyPath assignmentTarget) + : base(model) + { + this.Target = assignmentTarget; + } + + public PropertyPath Target { get; } + + protected void AssignTarget(ProcessActionContext context, FormulaValue result) + { + context.Engine.SetScopedVariable(context.Scopes, this.Target, result); + string? resultValue = result.Format(); + string valuePosition = (resultValue?.IndexOf('\n') ?? -1) >= 0 ? Environment.NewLine : " "; + context.Logger.LogDebug( + """ + !!! ASSIGN {ActionName} [{ActionId}] + NAME: {TargetName} + VALUE:{ValuePosition}{Result} ({ResultType}) + """, + this.GetType().Name, + this.Id, + this.Target.Format(), + valuePosition, + result.Format(), + result.GetType().Name); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ClearAllVariablesAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ClearAllVariablesAction.cs new file mode 100644 index 000000000000..1cb1fa2e3c7e --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ClearAllVariablesAction.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class ClearAllVariablesAction : ProcessAction +{ + public ClearAllVariablesAction(ClearAllVariables source) + : base(source) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Variables, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + + result.Value.Handle(new ScopeHandler(context)); + + return Task.CompletedTask; + } + + private sealed class ScopeHandler(ProcessActionContext context) : IEnumVariablesToClearHandler + { + public void HandleAllGlobalVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Global); + } + + public void HandleConversationHistory() + { + throw new System.NotImplementedException(); // %%% LOG / NO EXCEPTION - Is this to be supported ??? + } + + public void HandleConversationScopedVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Topic); + } + + public void HandleUnknownValue() + { + // No scope to clear for unknown values. + } + + public void HandleUserScopedVariables() + { + context.Engine.ClearScope(context.Scopes, ActionScopeType.Env); + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ConditionGroupAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ConditionGroupAction.cs new file mode 100644 index 000000000000..ea276e533f7e --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ConditionGroupAction.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class ConditionGroupAction : ProcessAction +{ + public ConditionGroupAction(ConditionGroup model) + : base(model) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + // %%% REMOVE + //foreach (ConditionItem condition in this.Action.Conditions) + //{ + // if (engine.Eval(condition.Condition?.ExpressionText ?? "true").AsBoolean()) + // { + // // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED + + // } + //} + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EditTableV2Action.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EditTableV2Action.cs new file mode 100644 index 000000000000..40858f8fcab7 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EditTableV2Action.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class EditTableV2Action : AssignmentAction +{ + public EditTableV2Action(EditTableV2 model) + : base(model, Throw.IfNull(model.ItemsVariable?.Path, $"{nameof(model)}.{nameof(model.ItemsVariable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override async Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + FormulaValue table = context.Scopes.Get(this.Target.VariableName!, ActionScopeType.Parse(this.Target.VariableScopeName)); + TableValue tableValue = (TableValue)table; + + EditTableOperation? changeType = this.Model.ChangeType; + if (changeType is AddItemOperation addItemOperation) + { + EvaluationResult result = context.ExpressionEngine.GetValue(addItemOperation.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), result.Value.ToFormulaValue()); + await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false); + this.AssignTarget(context, tableValue); + } + else if (changeType is ClearItemsOperation) + { + await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false); + } + else if (changeType is RemoveItemOperation) // %%% SUPPORT + { + } + else if (changeType is TakeFirstItemOperation) // %%% SUPPORT + { + } + + static RecordValue BuildRecord(RecordType recordType, FormulaValue value) + { + return FormulaValue.NewRecordFromFields(recordType, GetValues()); + + IEnumerable GetValues() + { + // %%% TODO: expression.StructuredRecordExpression.Properties ??? + foreach (NamedFormulaType fieldType in recordType.GetFieldTypes()) + { + if (value is RecordValue recordValue) + { + yield return new NamedValue(fieldType.Name, recordValue.GetField(fieldType.Name)); + } + else + { + yield return new NamedValue(fieldType.Name, value); + } + } + } + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EndConversationAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EndConversationAction.cs new file mode 100644 index 000000000000..1db9f20c6352 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/EndConversationAction.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class EndConversationAction : ProcessAction // %%% REMOVE ??? +{ + public EndConversationAction(EndConversation model) + : base(model) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ForeachAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ForeachAction.cs new file mode 100644 index 000000000000..b695b6bb0ec9 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ForeachAction.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class ForeachAction : ProcessAction +{ + private int _index; + private FormulaValue[] _values; + + public ForeachAction(Foreach model) + : base(model) + { + this._values = []; + } + + public bool HasValue { get; private set; } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + this._index = 0; + + if (this.Model.Items is null) + { + this._values = []; + this.HasValue = false; + return Task.CompletedTask; + } + + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Items, context.Scopes); + TableDataValue tableValue = (TableDataValue)result.Value; // %%% CAST - TYPE ASSUMPTION (TableDataValue) + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormulaValue())]; + return Task.CompletedTask; + } + + public void TakeNext(ProcessActionContext context) + { + if (this.HasValue = (this._index < this._values.Length)) + { + FormulaValue value = this._values[this._index]; + + context.Engine.SetScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value), value); + + if (this.Model.Index is not null) + { + context.Engine.SetScopedVariable(context.Scopes, this.Model.Index.Path, FormulaValue.New(this._index)); + } + + this._index++; + } + } + + public void Reset(ProcessActionContext context) + { + context.Engine.ClearScopedVariable(context.Scopes, Throw.IfNull(this.Model.Value)); + if (this.Model.Index is not null) + { + context.Engine.ClearScopedVariable(context.Scopes, this.Model.Index); + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ParseValueAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ParseValueAction.cs new file mode 100644 index 000000000000..80cfd23c7768 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ParseValueAction.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class ParseValueAction : AssignmentAction +{ + public ParseValueAction(ParseValue model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + if (this.Model.Value is null) + { + throw new InvalidActionException($"{nameof(ParseValue)} must define {nameof(ParseValue.Value)}"); + } + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value!, context.Scopes); // %%% FAILURE CASE (CATCH) & NULL OVERRIDE + + FormulaValue? parsedResult = null; + + if (result.Value is StringDataValue stringValue) + { + if (string.IsNullOrWhiteSpace(stringValue.Value)) + { + parsedResult = FormulaValue.NewBlank(); + } + else + { + parsedResult = + this.Model.ValueType switch + { + StringDataType => StringValue.New(stringValue.Value), + NumberDataType => NumberValue.New(stringValue.Value), + BooleanDataType => BooleanValue.New(stringValue.Value), + RecordDataType recordType => ParseRecord(recordType, stringValue.Value), + _ => null + }; + } + } + + if (parsedResult is null) + { + throw new ProcessActionException($"Unable to parse {result.Value.GetType().Name}"); + } + + this.AssignTarget(context, parsedResult); + + return Task.CompletedTask; + } + + private static RecordValue ParseRecord(RecordDataType recordType, string rawText) + { + string jsonText = rawText.TrimJsonDelimiter(); + JsonDocument json = JsonDocument.Parse(jsonText); + JsonElement currentElement = json.RootElement; + return recordType.ParseRecord(currentElement); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ResetVariableAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ResetVariableAction.cs new file mode 100644 index 000000000000..d87c5ba29991 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/ResetVariableAction.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class ResetVariableAction : AssignmentAction +{ + public ResetVariableAction(ResetVariable model) + : base(model, Throw.IfNull(model.Variable, $"{nameof(model)}.{nameof(model.Variable)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + context.Engine.ClearScopedVariable(context.Scopes, this.Target); + Console.WriteLine( // %%% LOGGER + $""" + !!! CLEAR {this.GetType().Name} [{this.Id}] + NAME: {this.Model.Variable!.Format()} + """); + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SendActivityAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SendActivityAction.cs new file mode 100644 index 000000000000..faf5a28a919f --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SendActivityAction.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class SendActivityAction : ProcessAction +{ + private readonly TextWriter _activityWriter; + + public SendActivityAction(SendActivity source, TextWriter activityWriter) + : base(source) + { + this._activityWriter = activityWriter; + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + Console.WriteLine($"\nACTIVITY: {this.Model.Activity?.GetType().Name ?? "Unknown"}"); // %%% LOGGER + + if (this.Model.Activity is MessageActivityTemplate messageActivity) + { + Console.ForegroundColor = ConsoleColor.Yellow; + try + { + if (!string.IsNullOrEmpty(messageActivity.Summary)) + { + this._activityWriter.WriteLine($"\t{messageActivity.Summary}"); + } + + string? activityText = context.Engine.Format(messageActivity.Text); + this._activityWriter.WriteLine(activityText + Environment.NewLine); + } + finally + { + Console.ResetColor(); + } + } + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetTextVariableAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetTextVariableAction.cs new file mode 100644 index 000000000000..bd02431fa592 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetTextVariableAction.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class SetTextVariableAction : AssignmentAction +{ + public SetTextVariableAction(SetTextVariable model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + FormulaValue result = FormulaValue.New(context.Engine.Format(this.Model.Value)); + + this.AssignTarget(context, result); + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetVariableAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetVariableAction.cs new file mode 100644 index 000000000000..61f60c20de91 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Actions/SetVariableAction.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Actions; + +internal sealed class SetVariableAction : AssignmentAction +{ + public SetVariableAction(SetVariable model) + : base(model, Throw.IfNull(model.Variable?.Path, $"{nameof(model)}.{nameof(model.Variable)}.{nameof(InitializablePropertyPath.Path)}")) + { + } + + protected override Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + if (this.Model.Value is null) + { + this.AssignTarget(context, FormulaValue.NewBlank()); + } + else + { + EvaluationResult result = context.ExpressionEngine.GetValue(this.Model.Value, context.Scopes); // %%% FAILURE CASE (CATCH) + + this.AssignTarget(context, result.Value.ToFormulaValue()); + } + + return Task.CompletedTask; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidActionException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidActionException.cs new file mode 100644 index 000000000000..c55306c99abe --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class InvalidActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidScopeException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidScopeException.cs new file mode 100644 index 000000000000..adfda388ed82 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidScopeException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when the specific scope is invalid. +/// +public sealed class InvalidScopeException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidScopeException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidScopeException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidScopeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidSegmentException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidSegmentException.cs new file mode 100644 index 000000000000..20fdae88d56b --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/InvalidSegmentException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class InvalidSegmentException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidSegmentException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public InvalidSegmentException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public InvalidSegmentException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessActionException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessActionException.cs new file mode 100644 index 000000000000..2b5c016bda6a --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs during the execution of a process action. +/// +public class ProcessActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public ProcessActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public ProcessActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public ProcessActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessWorkflowException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessWorkflowException.cs new file mode 100644 index 000000000000..b8fd4631f711 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/ProcessWorkflowException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents any exception that occurs during the execution of a process workflow. +/// +public class ProcessWorkflowException : KernelException +{ + /// + /// Initializes a new instance of the class. + /// + public ProcessWorkflowException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public ProcessWorkflowException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public ProcessWorkflowException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownActionException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownActionException.cs new file mode 100644 index 000000000000..b30dbc4c8330 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownActionException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when an action is invalid or cannot be processed. +/// +public sealed class UnknownActionException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public UnknownActionException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public UnknownActionException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public UnknownActionException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownDataTypeException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownDataTypeException.cs new file mode 100644 index 000000000000..065ac6c222df --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/UnknownDataTypeException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when an unknown data type is encountered. +/// +public sealed class UnknownDataTypeException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public UnknownDataTypeException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public UnknownDataTypeException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public UnknownDataTypeException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/WorkflowBuilderException.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/WorkflowBuilderException.cs new file mode 100644 index 000000000000..ed7d2c8ecc77 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Exceptions/WorkflowBuilderException.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Represents an exception that occurs when building the process workflow. +/// +public class WorkflowBuilderException : ProcessWorkflowException +{ + /// + /// Initializes a new instance of the class. + /// + public WorkflowBuilderException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The error message that explains the reason for the exception. + public WorkflowBuilderException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public WorkflowBuilderException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/BotElementExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/BotElementExtensions.cs new file mode 100644 index 000000000000..bbed0205f0df --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/BotElementExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class DataValueExtensions +{ + public static string? GetParentId(this BotElement element) => element.Parent?.GetId(); + + public static string GetId(this BotElement element) + { + return element switch + { + DialogAction action => action.Id.Value, + ConditionItem conditionItem => conditionItem.Id ?? throw new InvalidActionException($"Undefined identifier for {nameof(ConditionItem)} that is member of {conditionItem.GetParentId() ?? "(root)"}."), + OnActivity activity => activity.Id.Value, + _ => throw new InvalidActionException($"Unknown element type: {element.GetType().Name}"), + }; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/DataValueExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/DataValueExtensions.cs new file mode 100644 index 000000000000..46ac2a79e0bc --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/DataValueExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class BotElementExtensions +{ + public static FormulaValue ToFormulaValue(this DataValue? value) => + value switch + { + null => FormulaValue.NewBlank(), + BlankDataValue blankValue => BlankValue.NewBlank(), + BooleanDataValue boolValue => FormulaValue.New(boolValue.Value), + NumberDataValue numberValue => FormulaValue.New(numberValue.Value), + FloatDataValue floatValue => FormulaValue.New(floatValue.Value), + StringDataValue stringValue => FormulaValue.New(stringValue.Value), + DateTimeDataValue dateTimeValue => FormulaValue.New(dateTimeValue.Value.DateTime), + DateDataValue dateValue => FormulaValue.NewDateOnly(dateValue.Value), + TimeDataValue timeValue => FormulaValue.New(timeValue.Value), + TableDataValue tableValue => FormulaValue.NewTable(ParseRecordType(tableValue.Values.First()), tableValue.Values.Select(value => value.ToRecordValue())), // %%% TODO: RecordType + RecordDataValue recordValue => recordValue.ToRecordValue(), + //FileDataValue // %%% SUPPORT ??? + //OptionDataValue // %%% SUPPORT - Enum ??? + _ => FormulaValue.NewError(new Microsoft.PowerFx.ExpressionError { Message = $"Unknown literal type: {value.GetType().Name}" }), + }; + + public static FormulaType ToFormulaType(this DataType? type) => + type switch + { + null => FormulaType.Blank, + BooleanDataType => FormulaType.Boolean, + NumberDataType => FormulaType.Number, + FloatDataType => FormulaType.Decimal, + StringDataType => FormulaType.String, + DateTimeDataType => FormulaType.DateTime, + DateDataType => FormulaType.Date, + TimeDataType => FormulaType.Time, + //TableDataType => new TableType(), %%% ELEMENT TYPE + RecordDataType => RecordType.Empty(), + //FileDataType // %%% SUPPORT ??? + //OptionDataType // %%% SUPPORT - Enum ??? + DataType dataType => FormulaType.Blank, // %%% HANDLE ??? (FALLTHROUGH???) + //_ => FormulaType.Unknown, + }; + + public static RecordValue ToRecordValue(this RecordDataValue recordDataValue) => + FormulaValue.NewRecordFromFields( + recordDataValue.Properties.Select( + property => new NamedValue(property.Key, property.Value.ToFormulaValue()))); + + private static RecordType ParseRecordType(RecordDataValue record) + { + RecordType recordType = RecordType.Empty(); + foreach (KeyValuePair property in record.Properties) + { + recordType.Add(property.Key, property.Value.GetDataType().ToFormulaType()); + } + return recordType; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/FormulaValueExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/FormulaValueExtensions.cs new file mode 100644 index 000000000000..e2c837506fa3 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/FormulaValueExtensions.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Drawing; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using BlankType = Microsoft.PowerFx.Types.BlankType; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal delegate object? GetFormulaValue(FormulaValue value); + +internal static class FormulaValueExtensions +{ + public static DataValue GetDataValue(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => booleanValue.ToDataValue(), + DecimalValue decimalValue => decimalValue.ToDataValue(), + NumberValue numberValue => numberValue.ToDataValue(), + DateValue dateValue => dateValue.ToDataValue(), + DateTimeValue datetimeValue => datetimeValue.ToDataValue(), + TimeValue timeValue => timeValue.ToDataValue(), + StringValue stringValue => stringValue.ToDataValue(), + GuidValue guidValue => guidValue.ToDataValue(), // %%% CORRECT ??? + BlankValue blankValue => blankValue.ToDataValue(), + VoidValue voidValue => voidValue.ToDataValue(), + TableValue tableValue => tableValue.ToDataValue(), + RecordValue recordValue => recordValue.ToDataValue(), + //BlobValue // %%% DataValue ??? + //ErrorValue // %%% DataValue ??? + _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + }; + + public static DataType GetDataType(this FormulaValue value) => + value.Type switch + { + null => DataType.Blank, + BooleanType => DataType.Boolean, + DecimalType => DataType.Number, + NumberType => DataType.Float, + DateType => DataType.Date, + DateTimeType => DataType.DateTime, + TimeType => DataType.Time, + StringType => DataType.String, + GuidType => DataType.String, + BlankType => DataType.String, + RecordType => DataType.EmptyRecord, + //BlobValue // %%% DataType ??? + //ErrorValue // %%% DataType ??? + UnknownType => DataType.Unspecified, + _ => DataType.Unspecified, + }; + + public static string? Format(this FormulaValue value) => + value switch + { + BooleanValue booleanValue => $"{booleanValue.Value}", + DecimalValue decimalValue => $"{decimalValue.Value}", + NumberValue numberValue => $"{numberValue.Value}", + DateValue dateValue => $"{dateValue.GetConvertedValue(TimeZoneInfo.Utc)}", + DateTimeValue datetimeValue => $"{datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)}", + TimeValue timeValue => $"{timeValue.Value}", + StringValue stringValue => $"{stringValue.Value}", + GuidValue guidValue => $"{guidValue.Value}", + BlankValue blankValue => string.Empty, + VoidValue voidValue => string.Empty, + TableValue tableValue => tableValue.ToString(), // %%% WORK ??? + RecordValue recordValue => recordValue.ToString(), + //BlobValue // %%% DataValue ??? + //ErrorValue // %%% DataValue ??? + _ => throw new NotSupportedException($"Unsupported FormulaValue type: {value.GetType().Name}"), + }; + + // %%% TODO: Type conversion + + public static BooleanDataValue ToDataValue(this BooleanValue value) => BooleanDataValue.Create(value.Value); + public static NumberDataValue ToDataValue(this DecimalValue value) => NumberDataValue.Create(value.Value); + public static FloatDataValue ToDataValue(this NumberValue value) => FloatDataValue.Create(value.Value); + public static DateTimeDataValue ToDataValue(this DateTimeValue value) => DateTimeDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); + public static DateDataValue ToDataValue(this DateValue value) => DateDataValue.Create(value.GetConvertedValue(TimeZoneInfo.Utc)); + public static TimeDataValue ToDataValue(this TimeValue value) => TimeDataValue.Create(value.Value); + public static StringDataValue ToDataValue(this StringValue value) => StringDataValue.Create(value.Value); + public static StringDataValue ToDataValue(this GuidValue value) => StringDataValue.Create(value.Value.ToString("N")); // %%% FORMAT ??? + public static DataValue ToDataValue(this BlankValue _) => BlankDataValue.Blank(); + public static DataValue ToDataValue(this VoidValue _) => BlankDataValue.Blank(); // %%% CORRECT ??? + public static StringDataValue ToDataValue(this ColorValue value) => StringDataValue.Create(Enum.GetName(typeof(Color), value.Value)!); // %%% CORRECT ??? + + public static TableDataValue ToDataValue(this TableValue value) => + TableDataValue.TableFromRecords(value.Rows.Select(row => row.Value.ToDataValue()).ToImmutableArray()); + + public static RecordDataValue ToDataValue(this RecordValue value) => + RecordDataValue.RecordFromFields(value.OriginalFields.Select(field => field.GetKeyValuePair()).ToImmutableArray()); + + private static KeyValuePair GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.GetDataValue()); +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/PropertyPathExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/PropertyPathExtensions.cs new file mode 100644 index 000000000000..53dca4ce5aec --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/PropertyPathExtensions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class PropertyPathExtensions +{ + public static string Format(this PropertyPath path) => $"{path.VariableScopeName}.{path.VariableName}"; +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/RecordDataTypeExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/RecordDataTypeExtensions.cs new file mode 100644 index 000000000000..e06d92dbc171 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/RecordDataTypeExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class RecordDataTypeExtensions +{ + public static RecordValue ParseRecord(this RecordDataType recordType, JsonElement currentElement) + { + return FormulaValue.NewRecordFromFields(ParseValues()); + + IEnumerable ParseValues() + { + foreach (KeyValuePair property in recordType.Properties) + { + JsonElement propertyElement = currentElement.GetProperty(property.Key); + FormulaValue? parsedValue = + property.Value.Type switch + { + StringDataType => StringValue.New(propertyElement.GetString()), + NumberDataType => NumberValue.New(propertyElement.GetDecimal()), + BooleanDataType => BooleanValue.New(propertyElement.GetBoolean()), + DateTimeDataType dateTimeType => DateTimeValue.New(propertyElement.GetDateTime()), + DateDataType dateType => DateValue.New(propertyElement.GetDateTime()), + TimeDataType timeType => TimeValue.New(propertyElement.GetDateTimeOffset().TimeOfDay), + RecordDataType recordType => recordType.ParseRecord(propertyElement), + //TableDataValue tableValue => // %%% SUPPORT + _ => throw new UnknownDataTypeException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'") + }; + yield return new NamedValue(property.Key, parsedValue); + } + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/StringExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/StringExtensions.cs new file mode 100644 index 000000000000..6b8f0969dd7d --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/StringExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class StringExtensions +{ + private static readonly Regex s_regex = new(@"^```(?:\w*)\s*([\s\S]*?)\s*```$", RegexOptions.Compiled | RegexOptions.Multiline); + + public static string TrimJsonDelimiter(this string value) + { + Match match = s_regex.Match(value.Trim()); + if (match.Success) + { + return match.Groups[1].Value.Trim(); + } + + return value.Trim(); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/TemplateExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/TemplateExtensions.cs new file mode 100644 index 000000000000..3fd67118f6b1 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/Extensions/TemplateExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.SemanticKernel.Process.Workflows.Extensions; + +internal static class TemplateExtensions +{ + public static string? Format(this RecalcEngine engine, IEnumerable template) + { + return string.Concat(template.Select(line => engine.Format(line))); + } + + public static string? Format(this RecalcEngine engine, TemplateLine? line) + { + return string.Concat(line?.Segments.Select(segment => engine.Format(segment)) ?? [string.Empty]); + } + + private static string? Format(this RecalcEngine engine, TemplateSegment segment) + { + if (segment is TextSegment textSegment) + { + return textSegment.Value; + } + + if (segment is ExpressionSegment expressionSegment) + { + if (expressionSegment.Expression is not null) + { + if (expressionSegment.Expression.ExpressionText is not null) + { + FormulaValue expressionValue = engine.Eval(expressionSegment.Expression.ExpressionText); + return expressionValue.Format(); + } + if (expressionSegment.Expression.VariableReference is not null) + { + FormulaValue expressionValue = engine.Eval(expressionSegment.Expression.VariableReference.ToString()); + return expressionValue.Format(); + } + } + } + + throw new InvalidSegmentException($"Unsupported segment type: {segment.GetType().Name}"); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ObjectModelBuilder.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ObjectModelBuilder.cs new file mode 100644 index 000000000000..971cbd57e0fc --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ObjectModelBuilder.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Yaml; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows; + +namespace Microsoft.SemanticKernel; + +/// +/// Builder for converting a Foundry workflow object-model YAML definition into a process. +/// +public static class ObjectModelBuilder +{ + /// + /// Builds a process from the provided YAML definition of a CPS Topic ObjectModel. + /// + /// The reader that provides the workflow object model YAML. + /// The identifier for the message. + /// The hosting context for the workflow. + /// The that corresponds with the YAML object model. + public static KernelProcess Build(TextReader yamlReader, string messageId, WorkflowContext? context = null) + { + Console.WriteLine("@ PARSING YAML"); + BotElement rootElement = YamlSerializer.Deserialize(yamlReader) ?? throw new KernelException("Unable to parse YAML content."); + string rootId = GetRootId(rootElement); + + Console.WriteLine("@ INITIALIZING BUILDER"); + ProcessActionScopes scopes = new(); + ProcessBuilder processBuilder = new(rootId); + ProcessStepBuilder initStep = processBuilder.AddStepFromType(scopes, rootId); + processBuilder.OnInputEvent(messageId).SendEventTo(new ProcessFunctionTargetBuilder(initStep)); + + Console.WriteLine("@ INTERPRETING MODEL"); + ProcessActionVisitor visitor = new(processBuilder, context ?? new WorkflowContext(), scopes); + ProcessActionWalker walker = new(rootElement, visitor); + + Console.WriteLine("@ FINALIZING PROCESS"); + ProcessStepBuilder errorHandler = // %%% DYNAMIC/CONTEXT ??? + processBuilder.AddStepFromFunction( + $"{processBuilder.Name}_unhandled_error", + (kernel, context) => + { + // Handle unhandled errors here + Console.WriteLine("*** PROCESS ERROR - Unhandled error"); // %%% EXTERNAL + return Task.CompletedTask; + }); + processBuilder.OnError().SendEventTo(new ProcessFunctionTargetBuilder(errorHandler)); + + Console.WriteLine("@ PROCESS DEFINED"); + return processBuilder.Build(); + } + + private static string GetRootId(BotElement element) => + element switch + { + AdaptiveDialog adaptiveDialog => adaptiveDialog.BeginDialog?.Id.Value ?? throw new InvalidOperationException("Undefined dialog"), // %%% EXCEPTION TYPE + _ => throw new KernelException($"Unsupported root element: {element.GetType().Name}."), // %%% EXCEPTION TYPE + }; + + private sealed class RootWorkflowStep : KernelProcessStep + { + private ProcessActionScopes? _scopes; + + public override ValueTask ActivateAsync(KernelProcessStepState state) + { + this._scopes = state.State; + +#if !NETCOREAPP + return new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } + + [KernelFunction(KernelDelegateProcessStep.FunctionName)] + [Description("Initialize the process step")] + public void InitializeProcess(string? message) + { + if (this._scopes == null) + { + throw new KernelException("Scopes have not been initialized. Ensure that the step is activated before calling this function."); + } + + Console.WriteLine("!!! INIT WORKFLOW"); // %%% REMOVE + this._scopes.Set("LastMessage", ActionScopeType.System, StringValue.New(message)); // %%% MAGIC CONST "LastMessage" + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/FoundryExpressionEngine.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/FoundryExpressionEngine.cs new file mode 100644 index 000000000000..a0ac35113d2c --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/FoundryExpressionEngine.cs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.Bot.ObjectModel.Exceptions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +internal class FoundryExpressionEngine : IExpressionEngine +{ + private static readonly JsonSerializerOptions s_options = new(); // %%% INVESTIGATE: ElementSerializer.CreateOptions(); + + private readonly RecalcEngine _engine; + + public FoundryExpressionEngine(RecalcEngine engine) + { + this._engine = engine; + } + + public EvaluationResult GetValue(BoolExpression boolean, ProcessActionScopes state) => this.GetValue(boolean, state, this.EvaluateScope); + + public EvaluationResult GetValue(BoolExpression boolean, RecordDataValue state) => this.GetValue(boolean, state, this.EvaluateState); + + public EvaluationResult GetValue(StringExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(StringExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ValueExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(ValueExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(IntExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(IntExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(NumberExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(NumberExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState); + + public EvaluationResult GetValue(ObjectExpression expression, ProcessActionScopes state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(ObjectExpression expression, RecordDataValue state) where TValue : BotElement => this.GetValue(expression, state, this.EvaluateState); + + public ImmutableArray GetValue(ArrayExpression expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + + public ImmutableArray GetValue(ArrayExpression expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; + + public ImmutableArray GetValue(ArrayExpressionOnly expression, ProcessActionScopes state) => this.GetValue(expression, state, this.EvaluateScope).Value; + + public ImmutableArray GetValue(ArrayExpressionOnly expression, RecordDataValue state) => this.GetValue(expression, state, this.EvaluateState).Value; + + public EvaluationResult GetValue(EnumExpression expression, ProcessActionScopes state) where TValue : EnumWrapper => + this.GetValue(expression, state, this.EvaluateScope); + + public EvaluationResult GetValue(EnumExpression expression, RecordDataValue state) where TValue : EnumWrapper => + this.GetValue(expression, state, this.EvaluateState); + + public DialogSchemaName GetValue(DialogExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + public EvaluationResult GetValue(AdaptiveCardExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + public EvaluationResult GetValue(FileExpression expression, RecordDataValue state) + { + throw new NotSupportedException(); + } + + private EvaluationResult GetValue(BoolExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(default, SensitivityLevel.None); + } + + if (expressionResult.Value is not BooleanValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Boolean); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(StringExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(string.Empty, expressionResult.Sensitivity); + } + + if (expressionResult.Value is RecordValue recordValue) + { + return new EvaluationResult(JsonSerializer.Serialize(recordValue, s_options), expressionResult.Sensitivity); + } + + if (expressionResult.Value is not StringValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.String); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(IntExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is not PrimitiveValue formulaValue) // %%% CORRECT ??? + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(NumberExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is not NumberValue formulaValue) + { + throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.Number); + } + + return new EvaluationResult(formulaValue.Value, expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(ValueExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue ?? BlankDataValue.Instance, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult(expressionResult.Value.GetDataValue(), expressionResult.Sensitivity); + } + + private EvaluationResult GetValue(EnumExpression expression, TState state, Func> evaluator) where TValue : EnumWrapper + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return expressionResult.Value switch + { + BlankValue => new EvaluationResult(EnumWrapper.Create(0), expressionResult.Sensitivity), + StringValue s when s.Value is not null => new EvaluationResult(EnumWrapper.Create(s.Value), expressionResult.Sensitivity), + StringValue => new EvaluationResult(EnumWrapper.Create(0), expressionResult.Sensitivity), + NumberValue number => new EvaluationResult(EnumWrapper.Create((int)number.Value), expressionResult.Sensitivity), + //OptionDataValue option => new EvaluationResult(EnumWrapper.Create(option.Value.Value), expressionResult.Sensitivity), // %%% SUPPORT + _ => throw new InvalidExpressionOutputTypeException(expressionResult.Value.GetDataType(), DataType.String), + }; + } + + private EvaluationResult GetValue(ObjectExpression expression, TState state, Func> evaluator) where TValue : BotElement + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.LiteralValue != null) + { + return new EvaluationResult(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + if (expressionResult.Value is BlankValue) + { + return new EvaluationResult(null, expressionResult.Sensitivity); + } + + if (expressionResult.Value is not RecordValue formulaValue) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), expressionResult.Value.GetDataType()); + } + + try + { + return new EvaluationResult(ObjectExpressionParser.Parse(formulaValue.ToDataValue()), expressionResult.Sensitivity); + } + catch (Exception exception) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), exception); + } + } + + private EvaluationResult> GetValue(ArrayExpression expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + if (expression.IsLiteral) + { + return new EvaluationResult>(expression.LiteralValue, SensitivityLevel.None); + } + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult>(ParseArrayResults(expressionResult.Value), expressionResult.Sensitivity); + } + + private EvaluationResult> GetValue(ArrayExpressionOnly expression, TState state, Func> evaluator) + { + Throw.IfNull(expression, nameof(expression)); + + EvaluationResult expressionResult = evaluator.Invoke(expression, state); + + return new EvaluationResult>(ParseArrayResults(expressionResult.Value), expressionResult.Sensitivity); + } + + private static ImmutableArray ParseArrayResults(FormulaValue value) + { + if (value is BlankValue) + { + return ImmutableArray.Create(); + } + + if (value is not TableValue tableValue) + { + throw new CannotParseObjectExpressionOutputException(typeof(ImmutableArray), value.GetDataType()); + } + + TableDataValue tableDataValue = tableValue.ToDataValue(); + try + { + List list = []; + foreach (RecordDataValue row in tableDataValue.Values) + { + TValue? s = TableItemParser.Parse(row); + if (s != null) + { + list.Add(s); + } + } + return list.ToImmutableArray(); + } + catch (Exception exception) + { + throw new CannotParseObjectExpressionOutputException(typeof(TValue), exception); + } + } + + private EvaluationResult EvaluateState(ExpressionBase expression, RecordDataValue state) + { + foreach (KeyValuePair kvp in state.Properties) + { + if (kvp.Value is RecordDataValue scopeRecord) + { + this._engine.SetScope(kvp.Key, scopeRecord.ToRecordValue()); + } + } + + return this.Evaluate(expression); + } + + private EvaluationResult EvaluateScope(ExpressionBase expression, ProcessActionScopes state) + { + this._engine.SetScope(ActionScopeType.System.Name, state.BuildRecord(ActionScopeType.System)); + this._engine.SetScope(ActionScopeType.Env.Name, state.BuildRecord(ActionScopeType.Env)); + this._engine.SetScope(ActionScopeType.Global.Name, state.BuildRecord(ActionScopeType.Global)); + this._engine.SetScope(ActionScopeType.Topic.Name, state.BuildRecord(ActionScopeType.Topic)); + + return this.Evaluate(expression); + } + + private EvaluationResult Evaluate(ExpressionBase expression) + { + string? expressionText = + expression.IsVariableReference ? + expression.VariableReference?.Format() : + expression.ExpressionText; + + return new(this._engine.Eval(expressionText), SensitivityLevel.None); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineExtensions.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineExtensions.cs new file mode 100644 index 000000000000..1d97a695fe7f --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +internal static class RecalcEngineExtensions +{ + public static void ClearScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + { + // Clear all scope values. + scopes.Clear(scope); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath) => + engine.ClearScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName)); + + public static void ClearScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName) + { + // Clear value. + scopes.Remove(varName, scope); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, PropertyPath variablePath, FormulaValue value) => + engine.SetScopedVariable(scopes, ActionScopeType.Parse(variablePath.VariableScopeName), Throw.IfNull(variablePath.VariableName), value); + + public static void SetScopedVariable(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope, string varName, FormulaValue value) + { + // Assign value. + scopes.Set(varName, scope, value); + + // Rebuild scope record and update engine + engine.UpdateScope(scopes, scope); + } + + public static void SetScope(this RecalcEngine engine, string scopeName, RecordValue scopeRecord) + { + engine.DeleteFormula(scopeName); + engine.UpdateVariable(scopeName, scopeRecord); + } + + private static void UpdateScope(this RecalcEngine engine, ProcessActionScopes scopes, ActionScopeType scope) + { + RecordValue scopeRecord = scopes.BuildRecord(scope); + engine.SetScope(scope.Name, scopeRecord); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineFactory.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineFactory.cs new file mode 100644 index 000000000000..f64209891df2 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/PowerFx/RecalcEngineFactory.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; + +namespace Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +internal static class RecalcEngineFactory +{ + public static RecalcEngine Create( + ProcessActionScopes scopes, + int? maximumExpressionLength = null, + int? maximumCallDepth = null) + { + RecalcEngine engine = new(CreateConfig()); + + SetScope(ActionScopeType.Topic); + SetScope(ActionScopeType.Global); + SetScope(ActionScopeType.Env); + SetScope(ActionScopeType.System); + + return engine; + + void SetScope(ActionScopeType scope) + { + RecordValue record = scopes.BuildRecord(scope); + engine.UpdateVariable(scope.Name, record); + } + + PowerFxConfig CreateConfig() + { + PowerFxConfig config = new(Features.PowerFxV1); + + if (maximumExpressionLength is not null) + { + config.MaximumExpressionLength = maximumExpressionLength.Value; + } + + if (maximumCallDepth is not null) + { + config.MaxCallDepth = maximumCallDepth.Value; + } + + config.EnableSetFunction(); + + return config; + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessAction.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessAction.cs new file mode 100644 index 000000000000..55117953b860 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessAction.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.PowerFx; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +internal sealed record class ProcessActionContext(RecalcEngine Engine, ProcessActionScopes Scopes, Func ClientFactory, ILogger Logger) +{ + private FoundryExpressionEngine? _expressionEngine; + + public FoundryExpressionEngine ExpressionEngine => this._expressionEngine ??= new FoundryExpressionEngine(this.Engine); +} + +internal abstract class ProcessAction(TAction model) : ProcessAction(model) + where TAction : DialogAction +{ + public new TAction Model => (TAction)base.Model; +} + +internal abstract class ProcessAction +{ + public const string RootActionId = "(root)"; + + private string? _parentId; + + public string Id => this.Model.Id.Value; + + public string ParentId => this._parentId ??= this.Model.GetParentId() ?? RootActionId; + + public DialogAction Model { get; } + + protected ProcessAction(DialogAction model) + { + if (!model.HasRequiredProperties) + { + throw new InvalidActionException($"Action {this.GetType().Name} [{model.Id}]"); + } + + this.Model = model; + } + + public async Task ExecuteAsync(ProcessActionContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Execute each action in the current context + await this.HandleAsync(context, cancellationToken).ConfigureAwait(false); + } + catch (ProcessWorkflowException exception) + { + context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); + throw; + } + catch (Exception exception) + { + context.Logger.LogError(exception, "*** ACTION [{Id}] ERROR - {TypeName}\n{Message}", this.Id, this.GetType().Name, exception.Message); + throw new ProcessWorkflowException($"Unexpected failure executing action #{this.Id} [{this.GetType().Name}]", exception); + } + } + + protected abstract Task HandleAsync(ProcessActionContext context, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionScopes.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionScopes.cs new file mode 100644 index 000000000000..5b61d6809d8f --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionScopes.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Describes the type of action scope. +/// +internal sealed class ActionScopeType // %%% NEEDED +{ + // https://msazure.visualstudio.com/CCI/_git/ObjectModel?path=/src/ObjectModel/Nodes/VariableScopeNames.cs&_a=contents&version=GBmain + public static readonly ActionScopeType Env = new(VariableScopeNames.Environment); + public static readonly ActionScopeType Topic = new(VariableScopeNames.Topic); + public static readonly ActionScopeType Global = new(VariableScopeNames.Global); + public static readonly ActionScopeType System = new(VariableScopeNames.System); + + public static ActionScopeType Parse(string? scope) + { + return scope switch + { + nameof(Env) => Env, + nameof(Global) => Global, + nameof(System) => System, + nameof(Topic) => Topic, + null => throw new InvalidScopeException("Undefined action scope type."), + _ => throw new InvalidScopeException($"Unknown action scope type: {scope}."), + }; + } + + private ActionScopeType(string name) + { + this.Name = name; + } + + public string Name { get; } + + public string Format(string name) => $"{this.Name}.{name}"; + + public override string ToString() => this.Name; + + public override int GetHashCode() => this.Name.GetHashCode(); + + public override bool Equals(object? obj) => + (obj is ActionScopeType other && this.Name.Equals(other.Name, StringComparison.Ordinal)) || + (obj is string name && this.Name.Equals(name, StringComparison.Ordinal)); +} + +/// +/// The set of variables for a specific action scope. +/// +internal sealed class ProcessActionScope : Dictionary +{ + public RecordValue BuildRecord() + { + return FormulaValue.NewRecordFromFields(GetFields()); + + IEnumerable GetFields() + { + foreach (KeyValuePair kvp in this) + { + yield return new NamedValue(kvp.Key, kvp.Value); + } + } + } + + public RecordDataValue BuildState() + { + RecordDataValue.Builder recordBuilder = new(); + + foreach (KeyValuePair kvp in this) + { + recordBuilder.Properties.Add(kvp.Key, kvp.Value.GetDataValue()); + } + + return recordBuilder.Build(); + } +} + +/// +/// Contains all action scopes for a process. +/// +internal sealed class ProcessActionScopes +{ + private readonly ImmutableDictionary _scopes; + + public ProcessActionScopes() + { + Dictionary scopes = + new() + { + { ActionScopeType.Env, [] }, + { ActionScopeType.Topic, [] }, + { ActionScopeType.Global, [] }, + { ActionScopeType.System, [] }, + }; + + this._scopes = scopes.ToImmutableDictionary(); + } + + public RecordValue BuildRecord(ActionScopeType scope) => this._scopes[scope].BuildRecord(); + + public RecordDataValue BuildState() + { + return RecordDataValue.RecordFromFields(BuildStateFields()); + + IEnumerable> BuildStateFields() + { + foreach (KeyValuePair kvp in this._scopes) + { + yield return new(kvp.Key.Name, kvp.Value.BuildState()); + } + } + } + + public FormulaValue Get(string name, ActionScopeType? type = null) + { + if (this._scopes[type ?? ActionScopeType.Topic].TryGetValue(name, out FormulaValue? value)) + { + return value; + } + + return FormulaValue.NewBlank(); + } + + public void Clear(ActionScopeType type) => this._scopes[type].Clear(); + + public void Remove(string name) => this.Remove(name, ActionScopeType.Topic); + + public void Remove(string name, ActionScopeType type) => this._scopes[type].Remove(name); + + public void Set(string name, FormulaValue value) => this.Set(name, ActionScopeType.Topic, value); + + public void Set(string name, ActionScopeType type, FormulaValue value) => this._scopes[type][name] = value; +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionStack.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionStack.cs new file mode 100644 index 000000000000..16d00c1b1796 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionStack.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +internal delegate void ScopeCompletionAction(string scopeId); // %%% NEEDED: scopeId ??? + +internal sealed class ProcessActionStack +{ + private readonly Stack _actionStack = []; + private readonly Dictionary _actionScopes = []; + + public string CurrentScope => + this._actionStack.Count > 0 ? + this._actionStack.Peek() : + throw new InvalidOperationException("No scope defined"); // %%% EXCEPTION TYPE + + public void Recognize(string scopeId, ScopeCompletionAction? callback = null) + { +#if NET + if (this._actionScopes.TryAdd(scopeId, callback)) + { +#else + if (!this._actionScopes.ContainsKey(scopeId)) + { + this._actionScopes[scopeId] = callback; +#endif + // If the scope is new, push it onto the stack + this._actionStack.Push(scopeId); + } + else + { + // Otherwise, unwind the stack to the given scope + string currentScopeId; + while ((currentScopeId = this.CurrentScope) != scopeId) + { + ScopeCompletionAction? unwoundCallback = this._actionScopes[currentScopeId]; + unwoundCallback?.Invoke(currentScopeId); + this._actionStack.Pop(); + this._actionScopes.Remove(currentScopeId); + } + } + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionVisitor.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionVisitor.cs new file mode 100644 index 000000000000..02694b23f80c --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionVisitor.cs @@ -0,0 +1,509 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Core; +using Azure.Core.Pipeline; +using Microsoft.Bot.ObjectModel; +using Microsoft.Identity.Client.Platforms.Features.DesktopOs.Kerberos; +using Microsoft.PowerFx; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +internal sealed class ProcessActionVisitor : DialogActionVisitor +{ + private readonly ProcessBuilder _processBuilder; + private readonly ProcessWorkflowBuilder _workflowBuilder; + private readonly ProcessActionStack _actionStack; + private readonly WorkflowContext _context; + private readonly ProcessActionScopes _scopes; + + public ProcessActionVisitor( + ProcessBuilder processBuilder, + WorkflowContext context, + ProcessActionScopes scopes) + { + this._actionStack = new ProcessActionStack(); + this._workflowBuilder = new ProcessWorkflowBuilder(processBuilder.Steps.Single()); + this._processBuilder = processBuilder; + this._context = context; + this._scopes = scopes; + } + + public void Complete() + { + // Process the cached links + this._workflowBuilder.ConnectNodes(); + } + + protected override void Visit(ActionScope item) + { + this.Trace(item, isSkipped: true); + + //string parentId = item.GetParentId(); + //this._workflowBuilder.AddNode(this.CreateEmptyStep(item.Id.Value), parentId); + //this._workflowBuilder.AddLink(parentId, item.Id.Value); + } + + protected override void Visit(ConditionGroup item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ConditionGroupAction(item)); + + // %%% SUPPORT: item.ElseActions + + int index = 1; + foreach (ConditionItem conditionItem in item.Conditions) + { + //KernelProcessEdgeCondition? condition = null; + + //if (conditionItem.Condition is not null) + //{ + // // %%% VERIFY IF ONLY ONE CONDITION IS EXPECTED / ALLOWED + // condition = + // new((stepEvent, state) => + // { + // RecalcEngine engine = this.CreateEngine(); + // bool result = engine.Eval(conditionItem.Condition.ExpressionText ?? "true").AsBoolean(); + // Console.WriteLine($"!!! CONDITION: {conditionItem.Condition.ExpressionText ?? "true"}={result}"); + // return Task.FromResult(result); + // }); + //} + + ////this.AddScope(conditionItem.Id ?? $"{item.Id.Value}_item{index}", condition); + + //// Visit each action in the condition item + //conditionItem.Accept(this); + + ////this._workflowBuilder.RemoveScope(); + + ++index; + } + } + + public override void VisitConditionItem(ConditionItem item) + { + Console.WriteLine($"###### ITEM {item.Id}"); + base.VisitConditionItem(item); // %%% + } + + protected override void Visit(GotoAction item) + { + this.Trace(item, isSkipped: false); + + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(GotoAction)), parentId); + this._workflowBuilder.AddLink(item.Id.Value, item.ActionId.Value); + this.RestartFrom(item.Id.Value, nameof(GotoAction), parentId); + } + + protected override void Visit(Foreach item) + { + this.Trace(item, isSkipped: false); + + ForeachAction action = new(item); + this.ContinueWith(action); + string restartId = this.RestartFrom(action); + string loopId = $"next_{action.Id}"; + this.ContinueWith(this.CreateStep(loopId, $"{nameof(ForeachAction)}_Next", action.TakeNext), action.Id, callback: CompletionHandler); + this._workflowBuilder.AddLink(loopId, restartId, () => !action.HasValue); + this.ContinueWith(this.CreateStep($"start_{action.Id}", $"{nameof(ForeachAction)}_Start"), action.Id, () => action.HasValue); + void CompletionHandler(string scopeId) + { + string completionId = $"end_{action.Id}"; + this.ContinueWith(this.CreateStep(completionId, $"{nameof(ForeachAction)}_End"), action.Id); + this._workflowBuilder.AddLink(completionId, loopId); + } + } + + protected override void Visit(BreakLoop item) // %%% SUPPORT + { + this.Trace(item, isSkipped: false); + + string? loopId = this._workflowBuilder.LocateParent(item.GetParentId()); + if (loopId is not null) + { + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(BreakLoop)), parentId); + this._workflowBuilder.AddLink(item.Id.Value, $"post_{loopId}"); // %%% TODO: DRY + this.RestartFrom(item.Id.Value, nameof(BreakLoop), parentId); + } + } + + protected override void Visit(ContinueLoop item) // %%% SUPPORT + { + this.Trace(item, isSkipped: false); + + string? loopId = this._workflowBuilder.LocateParent(item.GetParentId()); + if (loopId is not null) + { + string parentId = Throw.IfNull(item.GetParentId(), nameof(BotElement.Parent)); // %%% NULL PARENT CASE ??? + this.ContinueWith(this.CreateStep(item.Id.Value, nameof(ContinueLoop)), parentId); + this._workflowBuilder.AddLink(item.Id.Value, $"next_{loopId}"); // %%% TODO: DRY + this.RestartFrom(item.Id.Value, nameof(ContinueLoop), parentId); + } + } + + protected override void Visit(EndConversation item) + { + this.Trace(item, isSkipped: false); + + EndConversationAction action = new(item); + this.ContinueWith(action); + this.RestartFrom(action); + } + + protected override void Visit(AnswerQuestionWithAI item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new AnswerQuestionWithAIAction(item)); + } + + protected override void Visit(SetVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SetVariableAction(item)); + } + + protected override void Visit(SetTextVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SetTextVariableAction(item)); + } + + protected override void Visit(ClearAllVariables item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ClearAllVariablesAction(item)); + } + + protected override void Visit(ResetVariable item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ResetVariableAction(item)); + } + + protected override void Visit(EditTable item) + { + this.Trace(item); + } + + protected override void Visit(EditTableV2 item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new EditTableV2Action(item)); + } + + protected override void Visit(ParseValue item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new ParseValueAction(item)); + } + + protected override void Visit(SendActivity item) + { + this.Trace(item, isSkipped: false); + + this.ContinueWith(new SendActivityAction(item, this._context.ActivityChannel)); + } + + #region Not implemented + + protected override void Visit(DeleteActivity item) + { + this.Trace(item); + } + + protected override void Visit(GetActivityMembers item) + { + this.Trace(item); + } + + protected override void Visit(UpdateActivity item) + { + this.Trace(item); + } + + protected override void Visit(ActivateExternalTrigger item) + { + this.Trace(item); + } + + protected override void Visit(DisableTrigger item) + { + this.Trace(item); + } + + protected override void Visit(WaitForConnectorTrigger item) + { + this.Trace(item); + } + + protected override void Visit(InvokeConnectorAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeCustomModelAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeFlowAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeAIBuilderModelAction item) + { + this.Trace(item); + } + + protected override void Visit(InvokeSkillAction item) + { + this.Trace(item); + } + + protected override void Visit(AdaptiveCardPrompt item) + { + this.Trace(item); + } + + protected override void Visit(Question item) + { + this.Trace(item); + } + + protected override void Visit(CSATQuestion item) + { + this.Trace(item); + } + + protected override void Visit(OAuthInput item) + { + this.Trace(item); + } + + protected override void Visit(BeginDialog item) + { + this.Trace(item); + } + + protected override void Visit(UnknownDialogAction item) + { + this.Trace(item); + } + + protected override void Visit(EndDialog item) + { + this.Trace(item); + } + + protected override void Visit(RepeatDialog item) + { + this.Trace(item); + } + + protected override void Visit(ReplaceDialog item) + { + this.Trace(item); + } + + protected override void Visit(CancelAllDialogs item) + { + this.Trace(item); + } + + protected override void Visit(CancelDialog item) + { + this.Trace(item); + } + + protected override void Visit(EmitEvent item) + { + this.Trace(item); + } + + protected override void Visit(GetConversationMembers item) + { + this.Trace(item); + } + + protected override void Visit(HttpRequestAction item) + { + this.Trace(item); + } + + protected override void Visit(RecognizeIntent item) + { + this.Trace(item); + } + + protected override void Visit(TransferConversation item) + { + this.Trace(item); + } + + protected override void Visit(TransferConversationV2 item) + { + this.Trace(item); + } + + protected override void Visit(SignOutUser item) + { + this.Trace(item); + } + + protected override void Visit(LogCustomTelemetryEvent item) + { + this.Trace(item); + } + + protected override void Visit(DisconnectedNodeContainer item) + { + this.Trace(item); + } + + protected override void Visit(CreateSearchQuery item) + { + this.Trace(item); + } + + protected override void Visit(SearchKnowledgeSources item) + { + this.Trace(item); + } + + protected override void Visit(SearchAndSummarizeWithCustomModel item) + { + this.Trace(item); + } + + protected override void Visit(SearchAndSummarizeContent item) + { + this.Trace(item); + } + + #endregion + + private void ContinueWith( + ProcessAction action, + Func? condition = null, + ScopeCompletionAction? callback = null) => + this.ContinueWith(this.CreateActionStep(action), action.ParentId, condition, action.GetType(), callback); + + private void ContinueWith( + ProcessStepBuilder step, + string parentId, + Func? condition = null, + Type? actionType = null, + ScopeCompletionAction? callback = null) + { + this._actionStack.Recognize(parentId, callback); + this._workflowBuilder.AddNode(step, parentId, actionType); + this._workflowBuilder.AddLinkFromPeer(parentId, step.Id, condition); + } + + private string RestartFrom(ProcessAction action) => + this.RestartFrom(action.Id, action.GetType().Name, action.ParentId); + + private string RestartFrom(string actionId, string name, string parentId) + { + string restartId = $"post_{actionId}"; + this._workflowBuilder.AddNode(this.CreateStep(restartId, $"{name}_Restart"), parentId); + return restartId; + } + + private ProcessStepBuilder CreateStep(string actionId, string name, Action? stepAction = null) + { + return + this._processBuilder.AddStepFromFunction( + actionId, + (kernel, context) => + { + Console.WriteLine($"!!! STEP {name} [{actionId}]"); // %%% REMOVE + stepAction?.Invoke(this.CreateActionContext(actionId)); + return Task.CompletedTask; + }); + } + + // This implementation accepts the context as a parameter in order to pin the context closure. + // The step cannot reference this.CurrentContext directly, as this will always be the final context. + private ProcessStepBuilder CreateActionStep(ProcessAction action) + { + return + this._processBuilder.AddStepFromFunction( + action.Id, + async (kernel, context) => + { + Console.WriteLine($"!!! STEP {action.GetType().Name} [{action.Id}]"); // %%% REMOVE + + if (action.Model.Disabled) // %%% VALIDATE + { + Console.WriteLine($"!!! DISABLED {action.GetType().Name} [{action.Id}]"); // %%% REMOVE + return; + } + + try + { + await action.ExecuteAsync( + this.CreateActionContext(action.Id), + cancellationToken: default).ConfigureAwait(false); // %%% CANCELTOKEN + } + catch (ProcessActionException) + { + Console.WriteLine($"*** STEP [{action.Id}] ERROR - Action failure"); // %%% LOGGER + throw; + } + catch (Exception exception) + { + Console.WriteLine($"*** STEP [{action.Id}] ERROR - {exception.GetType().Name}\n{exception.Message}"); // %%% LOGGER + throw; + } + }); + } + + private ProcessActionContext CreateActionContext(string actionId) => new(this.CreateEngine(), this._scopes, this.CreateClient, this._context.LoggerFactory.CreateLogger(actionId)); + + private PersistentAgentsClient CreateClient() + { + PersistentAgentsAdministrationClientOptions clientOptions = new(); + + if (this._context.HttpClient is not null) + { + clientOptions.Transport = new HttpClientTransport(this._context.HttpClient); + //clientOptions.RetryPolicy = new RetryPolicy(maxRetries: 0); + } + + return new PersistentAgentsClient(this._context.ProjectEndpoint, this._context.ProjectCredentials, clientOptions); + } + + private RecalcEngine CreateEngine() => RecalcEngineFactory.Create(this._scopes, this._context.MaximumExpressionLength); + + private void Trace(DialogAction item, bool isSkipped = true) + { + Console.WriteLine($"> {(isSkipped ? "EMPTY" : "VISIT")}: {new string('\t', this._workflowBuilder.GetDepth(item.GetParentId()))}{FormatItem(item)} => {FormatParent(item)}"); // %%% LOGGER + } + + private static string FormatItem(BotElement element) => $"{element.GetType().Name} ({element.GetId()})"; + + private static string FormatParent(BotElement element) => + element.Parent is null ? + throw new InvalidActionException($"Undefined parent for {element.GetType().Name} that is member of {element.GetId()}.") : + $"{element.Parent.GetType().Name} ({element.GetParentId()})"; +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionWalker.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionWalker.cs new file mode 100644 index 000000000000..56f5d18a2035 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessActionWalker.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +internal sealed class ProcessActionWalker : BotElementWalker +{ + private readonly ProcessActionVisitor _visitor; + + public ProcessActionWalker(BotElement rootElement, ProcessActionVisitor visitor) + { + this._visitor = visitor; + this.Visit(rootElement); + this._visitor.Complete(); + } + + public override bool DefaultVisit(BotElement definition) + { + if (definition is DialogAction action) + { + action.Accept(this._visitor); + } + + return true; + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessWorkflowBuilder.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessWorkflowBuilder.cs new file mode 100644 index 000000000000..549442c15e32 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/ProcessWorkflowBuilder.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.SemanticKernel.Process.Workflows; + +/// +/// Provides builder patterns for constructing a declarative process workflow. +/// +internal sealed class ProcessWorkflowBuilder +{ + public ProcessWorkflowBuilder(ProcessStepBuilder rootStep) + { + this.RootNode = this.DefineNode(rootStep); + } + + private ProcessWorkflowNode RootNode { get; } + + private Dictionary Steps { get; } = []; + + private List Links { get; } = []; + + public int GetDepth(string nodeId) + { + if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) + { + throw new UnknownActionException($"Unresolved step: {nodeId}."); + } + + return sourceNode.Depth; + } + + public void AddNode(ProcessStepBuilder step, string parentId, Type? actionType = null) + { + if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + { + throw new UnknownActionException($"Unresolved parent for {step.Id}: {parentId}."); + } + + ProcessWorkflowNode stepNode = this.DefineNode(step, parentNode, actionType); + + parentNode.Children.Add(stepNode); + } + + public void AddLinkFromPeer(string parentId, string targetId, Func? condition = null) + { + if (!this.Steps.TryGetValue(parentId, out ProcessWorkflowNode? parentNode)) + { + throw new UnknownActionException($"Unresolved step: {parentId}."); + } + + if (parentNode.Children.Count == 0) + { + throw new WorkflowBuilderException($"Cannot add a link from a node with no children: {parentId}."); + } + + ProcessWorkflowNode sourceNode = parentNode.Children.Count == 1 ? parentNode : parentNode.Children[^2]; + + this.Links.Add(new ProcessWorkflowLink(sourceNode, targetId, condition)); + } + + public void AddLink(string sourceId, string targetId, Func? condition = null) + { + if (!this.Steps.TryGetValue(sourceId, out ProcessWorkflowNode? sourceNode)) + { + throw new UnknownActionException($"Unresolved step: {sourceId}."); + } + + this.Links.Add(new ProcessWorkflowLink(sourceNode, targetId, condition)); + } + + //public void AddStop(string nodeId) // %%% REMOVE + //{ + // if (!this.Steps.TryGetValue(nodeId, out ProcessWorkflowNode? sourceNode)) + // { + // throw new UnknownActionException($"Unresolved node: {nodeId}."); + // } + + // sourceNode.Step.OnFunctionResult(KernelDelegateProcessStep.FunctionName).StopProcess(); + //} + + public void ConnectNodes() + { + foreach (ProcessWorkflowLink link in this.Links) + { + if (!this.Steps.TryGetValue(link.TargetId, out ProcessWorkflowNode? targetNode)) + { + throw new WorkflowBuilderException($"Unresolved target for {link.Source.Id}: {link.TargetId}."); + } + + Console.WriteLine($"> CONNECT: {link.Source.Id} => {link.TargetId}"); // %%% LOGGER + + link.Source.Step.OnFunctionResult(KernelDelegateProcessStep.FunctionName) + .SendEventTo(new ProcessFunctionTargetBuilder(targetNode.Step)) + .OnCondition(link.CreateEdgeCondition()); + } + } + + private ProcessWorkflowNode DefineNode(ProcessStepBuilder step, ProcessWorkflowNode? parentNode = null, Type? actionType = null) + { + ProcessWorkflowNode stepNode = new(step, parentNode, actionType); + this.Steps[stepNode.Id] = stepNode; + + return stepNode; + } + + internal string? LocateParent(string? itemId) + { + if (string.IsNullOrEmpty(itemId)) + { + return null; + } + + while (itemId != null) + { + if (!this.Steps.TryGetValue(itemId, out ProcessWorkflowNode? itemNode)) + { + throw new UnknownActionException($"Unresolved child: {itemId}."); + } + + if (itemNode.ActionType == typeof(TAction)) + { + return itemNode.Id; + } + + itemId = itemNode.Parent?.Id; + } + + return null; + } + + private sealed class ProcessWorkflowNode(ProcessStepBuilder step, ProcessWorkflowNode? parent = null, Type? actionType = null) + { + public string Id => step.Id; + + public ProcessStepBuilder Step => step; + + public ProcessWorkflowNode? Parent { get; } = parent; + + public List Children { get; } = []; + + public int Depth => this.Parent?.Depth + 1 ?? 0; + + public Type? ActionType => actionType; + } + + private sealed record class ProcessWorkflowLink(ProcessWorkflowNode Source, string TargetId, Func? Condition = null) + { + public KernelProcessEdgeCondition? CreateEdgeCondition() => + this.Condition == null ? + null : + new KernelProcessEdgeCondition((stepEvent, state) => Task.FromResult(this.Condition.Invoke())); + } +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/WorkflowContext.cs b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/WorkflowContext.cs new file mode 100644 index 000000000000..b56e2e7ffbb5 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/WorkflowContext.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net.Http; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.SemanticKernel; + +/// +/// Provides configuration and context for workflow execution. +/// +public sealed class WorkflowContext +{ + /// + /// Defines the endpoint for the Foundry project. + /// + public string ProjectEndpoint { get; init; } = string.Empty; + + /// + /// Defines the credentials that authorize access to the Foundry project. + /// + public TokenCredential ProjectCredentials { get; init; } = new DefaultAzureCredential(); + + /// + /// Defines the maximum number of nested calls allowed in a PowerFx formula. + /// + public int? MaximumCallDepth { get; init; } + + /// + /// Defines the maximum allowed length for expressions evaluated in the workflow. + /// + public int? MaximumExpressionLength { get; init; } + + /// + /// Gets the instance used to send HTTP requests. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// Gets the used to create loggers for workflow components. + /// + public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance; + + /// + /// Gets the used for activity output and diagnostics. + /// + public TextWriter ActivityChannel { get; init; } = Console.Out; // %%% REMOVE: For POC only +} diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/readme.md b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/readme.md new file mode 100644 index 000000000000..844bc2d7812a --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/readme.md @@ -0,0 +1,58 @@ +# Declarative Process Workflows + +### Goal + +Use the _Process Framework_ to execute a workflow defined with _Copilot Studio Declarative Language_ (CPSDL). + + +### Requirements + +- Translate a workflow defined in CPSDL into a _Process_ for execution. +- Host the resulting _Process_ workflow in the same runtime as any other _Process_. + + +### Dependencies + +Outside of the _Process Framework_, the following packages are utilized to parse and define a workflow based on CSPDL: + +- `Microsoft.Bot.ObjectModel` +- `Microsoft.PowerFx.Interpreter` + + +### Inputs + +The runtime is expected to provide the following inputs: + +- Host Context: Configuration and channels provided by the runtime. +- Workflow Definition: A declarative YAML workflow that defines CPSDL actions. +- Task: User / application input that informs workflow execution. + + +### Invocation Pattern + +The only consideration for hosting a workflow process based on CSPDL that differs from +a pro-code workflow is how the builder is invoked: + +```c# +// Context defined by the runtime to provide configuration and channels +HostingContext hostContext = ...; +// Customer defined CPSDL workflow as YAML +TextReader yamlReader = ...; +// User input specific to this workflow execution +string inputMessage = "Why is the sky blue?"; + +// Parse the CPSDL yaml and provide the resulting KernelProcess instance +// This is should be the _only_ difference compared to running a "pro-code" process +KernelProcess process = ObjectModelBuilder.Build(yamlReader, hostContext); + +// Execute process with CPSDL specific extension +process.StartAsync(inputMessage); +``` + +### Open Issues + +- **Schema Versioning:** + + How will the runtime associate workflows defined with different versions of the CPSDL object model with the appropriate builder? + + Is this distinction necessary if object model maintains backward compatibility? \ No newline at end of file diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_objectmodel.md b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_objectmodel.md new file mode 100644 index 000000000000..883a839d9d39 --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_objectmodel.md @@ -0,0 +1,174 @@ +# Object Model Requirements + +## Behaviors + +### Is a _Foundry_ workflow specific to a single _Foundry_ project? + +Always. +This implies that a _Foundry_ workflow has access to the resources associated with its project, +including: models, agents, and connections. + +### Can user defined workflow YAML be uploaded to a _Foundry_ project? + +Yes. +As _VS Code_ extension will support the authoring of _Foundry_ workflows in YAML. +The resulting YAML can be uploaded to a _Foundry_ project. +Ostensibly, one could directly author raw YAML and upload. + + +### Can a Foundry workflow be hosted in different projects without modification? + +Agent identifiers differ across projects even if model deployments match. +This creates an incompatibility between projects, +exception in the case where an agent is identified by name only. + + +### Can a _Copilot Studio_ workflow be hosted in _Foundry_ or vice-versa? + +Since the actions diverge between what is supported for _Copilot Studio_ and _Foundry_, +interoperability between platforms is not generally supported. +The special case where a workflow contains only the core actions that are supported by both platforms +might allow for interoperablity. + +### How and when are declarative workflows (YAML) validated? + +Validating a declarative workflow prior to execution provides a superior user experience. +This validation should be triggered for any workflow update: +- Uploading a YAML file that creates or overwrites a workflow. +- Creating or editing a workflow from the designer. + +Validation must include: +- Schema validation: + Can the YAML be deserialized and parsed? +- Functional validation: + Does the YAML consist of only the actions supported by the _Foundry_ object model? + Is each action properly defined with valid identifiers? +- Reference validation: + Are all referenced resources (models, agents, connections) valid and accessible? + +At runtime the references shall be re-validated and, where relevant, logical names translated into physical identifiers. + +## **Types** + +- ✔ **Message** + - Corresponds with user input or an agent response. + - Requires robust support for how to access, identify most recent, transfer, count, etc... + +- ❌ **Thread** + - Every workflow invocation is associated with a single "user thread" + - Each agent is associated with its own dedicated thread that is implicitly managed. + - Exposing an explicit thread primitive within a workflow introduces additional complexity that is not required. + +## **Actions** + +The following table enumerates the actions currently supported by _Copilot Studio_ +and those to be introduced specific to _Foundry_ workflows. +_CopilotStudio_ actions not supported by _Foundry_ workflows are marked with `❌`. + +Name|Copilot Studio|Foundry|Priority|Note +:--|:--:|:--:|:--:|:-- +ActionScope|✔|✔|✔|Container for actions +ActivateExternalTrigger|✔|❓||Not supported for v0. Evaluate trigger actions in next phase. +AdaptiveCardPrompt|✔|❌ +AnswerQuestionWithAI|✔|❌||Insufficient definition for _Foundry_. Use `InvokeChatCompletion` instead. +BeginDialog|✔|❌ +BreakLoop|✔|✔|✔ +CSATQuestion|✔|❌ +CancelAllDialogs|✔|❌ +CancelDialog|✔|❌ +ClearAllVariables|✔|✔|✔ +ConditionGroup|✔|✔|✔|Includes one or more `ConditionItem` and an `ElseActions`. +ContinueLoop|✔|✔|✔ +CreateSearchQuery|✔|❌ +DeleteActivity|✔|❓|❓|How does an _Activity_ differ from a _Message_? +DisableTrigger|✔|❓||Not supported for v0. Evaluate trigger actions in next phase. +DisconnectedNodeContainer|✔|❌ +EditTable|✔|❌||Favor `EditTableV2` instead. +EditTableV2|✔|✔|✔ +EmitEvent|✔|❌ +EndConversation|✔|✔|✔|Terminal action when specified. A sequential workflow will automatically end after the final action. +EndDialog|✔|❌ +Foreach|✔|✔|✔ +GetActivityMembers|✔|❌ +GetConversationMembers|✔|❌ +GotoAction|✔|✔|✔ +HttpRequestAction|✔|❌||Favor usage of `InvokeTool` instead. +InvokeAIBuilderModelAction|✔|❌ +InvokeAgent|❌|✔|✔|Produce a response for _Foundry_ agent based on its name or identifier. +InvokeChatCompletion|❌|✔|✔|Invoke model using chat-completion API. +InvokeConnectorAction|✔|❌ +InvokeCustomModelAction|✔|❌ +InvokeFlowAction|✔|❌ +InvokeResponse|❌|✔||Invoke model using response API. Not supported for v0. +InvokeSkillAction|✔|❌ +InvokeTool|❌|✔|❓|Unify how tools are defined and invoke directly (outside of agent invocation). Can include _Open API_, _Azure Function_, _Search_, etc... +LogCustomTelemetryEvent|✔|❓||Not supported for v0. Could be captured as part of a [_Foundry_ Observability](https://learn.microsoft.com/azure/ai-foundry/agents/concepts/tracing). +OAuthInput|✔|❌ +ParseValue|✔|✔|✔ +Question|✔|✔|✔|Solicits user input (human-in-the-loop). +RecognizeIntent|✔|❌ +RepeatDialog|✔|❌ +ReplaceDialog|✔|❌ +ResetVariable|✔|✔|✔ +SearchAndSummarizeContent|✔|❌ +SearchAndSummarizeWithCustomModel|✔|❌ +SearchKnowledgeSources|✔|❌ +SendActivity|✔|❓|❓|How does an _Activity_ differ from a _Message_? +SetTextVariable|✔|✔|✔ +SetVariable|✔|✔|✔ +SignOutUser|✔|❌ +TransferConversation|✔|❌||Favor `TransferConversationV2` instead, if at all. +TransferConversationV2|✔|❌||Not supported for v0. Could invoke invoke a different workflow; although, overloading action may be undesirable. +UnknownDialogAction|✔|❌||Serialization construct that represents an unknown action. Not explicitly expressed as a workflow action. +UpdateActivity|✔|❓|❓|How does an _Activity_ differ from a _Message_? +WaitForConnectorTrigger|✔|❓||Not supported for v0. Evaluate trigger actions in next phase. + +## **Definitions** + +The following definitions are used to describe the actions specific to _Foundry_ workflows. + +### `InvokeAgent` + +Agent may be idenfiied by name, identifier, or both. +When identifier is provided, it take precedence. + +> What other parameters or options are supported? + +```yaml +- kind: InvokeAgent + id: invokeAgent_u4cBtN + name: Fred +``` + +```yaml +- kind: InvokeAgent + id: invokeAgent_u4cBtN + agentid: asst_ymran0gQaXGqyG0QZ4f1Yqxi +``` + +```yaml +- kind: InvokeAgent + id: invokeAgent_u4cBtN + name: Fred + agentid: asst_ymran0gQaXGqyG0QZ4f1Yqxi +``` + +### `InvokeChatCompletion` + +A chat-completion request requires that the target model is specified. + +> What other parameters or options are supported? + +```yaml +- kind: InvokeChatCompletion + id: invokeModel_u4cBtN + model: gpt-4.1-mini +``` + +### `InvokeResponse` + +> TBD + +### `InvokeTool` + +> TBD \ No newline at end of file diff --git a/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_process.md b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_process.md new file mode 100644 index 000000000000..cbb5156cd43b --- /dev/null +++ b/dotnet/src/Experimental/Process.Core/Workflow/ObjectModel/requirements_process.md @@ -0,0 +1,25 @@ +## Process Flavors + +#### Types of Processes +- Pro-Code: Developer coded process hosted as a foundry workflow +- Declarative: Process built by interpreting declarative yaml +- Custom: Developer coded process hosted in whatever service they choose + +#### Process Comparison +Aspect|Pro-Code|Declarative|Custom +:--|:--:|:--:|:--: +Accepts `ILoggerFactory`|✔|✔|✔ +Checkpoint & restore process|✔|✔|✔ +Hosted in _Foundry_ |✔|✔|❓ +Requires _Foundry_ endpoint|✔|✔|❌ +Supports custom steps|✔|❌|✔ +Emits execution events|❓|✔|❓ + +#### Open Questions +- Can _any_ process be hosted in _Foundry_? + (Is a "Pro-Code" process and "Custom" process equivalent?) +- Can a "Pro-Code" process be represented as a declarative workflow? **NO** +- Do execute events need to be emitted for any process? **YES** +- Can a _Foundry_ workflow be hosted in different projects without modification? **NO** + (Agent identifiers differ even if model deployments match.) +- Is a _Foundry_ workflow specific to a single _Foundry_ project? **YES** diff --git a/dotnet/src/Experimental/Process.Core/Workflow/WorkflowBuilder.cs b/dotnet/src/Experimental/Process.Core/Workflow/WorkflowBuilder.cs index 38d9d2b90a00..c8bb2ce62cc7 100644 --- a/dotnet/src/Experimental/Process.Core/Workflow/WorkflowBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/Workflow/WorkflowBuilder.cs @@ -435,7 +435,7 @@ private static Node BuildNode(KernelProcessStepInfo step, List { @@ -549,7 +549,7 @@ private static string ResolveEventName(string eventName) int index = eventName.IndexOf(ProcessConstants.EventIdSeparator); if (index > 0) { - eventName = eventName.Substring(index + 1); + eventName = eventName[(index + 1)..]; } return eventName; diff --git a/dotnet/src/Experimental/Process.LocalRuntime/LocalDelegateStep.cs b/dotnet/src/Experimental/Process.LocalRuntime/LocalDelegateStep.cs new file mode 100644 index 000000000000..1f88666ee9ca --- /dev/null +++ b/dotnet/src/Experimental/Process.LocalRuntime/LocalDelegateStep.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Process.Internal; + +namespace Microsoft.SemanticKernel.Process; + +internal class LocalDelegateStep : LocalStep +{ + private readonly KernelProcessDelegateStepInfo _delegateStep; + + public LocalDelegateStep(KernelProcessDelegateStepInfo stepInfo, Kernel kernel, string? parentProcessId = null) + : base(stepInfo, kernel, parentProcessId) + { + this._delegateStep = stepInfo; + } + + protected override ValueTask InitializeStepAsync() + { + this._stepInstance = new KernelDelegateProcessStep(this._delegateStep.StepFunction); + + var kernelPlugin = KernelPluginFactory.CreateFromObject(this._stepInstance, pluginName: this._stepInfo.State.Id); + + // Load the kernel functions + foreach (KernelFunction f in kernelPlugin) + { + this._functions.Add(f.Name, f); + } + + this._initialInputs = this.FindInputChannels(this._functions, logger: null, this.ExternalMessageChannel); + this._inputs = this._initialInputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); + +#if !NETCOREAPP + return new ValueTask(); +#else + return ValueTask.CompletedTask; +#endif + } +} diff --git a/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs b/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs index 77fa480c3b4e..5816395d0aa6 100644 --- a/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs +++ b/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs @@ -266,6 +266,14 @@ private async ValueTask InitializeProcessAsync() ExternalMessageChannel = this.ExternalMessageChannel, }; } + else if (step is KernelProcessDelegateStepInfo delegateStep) + { + localStep = new LocalDelegateStep(delegateStep, this._kernel) + { + ParentProcessId = this.Id, + EventProxy = this.EventProxy, + }; + } else if (step is KernelProcessMap mapStep) { localStep = @@ -399,16 +407,19 @@ private async Task EnqueueEdgesAsync(IEnumerable edges, Queue List defaultConditionedEdges = []; foreach (var edge in edges) { - if (edge.Condition.DeclarativeDefinition?.Equals(ProcessConstants.Declarative.DefaultCondition, StringComparison.OrdinalIgnoreCase) ?? false) + if (edge.Condition is not null) { - defaultConditionedEdges.Add(edge); - continue; - } + if (edge.Condition.DeclarativeDefinition?.Equals(ProcessConstants.Declarative.DefaultCondition, StringComparison.OrdinalIgnoreCase) ?? false) + { + defaultConditionedEdges.Add(edge); + continue; + } - bool isConditionMet = await edge.Condition.Callback(processEvent.ToKernelProcessEvent(), this._processStateManager?.GetState()).ConfigureAwait(false); - if (!isConditionMet) - { - continue; + bool isConditionMet = await edge.Condition.Callback(processEvent.ToKernelProcessEvent(), this._processStateManager?.GetState()).ConfigureAwait(false); + if (!isConditionMet) + { + continue; + } } // Handle different target types diff --git a/dotnet/src/Experimental/Process.UnitTests/Process.UnitTests.csproj b/dotnet/src/Experimental/Process.UnitTests/Process.UnitTests.csproj index 566e3f5559aa..f2c10974d010 100644 --- a/dotnet/src/Experimental/Process.UnitTests/Process.UnitTests.csproj +++ b/dotnet/src/Experimental/Process.UnitTests/Process.UnitTests.csproj @@ -8,7 +8,7 @@ true false 12 - $(NoWarn);CA2007,CA1812,CA1861,CA1063,VSTHRD111,SKEXP0001,SKEXP0050,SKEXP0080,SKEXP0110;OPENAI001,CA1024 + $(NoWarn);CA2007;CA1812;CA1861;CA1063;IDE1006;VSTHRD111;SKEXP0001;SKEXP0050;SKEXP0080;SKEXP0110;OPENAI001;CA1024 diff --git a/dotnet/src/Experimental/Process.UnitTests/TestOutputAdapter.cs b/dotnet/src/Experimental/Process.UnitTests/TestOutputAdapter.cs new file mode 100644 index 000000000000..8f04bf98aa5f --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/TestOutputAdapter.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests; + +public sealed class TestOutputAdapter(ITestOutputHelper output) : TextWriter, ILogger, ILoggerFactory +{ + private readonly Stack _scopes = []; + + public override Encoding Encoding { get; } = Encoding.UTF8; + + public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException(); + + public ILogger CreateLogger(string categoryName) => this; + + public bool IsEnabled(LogLevel logLevel) => true; + + public override void WriteLine(object? value = null) => this.SafeWrite($"{value}"); + + public override void WriteLine(string? format, params object?[] arg) => this.SafeWrite(string.Format(format ?? string.Empty, arg)); + + public override void WriteLine(string? value) => this.SafeWrite(value ?? string.Empty); + + public override void Write(object? value = null) => this.SafeWrite($"{value}"); + + public override void Write(char[]? buffer) => this.SafeWrite(new string(buffer)); + + public IDisposable BeginScope(TState state) where TState : notnull + { + this._scopes.Push($"{state}"); + return new LoggerScope(() => this._scopes.Pop()); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + string message = formatter(state, exception); + string scope = this._scopes.Count > 0 ? $"[{this._scopes.Peek()}] " : string.Empty; + output.WriteLine($"{scope}{message}"); + } + + private void SafeWrite(string value) + { + try + { + output.WriteLine(value ?? string.Empty); + } + catch (InvalidOperationException exception) when (exception.Message == "There is no currently active test.") + { + // This exception is thrown when the test output is accessed outside of a test context. + // We can ignore it since we are not in a test context. + } + } + + private sealed class LoggerScope(Action action) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!this._disposed) + { + action.Invoke(); + this._disposed = true; + } + } + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/ActionScopeTypeTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/ActionScopeTypeTests.cs new file mode 100644 index 000000000000..7a9e490981a3 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/ActionScopeTypeTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Process.Workflows; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows; + +public sealed class ActionScopeTypeTests(ITestOutputHelper output) : WorkflowTest(output) +{ + [Fact] + public void VerifyStaticInstances() + { + // Arrange & Act & Assert + Assert.Equal(nameof(ActionScopeType.Env), ActionScopeType.Env.Name); + Assert.Equal(nameof(ActionScopeType.Topic), ActionScopeType.Topic.Name); + Assert.Equal(nameof(ActionScopeType.Global), ActionScopeType.Global.Name); + Assert.Equal(nameof(ActionScopeType.System), ActionScopeType.System.Name); + } + + [Theory] + [InlineData(nameof(ActionScopeType.Env))] + [InlineData(nameof(ActionScopeType.Topic))] + [InlineData(nameof(ActionScopeType.Global))] + [InlineData(nameof(ActionScopeType.System))] + public void ParseValidScopeNames(string scopeName) + { + // Arrange & Act + ActionScopeType result = ActionScopeType.Parse(scopeName); + + // Assert + Assert.Equal(scopeName, result.Name); + } + + [Fact] + public void ParseNullInput() + { + // Arrange & Act & Assert + InvalidScopeException exception = Assert.Throws(() => ActionScopeType.Parse(null)); + Assert.Equal("Undefined action scope type.", exception.Message); + } + + [Fact] + public void ParseInvalidScopeName() + { + // Arrange + string invalidScope = "InvalidScope"; + + // Act & Assert + InvalidScopeException exception = Assert.Throws(() => ActionScopeType.Parse(invalidScope)); + Assert.Equal($"Unknown action scope type: {invalidScope}.", exception.Message); + } + + [Fact] + public void ToStringReturnsName() + { + // Arrange + ActionScopeType scope = ActionScopeType.Env; + + // Act + string result = scope.ToString(); + + // Assert + Assert.Equal(nameof(ActionScopeType.Env), result); + } + + [Fact] + public void VerifyGetHashCode() + { + // Arrange + ActionScopeType scope = ActionScopeType.Topic; + int expectedHashCode = "Topic".GetHashCode(); + + // Act + int result = scope.GetHashCode(); + + // Assert + Assert.Equal(expectedHashCode, result); + } + + [Fact] + public void VerifyEqualsWithSameInstance() + { + // Arrange + ActionScopeType scope = ActionScopeType.Global; + + // Act + bool result = scope.Equals(scope); + + // Assert + Assert.True(result); + } + + [Fact] + public void VerifyEqualsWithName() + { + // Arrange + ActionScopeType scope = ActionScopeType.System; + + // Act + bool result = scope.Equals(nameof(ActionScopeType.System)); + + // Assert + Assert.True(result); + } + + [Fact] + public void VerifyInequalityWithName() + { + // Arrange + ActionScopeType scope = ActionScopeType.Env; + + // Act + bool result = scope.Equals("DifferentName"); + + // Assert + Assert.False(result); + } + + [Fact] + public void VerifyInequalityWithNull() + { + // Arrange + ActionScopeType scope = ActionScopeType.Topic; + + // Act + bool result = scope.Equals(null); + + // Assert + Assert.False(result); + } + + [Fact] + public void VerifyInequalityWithOtherType() + { + // Arrange + ActionScopeType scope = ActionScopeType.Global; + int differentTypeObject = 42; + + // Act + bool result = scope.Equals(differentTypeObject); + + // Assert + Assert.False(result); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ClearAllVariablesActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ClearAllVariablesActionTest.cs new file mode 100644 index 000000000000..58d49a587f00 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ClearAllVariablesActionTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Tests for . +/// +public sealed class ClearAllVariablesActionTest(ITestOutputHelper output) : ProcessActionTest(output) +{ + [Fact] + public async Task ClearWorkflowScope() + { + // Arrange + this.Scopes.Set("NoVar", FormulaValue.New("Old value")); + + ClearAllVariables model = + this.CreateModel( + this.FormatDisplayName(nameof(ClearWorkflowScope)), + VariablesToClear.ConversationScopedVariables); + + // Act + ClearAllVariablesAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + } + + [Fact] + public async Task ClearUndefinedScope() + { + // Arrange + ClearAllVariables model = + this.CreateModel( + this.FormatDisplayName(nameof(ClearUndefinedScope)), + VariablesToClear.UserScopedVariables); + + // Act + ClearAllVariablesAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + } + + private ClearAllVariables CreateModel(string displayName, VariablesToClear variableTarget) + { + ClearAllVariables.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variables = EnumExpression.Literal(VariablesToClearWrapper.Get(variableTarget)), + }; + + ClearAllVariables model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/MockKernelProcessMessageChannel.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/MockKernelProcessMessageChannel.cs new file mode 100644 index 000000000000..65c58c2f242e --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/MockKernelProcessMessageChannel.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +internal sealed class MockKernelProcessMessageChannel : Mock +{ + public MockKernelProcessMessageChannel() + : base(MockBehavior.Strict) + { + this.Setup( + channel => + channel + .EmitEventAsync(It.IsAny())) + .Returns(ValueTask.CompletedTask); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ProcessActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ProcessActionTest.cs new file mode 100644 index 000000000000..5d8b9d2efc0d --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ProcessActionTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Base test class for implementations. +/// +public abstract class ProcessActionTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal ProcessActionScopes Scopes { get; } = new(); + + protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}"); + + protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; + + internal Task ExecuteAction(ProcessAction action, Kernel? kernel = null) => + action.ExecuteAsync( + new ProcessActionContext( + RecalcEngineFactory.Create(this.Scopes, 5000), + this.Scopes, + () => null!, // %%% FIX + this.Output), + cancellationToken: default); + + internal void VerifyModel(DialogAction model, ProcessAction action) + { + Assert.Equal(model.Id, action.Id); + Assert.Equal(model, action.Model); + } + + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, ActionScopeType.Topic, expectedValue); + + internal void VerifyState(string variableName, ActionScopeType scope, FormulaValue expectedValue) + { + FormulaValue actualValue = this.Scopes.Get(variableName, scope); + Assert.Equivalent(expectedValue, actualValue); + } + + protected void VerifyUndefined(string variableName) => this.VerifyUndefined(variableName, ActionScopeType.Topic); + + internal void VerifyUndefined(string variableName, ActionScopeType scope) + { + Assert.IsType(this.Scopes.Get(variableName, scope)); + } + + protected TAction AssignParent(DialogAction.Builder actionBuilder) where TAction : DialogAction + { + OnActivity.Builder activityBuilder = + new() + { + Id = new("root"), + }; + + activityBuilder.Actions.Add(actionBuilder); + + OnActivity model = activityBuilder.Build(); + + return (TAction)model.Actions[0]; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ResetVariableActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ResetVariableActionTest.cs new file mode 100644 index 000000000000..a6aeb551592e --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/ResetVariableActionTest.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Tests for . +/// +public sealed class ResetVariableTest(ITestOutputHelper output) : ProcessActionTest(output) +{ + [Fact] + public async Task ResetDefinedValue() + { + // Arrange + this.Scopes.Set("MyVar", FormulaValue.New("Old value")); + + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetDefinedValue)), + FormatVariablePath("MyVar")); + + // Act + ResetVariableAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("MyVar"); + } + + [Fact] + public async Task ResetUndefinedValue() + { + // Arrange + ResetVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(ResetUndefinedValue)), + FormatVariablePath("NoVar")); + + // Act + ResetVariableAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyUndefined("NoVar"); + } + + private ResetVariable CreateModel(string displayName, string variablePath) + { + ResetVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + }; + + ResetVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SendActivityActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SendActivityActionTest.cs new file mode 100644 index 000000000000..163a33a9053e --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SendActivityActionTest.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Tests for . +/// +public sealed class SendActivityActionTest(ITestOutputHelper output) : ProcessActionTest(output) +{ + [Fact] + public async Task CaptureActivity() + { + // Arrange + SendActivity model = + this.CreateModel( + this.FormatDisplayName(nameof(CaptureActivity)), + "Test activity message"); + await using StringWriter activityWriter = new(); + + // Act + SendActivityAction action = new(model, activityWriter); + await this.ExecuteAction(action); + activityWriter.Flush(); + + // Assert + this.VerifyModel(model, action); + Assert.NotEmpty(activityWriter.ToString()); + } + + private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null) + { + MessageActivityTemplate.Builder activityBuilder = + new() + { + Summary = summary, + Text = { TemplateLine.Parse(activityMessage) }, + }; + SendActivity.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Activity = activityBuilder.Build(), + }; + + SendActivity model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetTextVariableActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetTextVariableActionTest.cs new file mode 100644 index 000000000000..bdcc8edef45b --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetTextVariableActionTest.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Tests for . +/// +public sealed class SetTextVariableActionTest(ITestOutputHelper output) : ProcessActionTest(output) +{ + [Fact] + public async Task SetLiteralValue() + { + // Arrange + SetTextVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(SetLiteralValue)), + FormatVariablePath("TextVar"), + "Text variable value"); + + // Act + SetTextVariableAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("TextVar", FormulaValue.New("Text variable value")); + } + + [Fact] + public async Task UpdateExistingValue() + { + // Arrange + this.Scopes.Set("TextVar", FormulaValue.New("Old value")); + + SetTextVariable model = + this.CreateModel( + this.FormatDisplayName(nameof(UpdateExistingValue)), + FormatVariablePath("TextVar"), + "New value"); + + // Act + SetTextVariableAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState("TextVar", FormulaValue.New("New value")); + } + + private SetTextVariable CreateModel(string displayName, string variablePath, string textValue) + { + SetTextVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + Value = TemplateLine.Parse(textValue), + }; + + SetTextVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetVariableActionTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetVariableActionTest.cs new file mode 100644 index 000000000000..877d74870221 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Actions/SetVariableActionTest.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Actions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Actions; + +/// +/// Tests for . +/// +public sealed class SetVariableActionTest(ITestOutputHelper output) : ProcessActionTest(output) +{ + [Fact] + public void InvalidModel() + { + // Arrange, Act, Assert + Assert.Throws(() => new SetVariableAction(new SetVariable())); + } + + [Fact] + public async Task SetNumericValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetNumericValue), + variableName: "TestVariable", + variableValue: new NumberDataValue(42), + expectedValue: FormulaValue.New(42)); + } + + [Fact] + public async Task SetStringValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetStringValue), + variableName: "TestVariable", + variableValue: new StringDataValue("Text"), + expectedValue: FormulaValue.New("Text")); + } + + [Fact] + public async Task SetBooleanValue() + { + // Arrange, Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanValue), + variableName: "TestVariable", + variableValue: new BooleanDataValue(true), + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetBooleanExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("true || false")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetNumberExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("9 - 3")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(6)); + } + + [Fact] + public async Task SetStringExpression() + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression(@"Concatenate(""A"", ""B"", ""C"")")); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New("ABC")); + } + + [Fact] + public async Task SetBooleanVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New(true)); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(true)); + } + + [Fact] + public async Task SetNumberVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New(321)); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New(321)); + } + + [Fact] + public async Task SetStringVariable() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New("Test")); + ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(SetBooleanExpression), + variableName: "TestVariable", + valueExpression: expressionBuilder, + expectedValue: FormulaValue.New("Test")); + } + + [Fact] + public async Task UpdateExistingValue() + { + // Arrange + this.Scopes.Set("VarA", FormulaValue.New(33)); + + // Act, Assert + await this.ExecuteTest( + displayName: nameof(UpdateExistingValue), + variableName: "VarA", + variableValue: new NumberDataValue(42), + expectedValue: FormulaValue.New(42)); + } + + private Task ExecuteTest( + string displayName, + string variableName, + DataValue variableValue, + FormulaValue expectedValue) + { + // Arrange + ValueExpression.Builder expressionBuilder = new(ValueExpression.Literal(variableValue)); + + // Act & Assert + return this.ExecuteTest(displayName, variableName, expressionBuilder, expectedValue); + } + + private async Task ExecuteTest( + string displayName, + string variableName, + ValueExpression.Builder valueExpression, + FormulaValue expectedValue) + { + // Arrange + SetVariable model = + this.CreateModel( + displayName, + FormatVariablePath(variableName), + valueExpression); + + this.Scopes.Set(variableName, FormulaValue.New(33)); + + // Act + SetVariableAction action = new(model); + await this.ExecuteAction(action); + + // Assert + this.VerifyModel(model, action); + this.VerifyState(variableName, expectedValue); + } + + private SetVariable CreateModel(string displayName, string variablePath, ValueExpression.Builder valueExpression) + { + SetVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = InitializablePropertyPath.Create(variablePath), + Value = valueExpression, + }; + + SetVariable model = this.AssignParent(actionBuilder); + + return model; + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/FormulaValueExtensionsTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/FormulaValueExtensionsTests.cs new file mode 100644 index 000000000000..6c996b01ba8e --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/FormulaValueExtensionsTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Xunit; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Extensions; + +public class FormulaValueExtensionsTests +{ + [Fact] + public void BooleanValue() + { + BooleanValue formulaValue = FormulaValue.New(true); + BooleanDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + BooleanValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal(bool.TrueString, formulaValue.Format()); + } + + [Fact] + public void StringValues() + { + StringValue formulaValue = FormulaValue.New("test value"); + StringDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + StringValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal(formulaValue.Value, formulaValue.Format()); + } + + [Fact] + public void DecimalValues() + { + DecimalValue formulaValue = FormulaValue.New(45.3m); + NumberDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + DecimalValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("45.3", formulaValue.Format()); + } + + [Fact] + public void NumberValues() + { + NumberValue formulaValue = FormulaValue.New(3.1415926535897); + FloatDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + NumberValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("3.1415926535897", formulaValue.Format()); + } + + [Fact] + public void BlankValues() + { + BlankValue formulaValue = FormulaValue.NewBlank(); + + BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + + Assert.Equal(string.Empty, formulaValue.Format()); + } + + [Fact] + public void VoidValues() + { + VoidValue formulaValue = FormulaValue.NewVoid(); + BlankDataValue dataCopy = Assert.IsType(formulaValue.GetDataValue()); + } + + [Fact] + public void DateValues() + { + DateValue formulaValue = FormulaValue.NewDateOnly(DateTime.UtcNow.Date); + DateDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + + DateValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } + + [Fact] + public void DateTimeValues() + { + DateTimeValue formulaValue = FormulaValue.New(DateTime.UtcNow); + DateTimeDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), dataValue.Value); + + DateTimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc)); + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } + + [Fact] + public void TimeValues() + { + TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35")); + TimeDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Value, dataValue.Value); + + TimeValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue()); + Assert.Equal(dataValue.Value, formulaCopy.Value); + + Assert.Equal("10:35:00", formulaValue.Format()); + } + + [Fact] + public void RecordValues() + { + RecordValue formulaValue = FormulaValue.NewRecordFromFields( + new NamedValue("FieldA", FormulaValue.New("Value1")), + new NamedValue("FieldB", FormulaValue.New("Value2")), + new NamedValue("FieldC", FormulaValue.New("Value3"))); + + RecordDataValue dataValue = formulaValue.ToDataValue(); + Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count); + foreach (KeyValuePair property in dataValue.Properties) + { + Assert.Contains(property.Key, formulaValue.Fields.Select(field => field.Name)); + } + + RecordValue formulaCopy = Assert.IsType(dataValue.ToFormulaValue(), exactMatch: false); + Assert.Equal(formulaCopy.Fields.Count(), dataValue.Properties.Count); + foreach (NamedValue field in formulaCopy.Fields) + { + Assert.Contains(field.Name, dataValue.Properties.Keys); + } + + //Assert.Equal("45.3", formulaValue.Format()); // %%% TEST ASSERT ??? + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/StringExtensionsTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/StringExtensionsTests.cs new file mode 100644 index 000000000000..2e6b5b107a5a --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/Extensions/StringExtensionsTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Xunit; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.Extensions; + +public class StringExtensionsTests +{ + [Fact] + public void TrimJsonWithDelimiter() + { + // Arrange + const string input = + """ + ```json + { + "key": "value" + } + ``` + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + [Fact] + public void TrimJsonWithPadding() + { + // Arrange + const string input = + """ + + ```json + { + "key": "value" + } + ``` + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithUnqualifiedDelimiter() + { + // Arrange + const string input = + """ + ``` + { + "key": "value" + } + ``` + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithoutDelimiter() + { + // Arrange + const string input = + """ + { + "key": "value" + } + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimJsonWithoutDelimiterWithPadding() + { + // Arrange + const string input = + """ + + { + "key": "value" + } + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal( + """ + { + "key": "value" + } + """, + result); + } + + [Fact] + public void TrimMissingWithDelimiter() + { + // Arrange + const string input = + """ + ```json + ``` + """; + + // Act + string result = input.TrimJsonDelimiter(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void TrimEmptyString() + { + // Act + string result = string.Empty.TrimJsonDelimiter(); + + // Assert + Assert.Equal(string.Empty, result); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/FoundryExpressionEngineTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/FoundryExpressionEngineTests.cs new file mode 100644 index 000000000000..2e783436acaf --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/FoundryExpressionEngineTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Bot.ObjectModel; +using Microsoft.Bot.ObjectModel.Abstractions; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.PowerFx; + +public class FoundryExpressionEngineTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void DefaultNotNull() + { + // Act + RecalcEngine engine = this.CreateEngine(); + FoundryExpressionEngine expressionEngine = new(engine); + this.Scopes.Set("test", FormulaValue.New("value")); + engine.SetScopedVariable(this.Scopes, PropertyPath.TopicVariable("test"), FormulaValue.New("value")); + + EvaluationResult valueResult = expressionEngine.GetValue(StringExpression.Variable(PropertyPath.TopicVariable("test")), this.Scopes.BuildState()); + + // Assert + Assert.Equal("value", valueResult.Value); + Assert.Equal(SensitivityLevel.None, valueResult.Sensitivity); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineEvaluationTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineEvaluationTests.cs new file mode 100644 index 000000000000..159cb61b1c0a --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineEvaluationTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.PowerFx; + +#pragma warning disable CA1308 // Ignore "Normalize strings to uppercase" warning for test cases + +public sealed class RecalcEngineEvaluationTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void EvaluateConstant() + { + RecalcEngine engine = this.CreateEngine(); + + this.EvaluateExpression(engine, 0m, "0"); + this.EvaluateExpression(engine, -1m, "-1"); + this.EvaluateExpression(engine, true, "true"); + this.EvaluateExpression(engine, false, "false"); + this.EvaluateExpression(engine, (string?)null, string.Empty); + this.EvaluateExpression(engine, "Hi", "\"Hi\""); + } + + [Fact] + public void EvaluateInvalid() + { + RecalcEngine engine = this.CreateEngine(); + engine.UpdateVariable("Scoped.Value", FormulaValue.New(33)); + + this.EvaluateFailure(engine, "Hi"); + this.EvaluateFailure(engine, "True"); + this.EvaluateFailure(engine, "TRUE"); + this.EvaluateFailure(engine, "=1", canParse: false); + this.EvaluateFailure(engine, "=1+2", canParse: false); + this.EvaluateFailure(engine, "CustomValue"); + this.EvaluateFailure(engine, "CustomValue + 1"); + this.EvaluateFailure(engine, "Scoped.Value"); + this.EvaluateFailure(engine, "Scoped.Value + 1"); + this.EvaluateFailure(engine, "\"BEGIN-\" & Scoped.Value & \"-END\""); + } + + [Fact] + public void EvaluateFormula() + { + NamedValue[] recordValues = + [ + new NamedValue("Label", FormulaValue.New("Test")), + new NamedValue("Value", FormulaValue.New(54)), + ]; + FormulaValue complexValue = FormulaValue.NewRecordFromFields(recordValues); + + RecalcEngine engine = this.CreateEngine(); + engine.UpdateVariable("CustomLabel", FormulaValue.New("Note")); + engine.UpdateVariable("CustomValue", FormulaValue.New(42)); + engine.UpdateVariable("Scoped", complexValue); + + this.EvaluateExpression(engine, 2m, "1 + 1"); + this.EvaluateExpression(engine, 42m, "CustomValue"); + this.EvaluateExpression(engine, 43m, "CustomValue + 1"); + this.EvaluateExpression(engine, "Note", "CustomLabel"); + //this.EvaluateExpression(engine, "Note", "\"{CustomLabel}\""); + this.EvaluateExpression(engine, "BEGIN-42-END", "\"BEGIN-\" & CustomValue & \"-END\""); + this.EvaluateExpression(engine, 54m, "Scoped.Value"); + this.EvaluateExpression(engine, 55m, "Scoped.Value + 1"); + this.EvaluateExpression(engine, "Test", "Scoped.Label"); + //this.EvaluateExpression(engine, "Test", "\"{Scoped.Label}\""); + } + + private void EvaluateFailure(RecalcEngine engine, string sourceExpression, bool canParse = true) + { + CheckResult checkResult = engine.Check(sourceExpression); + Assert.False(checkResult.IsSuccess); + ParseResult parseResult = engine.Parse(sourceExpression); + Assert.Equal(canParse, parseResult.IsSuccess); + Assert.Throws(() => engine.Eval(sourceExpression)); + } + + private void EvaluateExpression(RecalcEngine engine, T expectedResult, string sourceExpression) + { + CheckResult checkResult = engine.Check(sourceExpression); + Assert.True(checkResult.IsSuccess); + ParseResult parseResult = engine.Parse(sourceExpression); + Assert.True(parseResult.IsSuccess); + FormulaValue valueResult = engine.Eval(sourceExpression); + if (expectedResult is null) + { + Assert.Null(valueResult.ToObject()); + } + else + { + Assert.IsType(valueResult.ToObject()); + Assert.Equal(expectedResult, valueResult.ToObject()); + } + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineFactoryTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineFactoryTests.cs new file mode 100644 index 000000000000..614d04c74112 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineFactoryTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.PowerFx; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.PowerFx; + +public class RecalcEngineFactoryTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void DefaultNotNull() + { + // Act + RecalcEngine engine = this.CreateEngine(); + + // Assert + Assert.NotNull(engine); + } + + [Fact] + public void NewInstanceEachTime() + { + // Act + RecalcEngine engine1 = this.CreateEngine(); + RecalcEngine engine2 = this.CreateEngine(); + + // Assert + Assert.NotNull(engine1); + Assert.NotNull(engine2); + Assert.NotSame(engine1, engine2); + } + + [Fact] + public void HasSetFunctionEnabled() + { + // Arrange + RecalcEngine engine = this.CreateEngine(); + + // Act + CheckResult result = engine.Check("1+1"); + + // Assert + Assert.True(result.IsSuccess); + } + + [Fact] + public void HasCorrectMaximumExpressionLength() + { + // Arrange + RecalcEngine engine = this.CreateEngine(2000); + + // Act: Create a long expression that is within the limit + string goodExpression = string.Concat(GenerateExpression(999)); + CheckResult goodResult = engine.Check(goodExpression); + + // Assert + Assert.True(goodResult.IsSuccess); + + // Act: Create a long expression that exceeds the limit + string longExpression = string.Concat(GenerateExpression(1001)); + CheckResult longResult = engine.Check(longExpression); + + // Assert + Assert.False(longResult.IsSuccess); + + static IEnumerable GenerateExpression(int elements) + { + yield return "1"; + for (int i = 0; i < elements - 1; i++) + { + yield return "+1"; + } + } + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineTest.cs new file mode 100644 index 000000000000..8a3fa6055a2a --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/RecalcEngineTest.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.PowerFx; +using Microsoft.SemanticKernel.Process.Workflows; +using Microsoft.SemanticKernel.Process.Workflows.PowerFx; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.PowerFx; + +/// +/// Base test class for PowerFx engine tests. +/// +public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest(output) +{ + internal ProcessActionScopes Scopes { get; } = new(); + + protected RecalcEngine CreateEngine(int maximumExpressionLength = 500) => RecalcEngineFactory.Create(this.Scopes, maximumExpressionLength); +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/TemplateExtensionsTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/TemplateExtensionsTests.cs new file mode 100644 index 000000000000..04697d49059b --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/PowerFx/TemplateExtensionsTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Bot.ObjectModel; +using Microsoft.PowerFx; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows; +using Microsoft.SemanticKernel.Process.Workflows.Extensions; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows.PowerFx; + +public class TemplateExtensionsTests(ITestOutputHelper output) : RecalcEngineTest(output) +{ + [Fact] + public void FormatTemplateLines() + { + // Arrange + List template = + [ + TemplateLine.Parse("Hello"), + TemplateLine.Parse(" "), + TemplateLine.Parse("World"), + ]; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(template); + + // Assert + Assert.Equal("Hello World", result); + } + + [Fact] + public void FormatTemplateLinesEmpty() + { + // Arrange + List template = []; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(template); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatTemplateLine() + { + // Arrange + TemplateLine line = TemplateLine.Parse("Test"); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Test", result); + } + + [Fact] + public void FormatTemplateLineNull() + { + // Arrange + TemplateLine? line = null; + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void FormatTextSegment() + { + // Arrange + TemplateSegment textSegment = TextSegment.FromText("Hello World"); + TemplateLine line = new([textSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } + + [Fact] + public void FormatExpressionSegment() + { + // Arrange + ExpressionSegment expressionSegment = new(ValueExpression.Expression("1 + 1")); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("2", result); + } + + [Fact] + public void FormatVariableSegment() + { + // Arrange + this.Scopes.Set("Source", FormulaValue.New("Hello World")); + ExpressionSegment expressionSegment = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source"))); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } + + //[Fact] + //public void Format_WithExpressionSegmentWithVariableReference_ReturnsEvaluatedValue() + //{ + // // Arrange + // Mock mockVariableRef = new(); + // mockVariableRef.Setup(vr => vr.ToString()).Returns("myVariable"); + + // Expression expression = new() { VariableReference = mockVariableRef.Object }; + // ExpressionSegment expressionSegment = new() { Expression = expression }; + // TemplateLine line = new([expressionSegment]); + + // _mockFormulaValue.Setup(fv => fv.Format()).Returns("VariableValue"); + // _mockEngine.Setup(e => e.Eval("myVariable")).Returns(_mockFormulaValue.Object); + + // // Act + // string? result = engine.Format(line); + + // // Assert + // Assert.Equal("VariableValue", result); + // _mockEngine.Verify(e => e.Eval("myVariable"), Times.Once); + // _mockFormulaValue.Verify(fv => fv.Format(), Times.Once); + //} + + [Fact] + public void FormatExpressionSegmentUndefined() + { + // Arrange + ExpressionSegment expressionSegment = new(); + TemplateLine line = new([expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act & Assert + Assert.Throws(() => engine.Format(line)); + } + + [Fact] + public void FormatMultipleSegments() + { + // Arrange + TemplateSegment textSegment = TextSegment.FromText("Hello "); + ExpressionSegment expressionSegment = new(ValueExpression.Expression(@"""World""")); + TemplateLine line = new([textSegment, expressionSegment]); + RecalcEngine engine = this.CreateEngine(); + + // Act + string? result = engine.Format(line); + + // Assert + Assert.Equal("Hello World", result); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionScopesTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionScopesTests.cs new file mode 100644 index 000000000000..63a669edacc5 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionScopesTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.PowerFx.Types; +using Microsoft.SemanticKernel.Process.Workflows; +using Xunit; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows; + +public class ProcessActionScopesTests +{ + [Fact] + public void ConstructorInitializesAllScopes() + { + // Arrange & Act + ProcessActionScopes scopes = new(); + + // Assert + RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); + RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); + RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); + RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + + Assert.NotNull(envRecord); + Assert.NotNull(topicRecord); + Assert.NotNull(globalRecord); + Assert.NotNull(systemRecord); + } + + [Fact] + public void BuildRecordWhenEmpty() + { + // Arrange + ProcessActionScopes scopes = new(); + + // Act + RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + + // Assert + Assert.NotNull(record); + Assert.Empty(record.Fields); + } + + [Fact] + public void BuildRecordContainsSetValues() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Topic, testValue); + + // Act + RecordValue record = scopes.BuildRecord(ActionScopeType.Topic); + + // Assert + Assert.NotNull(record); + Assert.Single(record.Fields); + Assert.Equal("key1", record.Fields.First().Name); + Assert.Equal(testValue, record.Fields.First().Value); + } + + [Fact] + public void BuildRecordForAllScopeTypes() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act & Assert + scopes.Set("envKey", ActionScopeType.Env, testValue); + RecordValue envRecord = scopes.BuildRecord(ActionScopeType.Env); + Assert.Single(envRecord.Fields); + + scopes.Set("topicKey", ActionScopeType.Topic, testValue); + RecordValue topicRecord = scopes.BuildRecord(ActionScopeType.Topic); + Assert.Single(topicRecord.Fields); + + scopes.Set("globalKey", ActionScopeType.Global, testValue); + RecordValue globalRecord = scopes.BuildRecord(ActionScopeType.Global); + Assert.Single(globalRecord.Fields); + + scopes.Set("systemKey", ActionScopeType.System, testValue); + RecordValue systemRecord = scopes.BuildRecord(ActionScopeType.System); + Assert.Single(systemRecord.Fields); + } + + [Fact] + public void GetWithImplicitScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Topic, testValue); + + // Act + FormulaValue result = scopes.Get("key1"); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void GetWithSpecifiedScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + scopes.Set("key1", ActionScopeType.Global, testValue); + + // Act + FormulaValue result = scopes.Get("key1", ActionScopeType.Global); + + // Assert + Assert.Equal(testValue, result); + } + + [Fact] + public void SetDefaultScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", testValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetSpecifiedScope() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue testValue = FormulaValue.New("test"); + + // Act + scopes.Set("key1", ActionScopeType.System, testValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.System); + Assert.Equal(testValue, result); + } + + [Fact] + public void SetOverwritesExistingValue() + { + // Arrange + ProcessActionScopes scopes = new(); + FormulaValue initialValue = FormulaValue.New("initial"); + FormulaValue newValue = FormulaValue.New("new"); + + // Act + scopes.Set("key1", ActionScopeType.Topic, initialValue); + scopes.Set("key1", ActionScopeType.Topic, newValue); + + // Assert + FormulaValue result = scopes.Get("key1", ActionScopeType.Topic); + Assert.Equal(newValue, result); + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionStackTests.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionStackTests.cs new file mode 100644 index 000000000000..fb0e418fb1c3 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/ProcessActionStackTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.SemanticKernel.Process.Workflows; +using Xunit; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows; + +public sealed class ProcessActionStackTests +{ + [Fact] + public void EmptyStackFailure() + { + // Arrange + ProcessActionStack stack = new(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => stack.CurrentScope); + Assert.Equal("No scope defined", exception.Message); + } + + [Fact] + public void RootScope() + { + // Arrange + ProcessActionStack stack = new(); + + // Act + stack.Recognize("missing"); + string actualScope = stack.CurrentScope; + + // Assert + Assert.Equal("missing", actualScope); + } + + [Fact] + public void NewScope() + { + // Arrange + ProcessActionStack stack = new(); + + // Act + stack.Recognize("rootscope"); + stack.Recognize("childscope"); + + // Assert + Assert.Equal("childscope", stack.CurrentScope); + } + + [Fact] + public void SameConsecutiveScope() + { + // Arrange + ProcessActionStack stack = new(); + + // Act + stack.Recognize("rootscope"); + stack.Recognize("childscope"); + stack.Recognize("childscope"); + + // Assert + Assert.Equal("childscope", stack.CurrentScope); + } + + [Fact] + public void CompletedScope() + { + // Arrange + ProcessActionStack stack = new(); + + // Act + stack.Recognize("rootscope"); + stack.Recognize("childscope"); + stack.Recognize("deepscope"); + stack.Recognize("childscope"); + + // Assert + Assert.Equal("childscope", stack.CurrentScope); + } + + [Fact] + public void DeepScope() + { + // Arrange + ProcessActionStack stack = new(); + + // Act + stack.Recognize("rootscope"); + stack.Recognize("childscope"); + stack.Recognize("deepscope"); + stack.Recognize("rootscope"); + + // Assert + Assert.Equal("rootscope", stack.CurrentScope); + } + + [Fact] + public void ScopeCallback() + { + // Arrange + ProcessActionStack stack = new(); + HashSet completedScopes = []; + + // Act + stack.Recognize("rootscope", HandleCompletion); + stack.Recognize("childscope", HandleCompletion); + stack.Recognize("deepscope", HandleCompletion); + stack.Recognize("rootscope", HandleCompletion); + + // Assert + Assert.Equal("rootscope", stack.CurrentScope); + Assert.Contains("deepscope", completedScopes); + Assert.Contains("childscope", completedScopes); + Assert.DoesNotContain("rootscope", completedScopes); + + void HandleCompletion(string scopeId) + { + completedScopes.Add(scopeId); + } + } +} diff --git a/dotnet/src/Experimental/Process.UnitTests/Workflow/WorkflowTest.cs b/dotnet/src/Experimental/Process.UnitTests/Workflow/WorkflowTest.cs new file mode 100644 index 000000000000..ff955404c402 --- /dev/null +++ b/dotnet/src/Experimental/Process.UnitTests/Workflow/WorkflowTest.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel.Process.Workflows; +using Xunit.Abstractions; + +namespace Microsoft.SemanticKernel.Process.UnitTests.Workflows; + +/// +/// Base class for workflow tests. +/// +public abstract class WorkflowTest : IDisposable +{ + public TestOutputAdapter Output { get; } + + protected WorkflowTest(ITestOutputHelper output) + { + this.Output = new TestOutputAdapter(output); + System.Console.SetOut(this.Output); + } + + public void Dispose() + { + this.Output.Dispose(); + GC.SuppressFinalize(this); + } + + internal static string FormatVariablePath(string variableName, ActionScopeType? scope = null) => $"{scope ?? ActionScopeType.Topic}.{variableName}"; +}