diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 465355394d67..c214e71a35de 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -46,6 +46,7 @@ + diff --git a/dotnet/SK-dotnet.sln b/dotnet/SK-dotnet.sln index 09bd6dc331b6..7fc1a685b001 100644 --- a/dotnet/SK-dotnet.sln +++ b/dotnet/SK-dotnet.sln @@ -432,6 +432,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sk-chatgpt-azure-function", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kernel-functions-generator", "samples\Demos\CreateChatGptPlugin\MathPlugin\kernel-functions-generator\kernel-functions-generator.csproj", "{78785CB1-66CF-4895-D7E5-A440DD84BE86}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessWithCloudEvents", "samples\Demos\ProcessWithCloudEvents\ProcessWithCloudEvents.csproj", "{065E7F63-3475-4EEE-93EE-D2A4BF7AA538}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1159,6 +1161,12 @@ Global {78785CB1-66CF-4895-D7E5-A440DD84BE86}.Publish|Any CPU.Build.0 = Debug|Any CPU {78785CB1-66CF-4895-D7E5-A440DD84BE86}.Release|Any CPU.ActiveCfg = Release|Any CPU {78785CB1-66CF-4895-D7E5-A440DD84BE86}.Release|Any CPU.Build.0 = Release|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Debug|Any CPU.Build.0 = Debug|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Publish|Any CPU.ActiveCfg = Debug|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Publish|Any CPU.Build.0 = Debug|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Release|Any CPU.ActiveCfg = Release|Any CPU + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1318,6 +1326,7 @@ Global {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} {2EB6E4C2-606D-B638-2E08-49EA2061C428} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {78785CB1-66CF-4895-D7E5-A440DD84BE86} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {065E7F63-3475-4EEE-93EE-D2A4BF7AA538} = {5D4C0700-BBB5-418F-A7B2-F392B9A18263} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FBDC56A3-86AD-4323-AA0F-201E59123B83} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/AppConfig.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/AppConfig.cs new file mode 100644 index 000000000000..d9d980ce5075 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/AppConfig.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +internal sealed class AppConfig +{ + /// + /// The configuration for the Azure EntraId authentication. + /// + public AzureEntraIdConfig? AzureEntraId { get; set; } + + /// + /// Ensures that the configuration is valid. + /// + internal void Validate() + { + ArgumentNullException.ThrowIfNull(this.AzureEntraId?.ClientId, nameof(this.AzureEntraId.ClientId)); + ArgumentNullException.ThrowIfNull(this.AzureEntraId?.TenantId, nameof(this.AzureEntraId.TenantId)); + + if (this.AzureEntraId.InteractiveBrowserAuthentication) + { + ArgumentNullException.ThrowIfNull(this.AzureEntraId.InteractiveBrowserRedirectUri, nameof(this.AzureEntraId.InteractiveBrowserRedirectUri)); + } + else + { + ArgumentNullException.ThrowIfNull(this.AzureEntraId?.ClientSecret, nameof(this.AzureEntraId.ClientSecret)); + } + } + + internal sealed class AzureEntraIdConfig + { + /// + /// App Registration Client Id + /// + public string? ClientId { get; set; } + + /// + /// App Registration Tenant Id + /// + public string? TenantId { get; set; } + + /// + /// The client secret to use for the Azure EntraId authentication. + /// + /// + /// This is required if InteractiveBrowserAuthentication is false. (App Authentication) + /// + public string? ClientSecret { get; set; } + + /// + /// Specifies whether to use interactive browser authentication (Delegated User Authentication) or App authentication. + /// + public bool InteractiveBrowserAuthentication { get; set; } + + /// + /// When using interactive browser authentication, the redirect URI to use. + /// + public string? InteractiveBrowserRedirectUri { get; set; } = "http://localhost"; + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterBaseController.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterBaseController.cs new file mode 100644 index 000000000000..98ef47862db0 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterBaseController.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Process.Models; +using ProcessWithCloudEvents.Processes; +using ProcessWithCloudEvents.Processes.Steps; + +namespace ProcessWithCloudEvents.Controllers; +/// +/// Base class that contains common methods to be used when using SK Processes and Counter common api entrypoints +/// +public abstract class CounterBaseController : ControllerBase +{ + /// + /// Kernel to be used to run the SK Process + /// + internal Kernel Kernel { get; init; } + + /// + /// SK Process to be used to hold the counter logic + /// + internal KernelProcess Process { get; init; } + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + internal Kernel BuildKernel(GraphServiceClient? graphClient = null) + { + var builder = Kernel.CreateBuilder(); + if (graphClient != null) + { + builder.Services.AddSingleton(graphClient); + } + return builder.Build(); + } + + internal KernelProcess InitializeProcess(ProcessBuilder process) + { + this.InitializeStateFile(process.Name); + var processState = this.LoadProcessState(process.Name); + return process.Build(processState); + } + + private string GetTemporaryProcessFilePath(string processName) + { + return Path.Combine(Path.GetTempPath(), $"{processName}.json"); + } + + internal void InitializeStateFile(string processName) + { + // Initialize the path for the temporary file + var tempProcessFile = this.GetTemporaryProcessFilePath(processName); + + // If the file does not exist, create it and initialize with zero + if (!System.IO.File.Exists(tempProcessFile)) + { + System.IO.File.WriteAllText(tempProcessFile, ""); + } + } + + internal void SaveProcessState(string processName, KernelProcessStateMetadata processStateInfo) + { + var content = JsonSerializer.Serialize(processStateInfo, s_jsonOptions); + System.IO.File.WriteAllText(this.GetTemporaryProcessFilePath(processName), content); + } + + internal KernelProcessStateMetadata? LoadProcessState(string processName) + { + try + { + using StreamReader reader = new(this.GetTemporaryProcessFilePath(processName)); + var content = reader.ReadToEnd(); + return JsonSerializer.Deserialize(content, s_jsonOptions); + } + catch (Exception) + { + return null; + } + } + + internal void StoreProcessState(KernelProcess process) + { + var stateMetadata = process.ToProcessStateMetadata(); + this.SaveProcessState(process.State.Name, stateMetadata); + } + + internal KernelProcessStepState? GetCounterState(KernelProcess process) + { + // TODO: Replace when there is a better way of extracting snapshot of local state + return process.Steps + .First(step => step.State.Name == RequestCounterProcess.StepNames.Counter).State as KernelProcessStepState; + } + + internal async Task StartProcessWithEventAsync(string eventName, object? eventData = null) + { + var runningProcess = await this.Process.StartAsync(this.Kernel, new() { Id = eventName, Data = eventData }); + var processState = await runningProcess.GetStateAsync(); + this.StoreProcessState(processState); + + return processState; + } + + /// + /// API entry point to increase the counter + /// + /// current counter value + public virtual async Task IncreaseCounterAsync() + { + return await Task.FromResult(0); + } + + /// + /// API entry point to decrease the counter + /// + /// current counter value + public virtual async Task DecreaseCounterAsync() + { + return await Task.FromResult(0); + } + + /// + /// API entry point to reset counter value to 0 + /// + /// current counter value + public virtual async Task ResetCounterAsync() + { + return await Task.FromResult(0); + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudStepsController.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudStepsController.cs new file mode 100644 index 000000000000..9b069851180e --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudStepsController.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph; +using ProcessWithCloudEvents.Processes; + +namespace ProcessWithCloudEvents.Controllers; +[ApiController] +[Route("[controller]")] +public class CounterWithCloudStepsController : CounterBaseController +{ + private readonly ILogger _logger; + + public CounterWithCloudStepsController(ILogger logger, GraphServiceClient graphClient) + { + this._logger = logger; + + this.Kernel = this.BuildKernel(graphClient); + this.Process = this.InitializeProcess(RequestCounterProcess.CreateProcessWithCloudSteps()); + } + + /// + [HttpGet("increase", Name = "IncreaseWithCloudSteps")] + public override async Task IncreaseCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.IncreaseCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } + + /// + [HttpGet("decrease", Name = "DecreaseWithCloudSteps")] + public override async Task DecreaseCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.DecreaseCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } + + /// + [HttpGet("reset", Name = "ResetCounterWithCloudSteps")] + public override async Task ResetCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.ResetCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudSubscribersController.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudSubscribersController.cs new file mode 100644 index 000000000000..bc57705e0a34 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Controllers/CounterWithCloudSubscribersController.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Graph; +using ProcessWithCloudEvents.Processes; + +namespace ProcessWithCloudEvents.Controllers; +[ApiController] +[Route("[controller]")] +public class CounterWithCloudSubscribersController : CounterBaseController +{ + private readonly ILogger _logger; + + public CounterWithCloudSubscribersController(ILogger logger, GraphServiceClient graphClient) + { + this._logger = logger; + this.Kernel = this.BuildKernel(); + + var serviceProvider = new ServiceCollection() + .AddSingleton(graphClient) + .BuildServiceProvider(); + this.Process = this.InitializeProcess(RequestCounterProcess.CreateProcessWithProcessSubscriber(serviceProvider)); + } + + /// + [HttpGet("increase", Name = "IncreaseCounterWithCloudSubscribers")] + public override async Task IncreaseCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.IncreaseCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } + + /// + [HttpGet("decrease", Name = "DecreaseCounterWithCloudSubscribers")] + public override async Task DecreaseCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.DecreaseCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } + + /// + [HttpGet("reset", Name = "ResetCounterWithCloudSubscribers")] + public override async Task ResetCounterAsync() + { + var eventName = RequestCounterProcess.GetEventName(RequestCounterProcess.CounterProcessEvents.ResetCounterRequest); + var runningProcess = await this.StartProcessWithEventAsync(eventName); + var counterState = this.GetCounterState(runningProcess); + + return counterState?.State?.Counter ?? -1; + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/GraphServiceProvider.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/GraphServiceProvider.cs new file mode 100644 index 000000000000..470352b928b6 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/GraphServiceProvider.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Graph; + +public static class GraphServiceProvider +{ + public static GraphServiceClient CreateGraphService() + { + string[] scopes; + + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) // Set the base path for appsettings.json + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) // Load appsettings.json + .AddUserSecrets() + .AddEnvironmentVariables() + .Build() + .Get() ?? + throw new InvalidOperationException("Configuration is not setup correctly."); + + config.Validate(); + + TokenCredential credential = null!; + if (config.AzureEntraId!.InteractiveBrowserAuthentication) // Authentication As User + { + /// Use this if using user delegated permissions + scopes = ["User.Read", "Mail.Send"]; + + credential = new InteractiveBrowserCredential( + new InteractiveBrowserCredentialOptions + { + TenantId = config.AzureEntraId.TenantId, + ClientId = config.AzureEntraId.ClientId, + AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + RedirectUri = new Uri(config.AzureEntraId.InteractiveBrowserRedirectUri!) + }); + } + else // Authentication As Application + { + scopes = ["https://graph.microsoft.com/.default"]; + + credential = new ClientSecretCredential( + config.AzureEntraId.TenantId, + config.AzureEntraId.ClientId, + config.AzureEntraId.ClientSecret); + } + + return new GraphServiceClient(credential, scopes); + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/MicrosoftGraph/GraphRequestFactory.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/MicrosoftGraph/GraphRequestFactory.cs new file mode 100644 index 000000000000..f7253d3e2833 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/MicrosoftGraph/GraphRequestFactory.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Graph.Me.SendMail; +using Microsoft.Graph.Models; + +namespace ProcessWithCloudEvents.MicrosoftGraph; + +/// +/// Factory that creates Microsoft Graph related objects +/// +public static class GraphRequestFactory +{ + /// + /// Method that creates MailPost Body with defined subject, content and recipients + /// + /// subject of the email + /// content of the email + /// recipients of the email + /// + public static SendMailPostRequestBody CreateEmailBody(string subject, string content, List recipients) + { + var message = new SendMailPostRequestBody() + { + Message = new Microsoft.Graph.Models.Message() + { + Subject = subject, + Body = new() + { + ContentType = Microsoft.Graph.Models.BodyType.Text, + Content = content, + }, + ToRecipients = recipients.Select(address => new Recipient { EmailAddress = new() { Address = address } }).ToList(), + }, + SaveToSentItems = true, + }; + + return message; + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.csproj b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.csproj new file mode 100644 index 000000000000..0d8d4711ef5b --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + $(NoWarn);CA2007,CA1861,CA1050,VSTHRD111,SKEXP0001,SKEXP0010,SKEXP0020,SKEXP0040,SKEXP0050,SKEXP0060,SKEXP0070,SKEXP0080,SKEXP0110 + + 5ee045b0-aea3-4f08-8d31-32d1a6f8fed0 + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.http b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.http new file mode 100644 index 000000000000..7111d4bcb145 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/ProcessWithCloudEvents.http @@ -0,0 +1,24 @@ +@ProcessWithCloudEvents_HostAddress = http://localhost:5077 + +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSteps/increase +Accept: application/json + +### +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSteps/decrease +Accept: application/json + +### +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSteps/reset +Accept: application/json + +### +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSubscribers/increase +Accept: application/json + +### +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSubscribers/decrease +Accept: application/json + +### +GET {{ProcessWithCloudEvents_HostAddress}}/CounterWithCloudSubscribers/reset +Accept: application/json \ No newline at end of file diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/RequestCounterProcess.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/RequestCounterProcess.cs new file mode 100644 index 000000000000..edef808000bb --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/RequestCounterProcess.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Graph; +using Microsoft.Graph.Me.SendMail; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Process; +using ProcessWithCloudEvents.MicrosoftGraph; +using ProcessWithCloudEvents.Processes.Steps; + +namespace ProcessWithCloudEvents.Processes; + +public static class RequestCounterProcess +{ + public static class StepNames + { + public const string Counter = nameof(Counter); + public const string CounterInterceptor = nameof(CounterInterceptor); + public const string SendEmail = nameof(SendEmail); + } + + public enum CounterProcessEvents + { + IncreaseCounterRequest, + DecreaseCounterRequest, + ResetCounterRequest, + OnCounterReset, + OnCounterResult + } + + public static string GetEventName(CounterProcessEvents processEvent) + { + return Enum.GetName(processEvent) ?? ""; + } + + public static ProcessBuilder CreateProcessWithCloudSteps() + { + var processBuilder = new ProcessBuilder("RequestCounterProcess"); + + var counterStep = processBuilder.AddStepFromType(StepNames.Counter); + var counterInterceptorStep = processBuilder.AddStepFromType(StepNames.CounterInterceptor); + var emailSenderStep = processBuilder.AddStepFromType(StepNames.SendEmail); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.IncreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.IncreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.DecreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.DecreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.ResetCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.ResetCounter)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.IncreaseCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.DecreaseCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.ResetCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(emailSenderStep, SendEmailStep.StepFunctions.SendCounterResetEmail)); + + counterInterceptorStep + .OnFunctionResult(CounterInterceptorStep.StepFunctions.InterceptCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(emailSenderStep, SendEmailStep.StepFunctions.SendCounterChangeEmail)); + + return processBuilder; + } + + public static ProcessBuilder CreateProcessWithProcessSubscriber(IServiceProvider serviceProvider) + { + var processBuilder = new ProcessBuilder("CounterWithProcessSubscriber"); + + var counterStep = processBuilder.AddStepFromType(StepNames.Counter); + var counterInterceptorStep = processBuilder.AddStepFromType(StepNames.CounterInterceptor); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.IncreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.IncreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.DecreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.DecreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.ResetCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.StepFunctions.ResetCounter)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.IncreaseCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.DecreaseCounter) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterStep + .OnFunctionResult(CounterStep.StepFunctions.ResetCounter) + .EmitAsProcessEvent(processBuilder.GetProcessEvent(CounterProcessEvents.OnCounterReset)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterInterceptorStep + .OnFunctionResult(CounterInterceptorStep.StepFunctions.InterceptCounter) + .EmitAsProcessEvent(processBuilder.GetProcessEvent(CounterProcessEvents.OnCounterResult)); + + processBuilder.LinkEventSubscribersFromType(serviceProvider); + + return processBuilder; + } + + public class CounterProcessSubscriber : KernelProcessEventsSubscriber + { + private SendMailPostRequestBody GenerateEmailRequest(int counter, string emailAddress, string subject) + { + var message = GraphRequestFactory.CreateEmailBody( + subject: $"{subject} - using SK event subscribers", + content: $"The counter is {counter}", + recipients: [emailAddress]); + + return message; + } + + [ProcessEventSubscriber(CounterProcessEvents.OnCounterResult)] + public async Task OnCounterResultReceivedAsync(int? counterResult) + { + if (!counterResult.HasValue) + { + return; + } + + try + { + var graphClient = this.ServiceProvider?.GetRequiredService(); + var user = await graphClient?.Me.GetAsync(); + var graphEmailMessage = this.GenerateEmailRequest(counterResult.Value, user!.Mail!, subject: "The counter has changed"); + await graphClient?.Me.SendMail.PostAsync(graphEmailMessage); + } + catch (Exception e) + { + throw new KernelException($"Something went wrong and couldn't send email - {e}"); + } + } + + [ProcessEventSubscriber(CounterProcessEvents.OnCounterReset)] + public async Task OnCounterResetReceivedAsync(int? counterResult) + { + if (!counterResult.HasValue) + { + return; + } + + try + { + var graphClient = this.ServiceProvider?.GetRequiredService(); + var user = await graphClient.Me.GetAsync(); + var graphEmailMessage = this.GenerateEmailRequest(counterResult.Value, user!.Mail!, subject: "The counter has been reset"); + await graphClient?.Me.SendMail.PostAsync(graphEmailMessage); + } + catch (Exception e) + { + throw new KernelException($"Something went wrong and couldn't send email - {e}"); + } + } + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterInterceptorStep.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterInterceptorStep.cs new file mode 100644 index 000000000000..827b346f9232 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterInterceptorStep.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; + +namespace ProcessWithCloudEvents.Processes.Steps; + +public class CounterInterceptorStep : KernelProcessStep +{ + public static class StepFunctions + { + public const string InterceptCounter = nameof(InterceptCounter); + } + + [KernelFunction(StepFunctions.InterceptCounter)] + public int? InterceptCounter(int counterStatus) + { + var multipleOf = 3; + if (counterStatus != 0 && counterStatus % multipleOf == 0) + { + // Only return counter if counter is a multiple of "multipleOf" + return counterStatus; + } + + return null; + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterStep.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterStep.cs new file mode 100644 index 000000000000..48738c0bfed4 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/CounterStep.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; + +namespace ProcessWithCloudEvents.Processes.Steps; + +public class CounterStep : KernelProcessStep +{ + public static class StepFunctions + { + public const string IncreaseCounter = nameof(IncreaseCounter); + public const string DecreaseCounter = nameof(DecreaseCounter); + public const string ResetCounter = nameof(ResetCounter); + } + + public static class OutputEvents + { + public const string CounterResult = nameof(CounterResult); + } + + internal CounterStepState? _state; + + public override ValueTask ActivateAsync(KernelProcessStepState state) + { + this._state = state.State; + return ValueTask.CompletedTask; + } + + [KernelFunction(StepFunctions.IncreaseCounter)] + public async Task IncreaseCounterAsync(KernelProcessStepContext context) + { + this._state!.Counter += this._state.CounterIncrements; + + if (this._state!.Counter > 5) + { + await context.EmitEventAsync(OutputEvents.CounterResult, this._state.Counter); + } + this._state.LastCounterUpdate = DateTime.UtcNow; + + return this._state.Counter; + } + + [KernelFunction(StepFunctions.DecreaseCounter)] + public async Task DecreaseCounterAsync(KernelProcessStepContext context) + { + this._state!.Counter -= this._state.CounterIncrements; + + if (this._state!.Counter > 5) + { + await context.EmitEventAsync(OutputEvents.CounterResult, this._state.Counter); + } + this._state.LastCounterUpdate = DateTime.UtcNow; + + return this._state.Counter; + } + + [KernelFunction(StepFunctions.ResetCounter)] + public async Task ResetCounterAsync(KernelProcessStepContext context) + { + this._state!.Counter = 0; + return this._state.Counter; + } +} + +public class CounterStepState +{ + public int Counter { get; set; } = 0; + public int CounterIncrements { get; set; } = 1; + + public DateTime? LastCounterUpdate { get; set; } = null; +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/SendEmailStep.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/SendEmailStep.cs new file mode 100644 index 000000000000..92fc6244c925 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Processes/Steps/SendEmailStep.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Graph; +using Microsoft.Graph.Me.SendMail; +using Microsoft.SemanticKernel; +using ProcessWithCloudEvents.MicrosoftGraph; + +namespace ProcessWithCloudEvents.Processes.Steps; + +public class SendEmailStep : KernelProcessStep +{ + public static class OutputEvents + { + public const string SendEmailSuccess = nameof(SendEmailSuccess); + public const string SendEmailFailure = nameof(SendEmailFailure); + } + + public static class StepFunctions + { + public const string SendCounterChangeEmail = nameof(SendCounterChangeEmail); + public const string SendCounterResetEmail = nameof(SendCounterResetEmail); + } + + public SendEmailStep() { } + + protected SendMailPostRequestBody PopulateMicrosoftGraphMailMessage(object inputData, string emailAddress, string subject) + { + var message = GraphRequestFactory.CreateEmailBody( + subject: $"{subject} - using SK cloud step", + content: $"The counter is {(int)inputData}", + recipients: [emailAddress]); + + return message; + } + + [KernelFunction(StepFunctions.SendCounterChangeEmail)] + public async Task PublishCounterChangedEmailMessageAsync(KernelProcessStepContext context, Kernel kernel, object inputData) + { + if (inputData == null) + { + return; + } + + try + { + var graphClient = kernel.GetRequiredService(); + var user = await graphClient.Me.GetAsync(); + var graphEmailMessage = this.PopulateMicrosoftGraphMailMessage(inputData, user!.Mail!, subject: "The counter has changed"); + await graphClient.Me.SendMail.PostAsync(graphEmailMessage).ConfigureAwait(false); + + await context.EmitEventAsync(OutputEvents.SendEmailSuccess); + } + catch (Exception e) + { + await context.EmitEventAsync(OutputEvents.SendEmailFailure, e, visibility: KernelProcessEventVisibility.Public); + throw new KernelException($"Something went wrong and couldn't send email - {e}"); + } + } + + [KernelFunction(StepFunctions.SendCounterResetEmail)] + public async Task PublishCounterResetEmailMessageAsync(KernelProcessStepContext context, Kernel kernel, object inputData) + { + if (inputData == null) + { + return; + } + + try + { + var graphClient = kernel.GetRequiredService(); + var user = await graphClient.Me.GetAsync(); + var graphEmailMessage = this.PopulateMicrosoftGraphMailMessage(inputData, user!.Mail!, subject: "The counter has been reset"); + await graphClient.Me.SendMail.PostAsync(graphEmailMessage).ConfigureAwait(false); + + await context.EmitEventAsync(OutputEvents.SendEmailSuccess); + } + catch (Exception e) + { + await context.EmitEventAsync(OutputEvents.SendEmailFailure, e, visibility: KernelProcessEventVisibility.Public); + throw new KernelException($"Something went wrong and couldn't send email - {e}"); + } + } +} diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/Program.cs b/dotnet/samples/Demos/ProcessWithCloudEvents/Program.cs new file mode 100644 index 000000000000..dae96b88b210 --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/Program.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Graph; +using ProcessWithCloudEvents.Controllers; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(GraphServiceProvider.CreateGraphService()); + +// For demo purposes making the Counter a singleton so it is not instantiated on every new request +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Add services to the container. +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/README.md b/dotnet/samples/Demos/ProcessWithCloudEvents/README.md new file mode 100644 index 000000000000..3d5f2983cdfe --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/README.md @@ -0,0 +1,251 @@ +# Process With Cloud Events Demo + +This demo contains an ASP.NET core API that showcases the use of cloud events using SK Processes Steps and SK Process with Event Subscribers. + + +For more information about Semantic Kernel Processes, see the following documentation: + +## Semantic Kernel Processes + +- [Overview of the Process Framework (docs)](https://learn.microsoft.com/semantic-kernel/frameworks/process/process-framework) +- [Getting Started with Processes (samples)](../../GettingStartedWithProcesses/) + +## Demo + +### Process: Counter with Cloud Events + +#### Steps + +##### Counter Step + +A simple counter has 3 main functionalities: + +- Increase count +- Decrease count +- Reset count (set counter to 0) + +To achieve this behavior the SK Stateful Step `Processes/Steps/CounterStep.cs` was created. +On every request it stores that state that can be used to restore the state on the next request. + +##### Counter Interceptor Step + +This step works as a filter that only passes the counter value if it is a multiple of `multipleOf` else passes a null value. + +##### Send Email Step + +This step sends an email if receiving a not nullable int value to the same email used on log in. + +#### Processes + +##### Process With Cloud Steps + +```mermaid +flowchart LR + subgraph API + ApiIncrease["/increase"] + ApiDecrease["/decrease"] + ApiReset["/reset"] + end + + subgraph process[SK Process] + direction LR + subgraph counter[Counter Step] + increaseCounter[IncreaseCounterAsync
Function] + decreaseCounter[DecreaseCounterAsync
Function] + resetCounter[ResetCounterAsync
Function] + end + + counterInterceptor[Counter
Interceptor
Step] + + subgraph sendEmail[Send Email Step] + sendCounterChangedEmail[PublishCounterChangedEmailMessageAsync
Function] + sendResetEmail[PublishCounterResetEmailMessageAsync
Function] + end + + increaseCounter--> counterInterceptor + decreaseCounter--> counterInterceptor + + counterInterceptor-->sendCounterChangedEmail + resetCounter-->sendResetEmail + end + + ApiIncrease<-->|IncreaseCounterRequest|increaseCounter + ApiDecrease<-->|DecreaseCounterRequest|decreaseCounter + ApiReset<-->|ResetCounterRequest|resetCounter +``` + +Cloud events related logic is encapsulated in a step. + +**Breakdown** + +- When building the process Kernel used in the SK Process, the cloud event client has to be passed to the Kernel. + +- When using `Microsoft Graph`, after completing the [Microsoft Graph Setup](./#microsoft-graph-setup), To achieve the proper setup the following is needed: + + 1. The specific service (`GraphServiceClient` in this case) needs to be added to the Services that are used by the kernel of the process: + + ```C# + internal Kernel BuildKernel(GraphServiceClient? graphClient = null) + { + var builder = Kernel.CreateBuilder(); + if (graphClient != null) + { + builder.Services.AddSingleton(graphClient); + } + return builder.Build(); + } + ``` + 2. Since now all steps have access to the configured kernel, inside a step, it now can make use of the service by doing: + ```C# + var graphClient = kernel.GetRequiredService(); + ``` + +##### Process With Cloud Process Subscribers + +Cloud events related logic is encapsulated in SK Event Subscribers. + +```mermaid +flowchart LR + subgraph API + ApiIncrease["/increase"] + ApiDecrease["/decrease"] + ApiReset["/reset"] + end + + subgraph process[SK Process - CreateProcessWithProcessSubscriber] + direction TB + subgraph counter[Counter Step] + increaseCounter[IncreaseCounterAsync
Function] + decreaseCounter[DecreaseCounterAsync
Function] + resetCounter[ResetCounterAsync
Function] + end + counterInterceptor[Counter
Interceptor
Step] + + increaseCounter--> counterInterceptor + decreaseCounter--> counterInterceptor + end + + subgraph processInterceptor[SK Process Subscribers - CounterProcessSubscriber] + OnCounterResultReceivedAsync + OnCounterResetReceivedAsync + end + + counterInterceptor-->|OnCounterResult|OnCounterResultReceivedAsync + resetCounter-->|OnCounterReset|OnCounterResetReceivedAsync + + ApiIncrease<-->|IncreaseCounterRequest|increaseCounter + ApiDecrease<-->|DecreaseCounterRequest|decreaseCounter + ApiReset<-->|ResetCounterRequest|resetCounter +``` +**Breakdown** + +- When building the process Kernel used in the SK Process, the cloud event client has to be passed to the Event Subscribers. + +- When using `Microsoft Graph`, after completing the [Microsoft Graph Setup](./#microsoft-graph-setup), the Event Subscribers can be linked by doing: + 1. Creating an enum that contains the process events of interest. + ```C# + public enum CounterProcessEvents + { + IncreaseCounterRequest, + DecreaseCounterRequest, + ResetCounterRequest, + OnCounterReset, + OnCounterResult + } + ``` + 2. On the existing process, adding which events can be accessed externally using `EmitAsProcessEvent()`: + ```C# + var processBuilder = new ProcessBuilder("CounterWithProcessSubscriber"); + + ... + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.IncreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.Functions.IncreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.DecreaseCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.Functions.DecreaseCounter)); + + processBuilder + .OnInputEvent(processBuilder.GetEventName(CounterProcessEvents.ResetCounterRequest)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterStep, functionName: CounterStep.Functions.ResetCounter)); + + ... + + counterStep + .OnFunctionResult(CounterStep.Functions.ResetCounter) + .EmitAsProcessEvent(processBuilder.GetProcessEvent(CounterProcessEvents.OnCounterReset)) + .SendEventTo(new ProcessFunctionTargetBuilder(counterInterceptorStep)); + + counterInterceptorStep + .OnFunctionResult(CounterInterceptorStep.Functions.InterceptCounter) + .EmitAsProcessEvent(processBuilder.GetProcessEvent(CounterProcessEvents.OnCounterResult)); + ``` + 3. Create a `KernelProcessEventsSubscriber` based class that with the `ProcessEventSubscriber` attributes to link specific process events to specific methods to execute. + ```C# + public class CounterProcessSubscriber : KernelProcessEventsSubscriber + { + [ProcessEventSubscriber(CounterProcessEvents.OnCounterResult)] + public async Task OnCounterResultReceivedAsync(int? counterResult) + { + if (!counterResult.HasValue) + { + return; + } + + try + { + var graphClient = this.ServiceProvider?.GetRequiredService(); + var user = await graphClient.Me.GetAsync(); + var graphEmailMessage = this.GenerateEmailRequest(counterResult.Value, user!.Mail!, subject: "The counter has changed"); + await graphClient?.Me.SendMail.PostAsync(graphEmailMessage); + } + catch (Exception e) + { + throw new KernelException($"Something went wrong and couldn't send email - {e}"); + } + } + } + ``` + 4. Link the `KernelProcessEventsSubscriber` based class (example: `CounterProcessSubscriber`) to the process builder. + ```C# + processBuilder.LinkEventSubscribersFromType(serviceProvider); + ``` + +### Setup + +#### Microsoft Graph Setup + +##### Create an App Registration in Azure Active Directory + +1. Go to the [Azure Portal](https://portal.azure.com/). +2. Select the Azure Active Directory service. +3. Select App registrations and click on New registration. +4. Fill in the required fields and click on Register. +5. Copy the Application **(client) Id** for later use. +6. Save Directory **(tenant) Id** for later use.. +7. Click on Certificates & secrets and create a new client secret. (Any name and expiration date will work) +8. Copy the **client secret** value for later use. +9. Click on API permissions and add the following permissions: + - Microsoft Graph + - Delegated permissions + - OpenId permissions + - email + - profile + - openid + - User.Read + - Mail.Send (Necessary for sending emails from your account) + +##### Set Secrets using .NET [Secret Manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) + +```powershell +dotnet user-secrets set "AzureEntraId:TenantId" " ... your tenant id ... " +dotnet user-secrets set "AzureEntraId:ClientId" " ... your client id ... " + +# App Registration Authentication +dotnet user-secrets set "AzureEntraId:ClientSecret" " ... your client secret ... " +# OR User Authentication (Interactive) +dotnet user-secrets set "AzureEntraId:InteractiveBrowserAuthentication" "true" +dotnet user-secrets set "AzureEntraId:RedirectUri" " ... your redirect uri ... " +``` \ No newline at end of file diff --git a/dotnet/samples/Demos/ProcessWithCloudEvents/appsettings.json b/dotnet/samples/Demos/ProcessWithCloudEvents/appsettings.json new file mode 100644 index 000000000000..b8a90ee3e4dc --- /dev/null +++ b/dotnet/samples/Demos/ProcessWithCloudEvents/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "MicrosoftGraph.TenantId": "", + "MicrosoftGraph.ClientId": "" + }, + "AzureEntraId": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "InteractiveBrowserAuthentication": true + }, + "MicrosoftGraph": { + "BaseUrl": "https://graph.microsoft.com/v1.0" + } +} diff --git a/dotnet/samples/GettingStartedWithProcesses/README.md b/dotnet/samples/GettingStartedWithProcesses/README.md index ff28c1a91a80..b207fae3c2e8 100644 --- a/dotnet/samples/GettingStartedWithProcesses/README.md +++ b/dotnet/samples/GettingStartedWithProcesses/README.md @@ -49,6 +49,27 @@ flowchart LR ### Step02_AccountOpening +The account opening sample has 3 different implementations covering the same scenario, it just uses different SK components to achieve the same goal. + +In addition, the sample introduces the concept of using smaller process as steps to maintain the main process readable and manageble for future improvements and unit testing. +Also introduces the use of SK Event Subscribers. + +A process for opening an account for this sample has the following steps: +- Fill New User Account Application Form +- Verify Applicant Credit Score +- Apply Fraud Detection Analysis to the Application Form +- Create New Entry in Core System Records +- Add new account to Marketing Records +- CRM Record Creation +- Mail user a user a notification about: + - Failure to open a new account due to Credit Score Check + - Failure to open a new account due to Fraud Detection Alert + - Welcome package including new account details + +A SK process that only connects the steps listed above as is (no use of subprocesses as steps) for opening an account look like this: + +#### Step02a_AccountOpening + ```mermaid flowchart LR User(User) -->|Provides user details| FillForm(Fill New
Customer
Form) @@ -79,6 +100,121 @@ flowchart LR Mailer -->|End of Interaction| User ``` +#### Step02b_AccountOpening + +After grouping steps that have a common theme/dependencies, and creating smaller subprocesses and using them as steps, +the root process looks like this: + +```mermaid +flowchart LR + User(User) + FillForm(Chat With User
to Fill New
Customer Form) + NewAccountVerification[[New Account Verification
Process]] + NewAccountCreation[[New Account Creation
Process]] + Mailer(Mail
Service) + + User<-->|Provides user details|FillForm + FillForm-->|New User Form|NewAccountVerification + NewAccountVerification-->|Account Verification
Failed|Mailer + NewAccountVerification-->|Account Verification
Succeded|NewAccountCreation + NewAccountCreation-->|Account Creation
Succeded|Mailer +``` + +Where processes used as steps, which are reusing the same steps used [`Step02a_AccountOpening`](#step02a_accountopening), are: + +```mermaid +graph LR + NewUserForm([New User Form]) + NewUserFormConv([Form Filling Interaction]) + + subgraph AccountCreation[Account Creation Process] + direction LR + AccountValidation([Account Verification Passed]) + NewUser1([New User Form]) + NewUserFormConv1([Form Filling Interaction]) + + CoreSystem(Core System
Record
Creation) + Marketing(New Marketing
Record Creation) + CRM(CRM Record
Creation) + Welcome(Welcome
Packet) + NewAccountCreation([New Account Success]) + + NewUser1-->CoreSystem + NewUserFormConv1-->CoreSystem + + AccountValidation-->CoreSystem + CoreSystem-->CRM-->|Success|Welcome + CoreSystem-->Marketing-->|Success|Welcome + CoreSystem-->|Account Details|Welcome + + Welcome-->NewAccountCreation + end + + subgraph AccountVerification[Account Verification Process] + direction LR + NewUser2([New User Form]) + CreditScoreCheck[Credit Check
Step] + FraudCheck[Fraud Detection
Step] + AccountVerificationPass([Account Verification Passed]) + AccountCreditCheckFail([Credit Check Failed]) + AccoutFraudCheckFail([Fraud Check Failed]) + + + NewUser2-->CreditScoreCheck-->|Credit Score
Check Passed|FraudCheck + FraudCheck-->AccountVerificationPass + + CreditScoreCheck-->AccountCreditCheckFail + FraudCheck-->AccoutFraudCheckFail + end + + AccountVerificationPass-->AccountValidation + NewUserForm-->NewUser1 + NewUserForm-->NewUser2 + NewUserFormConv-->NewUserFormConv1 + +``` + +#### Step02c_AccountOpeningWithCloudEvents + +An additional optimization that could be made to the Account Creation sample, is to make use of SK Event subscriber to isolate logic that has to do with cloud events. +In this sample, the cloud event logic is mocked by the Mail Service functionality, which mocks sending an email to the user in different circumstances: + +- When new user credit score check fails +- When new user fraud detection fails +- When a new account was created successfully after passing all checks and creation steps + +When using SK Event subscribers, specific process events when trigged will emit the event data externally to +any subscribers linked to specific events. + +```mermaid +graph LR + subgraph EventSubscribers[SK Event Subscribers] + OnSendMailDueCreditCheckFailure[OnSendMailDueCredit
CheckFailure] + OnSendMailDueFraudCheckFailure[OnSendMailDueFraud
CheckFailure] + OnSendMailWithNewAccountInfo[OnSendMailWith
NewAccountInfo] + end + + subgraph Process[SK Process] + direction LR + User(User) + FillForm(Chat With User
to Fill New
Customer Form) + NewAccountVerification[[New Account Verification
Process]] + NewAccountCreation[[New Account Creation
Process]] + + User<-->|Provides user details|FillForm + FillForm-->|New User Form|NewAccountVerification-->|Account Verification
Succeded|NewAccountCreation + end + + NewAccountVerification-->|Account Credit Check
Failed|OnSendMailDueCreditCheckFailure + NewAccountVerification-->|Account Fraud Detection
Failed|OnSendMailDueFraudCheckFailure + NewAccountCreation-->|Account Creation
Succeded|OnSendMailWithNewAccountInfo + +``` +Creating a separation with SK Process when using cloud events (even though in this sample it's a mock of a Mailer), it is useful since +it can help to isolate additional logic related to authentication, use of additional frameworks, etc. + +For a more realistic sample of SK Process emitting real cloud events check out the [`ProcessWithCloudEvents` Demo](../Demos/ProcessWithCloudEvents/README.md). + ### Step03a_FoodPreparation This tutorial contains a set of food recipes associated with the Food Preparation Processes of a restaurant. diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountDetails.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountDetails.cs index 6f732669d5dc..0e8274fe6900 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountDetails.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountDetails.cs @@ -4,7 +4,7 @@ namespace Step02.Models; /// /// Represents the data structure for a form capturing details of a new customer, including personal information, contact details, account id and account type.
-/// Class used in samples +/// Class used in samples ///
public class AccountDetails : NewCustomerForm { diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountOpeningEvents.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountOpeningEvents.cs index de1110854e27..32bcd0cca4d9 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountOpeningEvents.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountOpeningEvents.cs @@ -3,7 +3,7 @@ namespace Step02.Models; /// /// Processes Events related to Account Opening scenarios.
-/// Class used in samples +/// Class used in samples ///
public static class AccountOpeningEvents { diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountUserInteractionDetails.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountUserInteractionDetails.cs index 123f0b2e417d..0db9a7987fa1 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountUserInteractionDetails.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/AccountUserInteractionDetails.cs @@ -7,7 +7,7 @@ namespace Step02.Models; /// /// Represents the details of interactions between a user and service, including a unique identifier for the account, /// a transcript of conversation with the user, and the type of user interaction.
-/// Class used in samples +/// Class used in samples ///
public record AccountUserInteractionDetails { diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/MarketingNewEntryDetails.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/MarketingNewEntryDetails.cs index 057e97c81597..fd10646a8b74 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/MarketingNewEntryDetails.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/MarketingNewEntryDetails.cs @@ -4,7 +4,7 @@ namespace Step02.Models; /// /// Holds details for a new entry in a marketing database, including the account identifier, contact name, phone number, and email address.
-/// Class used in samples +/// Class used in samples ///
public record MarketingNewEntryDetails { diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/NewCustomerForm.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/NewCustomerForm.cs index c000b8491d24..1d469b19d994 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Models/NewCustomerForm.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Models/NewCustomerForm.cs @@ -7,7 +7,7 @@ namespace Step02.Models; /// /// Represents the data structure for a form capturing details of a new customer, including personal information and contact details.
-/// Class used in samples +/// Class used in samples ///
public class NewCustomerForm { diff --git a/dotnet/samples/GettingStartedWithProcesses/Step02/Step02_AccountOpening.cs b/dotnet/samples/GettingStartedWithProcesses/Step02/Step02a_AccountOpening.cs similarity index 99% rename from dotnet/samples/GettingStartedWithProcesses/Step02/Step02_AccountOpening.cs rename to dotnet/samples/GettingStartedWithProcesses/Step02/Step02a_AccountOpening.cs index a523dc4119a3..2c033dfad8e0 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step02/Step02_AccountOpening.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step02/Step02a_AccountOpening.cs @@ -14,7 +14,7 @@ namespace Step02; /// For each test there is a different set of user messages that will cause different steps to be triggered using the same pipeline.
/// For visual reference of the process check the diagram . /// -public class Step02_AccountOpening(ITestOutputHelper output) : BaseTest(output, redirectSystemConsoleOutput: true) +public class Step02a_AccountOpening(ITestOutputHelper output) : BaseTest(output, redirectSystemConsoleOutput: true) { // Target Open AI Services protected override bool ForceOpenAI => true; diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs index 3b72a9aff192..743851e7b14d 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcess.cs @@ -9,13 +9,15 @@ namespace Microsoft.SemanticKernel; /// /// A serializable representation of a Process. /// -public sealed record KernelProcess : KernelProcessStepInfo +public sealed record KernelProcess : KernelProcessStepInfo // TODO: Should be renamed to KernelProcessInfo to keep consistent names { /// /// The collection of Steps in the Process. /// public IList Steps { get; } + public KernelProcessEventsSubscriberInfo? EventsSubscriber { get; set; } = null; + /// /// Captures Kernel Process State into after process has run /// @@ -31,12 +33,14 @@ public KernelProcessStateMetadata ToProcessStateMetadata() /// The process state. /// The steps of the process. /// The edges of the process. - public KernelProcess(KernelProcessState state, IList steps, Dictionary>? edges = null) + /// TODO: may need to reorder params + public KernelProcess(KernelProcessState state, IList steps, Dictionary>? edges = null, KernelProcessEventsSubscriberInfo? eventsSubscriber = null) : base(typeof(KernelProcess), state, edges ?? []) { Verify.NotNull(steps); Verify.NotNullOrWhiteSpace(state.Name); this.Steps = [.. steps]; + this.EventsSubscriber = eventsSubscriber; } } diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs index 224d5b67bb56..71b86dd2b472 100644 --- a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEdge.cs @@ -17,6 +17,12 @@ public sealed class KernelProcessEdge [DataMember] public string SourceStepId { get; init; } + [DataMember] + public string SourceEventName { get; init; } + + [DataMember] + public string SourceEventId { get; init; } + /// /// The collection of s that are the output of the source Step. /// @@ -26,12 +32,16 @@ public sealed class KernelProcessEdge /// /// Creates a new instance of the class. /// - public KernelProcessEdge(string sourceStepId, KernelProcessFunctionTarget outputTarget) + public KernelProcessEdge(string sourceStepId, KernelProcessFunctionTarget outputTarget, string sourceEventName, string sourceEventId) { Verify.NotNullOrWhiteSpace(sourceStepId); + Verify.NotNullOrWhiteSpace(sourceEventId); + Verify.NotNullOrWhiteSpace(sourceEventName); Verify.NotNull(outputTarget); this.SourceStepId = sourceStepId; + this.SourceEventId = sourceEventId; + this.SourceEventName = sourceEventName; this.OutputTarget = outputTarget; } } diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriber.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriber.cs new file mode 100644 index 000000000000..acc60431cf23 --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriber.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Process; + +public class KernelProcessEventsSubscriber +{ + public IServiceProvider? ServiceProvider { get; init; } + + protected KernelProcessEventsSubscriber() { } +} + +/// +/// Attribute to set Process related steps to link Process Events to specific functions to execute when the event is emitted outside the Process +/// +/// Enum that contains all process events that could be subscribed to +public class KernelProcessEventsSubscriber : KernelProcessEventsSubscriber where TEvents : Enum +{ + /// + /// Initializes a new instance of the class. + /// + public KernelProcessEventsSubscriber() { } + + /// + /// Attribute to set Process related steps to link Process Events to specific functions to execute when the event is emitted outside the Process + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public sealed class ProcessEventSubscriberAttribute : Attribute + { + /// + /// Gets the enum of the event that the function is linked to + /// + public TEvents EventEnum { get; } + + /// + /// Gets the string of the event name that the function is linked to + /// + public string EventName { get; } + + /// + /// Initializes the attribute. + /// + /// Specific Process Event enum + public ProcessEventSubscriberAttribute(TEvents eventEnum) + { + this.EventEnum = eventEnum; + this.EventName = Enum.GetName(typeof(TEvents), eventEnum) ?? ""; + } + } +} diff --git a/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriberInfo.cs b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriberInfo.cs new file mode 100644 index 000000000000..bc60385721d2 --- /dev/null +++ b/dotnet/src/Experimental/Process.Abstractions/KernelProcessEventsSubscriberInfo.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.SemanticKernel.Process; + +namespace Microsoft.SemanticKernel; + +public class KernelProcessEventsSubscriberInfo +{ + private readonly Dictionary> _eventHandlers = []; + private readonly Dictionary _stepEventProcessEventMap = []; + + // potentially _processEventSubscriberType, _subscriberServiceProvider, _processEventSubscriber can be converted to a dictionary to support + // many unique subscriber classes that could be linked to different ServiceProviders + private Type? _processEventSubscriberType = null; + private IServiceProvider? _subscriberServiceProvider = null; + private KernelProcessEventsSubscriber? _processEventSubscriber = null; + + protected void Subscribe(string eventName, MethodInfo method) + { + if (this._eventHandlers.TryGetValue(eventName, out List? eventHandlers) && eventHandlers != null) + { + eventHandlers.Add(method); + } + } + + public void LinkStepEventToProcessEvent(string stepEventId, string processEventId) + { + this._stepEventProcessEventMap.Add(stepEventId, processEventId); + if (!this._eventHandlers.ContainsKey(processEventId)) + { + this._eventHandlers.Add(processEventId, []); + } + } + + public void TryInvokeProcessEventFromStepMessage(string stepEventId, object? data) + { + if (this._stepEventProcessEventMap.TryGetValue(stepEventId, out var processEvent) && processEvent != null) + { + this.InvokeProcessEvent(processEvent, data); + } + } + + public void InvokeProcessEvent(string eventName, object? data) + { + if (this._processEventSubscriberType != null && this._eventHandlers.TryGetValue(eventName, out List? linkedMethods) && linkedMethods != null) + { + if (this._processEventSubscriber == null) + { + try + { + this._processEventSubscriber = (KernelProcessEventsSubscriber?)Activator.CreateInstance(this._processEventSubscriberType, []); + this._processEventSubscriberType.GetProperty(nameof(KernelProcessEventsSubscriber.ServiceProvider))?.SetValue(this._processEventSubscriber, this._subscriberServiceProvider); + } + catch (Exception) + { + throw new KernelException($"Could not create an instance of {this._processEventSubscriberType.Name} to be used in KernelProcessSubscriberInfo"); + } + } + + foreach (var method in linkedMethods) + { + method.Invoke(this._processEventSubscriber, [data]); + } + } + } + + /// + /// Extracts the event properties and function details of the functions with the annotator + /// + /// + /// Type of the class that make uses of the annotators and contains the functionality to be executed + /// Enum that contains the process subscribable events + /// + public void SubscribeToEventsFromClass(IServiceProvider? serviceProvider = null) where TEventListeners : KernelProcessEventsSubscriber where TEvents : Enum + { + if (this._subscriberServiceProvider != null) + { + throw new KernelException("Already linked process to a specific service provider class"); + } + + var methods = typeof(TEventListeners).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly); + foreach (var method in methods) + { + if (method.GetCustomAttributes(typeof(KernelProcessEventsSubscriber<>.ProcessEventSubscriberAttribute), false).FirstOrDefault() is KernelProcessEventsSubscriber.ProcessEventSubscriberAttribute attribute) + { + if (attribute.EventEnum.GetType() != typeof(TEvents)) + { + throw new InvalidOperationException($"The event type {attribute.EventEnum.GetType().Name} does not match the expected type {typeof(TEvents).Name}"); + } + + this.Subscribe(attribute.EventName, method); + } + } + + this._subscriberServiceProvider = serviceProvider; + this._processEventSubscriberType = typeof(TEventListeners); + } + + public KernelProcessEventsSubscriberInfo() { } +} diff --git a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs index e8ed21744da1..6489cc806985 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.SemanticKernel.Process; using Microsoft.SemanticKernel.Process.Internal; using Microsoft.SemanticKernel.Process.Models; @@ -11,7 +12,7 @@ namespace Microsoft.SemanticKernel; /// /// Provides functionality for incrementally defining a process. /// -public sealed class ProcessBuilder : ProcessStepBuilder +public class ProcessBuilder : ProcessStepBuilder { /// The collection of steps within this process. private readonly List _steps = []; @@ -22,6 +23,8 @@ public sealed class ProcessBuilder : ProcessStepBuilder /// Maps external input event Ids to the target entry step for the event. private readonly Dictionary _externalEventTargetMap = []; + internal KernelProcessEventsSubscriberInfo _eventsSubscriber; + /// /// A boolean indicating if the current process is a step within another process. /// @@ -108,7 +111,6 @@ internal override KernelProcessStepInfo BuildStep(KernelProcessStepStateMetadata } #region Public Interface - /// /// A read-only collection of steps in the process. /// @@ -258,7 +260,7 @@ public KernelProcess Build(KernelProcessStateMetadata? stateMetadata = null) // Create the process var state = new KernelProcessState(this.Name, version: this.Version, id: this.HasParentProcess ? this.Id : null); - var process = new KernelProcess(state, builtSteps, builtEdges); + var process = new KernelProcess(state, builtSteps, builtEdges, this._eventsSubscriber); return process; } @@ -269,7 +271,49 @@ public KernelProcess Build(KernelProcessStateMetadata? stateMetadata = null) public ProcessBuilder(string name) : base(name) { + this._eventsSubscriber = new(); + } + + #endregion +} + +public sealed class ProcessBuilder : ProcessBuilder where TEvents : Enum, new() +{ + private readonly Dictionary _eventNames = []; + + private void PopulateEventNames() + { + foreach (TEvents processEvent in Enum.GetValues(typeof(TEvents))) + { + this._eventNames.Add(processEvent, Enum.GetName(typeof(TEvents), processEvent)!); + } + } + + #region Public Interface + + public void LinkEventSubscribersFromType(IServiceProvider? serviceProvider = null) where TEventListeners : KernelProcessEventsSubscriber + { + this._eventsSubscriber.SubscribeToEventsFromClass(serviceProvider); + } + + public ProcessEdgeBuilder OnInputEvent(TEvents eventId) + { + return this.OnInputEvent(this.GetEventName(eventId)); + } + + public string GetEventName(TEvents processEvent) + { + return this._eventNames[processEvent]; } + public ProcessEdgeBuilder GetProcessEvent(TEvents processEvent) + { + return this.OnInputEvent(this.GetEventName(processEvent)); + } + + public ProcessBuilder(string name) : base(name) + { + this.PopulateEventNames(); + } #endregion } diff --git a/dotnet/src/Experimental/Process.Core/ProcessEdgeBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessEdgeBuilder.cs index 076912f318ec..bc6712dcd75a 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessEdgeBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessEdgeBuilder.cs @@ -36,7 +36,7 @@ internal ProcessEdgeBuilder(ProcessBuilder source, string eventId) public ProcessEdgeBuilder SendEventTo(ProcessFunctionTargetBuilder target) { this.Target = target; - ProcessStepEdgeBuilder edgeBuilder = new(this.Source, this.EventId) { Target = this.Target }; + ProcessStepEdgeBuilder edgeBuilder = new(this.Source, this.EventId, this.EventId) { Target = this.Target }; this.Source.LinkTo(this.EventId, edgeBuilder); return new ProcessEdgeBuilder(this.Source, this.EventId); diff --git a/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs index 70749a751da3..be2f697f704d 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessStepBuilder.cs @@ -35,13 +35,12 @@ public abstract class ProcessStepBuilder /// /// Define the behavior of the step when the event with the specified Id is fired. /// - /// The Id of the event of interest. /// An instance of . - public ProcessStepEdgeBuilder OnEvent(string eventId) + public ProcessStepEdgeBuilder OnEvent(string eventName) { // scope the event to this instance of this step - var scopedEventId = this.GetScopedEventId(eventId); - return new ProcessStepEdgeBuilder(this, scopedEventId); + var scopedEventId = this.GetScopedEventId(eventName); + return new ProcessStepEdgeBuilder(this, scopedEventId, eventName); } /// diff --git a/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs b/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs index 2e4afbfa51e9..5aafadc096c0 100644 --- a/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs +++ b/dotnet/src/Experimental/Process.Core/ProcessStepEdgeBuilder.cs @@ -14,9 +14,15 @@ public sealed class ProcessStepEdgeBuilder /// /// The event Id that the edge fires on. + /// Unique event Id linked to the source id. /// internal string EventId { get; } + /// + /// The event name that the edge fires on. + /// + internal string EventName { get; } + /// /// The source step of the edge. /// @@ -27,13 +33,16 @@ public sealed class ProcessStepEdgeBuilder /// /// The source step. /// The Id of the event. - internal ProcessStepEdgeBuilder(ProcessStepBuilder source, string eventId) + /// The name of the event + internal ProcessStepEdgeBuilder(ProcessStepBuilder source, string eventId, string eventName) { Verify.NotNull(source); Verify.NotNullOrWhiteSpace(eventId); + Verify.NotNullOrWhiteSpace(eventName); this.Source = source; this.EventId = eventId; + this.EventName = eventName; } /// @@ -44,7 +53,7 @@ internal KernelProcessEdge Build() Verify.NotNull(this.Source?.Id); Verify.NotNull(this.Target); - return new KernelProcessEdge(this.Source.Id, this.Target.Build()); + return new KernelProcessEdge(this.Source.Id, this.Target.Build(), this.EventName, this.EventId); } /// @@ -62,7 +71,20 @@ public ProcessStepEdgeBuilder SendEventTo(ProcessFunctionTargetBuilder target) this.Target = target; this.Source.LinkTo(this.EventId, this); - return new ProcessStepEdgeBuilder(this.Source, this.EventId); + return new ProcessStepEdgeBuilder(this.Source, this.EventId, this.EventName); + } + + /// + /// Forward specific step events to process events so specific functions linked get executed + /// when receiving the specific event + /// + /// + /// + public ProcessStepEdgeBuilder EmitAsProcessEvent(ProcessEdgeBuilder processEdge) + { + processEdge.Source._eventsSubscriber?.LinkStepEventToProcessEvent(this.EventId, processEventId: processEdge.EventId); + + return new ProcessStepEdgeBuilder(this.Source, this.EventId, this.EventName); } /// diff --git a/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs b/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs index 7b4f239f8965..4234a727710a 100644 --- a/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs +++ b/dotnet/src/Experimental/Process.LocalRuntime/LocalProcess.cs @@ -256,6 +256,9 @@ private async Task Internal_ExecuteAsync(Kernel? kernel = null, int maxSuperstep List messageTasks = []; foreach (var message in messagesToProcess) { + // Check if message has external event handler linked to it + this.TryEmitMessageToExternalSubscribers(message); + // Check for end condition if (message.DestinationId.Equals(ProcessConstants.EndStepName, StringComparison.OrdinalIgnoreCase)) { @@ -291,6 +294,16 @@ private async Task Internal_ExecuteAsync(Kernel? kernel = null, int maxSuperstep return; } + private void TryEmitMessageToExternalSubscribers(string processEventId, object? processEventData) + { + this._process.EventsSubscriber?.TryInvokeProcessEventFromStepMessage(processEventId, processEventData); + } + + private void TryEmitMessageToExternalSubscribers(ProcessMessage message) + { + this.TryEmitMessageToExternalSubscribers(message.EventId, message.TargetEventData); + } + /// /// Processes external events that have been sent to the process, translates them to s, and enqueues /// them to the provided message channel so that they can be processed in the next superstep. @@ -338,9 +351,9 @@ private void EnqueueStepMessages(LocalStep step, Queue messageCh } // Error event was raised with no edge to handle it, send it to an edge defined as the global error target. - if (!foundEdge && stepEvent.IsError) + if (!foundEdge) { - if (this._outputEdges.TryGetValue(ProcessConstants.GlobalErrorEventId, out List? edges)) + if (stepEvent.IsError && this._outputEdges.TryGetValue(ProcessConstants.GlobalErrorEventId, out List? edges)) { foreach (KernelProcessEdge edge in edges) { @@ -348,6 +361,11 @@ private void EnqueueStepMessages(LocalStep step, Queue messageCh messageChannel.Enqueue(message); } } + else + { + // Checking in case the step with no edges linked to it has event that should be emitted externally + this.TryEmitMessageToExternalSubscribers(stepEvent.QualifiedId, stepEvent.Data); + } } } } diff --git a/dotnet/src/Experimental/Process.Runtime.Dapr.UnitTests/ProcessMessageSerializationTests.cs b/dotnet/src/Experimental/Process.Runtime.Dapr.UnitTests/ProcessMessageSerializationTests.cs index f3de5b7cfa32..494a468efd44 100644 --- a/dotnet/src/Experimental/Process.Runtime.Dapr.UnitTests/ProcessMessageSerializationTests.cs +++ b/dotnet/src/Experimental/Process.Runtime.Dapr.UnitTests/ProcessMessageSerializationTests.cs @@ -127,7 +127,7 @@ private static void VerifyContainerSerialization(ProcessMessage[] processMessage private static ProcessMessage CreateMessage(Dictionary values) { - return new ProcessMessage("test-source", "test-destination", "test-function", values) + return new ProcessMessage("test-event", "test-eventid", "test-source", "test-destination", "test-function", values) { TargetEventData = "testdata", TargetEventId = "targetevent", diff --git a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs index 07c4802c8731..0d5e085f2ac7 100644 --- a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs +++ b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepBuilderTests.cs @@ -91,7 +91,7 @@ public void LinkToShouldAddEdge() { // Arrange var stepBuilder = new TestProcessStepBuilder("TestStep"); - var edgeBuilder = new ProcessStepEdgeBuilder(stepBuilder, "TestEvent"); + var edgeBuilder = new ProcessStepEdgeBuilder(stepBuilder, "TestEvent", "TestEvent"); // Act stepBuilder.LinkTo("TestEvent", edgeBuilder); diff --git a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepEdgeBuilderTests.cs b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepEdgeBuilderTests.cs index 3e3f128e1753..35d81ef1ca97 100644 --- a/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepEdgeBuilderTests.cs +++ b/dotnet/src/Experimental/Process.UnitTests/Core/ProcessStepEdgeBuilderTests.cs @@ -21,7 +21,7 @@ public void ConstructorShouldInitializeProperties() var eventType = "Event1"; // Act - var builder = new ProcessStepEdgeBuilder(source, eventType); + var builder = new ProcessStepEdgeBuilder(source, eventType, eventType); // Assert Assert.Equal(source, builder.Source); @@ -36,7 +36,7 @@ public void SendEventToShouldSetOutputTarget() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); var outputTarget = new ProcessFunctionTargetBuilder(new ProcessStepBuilder("OutputStep")); // Act @@ -54,7 +54,7 @@ public void SendEventToShouldSetMultipleOutputTargets() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); var outputTargetA = new ProcessFunctionTargetBuilder(new ProcessStepBuilder("StepA")); var outputTargetB = new ProcessFunctionTargetBuilder(new ProcessStepBuilder("StepB")); @@ -75,7 +75,7 @@ public void SendEventToShouldThrowIfOutputTargetAlreadySet() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); var outputTarget1 = new ProcessFunctionTargetBuilder(source); var outputTarget2 = new ProcessFunctionTargetBuilder(source); @@ -94,7 +94,7 @@ public void StopProcessShouldSetOutputTargetToEndStep() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); // Act builder.StopProcess(); @@ -111,7 +111,7 @@ public void StopProcessShouldThrowIfOutputTargetAlreadySet() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); var outputTarget = new ProcessFunctionTargetBuilder(source); // Act @@ -129,7 +129,7 @@ public void BuildShouldReturnKernelProcessEdge() { // Arrange var source = new ProcessStepBuilder(TestStep.Name); - var builder = new ProcessStepEdgeBuilder(source, "Event1"); + var builder = new ProcessStepEdgeBuilder(source, "Event1", "Event1"); var outputTarget = new ProcessFunctionTargetBuilder(source); builder.SendEventTo(outputTarget); diff --git a/dotnet/src/Experimental/Process.Utilities.UnitTests/CloneTests.cs b/dotnet/src/Experimental/Process.Utilities.UnitTests/CloneTests.cs index e1f8957038cd..f615a2d2c4e8 100644 --- a/dotnet/src/Experimental/Process.Utilities.UnitTests/CloneTests.cs +++ b/dotnet/src/Experimental/Process.Utilities.UnitTests/CloneTests.cs @@ -139,16 +139,18 @@ private static void VerifyProcess(KernelProcess expected, KernelProcess actual) } } - private static Dictionary> CreateTestEdges() => - new() + private static Dictionary> CreateTestEdges() + { + return new() { { "sourceId", [ - new KernelProcessEdge("sourceId", new KernelProcessFunctionTarget("sourceId", "targetFunction", "targetParameter", "targetEventId")), + new KernelProcessEdge("sourceId", new KernelProcessFunctionTarget("sourceId", "targetFunction", "targetParameter", "targetEventId"), "eventName", "eventId"), ] } }; + } private sealed record TestState { diff --git a/dotnet/src/InternalUtilities/process/Runtime/ProcessMessage.cs b/dotnet/src/InternalUtilities/process/Runtime/ProcessMessage.cs index 6b7a73c57a15..92b15a5495f3 100644 --- a/dotnet/src/InternalUtilities/process/Runtime/ProcessMessage.cs +++ b/dotnet/src/InternalUtilities/process/Runtime/ProcessMessage.cs @@ -10,12 +10,16 @@ namespace Microsoft.SemanticKernel.Process.Runtime; /// /// Initializes a new instance of the class. /// +/// Original name of the name of the event triggered +/// Original name of the name of the event triggered /// The source identifier of the message. /// The destination identifier of the message. /// The name of the function associated with the message. /// The dictionary of values associated with the message. [KnownType(typeof(KernelProcessError))] public record ProcessMessage( + string EventName, + string EventId, string SourceId, string DestinationId, string FunctionName, diff --git a/dotnet/src/InternalUtilities/process/Runtime/ProcessMessageFactory.cs b/dotnet/src/InternalUtilities/process/Runtime/ProcessMessageFactory.cs index f1bcea825c22..2b706e8b39bf 100644 --- a/dotnet/src/InternalUtilities/process/Runtime/ProcessMessageFactory.cs +++ b/dotnet/src/InternalUtilities/process/Runtime/ProcessMessageFactory.cs @@ -24,7 +24,7 @@ internal static ProcessMessage CreateFromEdge(KernelProcessEdge edge, object? da parameterValue.Add(target.ParameterName!, data); } - ProcessMessage newMessage = new(edge.SourceStepId, target.StepId, target.FunctionName, parameterValue) + ProcessMessage newMessage = new(edge.SourceEventName, edge.SourceEventId, edge.SourceStepId, target.StepId, target.FunctionName, parameterValue) { TargetEventId = target.TargetEventId, TargetEventData = data